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)。灰度比例的可信度取决于键的数量与分布,不取决于你写的百分比。