QIAOPANG.COM · 陈天(TYR CHEN)2013–2026 精选技术与随笔文集 · 共 260 篇按时间顺序浏览(2013 → 2026)↗
程序人生 · 陈天文集QiaoPang · Systems, Craft & Agentic Era
时间轴 ↗

彻底抛弃多环境:用 Rust 与 Pingora 重塑下一代 Serverless 架构

彻底抛弃多环境:用 Rust 与 Pingora 重塑下一代 Serverless 架构

大知闲闲,小知间间;大言炎炎,小言詹詹。—— 庄子《齐物论》

题记:上个月的文章里,我讲了为什么给自己的「一人公司」设计了一套企业级 K8s 架构,然后又亲手把它鸽了。那篇文章的潜台词是:K8s 对独立开发者太重了。但如果把视角拉高一层,问题不只是「重」,而是整个范式可能走偏了。

这篇文章不是教程,不是最佳实践指南,而是一次思想实验。我想把过去十年后端基建的核心假设全部推翻,从第一性原理重新推导:如果今天从零开始设计一套支撑千万级日活的基础设施,不背任何历史包袱,它该长什么样?

答案可能会让你不舒服。

一、多环境是个幻觉

每个搞过后端的工程师都经历过这条链路:Dev → Staging → Preprod → Prod。两三套环境,两三倍的机器成本和运维心智。你在 Staging 上测了三天,信心满满地上 Prod,结果一个只有在真实流量下才触发的竞态条件让你凌晨三点爬起来修 Bug。

这不是个例。这是 结构性问题。

业界给它起了个好听的名字:环境偏离(Environment Drift)。听起来像是某种可以通过更严格的配置管理来消除的小毛病。但你仔细想想就会发现,环境偏离不是 Bug,而是 Feature —— 是多环境架构 逻辑上的必然结果。

为什么?因为 Staging 和 Prod 的区别不仅仅在于配置文件里几个变量的不同。真正让两个环境对不齐的,是那些 只有在真实环境下才会冒出来的东西:

  • 真实用户产生的脏数据分布(想想那些 UTF-8 编码错误、emoji、零宽字符)
  • 真实流量的时间模式(午高峰和凌晨的 QPS 曲线完全不同)
  • 真实的第三方 API 响应延迟和错误率
  • 真实的数据库锁竞争模式(你的 Staging 数据库只有十万条数据,Prod 有十亿条)

你没法在 Staging 里「模拟」这些东西。模拟本身就是一种失真。你模拟得越像,成本越高;成本越高,你就越没动力频繁更新它;更新越少,偏离就越大。这是一个 越努力越跑偏的死循环。

彻底抛弃多环境:用 Rust 与 Pingora 重塑下一代 Serverless 架构 架构图

业界推崇的「在生产环境测试(Testing in Production)」是对这个困境的正面回应。但传统做法里,Testing in Prod 意味着金丝雀发布、流量百分比切割、Feature Flag —— 都是在尽力 控制波及范围。问题在于:缩小不等于消除。1% 的流量打到有 Bug 的新版本,如果你的日活是一千万,那就是十万用户遭殃。

如果有一种方式,能让你在生产环境测试,但影响范围精确到单个请求呢?

这就是我们要讨论的第一个核心概念:基于 Baggage Header 的请求级路由隔离。

二、Baggage Header:流量的基因序列

OpenTelemetry 规范中有一个不太起眼但极其强大的概念:Baggage。它允许你在 HTTP/gRPC 请求头中附加任意键值对,这些键值对会沿着整个调用链自动传播。

大多数团队用 Baggage 做分布式追踪的上下文传递。但如果我们把它的用途推到极致 —— 用 Baggage 来决定一个请求应该被哪个版本的代码处理 —— 整个多环境架构就可以被干掉了。

物理统一,逻辑隔离

想象一下这样的请求头:

GET /api/v1/recommendations HTTP/1.1
Host: api.example.com
Baggage: x-env=pr-1234,x-traffic-type=shadow,x-tenant=acme-corp

这个请求带着三条「基因信息」:

  • x-env=pr-1234:我要打到 PR #1234 对应的代码版本
  • x-traffic-type=shadow:我是影子流量,出了问题不影响真实用户
  • x-tenant=acme-corp:我属于 acme-corp 这个租户

当这个请求进入系统时,不需要任何独立的 Staging 环境。物理节点上不存在「测试服务器」和「生产服务器」的区分。系统会根据 Baggage 中的信息,动态地将请求路由到对应的代码版本。

这意味着什么?你可以 同时 在生产环境运行成千上万个「逻辑环境」,每一个都是一个 PR、一个 Feature Branch、或者一个 A/B 测试变体。它们共享同一套物理基础设施,接收真实的生产流量,但彼此之间 完全隔离。

彻底抛弃多环境:用 Rust 与 Pingora 重塑下一代 Serverless 架构 架构图

全链路染色:Baggage 怎么不丢

Baggage 的真正威力在于 自动传播。一个请求从 API Gateway 进来,经过用户服务、推荐服务、搜索服务、最后落到数据库 —— 每一跳都会自动携带 Baggage。这意味着整条链路上的每一个服务都知道自己在服务哪个「逻辑环境」。

这在 OpenTelemetry 的规范里就叫 Context Propagation。不过我们需要比标准规范走得更远。标准的 Baggage 传播依赖于应用层的 HTTP Client 库(如 reqwest、hyper)或 gRPC 框架自动注入。但在我们的架构里,传播不仅发生在服务间调用,还要穿透到:

  1. 消息队列:生产者将 Baggage 序列化进消息的 metadata/header,消费者反序列化后恢复上下文
  2. 异步任务:tokio::spawn 的异步任务需要通过 tracing::Span 或自定义 TaskLocal 继承父任务的 Baggage
  3. 数据库连接:Data Bus 层需要从当前请求的 Baggage 中提取租户和环境信息,决定路由策略

用 Rust 的 trait 来描述这套协议:

/// 任何参与请求处理的组件都必须实现此 trait
pub trait BaggageAware {
    /// 从入站请求中提取 Baggage 上下文
    fn extract_baggage(&self, headers: &HeaderMap) -> BaggageContext;

    /// 向出站请求注入 Baggage 上下文
    fn inject_baggage(&self, ctx: &BaggageContext, headers: &mut HeaderMap);

    /// 基于 Baggage 上下文做路由决策
    fn resolve_target(&self, ctx: &BaggageContext) -> RouteDecision;
}

#[derive(Clone, Debug)]
pub struct BaggageContext {
    /// 目标环境标识,如 "pr-1234" 或 "production"
    pub env: EnvironmentId,
    /// 流量类型:正常、影子、金丝雀
    pub traffic_type: TrafficType,
    /// 租户标识,用于数据层隔离
    pub tenant_id: TenantId,
    /// 可选的采样率覆盖(用于调试时提升遥测粒度)
    pub trace_sample_override: Option<f64>,
}

pub enum RouteDecision {
    /// 路由到已有的运行实例
    ExistingInstance(InstanceAddr),
    /// 需要冷启动一个新实例
    ColdStart { artifact_id: ArtifactId, config: RuntimeConfig },
    /// 回退到默认 production 版本
    Fallback,
}

注意看:RouteDecision::ColdStart 不是返回错误,而是触发一条全新的执行路径。这就引出了第二个关键问题。

三、可变 TTL 运行时:重新定义 Serverless

传统 Serverless 有三个要命的问题 —— 冷启动慢、连接池被打爆、进程内状态留不住 —— 归根结底都是同一个毛病:生命周期管理太粗暴。

AWS Lambda 的模型是:请求来了,拉起容器,执行函数,销毁。生命周期由平台全权控制,开发者无法干预。直接后果就是三个老大难:

  1. 冷启动:每次拉起容器需要秒级时间(JVM 运行时甚至更长),面向用户的 API 根本扛不住
  2. 连接池:进程随时会被杀死,TCP 连接无法复用,高并发下数据库被海量的 SYN 洪水冲垮
  3. 缓冲写不成:进程活不够久,监控数据、埋点事件没法在内存里攒够一批再写出去,每条都是一次独立的 I/O

我们需要的不是「无服务器」,而是 「自适应生命周期的服务器」。

告别 Docker:纯 Rust 静态二进制

在这套架构中,我们彻底抛弃 Docker 镜像。分发的制品(Artifact)是一个经过 musl 静态链接的纯 Rust 可执行文件。

为什么?算一笔账:

指标 Docker 容器 (Alpine + Node.js) Docker 容器 (Distroless + Go) 纯 Rust musl 二进制
制品大小 ~150 MB ~30 MB 3-8 MB
冷启动时间 800ms - 3s 200-500ms < 5ms
内存占用(空载) ~60 MB ~15 MB < 2 MB
系统调用数(启动到就绪) ~2000 ~500 < 50
需要容器运行时 是 是 否

数字差距是数量级的。一个 3 MB 的 Rust 二进制,fork + exec 到开始接收 TCP 连接,时间在微秒到个位毫秒之间。没有镜像层解压,没有 overlay 文件系统,没有 cgroup 初始化 —— 就是操作系统加载一段机器码到内存里执行。

编译流水线大致如下:

# CI 中的构建步骤
# 使用 cross 工具链交叉编译 x86_64-unknown-linux-musl 目标
cross build --release --target x86_64-unknown-linux-musl

# 产物:一个完全静态链接的二进制文件
# 没有 libc 动态依赖,没有 .so 文件,什么都不需要
file target/x86_64-unknown-linux-musl/release/my-service
# my-service: ELF 64-bit LSB executable, x86-64, statically linked

# 用 upx 进一步压缩(可选,换启动时的几毫秒解压开销)
upx --best target/x86_64-unknown-linux-musl/release/my-service
# 8.2 MB -> 2.9 MB

# 上传到 S3 或对象存储,打上 Git SHA 标签
aws s3 cp target/x86_64-unknown-linux-musl/release/my-service \
  s3://artifacts/my-service/$(git rev-parse HEAD)

Firecracker 微虚拟机:毫秒级隔离

拿掉了 Docker,隔离怎么办?答案是 Firecracker。

Firecracker 是 AWS Lambda 和 Fargate 底层用的微虚拟机监视器(MicroVM Monitor),用 Rust 写的,开源的。最厉害的地方在于:125 毫秒内启动一个完整的 Linux 虚拟机,内存开销约 5 MB。

但 125 毫秒对我们来说还是太慢了 —— 别忘了,我们的 Rust 二进制本身只需要几毫秒就能就绪。所以我们不是每次请求都启动 VM,而是维护一个 温池(Warm Pool)。

彻底抛弃多环境:用 Rust 与 Pingora 重塑下一代 Serverless 架构 架构图

温池的关键参数:

  • 预热数量:根据历史流量模式预测。核心服务(如鉴权、用户网关)始终保持 N 个 Warm 实例;低频服务(如特定 PR 的测试版本)按需拉起
  • TTL 策略:不是固定值,而是 可变的(Variable)。连续 30 秒无请求?缩短 TTL。QPS 持续上升?延长 TTL。这就是「可变 TTL」名称的由来
  • 内存上限:每个 MicroVM 的内存通过 balloon device 动态调节,空载时缩到几 MB,高负载时扩到配置上限

用伪代码描述 TTL 决策逻辑:

fn compute_ttl(metrics: &InstanceMetrics) -> Duration {
    let base_ttl = Duration::from_secs(300); // 基础 5 分钟

    // 最近 30 秒的请求速率
    let recent_rps = metrics.requests_last_30s as f64 / 30.0;

    // 内存中 L1 缓存的命中率 —— 越高说明本地缓存越有价值,越值得续命
    let cache_value = metrics.l1_cache_hit_ratio;

    // 后台还有多少批数据没写完(如监控数据写入 Kafka)
    let pending_batches = metrics.pending_batch_tasks;

    let ttl = if recent_rps > 100.0 {
        // 高负载:大幅延长,最多 2 小时
        base_ttl * 24
    } else if recent_rps > 10.0 {
        // 中等负载:适度延长
        base_ttl * (1.0 + cache_value * 4.0) as u32
    } else if recent_rps > 0.0 {
        // 低频长尾:保持基础 TTL
        base_ttl
    } else if pending_batches > 0 {
        // 无新请求但还有一批数据没刷完:等它写完
        Duration::from_secs(30)
    } else {
        // 彻底空闲:快速回收
        Duration::from_secs(10)
    };

    ttl.min(Duration::from_secs(7200)) // 硬上限 2 小时
}

进程内的秘密武器:L1 缓存 + 后台批量刷写

Serverless 最让人头疼的问题之一,就是进程内的状态留不住。但在我们的 Variable TTL 模型里,一个 Rust Fn 实例可以活几分钟甚至几个小时 —— 这就有操作空间了:

L1 内存缓存(moka): moka 是 Rust 生态中最成熟的并发缓存库,灵感来自 Java 的 Caffeine,支持 TTL/TTI 过期、容量上限、异步加载。在我们的 Fn 内部,它充当热数据的一级缓存。

use moka::future::Cache;
use std::time::Duration;

// 每个 Fn 实例内部维护一个 L1 缓存
// 容量上限 10000 条,TTL 60 秒
let l1_cache: Cache<String, CatalogItem> = Cache::builder()
    .max_capacity(10_000)
    .time_to_live(Duration::from_secs(60))
    .time_to_idle(Duration::from_secs(30))
    // 异步加载:缓存未命中时自动从 Data Bus 拉取
    .build();

// 使用时:
let item = l1_cache
    .try_get_with(item_id.clone(), async {
        // 缓存未命中 —— 走 Data Bus 查数据库
        data_bus_client.query_catalog_item(&item_id).await
    })
    .await
    .map_err(|e| AppError::CacheMiss(e))?;

当一个 Fn 实例持续服务某个热门页面的请求时,L1 缓存命中率可以轻松达到 90% 以上。这意味着 90% 的请求 完全不需要跨网络 I/O,响应延迟从毫秒级降到微秒级。

后台批量刷写: 监控数据(Metrics、Traces、Logs)和业务埋点不需要来一条写一条。我们在每个 Fn 实例内部用 tokio::spawn 起一个轻量级的后台任务,先把事件攒在内存里,凑够一批或到了时间窗口再统一刷进 Kafka。

/// 后台批量刷写:每 5 秒或攒满 1000 条时统一写入 Kafka
async fn batch_telemetry_writer(
    rx: mpsc::Receiver<TelemetryEvent>,
    kafka_producer: FutureProducer,
) {
    let mut buffer: Vec<TelemetryEvent> = Vec::with_capacity(1024);
    let mut interval = tokio::time::interval(Duration::from_secs(5));
    let mut rx = rx;

    loop {
        tokio::select! {
            Some(event) = rx.recv() => {
                buffer.push(event);
                if buffer.len() >= 1000 {
                    flush_batch(&kafka_producer, &mut buffer).await;
                }
            }
            _ = interval.tick() => {
                if !buffer.is_empty() {
                    flush_batch(&kafka_producer, &mut buffer).await;
                }
            }
        }
    }
}

这三板斧 —— 静态二进制干掉冷启动、温池 + Variable TTL 干掉连接池风暴、L1 缓存 + 批量刷写解决状态留不住的问题 —— 把传统 Serverless 的三个要命短板全补上了。

四、Pingora:不只是网关,是调度器

到目前为止,我们有了一堆随起随停的 Rust 运行时实例,有了基于 Baggage Header 的路由意图。但谁来做那个「大脑」—— 解析 Baggage、查路由表、实例不在的时候拉起新的、缩容的时候把连接排干净?

传统选项有 Nginx(性能好但不可编程,reload 重载慢)和 Envoy(功能全但配置复杂、内存重)。但在我们的场景里,网关需要做的事情远超传统 reverse proxy 的范畴:

  1. 深度解析 Baggage Header,做毫秒级路由决策
  2. 当目标实例不存在时,挂起请求 而不是返回 502
  3. 触发旁路系统拉起新的 MicroVM + Rust Fn
  4. 实例就绪后,恢复挂起的请求,客户端完全无感
  5. 在实例缩容时,做到 零丢包 的连接排空

Cloudflare 开源的 Pingora 是目前唯一能同时满足这五个需求的框架。

为什么是 Pingora

Pingora 不是一个现成的网关产品,而是一个用 Rust 写的 HTTP 代理框架。Cloudflare 用它替换了自己的 Nginx 集群,服务全球每秒数千万请求。它的设计哲学是:把代理逻辑暴露为 Rust trait,让你用编程的方式控制每一个 HTTP 请求的完整生命周期。

核心 trait 是 ProxyHttp,提供了十几个可覆盖的钩子,从 DNS 解析到响应过滤,全链路可编程:

use async_trait::async_trait;
use pingora::prelude::*;
use pingora::proxy::{ProxyHttp, Session};
use pingora::upstreams::peer::HttpPeer;
use std::sync::Arc;
use tokio::sync::Notify;

pub struct OrchestrationProxy {
    /// 活跃实例的路由表:env_id -> (地址, 健康状态)
    route_table: Arc<DashMap<EnvironmentId, InstanceInfo>>,
    /// Firecracker 控制器客户端
    vm_controller: Arc<VmController>,
    /// 冷启动等待通知器
    cold_start_notifiers: Arc<DashMap<EnvironmentId, Arc<Notify>>>,
}

#[async_trait]
impl ProxyHttp for OrchestrationProxy {
    type CTX = RequestContext;

    fn new_ctx(&self) -> Self::CTX {
        RequestContext::default()
    }

    /// 在连接上游之前执行 —— 这是我们的核心调度逻辑
    async fn upstream_peer(
        &self,
        session: &mut Session,
        ctx: &mut Self::CTX,
    ) -> Result<Box<HttpPeer>> {
        // 1. 从请求头解析 Baggage
        let baggage = extract_baggage(session.req_header());
        let env_id = baggage.env.clone();
        ctx.baggage = Some(baggage);

        // 2. 查路由表
        if let Some(instance) = self.route_table.get(&env_id) {
            // 快路径:实例存在且健康
            if instance.is_healthy() {
                return Ok(Box::new(HttpPeer::new(
                    instance.addr,
                    false, // TLS 在这层不需要,MicroVM 在同一物理机
                    String::new(),
                )));
            }
        }

        // 3. 慢路径:实例不存在,触发冷启动
        //    关键:我们不返回错误,而是 **挂起当前请求**
        let notifier = self.cold_start_notifiers
            .entry(env_id.clone())
            .or_insert_with(|| Arc::new(Notify::new()))
            .clone();

        // 触发 VM 控制器拉起新实例(异步,不阻塞)
        let controller = self.vm_controller.clone();
        let route_table = self.route_table.clone();
        let env = env_id.clone();
        tokio::spawn(async move {
            match controller.launch_instance(&env).await {
                Ok(instance_info) => {
                    route_table.insert(env, instance_info);
                    notifier.notify_waiters(); // 唤醒所有等待的请求
                }
                Err(e) => {
                    tracing::error!("Cold start failed for {}: {}", env, e);
                    notifier.notify_waiters(); // 也要唤醒,让它们走 fallback
                }
            }
        });

        // 4. 挂起当前请求,等待冷启动完成
        //    设置超时:如果 500ms 内启动不了,走 fallback
        tokio::select! {
            _ = notifier.notified() => {},
            _ = tokio::time::sleep(Duration::from_millis(500)) => {
                tracing::warn!("Cold start timeout for {}, falling back", env_id);
            }
        }

        // 5. 再次查路由表
        if let Some(instance) = self.route_table.get(&env_id) {
            Ok(Box::new(HttpPeer::new(
                instance.addr, false, String::new()
            )))
        } else {
            // Fallback 到 production 版本
            let prod = self.route_table.get(&EnvironmentId::production())
                .expect("Production instance must always exist");
            Ok(Box::new(HttpPeer::new(
                prod.addr, false, String::new()
            )))
        }
    }

    /// 请求过滤:注入追踪信息
    async fn request_filter(
        &self,
        session: &mut Session,
        ctx: &mut Self::CTX,
    ) -> Result<bool> {
        // 将路由决策记录到 span 中,方便调试
        if let Some(ref baggage) = ctx.baggage {
            session.req_header_mut().insert_header(
                "x-routed-env",
                &baggage.env.to_string(),
            )?;
        }
        Ok(false) // false = 继续代理,true = 短路返回
    }
}

仔细看 upstream_peer 的实现。传统网关遇到上游不存在,只能返回 502。但在 Pingora 里,我们利用 Rust 的异步特性 把 HTTP 请求挂起了。从客户端的视角看,这个请求只是「响应慢了几百毫秒」,而不是「失败了」。这几百毫秒里,系统悄悄拉起了一个全新的 MicroVM,加载了对应 PR 分支的 Rust 二进制,并开始接收流量。

这就是传统网关和编排式网关的本质区别:传统网关是路由器,编排式网关是调度器。

连接排空:零丢包缩容

当底层 Rust 运行时的 TTL 到期,或者缩容策略触发时,排空流程必须做到零丢包:

彻底抛弃多环境:用 Rust 与 Pingora 重塑下一代 Serverless 架构 架构图

DRAIN 信号的实现非常简单 —— 就是一个 Unix Domain Socket 上的单字节写入。Pingora 在事件循环中异步接收,整个过程在内存中完成,没有任何网络 I/O。

五、控制面与数据面:流媒体架构的切分艺术

到这里,你可能会问:这套架构听起来很美,但真的能用在高并发流媒体场景吗?视频字节流也要走这种随起随停的 Fn?

当然不能。 这是架构设计中最重要的判断力之一:知道什么该做,更要知道什么 不该做。

绝对的禁区:数据面

视频流分发(HLS/DASH chunk delivery)有三个硬性要求:

  1. 极高的 TCP 吞吐:4K 视频单流就是 20-50 Mbps,数万并发用户意味着 Tbps 级别的带宽
  2. 零拷贝(sendfile):操作系统通过 sendfile 系统调用直接把文件从磁盘 DMA 到网卡,绕过用户态内存拷贝
  3. 稳定的 TCP 拥塞窗口:视频播放需要持续稳定的数据流,频繁生灭的进程会破坏 TCP 的慢启动和拥塞控制算法,导致画面卡顿

彻底抛弃多环境:用 Rust 与 Pingora 重塑下一代 Serverless 架构 架构图

这条线必须画得非常清楚:所有视频字节流 100% 卸载给边缘 CDN,一个字节都不经过我们的 Fn 层。

完美的战场:控制面

反过来,API 控制面才是这套架构真正大展拳脚的地方:

用户鉴权与 Token 校验: JWT 验签是纯 CPU 密集型计算。Rust 的 ring 或 jsonwebtoken 库做 RS256 验签,单核可以轻松达到 50,000+ ops/sec。在 Fn 实例的 L1 缓存中缓存已验证的 Token(以 Token 的 jti 为 key,TTL 设为 Token 剩余有效期),二次请求直接命中缓存,连签名验证都省了。

元数据下发与首页瀑布流: 这是 Variable TTL 带来的本地内存缓存最能发力的场景。首页瀑布流的数据变化频率通常是分钟级的,但 QPS 可能高达几十万。在 Fn 内部用 moka 缓存最近的推荐结果,缓存命中率可以达到 95%+,意味着每秒只有不到 5% 的请求需要穿透到数据层。

DRM 播放授权: 这是 Baggage 路由最精妙的应用场景。不同版本的 DRM 认证逻辑(比如测试新的 Widevine L1 策略)可以通过 Baggage Header 精准路由到对应的 Fn 实例。出了问题?影响范围就锁死在带测试 Header 的那几个请求里,生产流量毫发无损。

弹幕与评论: WebSocket 长连接在 Fn 的 Variable TTL 模型下运作良好。只要有活跃连接,TTL 就不会触发回收。当用户退出直播间,连接断开,Fn 的 TTL 自然缩短,几秒后自动回收。天然适合弹幕这种潮起潮落的场景。

六、Data Bus:SQL 防火墙与数据层隔离

计算层的无状态和请求级隔离我们已经做到位了。但如果所有逻辑环境的 Fn 都直连同一个 PostgreSQL 实例 —— 测试流量的一句 DELETE FROM users WHERE id = 1 打到了生产数据库 —— 前面所有精心设计的隔离都白费了。

数据隔离是整套架构最后一块也是最难的拼图。

为了解决这个问题,我们在 Fn(计算层)和 PostgreSQL(存储层)之间引入了一个全 Rust 编写的 Data Bus(数据总线)。它不是一个简单的连接池代理(那是 PgBouncer 做的事),而是一个具备 SQL 语义理解 和 请求级鉴权 能力的数据库防火墙。

彻底抛弃多环境:用 Rust 与 Pingora 重塑下一代 Serverless 架构 架构图

为什么不做 AST 重写

第一反应可能是:在 Data Bus 层解析所有 SQL,自动把权限条件(如 WHERE tenant_id = $current_tenant)注入进去。这种「透明重写」的想法很诱人,但在工程实践中是一场噩梦。

原因有三:

  1. SQL 方言的无底洞。PostgreSQL 的 SQL 语法大得没边 —— 窗口函数、递归 CTE、LATERAL JOIN、RETURNING 子句、PL/pgSQL 函数调用 —— 每一个都可能成为重写逻辑的边界 case。你以为处理好了 SELECT,然后发现 INSERT ... ON CONFLICT ... DO UPDATE SET ... WHERE ... 里也需要注入条件。
  2. 子查询嵌套的越权风险。自动注入的 WHERE tenant_id = X 可能只加在了最外层 SELECT,而内层的子查询或 CTE 引用了其他租户的数据。这不是 Bug,而是 安全漏洞。
  3. 语义歧义。同一张表在不同上下文中可能有不同的隔离语义。audit_logs 表可能是全局共享的,而 user_profiles 表是租户隔离的。自动重写引擎怎么知道?你最终还是得写一套声明式的规则系统,那还不如让开发者直接在 SQL 里写明。

拦截与强校验:更优雅的道路

我们选择了一种更工程化的策略:开发者必须显式写明权限约束,Data Bus 只做审计员。

具体来说:

  1. 业务开发者在写 SQL 时,必须在 WHERE 子句中显式包含租户约束(如 WHERE tenant_id = $1)
  2. Data Bus 解析 SQL 的 AST,检查是否存在预期的约束列
  3. 将约束参数的值与请求 Baggage 中的身份信息进行 严格比对
  4. 一致则放行,任何偏差直接拦截返回 403
use sqlparser::ast::{Expr, BinaryOp, Statement, SetExpr, Select};
use sqlparser::dialect::PostgreSqlDialect;
use sqlparser::parser::Parser;

/// SQL 安全审计结果
#[derive(Debug)]
pub enum AuditResult {
    /// SQL 通过审计,记录约束参数的位置
    Approved {
        /// 租户约束参数在 prepared statement 中的位置($1, $2, ...)
        tenant_param_index: usize,
        /// SQL 骨架的 hash,用于后续缓存
        skeleton_hash: u64
    },
    /// SQL 被拒绝
    Rejected { reason: String },
}

/// 审计一条 SQL 语句,检查是否包含正确的租户隔离约束
pub fn audit_sql(sql: &str, required_column: &str) -> AuditResult {
    let dialect = PostgreSqlDialect {};
    let ast = match Parser::parse_sql(&dialect, sql) {
        Ok(ast) => ast,
        Err(e) => return AuditResult::Rejected {
            reason: format!("SQL parse error: {}", e)
        },
    };

    for statement in &ast {
        match statement {
            Statement::Query(query) => {
                if let SetExpr::Select(select) = query.body.as_ref() {
                    match find_tenant_constraint(select, required_column) {
                        Some(param_index) => {
                            let hash = compute_skeleton_hash(sql);
                            return AuditResult::Approved {
                                tenant_param_index: param_index,
                                skeleton_hash: hash,
                            };
                        }
                        None => return AuditResult::Rejected {
                            reason: format!(
                                "Missing required constraint on column '{}'",
                                required_column
                            ),
                        },
                    }
                }
            }
            Statement::Insert { .. } | Statement::Update { .. } | Statement::Delete { .. } => {
                // 对写操作执行更严格的审计...
                // INSERT 必须包含 tenant_id 列
                // UPDATE/DELETE 的 WHERE 必须包含 tenant_id 约束
            }
            _ => {
                return AuditResult::Rejected {
                    reason: "Only SELECT/INSERT/UPDATE/DELETE are allowed".into(),
                };
            }
        }
    }

    AuditResult::Rejected { reason: "Empty statement".into() }
}

/// 在 SELECT 的 WHERE 子句中查找类似 `tenant_id = $N` 的约束
fn find_tenant_constraint(select: &Select, column: &str) -> Option<usize> {
    let selection = select.selection.as_ref()?;
    walk_expr_for_constraint(selection, column)
}

/// 递归遍历表达式树,查找目标列的等值约束
fn walk_expr_for_constraint(expr: &Expr, column: &str) -> Option<usize> {
    match expr {
        // 匹配 `column = $N` 或 `$N = column` 模式
        Expr::BinaryOp { left, op: BinaryOp::Eq, right } => {
            // 左边是列名,右边是参数占位符
            if is_column(left, column) {
                return extract_param_index(right);
            }
            // 右边是列名,左边是参数占位符
            if is_column(right, column) {
                return extract_param_index(left);
            }
            None
        }
        // 对 AND 连接的子表达式递归查找
        Expr::BinaryOp { left, op: BinaryOp::And, right } => {
            walk_expr_for_constraint(left, column)
                .or_else(|| walk_expr_for_constraint(right, column))
        }
        // 括号表达式:展开继续查找
        Expr::Nested(inner) => walk_expr_for_constraint(inner, column),
        _ => None,
    }
}

这种设计的哲学是 Shift Left —— 把安全责任前移到开发者手上,而不是依赖后端的魔法自动修复。开发者多写一个 WHERE tenant_id = $1 不费吹灰之力,但换来的安全保障是实打实的、可审计的、没有灰色地带的。

性能核武:AST Hash 预编译缓存

你可能会担心:每个请求都解析一遍 SQL AST,这不是引入了一个巨大的性能瓶颈吗?

的确。sqlparser 解析一条中等复杂度的 SQL 大约需要 50-200 微秒。在 10 万 QPS 下,这意味着仅 SQL 解析就会占用 5-20 个 CPU 核心。不可接受。

解决方案是 基于 Hash 的预编译缓存:

彻底抛弃多环境:用 Rust 与 Pingora 重塑下一代 Serverless 架构 架构图

核心数据结构:

use dashmap::DashMap;
use std::hash::{Hash, Hasher};
use std::collections::hash_map::DefaultHasher;

/// 预编译缓存中的审计元数据
#[derive(Clone, Debug)]
struct AuditMeta {
    /// 是否通过了安全审计
    is_safe: bool,
    /// 租户约束参数在 prepared statement 中的索引
    check_param_index: usize,
    /// 对应的 PostgreSQL Prepared Statement 名称
    prepared_stmt_name: String,
    /// 首次审计通过的时间戳(用于可观测性)
    first_seen: Instant,
    /// 累计命中次数(用于热度统计)
    hit_count: AtomicU64,
}

/// Data Bus 的核心缓存
struct DataBus {
    /// SQL 骨架 Hash -> 审计元数据
    /// DashMap 是 Rust 生态中最快的并发 HashMap
    audit_cache: DashMap<u64, AuditMeta>,
    /// PostgreSQL 连接池
    pg_pool: deadpool_postgres::Pool,
}

impl DataBus {
    /// 处理一条 SQL 请求
    async fn execute(
        &self,
        sql: &str,
        params: &[&(dyn ToSql + Sync)],
        baggage: &BaggageContext,
    ) -> Result<Vec<Row>, DataBusError> {
        let hash = compute_skeleton_hash(sql);

        // 快路径:缓存命中
        if let Some(meta) = self.audit_cache.get(&hash) {
            meta.hit_count.fetch_add(1, Ordering::Relaxed);

            // O(1) 参数校验:检查 tenant_id 参数是否与 Baggage 中的身份一致
            let tenant_param = params.get(meta.check_param_index)
                .ok_or(DataBusError::ParamIndexOutOfBounds)?;

            if !verify_tenant_param(tenant_param, &baggage.tenant_id) {
                return Err(DataBusError::TenantMismatch {
                    expected: baggage.tenant_id.clone(),
                    sql_skeleton_hash: hash,
                });
            }

            // 直接使用 Prepared Statement 执行
            let conn = self.pg_pool.get().await?;
            let rows = conn.query(&meta.prepared_stmt_name, params).await?;
            return Ok(rows);
        }

        // 慢路径:首次见到这条 SQL 骨架
        match audit_sql(sql, "tenant_id") {
            AuditResult::Approved { tenant_param_index, skeleton_hash } => {
                // 注册 Prepared Statement
                let stmt_name = format!("databus_{}", skeleton_hash);
                let conn = self.pg_pool.get().await?;
                conn.execute(
                    &format!("PREPARE {} AS {}", stmt_name, sql),
                    &[]
                ).await?;

                // 写入缓存
                self.audit_cache.insert(skeleton_hash, AuditMeta {
                    is_safe: true,
                    check_param_index: tenant_param_index,
                    prepared_stmt_name: stmt_name.clone(),
                    first_seen: Instant::now(),
                    hit_count: AtomicU64::new(1),
                });

                // 校验并执行
                let tenant_param = params.get(tenant_param_index)
                    .ok_or(DataBusError::ParamIndexOutOfBounds)?;
                if !verify_tenant_param(tenant_param, &baggage.tenant_id) {
                    return Err(DataBusError::TenantMismatch {
                        expected: baggage.tenant_id.clone(),
                        sql_skeleton_hash: skeleton_hash,
                    });
                }

                let rows = conn.query(&stmt_name, params).await?;
                Ok(rows)
            }
            AuditResult::Rejected { reason } => {
                tracing::error!(
                    sql = sql,
                    reason = reason.as_str(),
                    "SQL rejected by Data Bus audit"
                );
                Err(DataBusError::AuditRejected(reason))
            }
        }
    }
}

/// 计算 SQL 的骨架 Hash
/// "骨架"意味着忽略参数值,只保留结构
/// SELECT * FROM users WHERE id = $1 AND name = $2
/// 和 SELECT * FROM users WHERE id = $1 AND name = $2
/// 的骨架是相同的(它们本来就一样,因为用了参数化查询)
fn compute_skeleton_hash(sql: &str) -> u64 {
    let mut hasher = DefaultHasher::new();
    sql.hash(&mut hasher);
    hasher.finish()
}

性能数字:慢路径约 200 微秒(解析 + 审计 + 注册 Prepared Statement),快路径约 50 纳秒(Hash 计算 + DashMap 查找 + 参数比对)。快路径比慢路径快 4000 倍。在稳态下,99.9% 的请求走快路径。

更重要的是,这套机制 从架构底层免疫了 SQL 注入。因为所有 SQL 都通过 Prepared Statement 执行,参数值永远不会被拼接到 SQL 字符串中。Data Bus 的审计确保了每条 SQL 都有正确的参数化约束,而 PostgreSQL 的 Prepared Statement 确保了参数值和 SQL 结构的严格分离。

数据层的动态环境隔离

最后一块拼图:当带有 x-env: pr-1234 的测试流量到达 Data Bus 时,它不应该打到生产数据库。

Data Bus 根据 Baggage 中的环境标识,将非生产流量路由到 影子数据库(Shadow Database)。这个影子数据库通过 PostgreSQL 的 写时复制(Copy-on-Write) 技术瞬间克隆:

-- 使用 pg_createcluster 或云服务商的快照克隆功能
-- AWS Aurora 的 clone 操作是 O(1) 的,不管数据库多大
-- 因为底层是 Copy-on-Write 的存储层

-- 或者用 PostgreSQL 15+ 的 CREATE DATABASE ... STRATEGY WAL_LOG
CREATE DATABASE shadow_pr_1234 TEMPLATE production_db;

在 AWS Aurora 上,克隆一个 TB 级的数据库只需要几分钟(不复制数据,只复制指向存储页的指针)。影子数据库与生产数据库共享未修改的存储页,只有被测试流量 写入 的页面才会产生新的副本。

彻底抛弃多环境:用 Rust 与 Pingora 重塑下一代 Serverless 架构 架构图

业务代码对这一切 毫无感知。它只是正常地向 Data Bus 发送 SQL,Data Bus 根据 Baggage 决定打到哪个数据库。连接字符串、认证凭证、连接池管理 —— 全部由 Data Bus 在背后搞定。

七、全景图:从请求到响应的完整链路

让我们把所有组件串起来,看一个请求从进入系统到返回响应的完整旅程:

彻底抛弃多环境:用 Rust 与 Pingora 重塑下一代 Serverless 架构 架构图

八、这套架构的代价与边界

写到这里,我必须泼一盆冷水。任何不谈 trade-off 的架构文章都是耍流氓。

你要付出的代价

调试复杂度暴增。 当一个 Bug 只在特定 Baggage 组合下触发,且只在特定 TTL 阶段出现,你需要的可观测性工具链必须是世界级的。分布式追踪不再是锦上添花,而是基本生存工具。每一跳的 Baggage 传播、每一次路由决策、每一次缓存命中或未命中,都必须记录在 Trace 中。

运维认知门槛极高。 K8s 虽然复杂,但它有庞大的社区、成熟的工具链、数不清的教程。而这套架构需要你同时精通 Rust、Pingora 框架、Firecracker 虚拟化、PostgreSQL 内核机制、以及分布式系统的方方面面。你雇不到现成的工程师来维护这套系统 —— 因为它太新了。

Firecracker 的硬限制。 Firecracker 只支持 Linux(需要 KVM),不支持 GPU 透传(至少目前不支持),不支持 macOS 开发环境本地运行。你的本地开发体验可能需要依赖远程 dev server 或者 Lima/UTM 等本地 Linux VM。

测试金字塔倒置。 传统的「大量单元测试 + 少量集成测试 + 极少量 E2E 测试」金字塔在这套架构里被颠覆了。由于核心价值在于 全链路的请求级隔离,你需要大量的端到端测试来验证 Baggage 传播、路由决策、数据隔离等跨组件行为。单元测试依然重要,但不再是质量保障的主力。

这套架构不适合谁

  • 团队规模 < 5 人且没有 Rust 经验:学习曲线太陡
  • QPS < 1000 的内部工具:杀鸡用牛刀,一个 Docker Compose + Nginx 就够了
  • 强监管行业需要物理环境隔离:某些合规要求测试和生产必须在物理隔离的网络中,Baggage 路由的逻辑隔离不满足这类要求
  • 已有成熟 K8s 平台且运行稳定的团队:迁移的风险和成本远大于收益

九、Post-Kubernetes 时代的曙光

让我们回到文章开头的问题:如果今天从零开始,不背历史包袱,基础设施该长什么样?

我认为答案的方向是清晰的:

Kubernetes 解决的核心问题 —— 容器编排、服务发现、滚动更新 —— 在一个以二进制分发为主、以请求级路由为核心的架构中,大部分变得不再必要。 你不需要 Pod、Deployment、Service、Ingress 这一堆抽象,因为计算单元就是一个几 MB 的二进制文件,路由就是一次 Baggage 解析,编排就是 Pingora 里的几十行 Rust 代码。

多环境架构 —— Dev、Staging、Preprod、Prod —— 在 Baggage 路由和 Copy-on-Write 数据库克隆的配合下,从物理隔离坍缩为逻辑隔离。 四倍的机器成本、四倍的运维心智,坍缩为一套物理集群上的动态逻辑分区。

数据库从全能型选手回归了最纯粹的 I/O 引擎。 连接池管理、权限校验、SQL 安全审计 —— 这些过去塞在 ORM 或数据库中间件里的职责,被 Data Bus 这个专注的组件接管了。数据库只需要做好一件事:快速、安全地存取数据。

这就是 Baggage Header 路由 + Pingora 智能网关 + Firecracker 纯 Rust 运行时 + Data Bus SQL 防火墙 组成的架构全貌。它不是对 Kubernetes 的修修补补,而是回到第一性原理重新想了一遍。

当然,这种推倒重来的事从来不是一夜之间发生的。Kubernetes 不会明天就消失,就像 Subversion 在 Git 出现后又活了十年一样。但方向已经露出端倪:基础设施正在变得越来越「无形」(Invisible),计算正在回归最纯粹、最高效的样子 —— 一段机器码,一个请求,一次干干净净的执行。

也许十年后回头看,我们会觉得 Kubernetes 时代的 YAML 编排,就像今天看 Ant 的 XML 构建脚本一样,是一段必要但终将被超越的历史。

而那个更纯粹的未来,正在用 Rust 一行一行地写出来。

MORE IN 系统与架构

同专栏延伸阅读

查看「系统与架构」全部文章 →

日志的未来:必也正名乎

我们花了十年,把可观测性盖成一座漂亮的大厦,地基却是漂浮的字符串:字段名靠默契、基数靠运气、账单靠忍。这篇文章给出一个激进的方案,用 protobuf 给每一件被观测的事正名,把日志、指标、审计统一成一份带版本、带 owner、带保留策略的事件目录,旁边留一扇叫 ForensicEvent 的逃生舱兜住所有未知未知。AI 已经成为代码的主笔,schema-first 的最后一道阻力随之消失。是时候让 schema 重新成为可观测性大厦的

New Hope (2025–2026)10,772 字 · 24 分钟