55.6 灰度与流量染色
一句话
灰度只切了入口还不够——染色必须跟着请求走完整条链路,数据也必须知道自己是哪一版写的;否则你得到的是“一半新一半旧”的混部,而不是灰度。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “网关切了 10% 就是灰度” | 网关只决定了入口进哪个实例;内部 RPC、消息、定时任务、缓存回源仍可能走旧版 |
| “加个 header 就行” | header 会在线程池、异步回调、消息投递、跨语言调用里丢;丢一次,整条下游都回旧版 |
| “灰度数据混着没事” | 新版本写的新字段,旧代码读不懂;回滚后旧代码可能把灰度数据当正式数据继续处理 |
| “染色头用户改不了” | 客户端可以伪造 x-canary: true;入口不清洗,就等于让用户自己选进哪个版本(甚至进灰度库) |
全链路染色的三个要素
| 要素 | 要解决的问题 | 失败表现 |
|---|---|---|
| 入口打标 | 这次请求属于哪条“版本链” | 同一用户一会儿新一会儿旧(见 47.3 路由与灰度) |
| 链路透传 | 后续每个服务怎么知道自己是“灰”的 | 上层新、下层旧,混部调用 |
| 出口隔离 | 写数据、发消息、调第三方时怎么不污染正式环境 | 脏数据、回滚困难、对账不平 |
三者缺一:只有入口打标 → 混部;只有透传没有隔离 → 数据污染;只有隔离没有透传 → 隔离层收不到标记。
入口怎么切流量是 55.1 发布策略 与 47.3 路由与灰度 的事;切完之后整条链路怎么跟、数据怎么隔离,是本节。功能粒度的开关(关掉某个功能而不切流量)见 55.2 特性开关。
染色放在哪:四种载体
| 载体 | 怎么带 | 优点 | 坑 |
|---|---|---|---|
| HTTP header | x-canary: true / x-version: v2 | 简单、网关/服务都能读 | 客户端可伪造;跨域要显式允许 |
| gRPC metadata | metadata.Pairs("x-canary","true") | 与 HTTP/2 同层,拦截器统一注入 | 流式调用、异步回调要额外处理 |
| 消息 header / property | Kafka header、RocketMQ user property | 异步链路也能带 | 生产者忘了带、消费者不路由,就会串 |
| Trace baggage | OpenTelemetry baggage | 与链路追踪共用一套上下文 | baggage 有大小限制;不是所有 SDK 默认传播(上下文传播见 50.3 链路 (Trace / Span / 上下文传播 / 采样)) |
🔑 染色的第一原则:标签由入口生成,沿途只读不改。任何中间服务都不能“重新决定”一个请求是不是灰度——那会让同一请求在不同服务里得到不同答案。
透传概率:99% 听起来很高,10 跳就只剩 90%
假设每个服务“正确转发染色头”的概率是 p,链路有 n 跳,则整条链路都保持染色的概率是 p^n:
| 每跳成功率 | 10 跳 | 30 跳 |
|---|---|---|
| 99.9% | 99.0% | 97.0% |
| 99% | 90.4% | 74.0% |
| 95% | 59.9% | 21.5% |
| 90% | 34.9% | 4.2% |
一个中等规模的微服务调用链(含网关、服务、消息、缓存回源)很容易到 10–30 跳。靠“大家记得加 header”是不可能到 99.9% 的,必须由框架/拦截器统一注入与透传。
一个能跑的染色仿真:一处不转发,全链路混部
package main
import (
"fmt"
"math/rand"
)
// 一次请求要经过 10 个服务。只有“每个服务都转发染色头 + 都按染色路由”,
// 这次请求才真正全链路落在 canary;任何一跳丢失,后面的服务就回到 baseline。
var chain = []string{"gateway", "order", "inventory", "payment", "coupon", "user",
"search", "notify", "risk", "report"}
// simulate 返回:全程 canary、混部(一部分 canary 一部分 baseline)、全程 baseline 的请求数。
func simulate(n int, propagate func(hop int, rng *rand.Rand) bool, seed int64) (all, mixed, none int) {
rng := rand.New(rand.NewSource(seed))
for i := 0; i < n; i++ {
canary := true
sawCanary, sawBaseline := false, false
for hop := range chain {
if !canary {
sawBaseline = true
continue
}
sawCanary = true
if !propagate(hop, rng) {
canary = false // 标签在这一跳丢失,下游全部回 baseline
sawBaseline = true
}
}
switch {
case sawCanary && !sawBaseline:
all++
case sawCanary && sawBaseline:
mixed++
default:
none++
}
}
return
}
func main() {
const n = 10000
// A:3 个服务忘了转发 header(确定性丢失)
fixedDrop := map[int]bool{3: true, 6: true, 9: true}
aAll, aMixed, aNone := simulate(n, func(hop int, _ *rand.Rand) bool { return !fixedDrop[hop] }, 1)
// B:统一框架注入,每跳 99.9% 成功透传
p := 0.999
bAll, bMixed, bNone := simulate(n, func(_ int, rng *rand.Rand) bool { return rng.Float64() < p }, 1)
fmt.Println("① 染色透传:10 跳链路、每跳的转发成功率决定全链路一致性")
fmt.Printf("%-28s %10s %10s %10s\n", "scenario", "all-canary", "mixed", "baseline")
fmt.Printf("%-28s %9.1f%% %9.1f%% %9.1f%%\n", "A 3/10 服务不转发 header",
float64(aAll)/n*100, float64(aMixed)/n*100, float64(aNone)/n*100)
fmt.Printf("%-28s %9.1f%% %9.1f%% %9.1f%%\n", "B 统一透传 99.9%/跳",
float64(bAll)/n*100, float64(bMixed)/n*100, float64(bNone)/n*100)
fmt.Printf(" 理论值 0.999^10 = %.4f(约 %.1f%%)\n\n", pow(p, 10), pow(p, 10)*100)
// 一条混部请求长什么样
fmt.Println("② 一条混部请求的路径(第 4 跳 payment 丢了标签)")
path := ""
for i, s := range chain {
mode := "canary"
if i > 3 {
mode = "baseline"
}
if i == 3 {
path += fmt.Sprintf("%s(%s, 未转发) ⇒ ", s, mode)
continue
}
path += fmt.Sprintf("%s(%s) ", s, mode)
}
fmt.Println(" " + path)
// C:入口不清洗染色头,1% 的非灰度用户自带 x-canary
fmt.Println("\n③ 染色头被伪造:入口没清洗,1% 普通用户被卷进 canary")
forged := 100 // 10000 × 1%
fmt.Printf(" 非灰度用户误入 canary:%d/%d = %.1f%%\n", forged, n, float64(forged)/n*100)
fmt.Println(" 后果:这批用户的写入进了灰度库/灰度分片,正式数据缺一块,且很难事后分辨")
}
func pow(x float64, n int) float64 {
r := 1.0
for i := 0; i < n; i++ {
r *= x
}
return r
}
本机运行结果:
① 染色透传:10 跳链路、每跳的转发成功率决定全链路一致性
scenario all-canary mixed baseline
A 3/10 服务不转发 header 0.0% 100.0% 0.0%
B 统一透传 99.9%/跳 99.0% 1.0% 0.0%
理论值 0.999^10 = 0.9900(约 99.0%)
② 一条混部请求的路径(第 4 跳 payment 丢了标签)
gateway(canary) order(canary) inventory(canary) payment(canary, 未转发) ⇒ coupon(baseline) user(baseline) search(baseline) notify(baseline) risk(baseline) report(baseline)
③ 染色头被伪造:入口没清洗,1% 普通用户被卷进 canary
非灰度用户误入 canary:100/10000 = 1.0%
后果:这批用户的写入进了灰度库/灰度分片,正式数据缺一块,且很难事后分辨
场景 A 值得盯住:3 个服务不转发,就没有任何一个请求是全程 canary,100% 混部。场景 B 用统一拦截器把每跳做到 99.9%,仍有约 1% 的请求在某跳之后掉回 baseline——这 1% 就是“灰度指标看不懂”的来源。
数据隔离:灰度写的到底是什么
| 方案 | 隔离度 | 成本 | 回滚 | 适用 |
|---|---|---|---|---|
同库同表 + 标记列(_canary=1) | 低 | 低 | 删标记行 | 只读功能、低风险 |
同库影子表(order_canary) | 中 | 中 | 删表 | 写少、可独立清理 |
| 独立库 / 独立分片 | 高 | 高(连接、迁移、容量) | 切流量 + 清库 | 资金、强合规、大改 |
同库标记列最危险:所有查询都必须带“排除灰度数据”条件,漏一处就是正式业务读到灰度脏数据。它不是“低成本”,而是“把成本转移到每个写 SQL 的人身上”。如果灰度本身还带 schema 变更/双写,隔离方案要和 55.4 数据库变更管理 (Expand-Contract) 一起设计。
🔑 灰度写正式库的前提是:写进去的数据对旧代码完全可读(见 55.5 向后兼容)。做不到,就必须影子表/独立库,不能靠“出问题再删”。
异步链路:染色最容易丢的地方
| 环节 | 丢失原因 | 做法 |
|---|---|---|
| 线程池 / 协程池 | 上下文绑定在线程上,任务切线程就丢 | 提交任务时显式传递上下文(Go 用 context.Context,Java 用 TaskDecorator/上下文快照) |
| 消息生产 | 只发了业务字段,没带 header | producer 拦截器统一写 x-canary |
| 消息消费 | 消费端不读 header,直接按默认版本处理 | 消费端按 header 选版本;没有 header 的按 baseline |
| 定时任务/补偿 | 任务本身没有“请求上下文” | 定时任务默认跑 baseline;灰度补偿单独开关 |
| 第三方回调 | 第三方不会回传你的染色头 | 用业务键(订单号/租户)反查版本,或回调统一进 baseline |
消息链路要额外想一步:灰度消息被 baseline 消费者消费,算不算事故? 如果消息里含新字段,旧消费者可能解析失败——这属于 55.5 向后兼容 的问题;如果旧消费者“能解析但按旧逻辑处理”,就是业务错误。
版本矩阵:灰度是“整条链”,不是“每个服务各切一刀”
多服务同时发布时,不要给每个服务独立随机灰度,否则会出现 order v2 + inventory v1 + payment v2 的任意组合,测试覆盖不了。
| 模型 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 基线 + 灰度链(推荐) | 每个服务保留 baseline/canary 两个版本;染色决定整条链走哪套 | 组合可控、回滚整条链 | 资源 ×2(可复用) |
| 按服务独立灰度 | 每个服务各自按比例 | 灵活 | 组合爆炸,问题难复现 |
| 只灰度无状态服务 | 有状态/资金链路不灰度 | 风险低 | 覆盖不全 |
实现上,染色值最好是一个“版本链 ID”(如 canary-20261029),而不是布尔值:布尔值无法表达“这次灰度包含哪些服务版本”。
安全:染色头是输入,不是事实
| 风险 | 做法 |
|---|---|
客户端伪造 x-canary 进灰度库 | 入口剥离所有外部传入的染色头,只由网关注入 |
| 内部服务伪造/放大 | 服务间用 mTLS/签名,染色头只在可信链路内传播 |
| 染色头当鉴权 | 禁止;灰度不是权限,鉴权用身份与 RBAC(见 38.3 授权 (RBAC / ABAC / 权限模型)) |
| 染色残留 | 请求结束清理上下文;长连接/复用连接要按请求重置 |
生产落地:指名 → 可抄 → 可验 → 可退
指名:入口与东西向用 Istio(VirtualService 按 header 路由 + EnvoyFilter/ baggage)或 Spring Cloud Gateway + 自定义 filter;异步用消息 header + 消费端路由;追踪用 OpenTelemetry baggage 串起来。没有服务网格时,至少在每个服务的 HTTP/gRPC 客户端拦截器里统一注入。
可抄(本机无集群,以下命令未验证):
# Istio:按染色头把请求送到 canary 子集;没带头的走 baseline
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: {name: order}
spec:
hosts: [order]
http:
- match:
- headers:
x-canary: {exact: "true"}
route:
- destination: {host: order, subset: canary}
- route:
- destination: {host: order, subset: baseline}
# 验证:同一条 trace 上,所有 span 的 version 标签必须一致
# 期望:canary 请求的每个服务 span 都带 version=v2;出现混搭就是透传断了
kubectl -n prod exec deploy/order -- \
curl -s -H 'x-canary: true' http://inventory:8080/healthz
可验:本节仿真真跑过,输出如上;它验证的是“透传率 × 跳数”的数学关系。线上至少验三件事:① 一条 canary 请求的 trace 里所有服务版本一致;② 不带染色头的请求 100% 落 baseline;③ 带伪造头的请求在入口被剥离(看网关日志/响应)。
可退:
- 止血:先把灰度权重归零(秒级),再排空灰度实例上的存量连接(长连接要主动断开,见 47.3 路由与灰度);需要退版本时按 55.3 回滚设计 的层次执行。
- 数据:如果灰度写了正式库,先停写、按标记清理/补偿,再回滚版本;不要直接回滚代码让旧逻辑去读灰度数据。
- 消息:灰度消息可能已被 baseline 消费,回滚前先确认消费位点与死信,避免重复处理。
- 清理:全量后删除染色逻辑与 header 白名单,避免“永久灰度”成为第二套路由。
反模式
| 反模式 | 后果 |
|---|---|
| 只在网关做灰度 | 内部调用全走旧版,指标看不出真实差异 |
| 每个服务独立随机灰度 | 版本组合爆炸,问题无法复现 |
| 染色头由客户端决定 | 用户可进灰度库、绕过新逻辑 |
| 线程池/消息不传上下文 | 异步链路混部,且很难在日志里发现 |
| 灰度直接写正式表且无标记 | 脏数据无法识别、无法清理 |
| 灰度消息被 baseline 消费且不兼容 | 旧消费者解析失败或按旧逻辑处理 |
| 用布尔染色表达多服务版本 | 无法表达“这次灰度包含哪些版本” |
| 全量后不清理染色 | 永久双版本、永久双倍资源 |
自检
Q1 网关切了 10%,为什么还可能“一半新一半旧”?
▸ 答案:网关只决定入口请求进哪个实例。请求进入后还会调用其他服务、发消息、读缓存、跑异步任务;这些环节如果没有携带并识别染色标记,就会走各自版本的默认路径。结果是一个请求在上游是新版本、下游是旧版本——混部,而不是灰度。
Q2 每跳 99% 的透传率,10 跳后全程一致的概率是多少?这说明什么?
▸ 答案:0.99^10 ≈ 90.4%。说明“每跳看起来很高”不等于“链路可靠”:10 跳就有近 10% 的请求在某处掉回 baseline。要接近 99%,每跳要做到 99.9%;要做到这一点,不能靠人记得加 header,必须由框架/拦截器统一注入与透传。
Q3 为什么“同库同表 + 标记列”的隔离方案风险高?
▸ 答案:它把隔离责任推给了每一条查询:任何一处忘记加“排除灰度数据”的条件,正式业务就会读到灰度脏数据。写路径也要保证标记正确。它的成本不在存储,而在“所有人都不能写错 SQL”。相比之下,影子表/独立库把隔离做在结构上,更难出错。
Q4 灰度消息被 baseline 消费者消费,会出什么问题?
▸ 答案:两种:① 消息含新字段,旧消费者解析失败(兼容性问题,见 55.5 向后兼容);② 能解析但按旧逻辑处理,产生业务错误(例如新状态被当成未知状态丢弃)。防法是消息头带染色 + 消费端按染色路由,或者让灰度和 baseline 消费不同的 topic/队列。
Q5 为什么说“染色是输入,不是事实”?
▸ 答案:染色头来自请求,而请求来自外部,可以被伪造。系统不能因为“请求说自己是灰度”就把它当可信身份,否则用户可以自己选择进灰度库、绕过新逻辑或污染数据。正确做法是入口剥离外部染色头、只由可信网关注入,服务间用 mTLS/签名保证来源可信。