核心问题
当系统表现不符合预期时,我们如何逼近真相?
调试不是到处加 log,也不是凭经验乱猜。
调试更像科学方法:
观察现象
-> 提出假设
-> 设计实验
-> 收集证据
-> 排除错误解释
-> 定位根因
-> 修复并防止复发
好的调试不是“我试试这个”,而是:
每一步都减少不确定性。
Bug 是系统和模型之间的冲突
当 bug 出现时,说明至少有一个模型是错的:
- 你对需求的理解错了
- 你对代码路径的理解错了
- 你对数据状态的理解错了
- 你对外部系统的理解错了
- 你对时间、并发、缓存的理解错了
- 测试环境和生产环境的差异被低估了
所以调试的第一步不是改代码,而是承认:
我现在对系统的理解不完整。
第一步:描述现象,不要先解释
坏开场:
应该是缓存问题。
好开场:
用户 A 在 2026-06-11 10:23 支付课程 C 成功,
订单状态为 paid,
但访问课程页时 canAccessCourse 返回 false,
拒绝原因是 no_active_grant。
先把现象讲清楚:
- 谁受影响?
- 什么时间发生?
- 期望行为是什么?
- 实际行为是什么?
- 是否稳定复现?
- 影响范围多大?
- 有哪些日志、指标、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”精确得多。
第三步:提出多个假设
不要只提出一个最顺手的解释。
例如访问权没有创建,可能原因包括:
- payment webhook 没收到。
- webhook 收到了,但幂等判断误判为已处理。
- purchase 创建成功,但 grant 创建事务失败。
- grant 创建了,但 courseId 不一致。
- grant 创建了,但 validFrom 在未来。
- grant 创建了,但 revokedAt 被错误写入。
- canAccessCourse 查询条件漏了某种 source。
- 读到了旧缓存。
调试时,假设列表越具体,越容易设计实验。
第四步:优先排除高信息量假设
不要按“最想改哪里”排序,要按信息量排序。
例如:
grant 是否存在?
这个问题信息量很高。
如果 grant 不存在,查创建链路。
如果 grant 存在,查访问判断、有效期、撤销、缓存。
一个好问题能把搜索空间切成两半。
第五步:一次只改一个变量
调试中最危险的动作是同时改很多东西:
加缓存刷新
改查询条件
重跑 worker
修 webhook
改状态判断
这样即使问题消失,也不知道真正原因是什么。
更好的做法:
- 先验证 grant 是否存在。
- 再验证 grant 字段是否正确。
- 再验证 canAccessCourse 查询是否命中。
- 再验证缓存是否返回旧结果。
每一步只回答一个问题。
第六步:区分根因和触发条件
很多事故有两层:
- 触发条件:这次为什么爆了?
- 根因:为什么系统允许它爆?
例如:
触发条件:某个企业用户同时拥有订阅访问和组织分配访问。
根因: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 时,按这个顺序问:
- 现象是什么?能否用具体对象、时间和结果描述?
- 期望行为来自哪个系统承诺?
- 正常证据链应该是什么?
- 当前证据链断在哪里?
- 有哪些可能假设?
- 哪个问题能最大幅度缩小范围?
- 这次修复的是触发条件,还是根因?
- 修复后应该补哪类测试或观测?
- 是否需要修复历史数据?
- 如何防止同类问题静默发生?
小结
- 调试是科学方法,不是凭感觉试错。
- Bug 说明我们对系统的某个模型是错的。
- 先描述现象,再提出解释。
- 画出证据链,找到链路断点。
- 一次只改一个变量。
- 区分触发条件和根因。
- 修复后补测试、日志、指标或告警,把事故变成系统免疫力。