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/签名保证来源可信。

关联