📑 本页目录(点开跳转)
03 · 灰度、影子与回滚
⏱ 42 分钟 | ⭐ 让「出错」变成一件便宜的事
🎯 一句话
你不可能保证不出错,但你可以保证「出错时只有 1% 的人受影响,而且五分钟内能撤回」。 这一章讲的三个机制,本质上都是在降低犯错的单价。
🎚️ 一、三种放量方式,解决不同的问题
| 方式 | 新模型拿到什么 | 用户受影响吗 | 解决什么 |
|---|---|---|---|
| 影子流量(shadow) | 真实请求的副本 | ❌ 完全不影响 ⭐ | 验证工程正确性 |
| 灰度(canary) | 真实流量的一小部分 | ✅ 少量人 | 验证真实效果 |
| A/B 实验 | 严格随机分流 | ✅ | 量化效果差异 |
⭐ 最常见的错误是跳过影子直接灰度。 灰度虽然只影响 1%,但如果模型有工程性 bug(特征算错、维度不对), 那 1% 的人拿到的是彻底错误的结果 —— 而这类 bug 影子流量能 100% 提前发现。
👻 二、影子流量:最被低估的一步
用户请求 ──┬──→ 旧模型 ──→ 返回给用户 ✅
│
└──→ 新模型 ──→ 【只记录,不返回】 ⭐
↓
对比两边的输出
它能发现什么(这些都是灰度发现不了或代价更大的):
| 能发现 | 说明 |
|---|---|
| 崩溃 / 异常 | 新模型遇到线上真实数据会不会挂 |
| 延迟不达标 | 离线测的延迟和线上完全不是一回事 |
| 输出分布异常 ⭐ | 新模型的预测分数分布和旧的差太多 = 警报 |
| 训练/推理不一致 ⭐⭐ | 对同一批请求,离线重算一遍特征,和线上比对(第 4 章) |
💡 影子流量最有价值的用法:
不只是「看新模型会不会崩」,而是:
① 记录线上实际用的特征值
② 用同一批样本,走离线管道再算一遍特征
③ 逐字段比对
⭐ 这是发现「训练/推理不一致」唯一可靠的办法
—— 而那是线上掉点最常见的原因(第 4 章)
⚠️ 影子流量的三个成本(要提前想好):
| 成本 | 应对 |
|---|---|
| 算力翻倍 | 只影子采样 5–10% 的流量就够 |
| 有副作用的操作会执行两次 ⭐ | 影子链路必须禁写:不发消息、不扣库存、不写业务库 💀 |
| 下游系统压力 | 影子调用要走独立配额,别把下游打挂 |
💀 第二条出过真事故:影子链路里的推荐模型会写「已曝光」表用于去重, 结果同一个物品被记了两次曝光,导致真实推荐里它被错误地过滤掉。 规则:影子链路只读,任何写操作都要显式关掉。
🐤 三、灰度:小步放量
1% → 5% → 20% → 50% → 100%
每一档观察足够长时间再往下走
「足够长」是多久:
⭐ 至少覆盖一个完整的业务周期
电商/内容:24 小时(早晚高峰、上下班行为完全不同)
To B 工具:一周(工作日 vs 周末)
有月度周期的(如报销、结算):一个月
⚠️ 只跑 2 小时就全量,是在赌「白天的表现代表全天」
灰度分流怎么切(很关键)
| 切法 | 问题 |
|---|---|
| ❌ 按请求随机 | 同一用户一会儿新一会儿旧,体验割裂,指标也没法解读 |
| ✅ 按用户 ID 哈希 ⭐ | 同一用户始终落在同一侧,可比 |
| ✅ 按设备 / 会话 | 无登录场景用这个 |
⚠️ 哈希要加盐:
如果多个实验都用 hash(user_id) % 100 < 1
→ 永远是【同一批用户】被选中做小流量实验 💀
→ 这批人的体验被反复折腾,而且实验之间互相污染
⭐ 正确做法:hash(user_id + 实验名) —— 每个实验的分流互相独立
⏮️ 四、回滚:唯一真正的安全网
能回滚,一切错误都是可修复的;不能回滚,任何错误都是事故。
回滚要满足三个条件:
① 快 —— 目标是 5 分钟内完成,最好是改个配置就行
② 完整 —— 模型 + 特征逻辑 + 配置要一起回,只回模型没用 ⭐
③ 可验证 —— 回滚后要能确认真的回去了(看版本号、看指标恢复)
⭐ 第 ② 条是最容易翻车的地方
新模型上线时,你可能同时改了:
· 模型文件
· 特征计算逻辑(加了个新特征)
· 配置(阈值、截断长度)
· 上游数据表结构
只回滚模型 → 新特征逻辑还在 → 旧模型拿到它没见过的特征 💀
→ 比不回滚还糟
🔑 解法:把这四样绑成一个「发布单元」,版本号统一,回滚是整体回滚。 🔗 第 17 章会讲具体怎么组织。
回滚触发条件要预先写死
❌ 「感觉不太对,讨论一下要不要回」 → 讨论时损失在扩大
✅ 预先定好自动触发线:
· 错误率 > 基线 3 倍 → 自动回滚
· P99 延迟 > SLA → 自动回滚
· 核心业务指标跌破阈值 → 告警 + 人工确认后回滚
· 模型输出分布突变 → 告警
💡 为什么业务指标不自动回滚:它有正常波动,也可能因为外部原因(大促、竞品)变化。 工程指标客观,可以自动;业务指标要人看一眼。
🧯 五、降级:模型挂了返回什么
必须在上线前就想清楚:
⚠️ 降级最容易漏的一点:降级本身要有监控和告警。 很多系统降级了几个月都没人知道 —— 因为降级路径不报错,它「正常工作」着。
🔗 推荐算法 16 实战项目的检查点里问过 「你的系统在什么情况下会降级、降级到什么」—— 就是这件事。
💀 六、一个专门讲"回滚失败"的事故
前面说回滚是安全网。但没演练过的安全网,出事时经常是破的。
某风控模型 v7 上线两小时,拒绝率从 12% 飙到 31%
→ 值班同学按手册回滚到 v6,用时 4 分钟 ✅ 看起来很顺利
回滚后 20 分钟:
拒绝率没回到 12%,反而变成 38% 💀 比不回滚还糟
原因:
v7 上线时顺手改了特征表 —— 加了 3 列、把 device_score 从 int 改成 float
v6 的模型文件里存的特征 schema 还是旧的
→ 按位置取特征 → 从第 12 列开始【整体错位】
→ v6 拿到的每一个特征值都是错的,但维度是对的,不报错
真正恢复:6 小时后(回滚特征表 ETL + 等一次重跑)
| 环节 | 声称的时间 | 实际时间 |
|---|---|---|
| 模型回滚 | 5 分钟 | 4 分钟 ✅ |
| 发现回滚没生效 | — | 20 分钟 |
| 定位到 schema 变更 | — | 90 分钟 |
| 特征表回滚 + 重跑 | — | 4 小时 |
| 端到端 MTTR | 5 分钟 | 6 小时 💀 |
⭐ 这张表是本章最该记住的东西: 「我们有回滚方案」和「我们的 MTTR 是 5 分钟」是两回事。 前者是文档,后者是演练出来的数字。
🏋️ 回滚演练(game day):怎么做
① 挑一个正常的工作日下午(不是深夜,要让人清醒)
② 不预告具体时间,只告诉团队"本周会有一次"
③ 在灰度环境把当前模型故意换成上一个版本
④ 计时:从触发到【指标确认恢复】用了多久
⑤ 记下每一个卡住的地方
⭐ 关注的不是"能不能回",是"卡在哪、卡了多久"
演练里最常暴露的五件事:
| 暴露的问题 | 典型表现 |
|---|---|
| 回滚只回了模型 ⭐⭐ | 特征逻辑 / schema / 配置还是新的 |
| 缺权限 | 值班的人没有改配置的权限,要找人审批 |
| 没有"确认已恢复"的手段 | 回滚完了不知道到底生没生效 |
| 手册过期 | 里面写的控制台路径半年前就改了 |
| 依赖某个具体的人 | 只有写这个服务的人知道怎么回 |
⭐ 一个可以当 KPI 的数字:端到端 MTTR(从决定回滚到指标确认恢复)。 每季度演练一次,把这个数字记下来。它比"我们有没有回滚方案"有意义得多。 🔗 和第 8 章的「发现延迟」是一对: 发现延迟 + MTTR = 一次事故的实际影响时长。
🎲 七、分流:怎么切、怎么验、需要多少流量
🔨 带盐的哈希分流(可以直接抄)
import hashlib
def bucket(unit_id: str, exp_name: str, buckets: int = 100) -> int:
"""unit_id: 用户ID/设备ID;exp_name: 实验名(就是那把盐)⭐"""
key = f"{exp_name}:{unit_id}".encode() # ⭐ 盐必须参与哈希,不能只当前缀存
h = hashlib.md5(key).digest() # ⭐ 用 md5/sha 而不是 Python 的 hash()
return int.from_bytes(h[:8], "big") % buckets # hash() 每次进程重启结果会变 💀
def in_treatment(unit_id, exp_name, pct):
return bucket(unit_id, exp_name) < pct
💀 注释里那条坑值得单独说:Python 内置
hash()对字符串是加盐随机的, 进程重启后同一个用户会落到不同桶。 线上表现是:服务每次发版,实验分组就洗一次牌 —— 而且它不报错。 规则:分流哈希只用 md5 / sha1 / murmur 这类确定性哈希。
✅ 分流上线前必须验的三件事
import numpy as np
from scipy import stats
def check_split(ids, exp_name, pct, feats=None):
"""feats: DataFrame,分流单元的历史特征(用于 A/A 检验)"""
flag = np.array([in_treatment(i, exp_name, pct) for i in ids])
# ⭐ ① 比例对不对(大流量下应该非常接近 pct%)
print(f"实际实验组占比 {flag.mean():.4%}(目标 {pct}%)")
# ⭐ ② 分桶均匀吗——卡方检验,p 太小说明哈希有问题
b = np.array([bucket(i, exp_name) for i in ids])
cnt = np.bincount(b, minlength=100)
print("分桶均匀性 chi2 p =", stats.chisquare(cnt).pvalue)
# ⭐⭐ ③ A/A 检验:分流【之前】的历史指标两组应该没差异
if feats is not None:
for c in feats.columns:
p = stats.ttest_ind(feats[c][flag], feats[c][~flag]).pvalue
if p < 0.01:
print(f"⚠️ 分流前 {c} 就已经有显著差异(p={p:.4f})—— 分流不可信")
⭐ 第 ③ 条(A/A 检验)是最值钱的一步: 用分流之前的历史数据比较两组 —— 如果那时候就有显著差异, 说明分流本身有问题,之后所有实验结论都是假的。 🔗 第 12 章会详细讲 A/A。
📊 1% 流量到底够不够
一个常被跳过的算术(详细公式在第 12 章):
| 基线 CTR | 想检出的相对提升 | 每组需要的样本量 | 1% 流量、日均 100 万请求时要跑多久 |
|---|---|---|---|
| 4% | +5% | ≈ 75 万 | 150 天 💀 |
| 4% | +10% | ≈ 19 万 | 38 天 |
| 4% | +20% | ≈ 4.8 万 | 10 天 |
⭐ 结论很反直觉但很重要: 1% 灰度看不出效果 —— 它压根不是用来看效果的。 1% 灰度是用来看「有没有崩、有没有异常」的; 想看效果,要么放大到 10–50%,要么接受只能检出很大的提升。
⚠️ 由此产生的头号误用:在 1% 灰度上看到 CTR "涨了 3%" 就宣布成功。 那个 3% 几乎完全是噪声 —— 这个样本量下 ±8% 的波动都是正常的。
⚠️ 灰度阶段的五个坑:
| 坑 | 后果 | 怎么修 |
|---|---|---|
| 只看均值不看分位数 | P99 延迟翻倍但均值只涨 5%,被忽略 | 每档都看 P50/P95/P99 |
| 灰度组和对照组不可比 | 灰度先给了内部员工/新用户 | 严格随机,做 A/A 检验 ⭐ |
| 放量太快 | 1%→100% 一步到位,出事影响全量 | 每档至少一个业务周期 |
| 多个改动同时灰度 ⭐ | 出事了不知道是哪个引起的 | 一次只放一个变量 |
| 灰度期间改了代码 | 前后半段的"新模型"不是同一个东西 | 灰度期间代码冻结 💀 |
🔗 八、和站内其他章的关系
| 相关的地方 | 和这一章的关系 |
|---|---|
| 推荐算法 15工程落地 | 推荐场景的降级与放量 |
| 智能体工程 16 | Agent 的灰度与回滚 |
| Kaggle 22 shake-up | 「小流量结论不稳」是同一个统计问题 |
✅ 检查点
- 影子、灰度、A/B 各解决什么问题?正确顺序是什么?
- 为什么不能跳过影子直接灰度?
- 影子流量最有价值的用法是什么?
- 影子链路必须注意什么?举那个真实事故。
- 灰度每一档要观察多久?依据是什么?
- 灰度分流为什么要按用户哈希、且要加盐?
- 回滚的三个条件是什么?哪条最容易翻车?
- 为什么工程指标可以自动回滚、业务指标不行?
- 降级最容易漏的一点是什么?
- 「回滚失败」那个事故里,端到端 MTTR 是多少?和声称的差多少?根本原因是什么?
- 回滚演练怎么做?它最常暴露哪五个问题?
- 为什么分流哈希不能用 Python 内置的
hash()?线上会表现成什么样? - A/A 检验是什么?为什么说它是分流验证里最值钱的一步?
- 1% 灰度能不能看出效果?为什么?它到底是用来干什么的?
👀 答案
- 影子验证工程正确性(不影响用户)、灰度验证真实效果(少量用户)、A/B 量化效果差异。顺序:影子 → 灰度 1% → 10% → A/B → 全量。
- 因为灰度虽只影响 1%,但如果有工程性 bug(特征算错、维度不对),那 1% 拿到的是彻底错误的结果;而这类 bug 影子能 100% 提前发现。
- 不只是看会不会崩,而是:记录线上实际用的特征值 → 用同一批样本走离线管道再算一遍 → 逐字段比对。这是发现「训练/推理不一致」唯一可靠的办法。
- 影子链路必须禁写。真实事故:影子里的推荐模型写了「已曝光」表,同一物品被记两次曝光,导致真实推荐里被错误过滤掉。
- 至少覆盖一个完整业务周期:电商/内容 24 小时(早晚高峰行为完全不同)、To B 一周、有月度周期的一个月。
- 按请求随机会让同一用户一会儿新一会儿旧,体验割裂且指标没法解读。加盐是因为多个实验都用
hash(uid)%100会永远选中同一批用户,实验之间互相污染 → 用hash(uid + 实验名)。 - ①快(5 分钟内)②完整(模型+特征逻辑+配置一起回)③可验证。第②条最容易翻车:只回模型不回特征逻辑,旧模型会拿到它没见过的特征,比不回滚还糟。
- 工程指标客观(错误率、延迟),可以自动;业务指标有正常波动,也可能因大促/竞品变化,要人看一眼。
- 降级本身要有监控和告警。很多系统降级几个月没人知道,因为降级路径不报错、它「正常工作」着。
- 声称 5 分钟,实际端到端 MTTR 是 6 小时(模型回滚 4 分钟 + 发现没生效 20 分钟 + 定位 90 分钟 + 特征表回滚重跑 4 小时)。根本原因:v7 顺手改了特征表 schema(加 3 列、改了一列类型),只回模型不回 schema → 特征整体错位,维度对、不报错,回滚后拒绝率反而从 31% 变 38%。
- 挑正常工作日下午、不预告具体时间、在灰度环境故意换回上一版、计时到"指标确认恢复"、记下卡住的地方。五个常见暴露:回滚只回了模型、缺权限、没有确认已恢复的手段、手册过期、依赖某个具体的人。
- 因为 Python 内置
hash()对字符串是加盐随机的,进程重启后同一用户会落到不同桶。线上表现是服务每次发版实验分组就洗一次牌,而且不报错。只能用 md5/sha1/murmur 这类确定性哈希。 - 用分流之前的历史指标比较两组,如果那时候就有显著差异,说明分流本身有问题。值钱是因为它一票否决——分流坏了的话,之后所有实验结论都是假的。
- 看不出。1% 流量、日均 100 万请求、基线 CTR 4% 时,检出 +5% 的相对提升要跑 150 天。1% 灰度是用来看"有没有崩、有没有异常"的,不是用来看效果的;这个样本量下 ±8% 的波动都正常,看到"涨了 3%"基本是噪声。
🛑 可以停在这里
⚡ 走神救援
三个机制本质都是在降低犯错的单价。顺序:影子 → 灰度1% → 10% → A/B → 全量。⭐最常见的错误是跳过影子——灰度只影响1%,但有工程bug时那1%拿到的是彻底错误的结果,而这类bug影子能100%提前发现。⭐⭐影子最有价值的用法不是看崩不崩,是:记录线上实际特征值 → 走离线管道再算一遍 → 逐字段比对,这是发现「训练/推理不一致」唯一可靠的办法。💀影子链路必须禁写(真实事故:影子写了"已曝光"表,同一物品记两次曝光,真实推荐里被错误过滤)。灰度每档要覆盖一个完整业务周期(电商24小时,因为早晚高峰行为完全不同)。分流按用户哈希且要加盐(
hash(uid+实验名)),否则多个实验永远选中同一批人、互相污染。回滚三条件:快、完整、可验证;⭐最容易翻车是"完整"——只回模型不回特征逻辑,旧模型拿到没见过的特征,比不回滚还糟 → 把模型+特征+配置绑成一个发布单元。工程指标可自动回滚,业务指标要人看(有正常波动)。⚠️降级本身要有监控——很多系统降级几个月没人知道,因为它不报错、"正常工作"着。💀回滚失败的真事故:模型 4 分钟就回去了,但 v7 顺手改过特征表 schema,只回模型不回 schema → 特征整体错位,拒绝率从 31% 变成 38%(比不回还糟),端到端 MTTR 是 6 小时而不是声称的 5 分钟。⭐「我们有回滚方案」和「我们的 MTTR 是 5 分钟」是两回事——后者只能靠演练得出;演练最常暴露:只回了模型、缺权限、没有确认已恢复的手段、手册过期、依赖某个具体的人。⭐发现延迟 + MTTR = 一次事故的实际影响时长。分流:💀绝不能用 Python 内置hash()(对字符串加盐随机,每次发版实验分组就洗一次牌,且不报错),只用 md5/sha1/murmur;⭐⭐上线前做 A/A 检验——用分流之前的历史指标比两组,那时就有显著差异说明分流本身坏了,之后所有结论都是假的。⭐反直觉但重要:1% 灰度看不出效果(基线 CTR 4%、日均百万请求,检出 +5% 相对提升要 150 天),它是用来看崩不崩的;在 1% 上看到"涨了 3%"基本全是噪声。灰度五坑:只看均值不看分位、灰度组不可比、放量太快、多个改动一起灰度、灰度期间改代码。
下一节 👉 04-训练推理一致性.md ⭐