你有一个实时 GMV 聚合 API:底层 10 亿行订单、256 个 shard,要在 p99 < 200ms 内返回「某商家过去 30 天成交额」,扛 50k QPS。你已做了 Day 44 的混沌工程——每天注入 shard 超时、replica 落后、丢包,验证系统「扛得住」。
但有个洞:某次注入「shard-137 超时」,服务没挂、错误率 0、p99 甚至更快——因为它悄悄跳过那个 shard,把 255/256 的部分和当完整答案返回。商家 GMV 少了 0.4%,零告警。这就是 gray failure 的差分可观测性(Microsoft, HotOS 2017):失败检测器没发现,应用却在受害。
约束:oracle 要在线跑;要容忍合法降级(部分结果在设计上被允许);判定本身误报不能多,否则重蹈 Day 51——低底率下误报淹没真报。
graph TD
GEN["场景生成器
AI/规则 产故障场景"] --> INJ["故障注入器
FIT · shard 超时/replica lag"]
Q["同一请求
+ 冻结种子"] --> REF["参照路径
无故障 · 独立实现"]
Q --> EXP["实验路径
被注入故障"]
INJ -.施加.-> EXP
REF --> NR["归一化
定序/量化/屏蔽非确定"]
EXP --> NE["归一化"]
NR --> CMP{"差分比对
checksum"}
NE --> CMP
CMP -->|一致| PASS["✅ 通过"]
CMP -->|受控差异| DEG["🟡 可接受降级
标注 + 记账"]
CMP -->|越界差异| BUG["🔴 正确性 bug
→ 自动中止 + 归档"]
classDef gen fill:#1a1a30,stroke:#ffb450,color:#e8eef5
classDef path fill:#0e2030,stroke:#5eead4,color:#e8eef5
classDef norm fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef bad fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
class GEN,INJ gen
class REF,EXP path
class NR,NE,CMP norm
class BUG bad
关键:参照路径不受故障影响、且尽量用独立实现,才能当「真值」;差异经归一化后再判,避免非确定性刷屏
四个组件各司其职:生成器产故障场景(AI 只坐这个席位);故障注入器只作用于实验路径;参照路径提供「本该返回什么」的真值;归一化 + 差分把「静默错结果」变成一个可 diff 的信号,落到三态而非二态。
原理:oracle 最难的是「对的答案从哪来」。硬编码 golden file 只覆盖固定输入,5w QPS 真实流量没法枚举。差分测试(McKeeman 1998)给另一条路:同一输入喂给两个应等价的执行,比结果。混沌里「两个执行」= 无故障参照路径 vs 被注入故障的实验路径;归一化后不一致,就是故障导致了错误输出。真值是「跑出来的」,随流量覆盖长尾。
实战常组合:差分抓「值错了」,不变式兜「参照路径本身也错了」的共模情况。
# 差分 oracle 骨架 (Python, pseudo-code)
def differential_check(req, fault):
seed = freeze_seed(req) # 冻结时间/随机,两路可比
ref = run_reference(req, seed) # 参照路径:无故障、独立实现
exp = run_with_fault(req, seed, fault) # 实验路径:注入 fault
nr, ne = normalize(ref), normalize(exp)
if checksum(nr) == checksum(ne):
return "PASS"
return classify(nr, ne, fault) # → 三态裁决 (见技术点 3)
原理:两条路的输出天然带非确定性:分页顺序不同、浮点求和顺序不同(IEEE 754 下 a+b+c ≠ c+b+a)、含 now()/自增 ID/随机 tie-break、map 遍历不定。直接 checksum 会次次不等,oracle 沦为噪声源。归一化层抹平「无关差异」只留「语义差异」:全列定序(按稳定 key 排)、浮点量化(舍入到业务精度,如金额到分)、屏蔽非确定函数(时间/随机/机器名→占位符)。
# 归一化:把「无关差异」抹平,保留语义 (pseudo-code)
def normalize(rows):
out = []
for r in rows:
r = mask_nondeterministic(r) # now()/rand/host/auto_id → 占位符
r = {k: quantize(v, PRECISION[k]) for k, v in r.items()} # 金额→分
out.append(r)
out.sort(key=lambda r: r[STABLE_KEY]) # 全列定序:消分页/并发乱序
return out
ORDER BY + 浮点规约后再 diff,正是同一层。原理:二态(通过/失败)在混沌里必然误报——因为很多降级是设计允许的。shard 超时后返回「255/256 的部分结果 + partial=true 标记」是正确行为;replica 落后导致读到 1 秒前的值,在最终一致语义下也合法。所以裁决要三态:PASS(归一化后完全一致)、可接受降级(差异落在预先声明的降级契约内——如标了 partial、或差值 < 声明的 staleness 窗口)、正确性 bug(差异越界,或声称完整却缺数据)。关键区别:漏掉数据却不声明 = bug;漏掉数据且如实声明 partial = 可接受。
def classify(ref, exp, fault):
if exp.claims_complete and exp.rows < ref.rows:
return "CORRECTNESS_BUG" # 🔴 声称完整却缺数据 → 静默错
if exp.partial and within_degradation_contract(exp, fault):
return "ACCEPTABLE_DEGRADATION" # 🟡 如实声明 + 落在契约内
if numeric_gap(ref, exp) <= staleness_budget(fault):
return "ACCEPTABLE_DEGRADATION" # 🟡 最终一致允许的偏差
return "CORRECTNESS_BUG" # 🔴 越界
degraded,属于 🟡 而非 🔴——前提是如实声明。原理:差分 oracle 的命门是错误趋同(correlated failure)——参照路径和实验路径共享同一段 bug 代码,两边一起算错、checksum 照样相等,给你一个虚假 PASS。防法:参照跑走尽量独立的实现或路径(不同代码、不加故障、甚至离线批处理重算),让失败模式不相关。这与 Day 51「独立 oracle vs 自任裁判」同构:共享盲区会让「相乘降误报」退化成「不降」。推论:让 AI/LLM 只坐生成器席——产刁钻故障场景、排列注入点——但不当裁判。裁判必须是确定性的差分 + 契约判定,否则你在正确性回路里塞了个自身会「静默出错」的组件。
log 出被跳过的比例,别让「采样」冒充「全覆盖」。now() 会让 oracle 次次报错,一周内被工程师关掉。追问:两路都「对」为什么 checksum 不等?jepsen-io/elle):黑盒差分式一致性检查器的范本。核心是让两路失败不相关,或让共模错误被第三方信号抓到:
成本序:不变式 < 版本错开 < 探针 < 多参照投票。生产常「不变式 + 探针」打底,关键路径才上多参照。
原则:量化精度 = 业务能容忍的最小语义单位,而非浮点机器精度。
反模式:用一个全局「模糊阈值」近似比较——那只是把误报/漏报旋钮藏进一个魔数。
难点在于:可用性演练可以「同请求打两次」,正确性演练却要求两路看到同一份世界状态,否则差异来自数据本身而非故障。手段:
tso),把底层数据冻结成可比常量——最干净。staleness_budget),而非假装能完全对齐。本质:正确性 oracle 的前提是可复现的输入;拿不到快照语义时,就把不可复现的部分显式降级为「可接受差异」,而不是让它冒充 bug 或被静默。
它是个信号,要拆开看:
正确用法:🟡 是趋势指标(降级发生率,像 error budget),🔴 才 page。🟡 上升是「该还债」的领先信号,不是「调阈值让它闭嘴」的理由。
扩展:原稳态假设是聚合、统计的——「吞吐/成功率曲线有没有塌」。正确性 oracle 补的是逐请求、语义的——「返回的值对不对」。新稳态假设两条:① 业务指标无显著差异;② 🔴 率 ≈ 0、🟡 率在契约内。
会冲突,且极有价值:经典陷阱正是曲线完全正常(①通过),🔴 率却飙升(②失败)——gray failure:跳过 shard 后更快返回,延迟曲线更好看,结果却是错的。若只有 ①,你会把它判成「通过」,把静默 bug 放进生产。
两者不是冗余而是正交:① 抓「塌了」,② 抓「悄悄错了」。真正的稳态 = 既没塌也没错——这就是可用性 oracle 永远替代不了正确性 oracle 的原因。