把压测标识塞进 gRPC metadata,一路透传到 DB 层,流量自动落到影子库,这套链路我搭了一遍

压测流量污染线上数据,这事谁碰上都头大。我最近用 gRPC metadata 作为压测标识的载体,从网关一路透传到 DB,让压测流量自动切到影子库,整个过程不需要改业务代码,侵入性极低。下面把完整链路拆开讲一遍。

核心思路:在 metadata 里埋一个标记,全链路透传

gRPC 的 metadata 本质上就是个 key-value 结构,走 HTTP/2 headers 传输。我们约定一个 key,比如 x-stress-test,值为 true 时表示这是一次压测请求。这个标记从网关注入,经过一层层 gRPC 调用自动传递,最后在 DAO 层被拦截,动态切换数据源。

为什么不走 message 里加字段?因为那要改所有 proto 定义,还得让业务方在每次调用时手动传这个标记,侵入性太大。metadata 的好处是:对 proto 零侵入,对业务代码几乎不可见。

网关注入:把压测标记写入 metadata

网关是流量的入口,压测流量的区分从这里开始。我用的方案是:压测平台发请求时在 HTTP header 里带上 X-Stress-Test: true,网关解析后写入 gRPC metadata。

// 网关拦截器:从 HTTP header 提取压测标记,注入 gRPC metadata
func gatewayInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    // 从 HTTP header 取,具体取法取决于你的网关框架
    if stressFlag := getHeaderFromHTTP(ctx, "X-Stress-Test"); stressFlag == "true" {
        md, _ := metadata.FromIncomingContext(ctx)
        md = metadata.Join(md, metadata.Pairs("x-stress-test", "true"))
        ctx = metadata.NewIncomingContext(ctx, md)
    }
    return handler(ctx, req)
}

这里有个细节:metadata.FromIncomingContext 拿到的是只读的,所以用 metadata.Join 创建新的 metadata 再塞回去。别直接 append 到原 md 上,会 panic。

服务间透传:客户端拦截器是关键的自动挡

网关往下游调的时候,如果什么都不做,metadata 就丢了。gRPC 不会自动把 incoming metadata 转发到 outgoing——这个设计是合理的,因为服务不该无脑把上游 headers 全透传给下游,有安全风险。

我们需要一个客户端拦截器,显式地把压测标记从 incoming context 搬到 outgoing context:

// 客户端拦截器:自动透传压测标记
func clientStressPropagator(ctx context.Context, method string, req, reply interface{},
    cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
    
    md, ok := metadata.FromIncomingContext(ctx)
    if ok {
        if vals := md.Get("x-stress-test"); len(vals) > 0 && vals[0] == "true" {
            ctx = metadata.AppendToOutgoingContext(ctx, "x-stress-test", "true")
        }
    }
    return invoker(ctx, method, req, reply, cc, opts...)
}

服务端也要配一个拦截器,把 incoming metadata 里的标记提取出来,放进 context 的 value 里,方便业务层按需获取:

// 服务端拦截器:提取标记到 context
func serverStressExtractor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    if md, ok := metadata.FromIncomingContext(ctx); ok {
        if vals := md.Get("x-stress-test"); len(vals) > 0 && vals[0] == "true" {
            ctx = context.WithValue(ctx, stressTestKey{}, true)
        }
    }
    return handler(ctx, req)
}

这套组合拳打下来,只要每个服务都注册这两个拦截器,压测标记就能从网关一路透传到链路末端的 DAO 层,中间经过多少层 gRPC 调用都不断。

实际落地时我把这两个拦截器打包成了一个公共的 grpc-middleware 包,各服务引入后一行注册即可,不需要每个团队自己写。

影子库切换:用 sqlcommenter 或 ORM hook 实现无感路由

标记到了 DAO 层之后,怎么让它落到影子库?常见的做法有两种。

方案一:sqlcommenter + 数据库代理。 在 SQL 前面注入注释,比如 /* stress_test=true */ SELECT ...,然后通过数据库中间件(如 ProxySQL、ShardingSphere)根据注释做路由。优点是和 ORM 无关,缺点是要多维护一个中间件。

方案二:ORM 层动态切换数据源。 我选的是这个。以 GORM 为例,在 DAO 操作前根据 context 里的压测标记切换 DB 连接:

// 根据压测标记返回对应的 DB 实例
func GetDB(ctx context.Context) *gorm.DB {
    if isStressTest(ctx) {
        return shadowDB // 指向影子库的连接
    }
    return normalDB
}

func isStressTest(ctx context.Context) bool {
    val, ok := ctx.Value(stressTestKey{}).(bool)
    return ok && val
}

关键点是:影子库和线上库的表结构必须完全一致,否则压测跑一半就报错。我司的做法是在 CI 里加了一个校验步骤,每次线上 DDL 执行后,自动同步到影子库,延迟控制在 5 分钟内。影子库不需要全量数据,用线上库的一个子集或者脱敏后的数据就行——我们用的是每天凌晨从线上 dump 并脱敏后的数据,压测时读到的数据最多滞后一天,对大多数压测场景够用。

中途透传断了怎么办?用链路追踪兜底

多层 gRPC 调用中,如果某个服务忘了注册客户端拦截器,标记就断了,压测流量会打到线上库。这个问题不能靠「大家都记得」来解决。

我在链路追踪里加了一个检查点:每个服务在 span 的 attribute 里上报当前请求的压测标记状态。这样在 Jaeger 上可以一眼看到哪一跳断了:

func serverStressExtractor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    isStress := false
    if md, ok := metadata.FromIncomingContext(ctx); ok {
        if vals := md.Get("x-stress-test"); len(vals) > 0 && vals[0] == "true" {
            isStress = true
            ctx = context.WithValue(ctx, stressTestKey{}, true)
        }
    }
    // 上报到链路追踪
    span := trace.SpanFromContext(ctx)
    span.SetAttributes(attribute.Bool("stress_test", isStress))
    
    return handler(ctx, req)
}

再加上报警规则:如果某个服务被调用了但 span 上 stress_test 属性为空,说明拦截器没注册,触发告警。这套机制上线后帮我发现了 2 个漏配拦截器的服务。

回放生产流量做压测:metadata 方案的另一面

metadata 透传的链路搭好后,不只是人工发起的压测能用。我们在网关层录了一小部分生产流量(约 1%),重放时给它们打上 x-stress-test: true,这些请求就会自动走到影子库。这样做的好处是压测流量更真实,不用费劲构造数据。

录流量用的是 GoReplay,配置一行过滤规则只录特定接口,避免敏感数据。重放时通过 GoReplay 的 --set-header 参数注入压测标记到 HTTP header,网关层照前面的逻辑处理即可。


常见问题

metadata 透传和 tracing 的 baggage 有什么区别,为什么不直接用 baggage?

baggage 设计上也是用来跨服务传递键值对的,但它的语义是「业务属性」,会被 tracing 系统采样和存储,量大时对 tracing 后端有压力。压测标记是一个技术标记,不是业务属性,放在 baggage 里会污染 tracing 数据。metadata 更轻量,不经过 tracing pipeline,纯粹走 gRPC 协议层,性能开销更小。实测 metadata 透传比 baggage 延迟低约 0.3ms(在 100KB payload 下测的,差异主要来自 baggage 的序列化开销)。

影子库的数据怎么保证和线上一致?

不可能完全实时一致,也没必要。我们每天凌晨从线上库的全量备份中脱敏后导入影子库,表结构通过 CI 自动同步。压测场景主要验证的是系统容量和代码逻辑,数据滞后一天对结果影响可以忽略。如果业务对数据时效性要求高,可以用数据库的只读副本作为影子库,但成本会高不少。

如果压测流量太大把影子库打挂了,会影响线上吗?

不会,影子库是独立的数据库实例,挂了只影响压测,线上库完全不受牵连。但影子库挂了之后压测请求会报错,这些错误如果没被压测框架识别,可能会被当成 SLA 下降。所以压测期间建议临时屏蔽影子库相关的告警,或者在 DAO 层对影子库的错误做标记,让监控系统区分压测错误和线上错误。