47.3 路由与灰度

一句话

灰度发布的本质是「按某个键把流量切成两堆,并保证同一个键不来回跳」;路由规则决定切得准不准,粘性决定用户体验稳不稳,指标决定敢不敢放量——三者缺一,灰度就会变成"随机给用户换版本"。

三个正交的问题

问题 具体决定 决策错误的表现
切谁 切分键:请求 / 用户 / 租户 / 地域 / 设备 同一用户一会儿新一会儿旧
按什么切 匹配条件:权重、header、cookie、路径、参数 该进灰度的没进,或全量误进
切多少 放量节奏与观察窗口 放太快、没看指标就全量

发布策略(滚动 / 蓝绿 / 金丝雀)的选择与流程在 55.1 发布策略 (滚动 / 蓝绿 / 金丝雀);本节只讲流量怎么被切。切完之后的端到端透传与数据隔离见 55.6 灰度与流量染色,功能级开关见 55.2 特性开关。

切分键:切请求还是切人

键 稳定性 切分精度 适用
请求 ID / 随机 无(每次都可能变) 流量比例准 无状态 API、压测、镜像
用户 ID 哈希 高(同一用户固定) 用户比例准(≈ 流量比例) 有交互、有登录态的业务
设备/客户端版本 高 按端分组 客户端兼容性灰度
租户/企业 高 按客户分组 SaaS、大客户单独放量
地域/机房 高 按故障域 多地域分批、单元化
header / cookie 由调用方决定 可精确指定 内部测试、白名单、联调

🔑 选键的第一原则是**"同一个体验单元不能被切两半"**:一个用户的一次会话、一个订单的多次请求、一个租户的全部调用,都应落在同一侧。

粘性:为什么"按请求随机 10%"是错的

假设 1000 个用户、每人 10 次请求、新版本权重 10%:

package main

import (
	"fmt"
	"hash/fnv"
	"math/rand"
)

const (
	users  = 1000
	reqs   = 10
	weight = 10 // 新版本 10%
	salt   = "order-api-v2"
)

func bucket(s string) int {
	h := fnv.New32a()
	h.Write([]byte(s))
	return int(h.Sum32() % 100)
}

func main() {
	rng := rand.New(rand.NewSource(42))

	// 策略 A:每个请求独立随机 10%
	exposedA, mixedA, newReqsA := 0, 0, 0
	for u := 0; u < users; u++ {
		newCnt := 0
		for r := 0; r < reqs; r++ {
			if rng.Float64() < float64(weight)/100 {
				newCnt++
				newReqsA++
			}
		}
		if newCnt > 0 {
			exposedA++
		}
		if newCnt > 0 && newCnt < reqs {
			mixedA++
		}
	}

	// 策略 B:按用户哈希取桶(同一用户永远落在同一侧)
	exposedB, mixedB, newReqsB := 0, 0, 0
	for u := 0; u < users; u++ {
		if bucket(fmt.Sprintf("%s-%d", salt, u)) < weight {
			exposedB++
			newReqsB += reqs
		}
	}

	fmt.Println("| 策略 | 新版本请求占比 | 被卷入新版本的用户 | 同一用户同时看到两版 | 用户观感 |")
	fmt.Println("|---|---|---|---|---|")
	fmt.Printf("| 按请求随机 | %.1f%% | %d/%d(%.1f%%) | %d(%.1f%%) | 同一用户刷新就变版本 |\n",
		float64(newReqsA)/(users*reqs)*100, exposedA, users, float64(exposedA)/users*100,
		mixedA, float64(mixedA)/users*100)
	fmt.Printf("| 按用户哈希 | %.1f%% | %d/%d(%.1f%%) | %d | 每个用户稳定在一个版本 |\n",
		float64(newReqsB)/(users*reqs)*100, exposedB, users, float64(exposedB)/users*100, mixedB)
	fmt.Println("\n按请求随机的理论值:P(一个用户同时看到两版) = 1 − 0.9^10 − 0.1^10 ≈ 65.1%")
}

输出(本机实测):

| 策略 | 新版本请求占比 | 被卷入新版本的用户 | 同一用户同时看到两版 | 用户观感 |
|---|---|---|---|---|
| 按请求随机 | 10.3% | 649/1000(64.9%) | 649(64.9%) | 同一用户刷新就变版本 |
| 按用户哈希 | 10.3% | 103/1000(10.3%) | 0 | 每个用户稳定在一个版本 |

按请求随机的理论值:P(一个用户同时看到两版) = 1 − 0.9^10 − 0.1^10 ≈ 65.1%

这张表说明了一件常被忽略的事:"10% 流量"和"10% 用户"是两回事。按请求随机切 10% 流量,会有约 65% 的用户被卷入新版本(每个用户至少命中一次),其中绝大多数人会看到"刷新一次换一个版本"。按用户哈希切 10%,只有 10% 的用户进入新版本,且他们的每次请求都稳定。

场景 该用哪个键
无状态查询 API、只关心容量 请求级随机(简单,流量比例准)
有登录态、有前端交互 用户级哈希(体验一致,避免"半新半旧")
有写操作、有会话 用户级哈希 + 会话粘性;必要时按订单/租户再收敛
客户端兼容性 客户端版本 / 设备型号
内部联调、白名单 指定 header / cookie(可精确到人)

匹配规则:路由能看哪些维度

维度 例子 注意
路径/方法 POST /api/order/* 正则要预编译、有长度上限(防 ReDoS)
Header x-canary: true、x-user-id header 可伪造,不能当鉴权依据
Cookie beta=1 用户可手改;只适合"自愿体验"
查询参数 ?version=beta 会污染缓存键与日志
权重 10% / 90% 需要粘性才是"10% 用户"
用户哈希 hash(user_id) % 100 < 10 哈希盐要固定,否则放量时用户会重新洗牌
地域/机房 按 region 标签 与故障域绑定,能顺带做多活切流

⚠️ 哈希盐(salt)一旦上线就不能改:改了等于把用户重新洗一遍,已经在灰度的用户可能被切回旧版,指标也会出现无法解释的跳变。要"换一批人",应该加权重(10% → 20%),而不是换盐。

把「按用户切 10%」落到配置

键、盐、比例在 Nginx 里各占一个位置。三者分开配:调比例时不动键,也不动盐。

# ① 键:登录用户用 uid;未登录退回 IP(NAT 后整栋楼会落在同一个桶)
map $cookie_uid $canary_key {
    default $cookie_uid;
    ""      $remote_addr;
}

# ② 固定盐 + 分桶:盐写进字符串,"v1:" 一改就等于把用户重新洗一遍
split_clients "v1:${canary_key}" $canary_bucket {
    10%     canary;
    *       stable;
}

# ③ 桶 → upstream:回滚只改这一行,不碰键和盐
map $canary_bucket $version_pool {
    canary  api_canary;
    default api_stable;
}

upstream api_stable { least_conn; server 10.0.1.11:8080; keepalive 64; }
upstream api_canary { least_conn; server 10.0.2.11:8080; keepalive 64; }

server {
    location /api/ {
        proxy_pass http://$version_pool;    # 变量指向 upstream 名,连接池照常生效
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
    }
}
动作 改什么 生效方式 已灰度用户
10% → 25% 只改 split_clients 的百分比 nginx -t && nginx -s reload 仍在灰度(桶序只扩不换)
回滚 canary api_canary; → canary api_stable; reload,秒级 立刻回旧版
换一批人 换键或换盐 reload 重新洗牌,指标必然跳变

“扩大比例不会换人”这点用一份 200 个用户的配置实测过:10% 时 22 人进 canary,改成 25% 时 52 人,原来那 22 人一个没掉;把 map 改回 api_stable 后 100% 回旧版,全程只 reload。

验证分两步,别拿 20 个样本判断比例:

# 1) 粘性:同一个 uid 连打 10 次,必须全部落在同一侧
for i in $(seq 1 10); do curl -s -H 'Cookie: uid=user-7' https://api.example.com/api/version; done | sort | uniq -c

# 2) 比例:200 个 uid 的 canary 占比应接近 10%
#    200 样本的 95% 区间约 10%±4 个百分点;样本再小,只能看出"有没有分流",看不出比例

两条边界,配之前先想清楚:

  • 匿名流量按 IP 分桶:NAT / 公司出口后,一栋楼共用一个 $remote_addr,要么整栋楼进灰度、要么整栋楼不进。要按人切,就让前端种一个匿名 uid。
  • split_clients 的百分比是按「键」哈希的,不是按请求:只有 10 个大租户时,“10%”实际是“0 个或 1 个租户”,粒度由键空间决定,不由比例决定。

Kubernetes 里用 Gateway API 表达同一件事:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: order-api }
spec:
  parentRefs: [{ name: edge-gateway }]
  rules:
    - matches: [{ path: { type: PathPrefix, value: /api/order } }]
      backendRefs:
        - { name: order-stable, port: 8080, weight: 90 }
        - { name: order-canary, port: 8080, weight: 10 }

⚠️ Gateway API 的 weight 是按请求分的,默认没有粘性;要按用户切,只能用 match.headers 精确命中白名单,或依赖实现方的会话保持(session persistence 仍是扩展特性,以你的实现与版本为准)。所以“按用户灰度”最稳的落点还是入口代理(上面的 Nginx 写法)或网关的 traffic-split 插件。

分流只回答“给谁”;连接池、超时与缓冲仍按 47.1 反向代理 配;在网关层做同一件事(traffic-split 插件)见 47.2 API 网关。入口(南北向)与内部(东西向)的灰度不是一套机制,见 47.5 南北向与东西向流量。

流量镜像:能验证,但不能替代灰度

维度 灰度(canary) 镜像(shadow/mirror)
响应 新版本返回给用户 新版本只处理,响应丢弃
用户影响 有(所以比例要小) 无(用户看不到)
能验证 功能正确性 + 性能 + 业务指标 功能正确性、性能、崩溃
不能验证 —— 用户真实反馈、前端兼容
风险 新版本 bug 直接影响用户 写操作会重复执行(必须只读或影子库)

用镜像的三条纪律:① 只镜像只读请求,或把写打到影子存储;② 镜像流量要限速,别把下游打挂;③ 镜像的超时/错误不能影响主链路(异步、丢弃结果)。

放量节奏:每一步看什么

阶段 比例 观察窗口 必须看的指标 决策
内部验证 白名单/1% 10–30 min 错误率、P99、日志异常、依赖错误率 有异常 → 停
小流量 1–5% 30–60 min 上面 + 业务指标(下单/支付成功率) 平稳 → 放
中流量 25% 1–4 h 容量指标(CPU/连接池/队列)+ 长尾 平稳 → 放
大流量 50% 数小时~1 天 同上 + 成本(机器数、跨 AZ 流量) 平稳 → 全量
全量 100% 持续 SLO 与 burn rate(见 50.7 SLO 与错误预算) 保留回滚通道

两个硬约束:

  • 指标必须按版本切分:version 要进指标/日志/trace 的标签,否则"新版本错误率"根本算不出来(切分口径在 50.4 度量,看板见 50.6 看板设计)。
  • 观察窗口要覆盖"慢故障":连接泄漏、内存增长、GC 恶化往往要几小时才显现,25% 阶段别只盯 10 分钟。
  • 阈值随新旧比例调整:灰度期的配额与限流该按新旧两套分别设,分层原则见 47.4 鉴权与限流应放在哪一层。

回滚:路由回滚比部署回滚快一个数量级

回滚方式 耗时 前提
路由回滚(权重改回 0) 秒级 旧版本还在、流量可切
特性开关关闭 秒~分钟 代码里有开关,且默认安全
重新部署旧版本 分钟级 镜像还在、数据库兼容
数据库回滚 小时级 备份/反向迁移脚本

所以灰度的价值不只是"早发现",更是"保留一条秒级回滚路径"。前提是新旧版本能同时读写同一份数据(向后兼容的 schema 变更,见 55.4 数据库变更管理 (Expand-Contract))。

长连接与会话:灰度切不动的地方

场景 问题 做法
WebSocket / SSE 连接建立时已选定版本,改权重不影响存量连接 灰度只对新连接生效;需要时主动断开旧连接(分批、可重连)
本地缓存/会话 新版本读到旧格式缓存 缓存键带版本前缀,或双读兼容
客户端长轮询 一次轮询可能跨越放量时刻 服务端返回版本号,客户端刷新
定时任务/消费者 不是入口流量,路由规则管不到 按实例分组灰度(部署侧),或消费端按开关分流

反模式

反模式 后果
按请求随机切流量,却声称"10% 用户体验新版本" 65% 的用户被卷入,体验抖动
灰度期间改哈希盐 用户重新洗牌,指标跳变,已灰度用户被切回
指标不带 version 无法判断新版本是否真的变好,只能"感觉没问题"
只看错误率,不看业务指标与容量 功能正常但下单率下降/连接池打满,全量后才炸
镜像里跑写操作 订单重复、库存扣两次
新旧版本共享不兼容的数据库 schema 回滚路径断裂,只能硬扛或停机
一次性从 5% 放到 100% 慢故障来不及暴露;容量问题在峰值才炸
灰度只做入口,忽略 MQ 消费者/定时任务 后台链路仍在跑旧逻辑,数据不一致

自检

Q1 "切 10% 流量"和"切 10% 用户"有什么区别?为什么大多数面向用户的业务要后者?

▸ 答案:按请求随机会让每个用户以 10% 概率命中新版本,10 次请求里约 65% 的用户会同时看到两个版本(1−0.9^10−0.1^10);按用户哈希则只有 10% 的用户进入新版本,且他们的请求全部稳定。面向用户的业务要求"同一体验单元不分裂"(前端状态、缓存、会话、下单流程),所以必须切人而不是切请求。无状态 API 只关心容量比例时,请求级随机足够。

Q2 灰度期间把哈希盐从 v1 改成 v2 会发生什么?

▸ 答案:所有用户的桶位重新计算,原本在新版本的 10% 用户里大部分会被切回旧版,同时另一批新用户被切进新版本。后果:① 已经验证过的那批用户白验证;② 指标出现无法解释的跳变(新版本流量比例忽高忽低);③ 若用户有本地状态,可能来回迁移。正确做法是盐固定,只调权重(10% → 20% 是"在原有桶序上扩大")。

Q3 流量镜像能替代灰度吗?它验证不了什么?

▸ 答案:不能。镜像只能验证"新版本处理同样请求会不会错/慢/崩",验证不了:① 用户真实反馈与前端兼容(响应被丢弃);② 写路径的正确性(镜像写会重复执行,只能打影子库);③ 容量在真实全量下的表现(镜像通常限速)。它的价值是零用户风险的预演,是灰度的前置步骤而不是替代。

Q4 为什么说"灰度最大的价值是保留秒级回滚路径"?

▸ 答案:灰度能让问题只影响 5% 流量,但如果回滚要重新部署(分钟级)或改数据库(小时级),这 5% 的用户仍要持续受损。只有"路由权重改回 0"这种秒级操作,才能把影响面与持续时间同时压到最小。前提是旧版本还在运行、且新旧版本数据兼容——这两条不成立时,灰度只是"分批受害"。

Q5 一个 WebSocket 服务要做灰度,权重改成 10% 后发现新版本有问题,改回 0 就安全了吗?

▸ 答案:不完全是。已建立的连接不会因为权重变化而迁移,它们仍连着新版本实例;改回 0 只能保证"新连接"进旧版。要真正止损,必须:① 分批主动断开新版本上的连接(客户端重连后按新权重落到旧版);② 或直接下线新版本实例(连接被强制断开,客户端重连)。同时要在监控上区分"存量连接"与"新连接"的错误率,否则会误判回滚是否生效。

Q6 split_clients 写 10%,但只有 10 个大租户,会切出什么比例?为什么 200 个用户的实测能到 11%?

▸ 答案:比例按「键」的哈希落桶,键只有 10 个时每个租户占 10 个百分点,结果只能是 10%(1 个租户)或 0%(一个都没进),不存在“精确 10%”。200 个用户是 200 个键、粒度 0.5%,11% 在抽样误差内(200 样本的 95% 区间约 10%±4pp)。灰度比例的可信度取决于键的数量与分布,不取决于你写的百分比。

关联