流量复制与影子发布 灰度之外的第三种验证方式
灰度发布能验证"新版本在真实流量下不出错",但它有两个无法克服的局限:流量占比小意味着长尾输入覆盖不到,以及写操作没办法"试跑"。流量复制(Traffic Mirror 或 Shadow)补上这个缺口——把生产流量复制一份打到新版本,响应不返回给用户,只用于对比。它能把"新功能第一次接待真实流量"这件事从发布日提前到测试期。
一、三种能力要分清
| 能力 | 用户是否看到新版本响应 | 写是否落库 | 用途 | |---|---|---|---| | 金丝雀与灰度 | 是(小比例) | 是 | 验证真实可用性 | | 蓝绿 | 是(全量切换) | 是 | 快速回滚 | | 流量复制(影子) | 否 | 必须隔离 | 覆盖长尾、响应 diff |
流量复制的价值点是用生产的真实分布跑一遍,但不承担任何用户影响。典型场景:搜索算法重构、缓存层替换、序列化协议变更、风控规则上线前。
二、数据面镜像的写法
在数据面做复制最稳,因为它处在真实请求路径上,能拿到完整原始请求。以 Envoy 为例:
route:
cluster: backend-v1 # 真实流量走这里,响应返回用户
timeout: 1s
request_mirror_policies:
- cluster: backend-v2-shadow
runtime_fraction:
default_value: { numerator: 10, denominator: HUNDRED }
clusters:
- name: backend-v2-shadow
type: STRICT_DNS
connect_timeout: 0.2s
lb_policy: LEAST_REQUEST
load_assignment:
cluster_name: backend-v2-shadow
endpoints:
- lb_endpoints:
- endpoint: { address: { socket_address: { address: svc-shadow, port_value: 8080 } } }
四条必须知道的行为:镜像请求异步发送,其超时与报错都不影响真实响应(这正是设计目的);响应体被丢弃,但要为它付出算力;必须采样,全量复制等于容量翻倍;建议显式加一个 x-shadow: 1 请求头,便于全链路识别。
三、写操作怎么隔离
这是流量复制最容易出事故的环节。影子请求绝不能写生产库,三种隔离强度按需选:
方案 A 影子库:影子集群的 DSN 指向结构相同、数据定期同步的独立数据库。最安全,成本是要维护同步链路。
方案 B 影子表前缀:同一个库中表名加前缀,适合中间件类改造。
type shadowKey struct{}
func WithShadow(ctx context.Context) context.Context {
return context.WithValue(ctx, shadowKey{}, true)
}
func TableName(ctx context.Context, base string) string {
if v, _ := ctx.Value(shadowKey{}).(bool); v {
return "shadow_" + base
}
return base
}
方案 C 只读化:给影子请求注入"只允许读"的开关,写操作直接返回成功。适合纯读路径验证。
三条铁律:影子流量必须端到端带标记,并且要一路透传(HTTP、消息、DB 连接属性);影子流量不得发通知、不写共享缓存、不产生业务指标;否则告警与报表会被污染,而这类污染通常最先被发现。
func ShadowGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-shadow") != "1" {
next.ServeHTTP(w, r)
return
}
r = r.WithContext(WithShadow(r.Context()))
external.DisableOutbound(r.Context()) // 关闭推送与第三方调用
cache.DisableWrite(r.Context()) // 不写共享缓存
next.ServeHTTP(w, r)
})
}
四、响应一致性对比
复制本身没有价值,价值在对比。对比要避开三个噪声源:时间戳、随机 ID、排序不稳定。做法是先规范化再比较关键字段。
type Diff struct {
Path string `json:"path"`
Expected any `json:"expected"`
Actual any `json:"actual"`
}
func DiffJSON(a, b map[string]any, ignore map[string]struct{}, prefix string) []Diff {
var out []Diff
keys := map[string]struct{}{}
for k := range a { keys[k] = struct{}{} }
for k := range b { keys[k] = struct{}{} }
for k := range keys {
p := prefix + "." + k
if _, skip := ignore[k]; skip { continue } // created_at、trace_id 等字段
av, bv := a[k], b[k]
switch x := av.(type) {
case map[string]any:
y, ok := bv.(map[string]any)
if !ok { out = append(out, Diff{p, av, bv}); continue }
out = append(out, DiffJSON(x, y, ignore, p)...)
case []any:
y, ok := bv.([]any)
if !ok || len(x) != len(y) { out = append(out, Diff{p, av, bv}); continue }
for i := range x { // 先按 id 稳定排序,再逐项比
out = append(out, DiffJSON(wrap(x[i]), wrap(y[i]), ignore,
fmt.Sprintf("%s[%d]", p, i))...)
}
default:
if !reflect.DeepEqual(av, bv) { out = append(out, Diff{p, av, bv}) }
}
}
return out
}
两个配套要求:影子请求要能拿到与真实请求相同的时间基准(注入 now 或冻结时钟),否则时间相关逻辑必然产生差异;列表类响应必须在比对前按稳定键排序,否则会报出大量噪声差异。
五、容量与指标两笔账
上影子之前先算两道账。
容量账:复制 10% 意味着影子集群以及它的下游要能承接 10% 全量流量的处理能力。若影子库与影子缓存未预热,冷启动阶段 P99 会非常差,这不代表新版本有问题,但会被误读。
影子集群实例数 ≈ 生产实例数 × 复制比例
下游 QPS 增量 ≈ 生产 QPS × 复制比例 × 每请求下游调用数
指标账:影子流量必须打独立标签(traffic="shadow"),所有告警规则默认过滤该标签,否则影子里的 5xx 与慢请求会天天误报。
http_requests_total{traffic="shadow", code=~"5.."}
shadow_diff_total{field="data.items.price"}
envoy_cluster_upstream_rq_total{envoy_cluster_name="backend-v2-shadow"}
六、落地节奏与退出条件
推荐四步走:
- 只读接口加 1% 复制,跑 3 天,把差异噪声治理干净(时间戳、排序、随机字段);
- 比例提到 10% 到 20%,覆盖读接口的长尾输入分布,让差异率稳定低于阈值;
- 写接口接入影子库,验证落库一致与副作用隔离;
- 影子验证通过后走正常金丝雀,此时"新版本第一次面对用户"已经跑过大量真实请求。
退出条件同样要提前定义:差异率连续 24 小时低于阈值(例如 0.01%)、影子 RT 与真实 RT 偏差在可接受范围、没有尚未解释的差异类别。达标即下线复制链路,不要让它长期占着一份容量与运维注意力。
小结
流量复制把验证从"发布当天的小比例灰度"变成"发版前用真实分布的大规模对拍"。落地难点全在副作用治理:镜像要采样、影子标记要端到端透传、写路径要影子库或只读化、对比要对抗时间戳与排序噪声、指标与告警要按标签隔离。按"小比例起步、清噪声、加比例、写接口影子库、最后金丝雀"的节奏推进,影子发布就能成为一条稳定的质量门禁——但它是阶段性的验证手段,不该长期常驻在架构里。
评论(0)
登录 后参与评论。