核心问题

当系统表现不符合预期时,我们如何逼近真相?

调试不是到处加 log,也不是凭经验乱猜。

调试更像科学方法:

观察现象
  -> 提出假设
  -> 设计实验
  -> 收集证据
  -> 排除错误解释
  -> 定位根因
  -> 修复并防止复发

好的调试不是“我试试这个”,而是:

每一步都减少不确定性。

Bug 是系统和模型之间的冲突

当 bug 出现时,说明至少有一个模型是错的:

  • 你对需求的理解错了
  • 你对代码路径的理解错了
  • 你对数据状态的理解错了
  • 你对外部系统的理解错了
  • 你对时间、并发、缓存的理解错了
  • 测试环境和生产环境的差异被低估了

所以调试的第一步不是改代码,而是承认:

我现在对系统的理解不完整。

第一步:描述现象,不要先解释

坏开场:

应该是缓存问题。

好开场:

用户 A 在 2026-06-11 10:23 支付课程 C 成功,
订单状态为 paid,
但访问课程页时 canAccessCourse 返回 false,
拒绝原因是 no_active_grant。

先把现象讲清楚:

  1. 谁受影响?
  2. 什么时间发生?
  3. 期望行为是什么?
  4. 实际行为是什么?
  5. 是否稳定复现?
  6. 影响范围多大?
  7. 有哪些日志、指标、trace 支持?

现象越具体,错误假设越少。

第二步:画出证据链

以“支付成功但不能看课”为例。

正确链路应该是:

payment.succeeded
  -> purchase.created
  -> course_access_grant.created
  -> canAccessCourse=true
  -> course page playable

现在结果是:

payment.succeeded
  -> purchase.created
  -> course_access_grant missing
  -> canAccessCourse=false

问题范围立刻缩小到:

purchase.created -> course_access_grant.created

这比“支付系统有 bug”精确得多。

第三步:提出多个假设

不要只提出一个最顺手的解释。

例如访问权没有创建,可能原因包括:

  1. payment webhook 没收到。
  2. webhook 收到了,但幂等判断误判为已处理。
  3. purchase 创建成功,但 grant 创建事务失败。
  4. grant 创建了,但 courseId 不一致。
  5. grant 创建了,但 validFrom 在未来。
  6. grant 创建了,但 revokedAt 被错误写入。
  7. canAccessCourse 查询条件漏了某种 source。
  8. 读到了旧缓存。

调试时,假设列表越具体,越容易设计实验。

第四步:优先排除高信息量假设

不要按“最想改哪里”排序,要按信息量排序。

例如:

grant 是否存在?

这个问题信息量很高。

如果 grant 不存在,查创建链路。

如果 grant 存在,查访问判断、有效期、撤销、缓存。

一个好问题能把搜索空间切成两半。

第五步:一次只改一个变量

调试中最危险的动作是同时改很多东西:

加缓存刷新
改查询条件
重跑 worker
修 webhook
改状态判断

这样即使问题消失,也不知道真正原因是什么。

更好的做法:

  1. 先验证 grant 是否存在。
  2. 再验证 grant 字段是否正确。
  3. 再验证 canAccessCourse 查询是否命中。
  4. 再验证缓存是否返回旧结果。

每一步只回答一个问题。

第六步:区分根因和触发条件

很多事故有两层:

  • 触发条件:这次为什么爆了?
  • 根因:为什么系统允许它爆?

例如:

触发条件:某个企业用户同时拥有订阅访问和组织分配访问。
根因:canAccessCourse 假设每个 account-course 只有一个访问来源。

如果只修触发条件:

if (source === "organization_assignment") {
  // special case
}

类似问题还会回来。

真正修复应该回到模型:

CourseAccessGrant[]

承认一个账号对一门课可以有多个访问权来源。

第七步:修复后补证据

修复 bug 不是结束。

要问:

为什么这个问题之前没有被发现?

然后补上对应证据:

  • 单元测试:规则漏了
  • 集成测试:模块协作漏了
  • E2E:关键用户路径漏了
  • 日志:没有记录原因
  • 指标:没有发现比例异常
  • 告警:承诺被破坏时没人知道
  • 类型:ID 或状态混用了

例如修复访问权 bug 后,补测试:

it("allows access when account has multiple valid grant sources", async () => {
  const account = await createAccount()
  const course = await createCourse()

  await grantAccessFromSubscription(account.id, course.id)
  await grantAccessFromOrganizationAssignment(account.id, course.id)

  await expect(canAccessCourse(account.id, course.id)).resolves.toBe(true)
})

补日志:

logger.warn("course_access_denied", {
  accountId,
  courseId,
  reason: decision.reason,
  grantCount: grants.length,
})

这叫把一次事故转化成系统免疫力。

常见调试坏习惯

1. 先改再查

我先试着改一下。

这会让系统状态变得更混乱。

2. 只找能支持自己猜测的证据

如果你觉得是缓存问题,你会只看缓存。

成熟调试要主动寻找能推翻自己假设的证据。

3. 把相关性当因果

刚上线了推荐系统,所以一定是推荐系统导致的。

也许是,也许只是时间上接近。

4. 只修数据,不修规则

手动给用户补访问权可以止血,但如果创建规则没修,问题还会继续发生。

5. 没有留下复盘证据

问题解决了,但没人知道为什么发生、怎么发现、怎么避免。

这会让团队反复交同一笔学费。

调试检查清单

遇到 bug 时,按这个顺序问:

  1. 现象是什么?能否用具体对象、时间和结果描述?
  2. 期望行为来自哪个系统承诺?
  3. 正常证据链应该是什么?
  4. 当前证据链断在哪里?
  5. 有哪些可能假设?
  6. 哪个问题能最大幅度缩小范围?
  7. 这次修复的是触发条件,还是根因?
  8. 修复后应该补哪类测试或观测?
  9. 是否需要修复历史数据?
  10. 如何防止同类问题静默发生?

小结

  1. 调试是科学方法,不是凭感觉试错。
  2. Bug 说明我们对系统的某个模型是错的。
  3. 先描述现象,再提出解释。
  4. 画出证据链,找到链路断点。
  5. 一次只改一个变量。
  6. 区分触发条件和根因。
  7. 修复后补测试、日志、指标或告警,把事故变成系统免疫力。