图 07 · 调试

如何从"有什么东西坏了"走到有据可查的修复?

证据漏斗:从模糊症状开始,逐步收窄到最小可复现案例,建立假设,通过仪表化验证,最后产出一个防止回归的测试。每一级都排除一类可能的原因。

调试证据漏斗,七个阶段从症状到回归测试 阶段 1 · 症状 「生产环境上有实体随机消失,复现概率约 5%」 {diagnosing-bugs} 入口 阶段 2 · 建立可复现的失败 先建立反馈循环(快速失败),再分析原因 产物: 失败的测试 阶段 3 · 最小化案例 移除所有不相关因素,直到只剩触发 bug 的核心条件 产物: 最小案例 阶段 4 · 建立假设 「竞态条件发生在 activate() 和 onMount 回调之间」 优先级: 最简假设优先 阶段 5 · 仪表化验证 添加日志 / 断点 / 时序记录,观察是否符合假设 如果 不符 → 阶段 4 阶段 6 · 修复 针对根因做最小修改,不过度修复 阶段 7 · 回归测试 将阶段 2 的失败测试变为永久守卫 排除的干扰因素(漏斗向下收窄) 常见反模式 × 在没有假设前就修复 × 假设和仪表化同时做 × 跳过阶段 2(未建立 反馈循环就开始分析) × 不写回归测试就合并 × 过度修复(改了太多) 产物:已合并的 fix + 永久回归测试 这个 bug 不会再次出现

为什么先建立反馈循环?(阶段 2 的重要性)

在可复现失败存在之前进行的所有分析都是猜测。一旦有了失败测试,你就有了一个可以对抗的目标——每次修改后立刻知道是否更接近修复。没有反馈循环,调试是一场猜谜游戏。

阶段 7 不是可选的

修复后如果不写回归测试,这个 bug 会在三个月后由不同的人用完全相同的方式再次引入。回归测试将「发现过这个 bug」的知识编码进代码库,比任何文档都更可靠。

阶段 2 → 5 的过程

阶段 2:写一个并发测试,在 100 次 activate() 后检查实体计数。立刻失败(约 3%)。
阶段 3:移除网络层、UI 层,只保留 EntityRegistry 的两个并发操作。仍然失败。
阶段 4:假设是 Set 的 delete() 和 add() 在同一 microtask 内无锁执行。
阶段 5:添加 console.time,确认两个操作在同一 tick 内。

阶段 6 → 7 的产物

阶段 6:最小修复:在 activate() 中加入操作队列(单消费者模式),解决竞态。
阶段 7:将阶段 2 的 100 次并发测试重命名为 regression: entity count stable under concurrency,加入 CI。

线性阅读顺序:症状(模糊报告)→ 建立可复现失败(先有失败测试,再分析)→ 最小化案例(排除干扰因素)→ 建立最简假设 → 仪表化验证(如不符 → 退回阶段 4)→ 最小修复 → 回归测试(永久守卫,合并到 CI)→ 产出:fix + 防回归测试。