上线前做了一轮影子验证:把生产流量复制一份打到新服务,比对新旧两边的输出。三天下来差异为零,团队放心切换。切换后一小时,用户投诉说"下单后看不到订单"——新服务确实算对了每个接口的返回,但它没有把结果写进正确的库。
影子流量验证的是读路径的输出一致性,而这次事故在写路径的落点上。复制流量很容易做到"重放请求",很难做到"重放世界":它不复制数据库状态、不复制下游副作用、不复制并发时序。
1. 影子流量能验证的四件事
| 能验证 | 前提 | 典型收益 |
|---|---|---|
| 读接口输出是否一致 | 新旧两侧读同一份数据 | 发现逻辑差异 |
| 新服务能否承受真实流量 | 影子侧不写、不影响主链路 | 发现容量问题 |
| 新服务的错误率与延迟 | 有独立观测 | 提前发现稳定性问题 |
| 依赖调用是否符合预期 | 下游允许影子调用 | 发现参数差异 |
第三项常被当成主要目的:影子是把生产流量当压测用,而且比造数据压测真实得多。它的代价是要有隔离的观测口径,否则新旧两边的指标混在一起。
2. 影子流量不能验证的四件事
| 不能验证 | 原因 |
|---|---|
| 写路径的落点是否正确 | 影子通常不写,或写到隔离库 |
| 数据迁移后的状态 | 影子不改变数据 |
| 并发与时序相关的行为 | 重放不是同一时刻发生 |
| 有外部副作用的操作 | 不能真的发短信、扣款 |
这四件事的共同点是:它们都发生在"写"或"时序"这一侧,而影子机制在那一侧是残缺的。所以影子不是什么都能验,它在读路径上接近真实,在写路径上只是模拟。
第二项在迁移类项目里最致命:影子验证通过,不代表数据迁移后的读路径正确。因为影子读的是旧库,迁移后读的是新库,两者数据形状可能不同。
第四项需要显式处理:影子侧必须把外部调用替换为桩,而桩的行为与真实服务不同——这意味着"影子通过"无法覆盖外部依赖的行为差异。
3. 三种影子形态与它们的隔离强度
| 形态 | 写行为 | 隔离强度 |
|---|---|---|
| 只读影子(镜像请求,不写) | 不写 | 高 |
| 写隔离库影子 | 写到影子库 | 中 |
| 双写对比(主写 + 影子写) | 两边都写 | 低 |
第二种最实用:既能验证写路径,又不污染生产数据。代价是需要维护一套影子库与清理机制,而且要接受"影子库的数据与生产不同步"这一事实。
// 三种形态的取舍
只读影子:验证读逻辑,最安全,但覆盖不到写
写隔离影子:验证写逻辑与落点,需要影子库与清理
双写对比:最真实,但一旦影子侧出错会影响主链路 —— 必须有熔断开关
4. 差异比对的三个陷阱
| 陷阱 | 表现 | 处理 |
|---|---|---|
| 非确定性字段 | 时间戳、随机 ID、排序不稳定 | 归一化后再比 |
| 上游状态漂移 | 两次请求之间数据变了 | 固定快照或限窗口 |
| 浮点与格式差异 | 精度、空值表示不同 | 定义比较规则 |
第二项也有具体机制:影子请求到达时间与原请求不同,如果业务依赖"请求到达时刻"(比如按当前时间算到期日、按当天库存判断),两次结果会天然不同。这类差异不是缺陷,但如果被当作缺陷去"修",反而会引入错误。
第一项最容易被忽略,也最容易造成"差异为零"的假象:如果比对逻辑把时间戳字段剔除了,那么一个真的算错的时间字段也不会被发现。比对规则本身需要被审查。
// 归一化比对:把非确定性字段显式列出,而不是笼统忽略
IGNORE = {"timestamp", "trace_id", "server_time"} // 必须逐项确认,不可通配
def normalized(payload: dict) -> dict:
return {k: v for k, v in payload.items() if k not in IGNORE}
// 数值比较要带容差,否则浮点误差会淹没真实差异
def close(a, b, tol=1e-6) -> bool:
return abs(a - b) <= tol * max(1.0, abs(a), abs(b))
比对还要回答"差异长什么样",而不只是"有多少":
// 按差异类型分组,比单一差异率更有信息量
diff_report --window 24h --group-by field --top 10
// field=amount diff=1200 ratio=0.25% → 精度或舍入规则不同
// field=status diff= 12 ratio=0.00% → 状态机边界差异
// field=items diff= 3 ratio=0.00% → 排序不稳定,归一化后再看
5. 影子期的观测口径
| 指标 | 作用 |
|---|---|
| 影子请求成功率 | 影子链路本身是否正常 |
| 新旧差异率 | 逻辑差异的量化 |
| 影子侧延迟分布 | 容量评估 |
| 影子侧下游调用量 | 是否给下游造成压力 |
| 差异样本留存 | 事后分析依据 |
标记可以透传到整条链路,让下游据此决定"是否真的执行副作用":
// 影子标记贯穿链路,由下游决定行为
X-Shadow: 1 // 入口打标
X-Shadow-Origin: prod-mirror // 标出来源,便于下游统计
// 下游策略:不写库、不发消息、不计入业务指标、限流处理
第四项是必须的:影子流量会把下游调用量翻倍。如果下游没有余量,影子验证本身就会造成故障。所以影子期要给下游打标(如 X-Shadow: 1),并在下游侧限流或旁路。
影子验证的结论要写成"覆盖了什么、没覆盖什么",而不是一句"通过":
shadow_result:
covered:
- 读接口 12 个,差异率 0(样本 48 万,含一次峰值)
- 新服务 P99 与旧服务持平
not_covered:
- 写路径落点(只读影子,未写)
- 外部通知(影子侧为桩)
- 迁移后的新库读路径
conclusion: 可切读流量;写流量需另做对账验证
not_covered 这一栏是结论的主体。没有它,"通过"会被当成全量背书。
6. 排错:五种"影子通过但上线出问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 读一致但写错 | 只做了只读影子 | 补写隔离影子或双写对比 |
| 差异率为零但行为不同 | 比对规则剔除了关键字段 | 审查 IGNORE 列表 |
| 上线后下游报错 | 影子期未打标,下游未准备 | 加影子标记与限流 |
| 上线后容量不足 | 影子流量小于真实峰值 | 按峰值而非均值设计影子比例 |
| 迁移后读错数据 | 影子读旧库 | 迁移项目要单独验证新库读路径 |
影子期还要设一个明确的退出条件,否则它容易变成常态:跑够样本量、差异归零、且覆盖过一次高峰,三项都满足就结束。长期挂着影子,等于长期付双份下游成本。
第四项很实际:影子通常按固定比例复制(如 10%),而真实验证需要覆盖峰值。低谷期的影子流量几乎不构成压力测试,所以关键指标要在高峰时段观察。
7. 边界:什么情况不必做影子
| 场景 | 为什么 | 做法 |
|---|---|---|
| 纯新增功能(无旧版本对比) | 无对比基准 | 用灰度发布 |
| 改动集中在配置 | 无逻辑变化 | 配置对比即可 |
| 有完整契约测试覆盖 | 已验证语义 | 直接灰度 |
| 无法复制流量(有状态会话) | 影子会破坏会话 | 用回放或压测 |
还有一条与组织有关的边界:影子结论必须由提出质疑的人确认。做影子的人容易只看"差异率"这个数字,而真正关心行为差异的人(业务方、下游负责人)会问"哪些情况没覆盖"。让后者签字,比让前者的报表归零更有意义。
判断口径一句话:要验证的是"输出一致"还是"状态正确"。前者影子很有效;后者影子基本无效,需要迁移演练或对账。
8. 自测
- 影子流量最容易验证什么?它在写路径上为什么天然失效?
- 三种影子形态的隔离强度如何排序?写隔离库影子的主要代价是什么?
- 为什么"差异率为零"可能是假象?比对规则本身该被如何审查?
- 影子期为什么要给下游打标?不打标会发生什么?
- 按 10% 比例复制流量为什么可能掩盖容量问题?
- 判断"要不要做影子"的那个问题是什么?什么情况下影子基本无效?