debugging - observability
09. 调试、观测与证据链
0. 本章先解决什么问题
调试不是猜谜,也不是靠经验随便改几行代码试试。
真正可靠的调试过程是:
现象
-> 假设
-> 证据
-> 验证
-> 缩小范围
-> 找到第一次偏离预期的位置
初学者常见的问题是:看到错误后立刻跳到结论。
“应该是网络问题”
“应该是缓存问题”
“应该是系统慢”
“应该是代码这里错了”
这些话可能对,也可能错。关键在于你有没有证据。
本章要建立一个通用调试框架:
- 如何把模糊现象变成可验证描述。
- 日志、指标、追踪、堆栈、抓包、快照分别能证明什么。
- 如何按层排查,而不是把所有问题都叫成“系统问题”。
- 如何用二分定位和最小复现缩小范围。
- 正确性问题和性能问题的排查顺序有什么不同。
- 学完后如何从一个真实问题建立完整证据链。
一句话:
调试的本质,是用证据把“不知道”变成“确定”。
这张阶梯适用于所有四大件章节。你学到一个机制以后,不要只停在“我知道定义”,还要能回答:它失败时外部是什么症状?我能用什么证据把症状和机制连起来?
这张图怎么读
排障阶梯不是线性 checklist,而是一种避免乱猜的节奏:
先把现象说清楚
再提出可证伪假设
再找最小证据
再决定是否下钻或排除
每一层都在减少不确定性:
| 阶梯 | 目标 | 失败表现 |
|---|---|---|
| 现象定义 | 让问题可比较 | “系统不行”“偶尔很慢” |
| 分层定位 | 把范围钉到某层 | 所有问题都叫网络/缓存/性能 |
| 证据选择 | 找能证明或反驳假设的材料 | 日志很多,但没有结论 |
| 最小复现 | 找必要触发条件 | 只能在生产碰运气 |
| 修复验证 | 证明改动解决根因 | 改了很多,不知道哪一处有效 |
调试时你可以在阶梯之间来回走,但不要跳过“证据”。跳过证据的调试,本质上是在碰运气。
1. 调试的第一原则:先定义现象
模糊现象无法调试。
程序有问题。
网络不行。
系统很慢。
数据不对。
偶尔会挂。
这些描述都不够,因为它们没有说明:
- 什么输入触发?
- 什么时间触发?
- 期望是什么?
- 实际是什么?
- 影响范围多大?
- 是否稳定复现?
- 最近有什么变化?
可调试的描述应该像这样:
当输入 n = 1_000_000 时,排序耗时从 20ms 增加到 8s。
连接目标端口时,TCP connect 在 3s 后超时,DNS 解析正常。
文件写入后立刻断电,重启发现末尾 4KB 仍是旧数据。
任务状态已经变成 done,但结果文件不存在。
这类描述有三个优点:
| 优点 | 说明 |
|---|---|
| 可比较 | 有预期和实际 |
| 可验证 | 能用日志、命令、输入重复检查 |
| 可缩小范围 | 能判断问题属于哪一层 |
调试前先把现象写清楚,这不是形式主义。它决定你后面收集什么证据。
2. 证据链:从现象到原因
证据链不是“很多日志堆在一起”,而是一条能支持结论的推理路径。
现象: 请求超时
- 证据 1:DNS 在 5ms 内返回 IP
- 证据 2:TCP connect 成功
- 证据 3:TLS 握手成功
- 证据 4:请求已经发出
- 证据 5:服务端没有对应请求日志
结论方向:
请求可能在代理、网关或服务入口前丢失
证据链要避免跳跃。
不严谨:
请求超时 -> 服务端挂了
严谨一点:
请求超时
-> DNS 正常
-> TCP 正常
-> TLS 正常
-> 客户端已发送请求
-> 服务端无入口日志
-> 问题更可能在中间层或路由
调试时,目标不是一开始就猜中原因,而是每一步都排除一部分可能性。
3. 证据类型:每种工具证明不同层次
不同证据回答不同问题。
| 证据 | 能证明什么 | 不能直接证明什么 |
|---|---|---|
| 日志 | 程序走过哪些路径,记录了哪些状态 | 没日志不代表没发生 |
| 错误码 | 哪一层报告了什么失败 | 错误码可能被包装或误用 |
| 调用栈 | 当前执行停在哪里,谁调用了谁 | 不一定说明根因在最底层 |
| 指标 | CPU、内存、IO、延迟、吞吐变化 | 平均值可能掩盖局部问题 |
| 追踪 | 一次请求经过哪些阶段 | 没埋点的阶段不可见 |
| 抓包 | 网络上真实传了什么字节 | 解释应用语义还要结合协议 |
| 文件/内存快照 | 某一刻保存的状态 | 快照之外的变化不可见 |
| 最小复现 | 触发问题的必要条件 | 不能自动解释所有生产差异 |
几个典型例子:
- 日志能说明程序记录过某个状态,但日志可能延迟刷新,也可能漏打。
- 调用栈能告诉你“此刻卡在哪里”,但上游传入的错误数据可能才是真正原因。
- 指标能告诉你系统负载变化,但不能单独告诉你是哪一行代码导致。
- 抓包能证明网络层收发了什么,但不能代替应用状态分析。
所以调试要组合证据,而不是迷信某一种工具。
反例:没有日志,不代表事件没有发生
很多排查会犯一个错误:
日志里没有 X
-> X 没有发生
这个推理通常不成立。日志缺失可能有很多原因:
| 原因 | 说明 |
|---|---|
| 根本没埋点 | 代码路径执行了,但没有记录 |
| 日志级别被过滤 | debug/info 在当前环境没输出 |
| 异步日志丢失 | 崩溃或队列满导致没写出 |
| buffer 未刷新 | 进程退出前日志还在内存 |
| 采样 | 只有一部分请求被记录 |
| correlation id 断裂 | 同一次请求的日志无法串起来 |
| 时间不同步 | 看起来没有对应时间点 |
| 记录在另一层 | 入口层有日志,工作线程或下层没有 |
更严谨的说法应该是:
在当前日志源、当前级别、当前时间范围内,没有观察到 X。
然后继续找反证:
有没有状态变化能证明 X 发生过?
有没有下游副作用?
有没有计数器增加?
有没有抓包或文件变化?
有没有另一个组件记录到同一 correlation id?
日志是证据,不是世界本身。没有日志只能说明“这个观测点没有记录到”,不能自动推出“事件不存在”。
4. 观测性:系统能不能说清自己发生了什么
观测性是系统对外暴露内部状态的能力。
常见三件套:
| 观测手段 | 关注点 | 例子 |
|---|---|---|
| Logs | 离散事件 | 某请求进入、某状态改变、某错误发生 |
| Metrics | 聚合趋势 | QPS、错误率、延迟、CPU、内存 |
| Traces | 单次链路 | 一次请求经过哪些服务和阶段 |
它们互补:
metrics 告诉你哪里变异常。
traces 告诉你一次请求慢在哪段。
logs 告诉你某一步发生了什么状态变化。
例如一个服务变慢:
- 指标显示 P99 延迟从 100ms 涨到 3s。
- 追踪显示慢在读取文件阶段。
- 日志显示读取时频繁等待锁。
- 线程快照显示多个执行流都在等同一把锁。
这样才形成证据链。
如果系统没有观测点,调试会变成盲人摸象。你只能猜,或者不断改代码加打印。
好的程序应该在关键边界留下证据:
- 输入是什么。
- 状态从什么变成什么。
- 调用了哪个外部资源。
- 等待了多久。
- 失败原因是什么。
- 请求或任务的唯一标识是什么。
4.1 好日志不是越多越好
日志的价值取决于它能不能回答问题。关键边界日志通常包含:
| 字段 | 作用 |
|---|---|
| correlation id | 把同一次请求或任务串起来 |
| stage | 当前处在哪个阶段 |
| old_state / new_state | 看状态如何变化 |
| input summary | 记录必要输入,不泄漏敏感数据 |
| duration | 区分执行时间和等待时间 |
| error type | 区分可重试、不可重试、权限、容量、格式等 |
| dependency | 知道调用了哪个外部资源 |
| version/config | 排查变更和环境差异 |
坏日志常见长这样:
failed
error occurred
timeout
data invalid
它们看似记录了错误,实际上没有告诉你在哪一步、哪个对象、什么输入、什么状态、是否可重试。这样的日志只能证明“有事发生”,不能支撑定位。
5. 分层排查:别把所有问题都叫系统问题
复杂系统一定要按层拆。
例子:访问远程服务失败。
不要直接说“网络不行”。按层问:
输入 URL 是否正确?
域名能解析吗?
解析到的 IP 是否符合预期?
本机能到达这个 IP 吗?
目标端口是否可连接?
TCP 是否完成握手?
TLS 是否协商成功?
应用协议格式是否正确?
服务端是否收到请求?
服务端是否处理成功?
响应是否返回到客户端?
客户端是否正确解析响应?
每一步都对应不同证据:
| 层次 | 可能证据 |
|---|---|
| 输入层 | 配置、参数、环境变量 |
| DNS | 解析结果、缓存记录 |
| 网络连通性 | ping、路由、连接尝试 |
| 传输层 | connect 成功/失败、抓包 |
| 加密层 | TLS 错误、证书信息 |
| 应用协议 | 请求头、响应码、协议日志 |
| 服务端处理 | 入口日志、业务日志、错误栈 |
| 客户端解析 | 解析错误、返回值、状态变化 |
分层排查的价值是:
每确认一层正常,就把问题范围向后推进。
每发现一层异常,就把问题范围钉在那一层附近。
这比“感觉是网络问题”可靠得多。
5.1 每一层都要有正反证据
分层排查不是把层名念一遍,而是为每层找证据。
| 层 | 支持该层有问题的证据 | 反证 |
|---|---|---|
| 输入/配置 | 错误参数、配置缺失、版本不一致 | 同样输入在另一环境正常 |
| DNS | 解析慢、解析到错误地址 | 解析稳定且地址符合预期 |
| TCP | connect 超时、连接被拒绝、重传高 | connect 快且稳定 |
| TLS | 证书错误、握手失败、协议不兼容 | 握手成功且耗时正常 |
| 应用入口 | 无入口日志、网关拒绝 | 服务端收到请求 |
| 业务处理 | 慢查询、锁等待、异常栈 | 业务处理耗时正常 |
| 响应传输 | 服务端已发,客户端未收完整 | 客户端收到完整响应 |
| 客户端解析 | 解析异常、状态转换错 | 原始响应正确且解析正常 |
如果某层既没有支持证据,也没有反证,就不要急着下结论。它只是“还没被验证”。
证据链模板:最小可协作证据包
一次严肃排查至少要留下一个最小证据包。它不用很长,但必须让别人能复核你的结论。
现象:
用户或系统看到的具体失败是什么?
范围:
影响哪些输入、环境、时间段、比例?
预期:
正常状态应该是什么?
证据:
- 支持问题存在的证据
- 已经排除的方向和反证
- 尚未验证的关键假设
时间线:
变更、报警、第一次出现、恢复动作
当前结论:
最可能的层次和原因,不超过一句
下一步:
需要采集哪份证据,或做哪个低风险验证
写证据包时要避免三种句子:
| 坏句子 | 问题 |
|---|---|
| “应该是某层问题” | 没有证据 |
| “看起来正常” | 没说看了什么 |
| “偶尔失败” | 没有比例、时间、输入范围 |
更好的句子是:
代码块收起展开
在 10:00-10:20 的 1200 次请求中,约 8% 在 connect 阶段超过 3s;
DNS 耗时和解析结果稳定,目标端口没有拒绝连接;下一步需要采集失败样本的 SYN 重传和路由路径。
这类记录能让排查从个人直觉变成团队可复核的工程过程。
6. 二分定位:把范围切半
二分定位适合任何长链路:
输入 -> A -> B -> C -> D -> 输出
如果输出错了,不要从 A 到 D 全部乱看。先切中间:
B 的输出是否正确?
如果 B 正确,问题在 C/D。
如果 B 错误,问题在 A/B。
继续切:
A 的输出是否正确?
这种方法适用于:
- 数据处理管线。
- 编译/构建流程。
- 网络请求链路。
- 状态转换流程。
- 文件读写流程。
- 性能瓶颈定位。
二分定位的关键是找到“可观察边界”。
模块输入是什么?
模块输出是什么?
中间状态能不能打印、断点、记录、抓取?
没有边界,二分就做不起来。因此良好的抽象和观测性会直接提高可调试性。
7. 最小复现:逼自己理解触发条件
最小复现是调试里最有价值的工具之一。
它要满足四个要求:
| 要求 | 含义 |
|---|---|
| 最小 | 去掉无关条件 |
| 稳定 | 尽量每次都能触发 |
| 可观察 | 能看到失败结果 |
| 可解释 | 能说明触发条件 |
最小复现不是为了“写给别人看”才做的。它能逼你回答:
- 哪些条件是必要的?
- 哪些条件只是噪声?
- 问题和输入规模有关吗?
- 问题和顺序有关吗?
- 问题和时间、并发、缓存有关吗?
比如一个排序函数在大输入下变慢。你可以逐步去掉:
- 无关字段。
- 无关业务流程。
- 外部服务调用。
- UI 或展示层。
- 非必要配置。
最后只留下:
输入数组 -> 排序函数 -> 耗时异常
如果最小复现还能触发,问题范围就很小。
如果不能触发,说明被去掉的某个条件参与了问题。
8. 正确性排查:找第一次状态偏离
正确性问题问的是:
为什么结果错了?
排查顺序建议是:
- 输入是什么?
- 预期输出是什么?
- 实际输出是什么?
- 中间状态从哪里开始偏离?
- 哪个操作破坏了不变量?
- 是否有边界输入?
- 是否有并发交错?
- 是否有表示转换或精度问题?
正确性排查的核心是:
找到第一次偏离预期的位置。
例子:
输入正确
解析后正确
归一化后错误
后续计算都基于错误状态
那根因大概率在归一化阶段,而不是最后输出阶段。
不变量在这里非常重要。
队列长度不能为负
金额不能凭空增加
已关闭连接不能继续发送数据
文件 header 声明长度要和实际内容匹配
如果你能找到“不变量第一次被破坏”的地方,bug 通常已经接近答案。
9. 性能排查:先找瓶颈,不要盲目优化
性能问题问的是:
时间或资源花在哪里?
不要在不知道瓶颈的情况下优化局部代码。
先按资源问:
| 资源 | 观察问题 |
|---|---|
| CPU | 是否算得太多,是否频繁上下文切换 |
| 内存 | 是否分配过多,是否频繁回收 |
| 磁盘 IO | 是否读写太多,是否等待磁盘 |
| 网络 IO | 是否等待响应,是否丢包或重传 |
| 锁 | 是否大量时间花在等待临界区 |
| 队列 | 是否生产快于消费,任务堆积 |
| 缓存 | 是否命中率低,是否频繁失效 |
性能排查常用路径:
确认现象
-> 看整体指标
-> 找最忙或最慢的资源
-> 定位到具体阶段
-> 定位到具体操作
-> 再决定优化手段
例如:
响应慢
-> CPU 不忙
-> 大量请求在等待 IO
-> 追踪显示慢在读取远程资源
-> 日志显示缓存频繁未命中
-> 优化方向是缓存和数据访问路径
如果 CPU 本来就不忙,盲目优化计算代码可能没有意义。
10. 偶发问题:把不确定性变成可观察
偶发问题难,因为它不稳定。
常见来源:
| 来源 | 例子 |
|---|---|
| 并发交错 | 某种线程顺序才触发 |
| 时间窗口 | 超时、重试、定时器竞争 |
| 缓存状态 | 命中和未命中路径不同 |
| 资源压力 | 高负载下才出现 |
| 初始化顺序 | 某些组件先后不同 |
| 外部依赖 | 第三方服务偶尔慢或失败 |
偶发问题的策略不是“等它再出现”。要提高可观察性:
- 给请求或任务加唯一 ID。
- 记录状态转换前后。
- 记录关键输入和边界值。
- 记录线程、进程、机器、时间和版本。
- 记录重试次数、超时值、队列长度。
- 保留失败现场的快照或最小输入。
目标是:
下次出现时,系统能留下足够证据。
偶发问题也适合做压力复现:
增加并发
缩短时间窗口
重复运行
随机化顺序
模拟慢 IO
模拟失败和超时
这不是为了制造混乱,而是为了让隐藏的时序条件更容易暴露。
11. 修改前先建立假设,修改后验证假设
调试中的一次代码修改应该对应一个明确假设。
假设:
问题是解析阶段把空输入当成合法输入。
修改:
在解析阶段拒绝空输入。
验证:
旧失败输入不再通过解析。
正常输入仍然通过。
边界输入有明确行为。
如果你同时改十个地方,问题消失了,也很难知道到底哪个改动有效。
可靠调试节奏:
- 写下当前现象。
- 提出一个可验证假设。
- 找证据支持或反驳。
- 做最小修改。
- 重新运行能证明问题的检查。
- 确认没有破坏相邻行为。
这和科学实验非常像:一次只改变少量变量,否则结论不可靠。
12. 常见调试反模式
| 反模式 | 问题 |
|---|---|
| 凭感觉改代码 | 可能碰巧遮住问题,根因还在 |
| 只看最后一条错误 | 根因可能在更早的状态变化 |
| 只看平均指标 | 长尾延迟和局部峰值被掩盖 |
| 把超时等同失败 | 对方可能已经成功处理 |
| 把日志顺序当因果顺序 | 并发或缓冲可能改变输出顺序 |
| 只在正常路径打日志 | 失败路径没有证据 |
| 没有最小复现 | 问题范围一直很大 |
| 修完不验证 | 不知道是假修还是真修 |
调试能力的提升,不是记住更多“经验结论”,而是让每一步都有证据支撑。
13. 联系实际:从“远程服务偶尔超时”建立证据链
假设现象是:
远程服务偶尔超时,重试后大多成功。
第一步,把现象具体化:
哪个接口?
多大比例?
发生在什么时间段?
超时时间是多少?
请求体大小是否相关?
是否只发生在某些机器?
第二步,按层收集证据:
客户端是否真的发出请求?
DNS 是否稳定?
TCP connect 是慢还是应用响应慢?
TLS 是否有重协商或失败?
服务端入口有没有收到?
服务端内部慢在哪个阶段?
响应是否发出但客户端没收到?
第三步,区分几种可能:
| 证据 | 更可能方向 |
|---|---|
| connect 就超时 | 网络路径、端口、服务监听、负载 |
| connect 快,响应慢 | 服务端处理、队列、锁、IO |
| 服务端无日志 | 请求没到入口或日志缺失 |
| 服务端已返回,客户端超时 | 中间网络、客户端读取、连接状态 |
| 只在高峰发生 | 资源饱和或队列堆积 |
| 重试导致重复写 | 幂等性设计不足 |
第四步,验证假设:
如果怀疑队列堆积:
看队列长度指标和处理耗时。
如果怀疑锁等待:
看线程快照和锁等待时间。
如果怀疑网络丢包:
看抓包、重传、连接错误。
如果怀疑重试副作用:
查请求 ID 和结果写入记录。
最后形成结论时,应该能说出:
在哪个时间段
哪些请求
停在哪一层
证据是什么
根因是什么
修复如何验证
这就是证据链式调试。
排障卡:写一条可验证的调试假设
好的调试假设不是“可能哪里有问题”,而是可以被证据推翻的一句话。
| 组件 | 写法 |
|---|---|
| 现象 | 在什么输入、时间、环境下出现什么结果 |
| 假设 | 哪个状态在第几步偏离预期 |
| 证据 | 哪条日志、指标、堆栈、抓包或快照能证明 |
| 实验 | 改变一个条件后,预期现象如何变化 |
| 结论 | 证据支持、反驳,还是需要缩小范围 |
练习:把“服务偶尔超时”改写成三条假设:DNS 偶尔慢、连接建立偶尔慢、处理队列偶尔堆积。为每条假设写一个证据来源。你会发现问题还没解决,但排查路径已经从模糊变成可执行。
失败指纹表:从模糊现象到第一份证据
| 外部现象 | 第一种可验证假设 | 最小证据 | 如果证据支持,下一步 |
|---|---|---|---|
| 偶尔超时,但重试成功 | 某个阶段有尾延迟峰值 | 分阶段耗时、trace span、请求开始/结束时间 | 找出峰值落在 DNS、连接、队列、处理还是下载 |
| 数据偶尔不一致 | 第一次状态偏离发生在写入或同步边界 | 写前值、写后值、版本号、操作 id | 加入不变量检查,缩小到具体转移规则 |
| 只在高峰期出错 | 队列、锁或资源池进入饱和区 | 并发数、队列长度、等待时间、拒绝数 | 验证容量、限流、背压和峰值批处理 |
| 本地无法复现 | 触发条件依赖环境或时序 | 环境差异、配置 diff、输入样本、随机种子、时钟 | 固化触发条件,构造最小复现 |
| 修复后又复发 | 之前修的是现象,不是根因 | 同类错误的 trace、变更记录、回归用例 | 写回归验证,确认根状态转移已被约束 |
问题:给一次慢请求写证据链报告
学完本章,可以把一次慢请求报告写成这种结构:
现象:
某个时间段内,接口 X 的 p99 从 120ms 升到 2.8s。
影响范围:
约 3% 请求超过 2s,集中在带大响应体的请求。
分层证据:
DNS / connect / TLS 正常。
服务端入口日志显示请求已进入。
trace 显示 2.5s 花在读取远程对象。
远程对象读取阶段 cache miss 从 5% 升到 70%。
线程快照没有明显锁等待,CPU 使用率不高。
结论:
慢点不是本地计算,而是远程对象读取和缓存失效。
修复:
调整缓存预热和回源并发限制,给远程读取加阶段超时。
验证:
p99 回到 180ms,cache miss 恢复到 8%,远程读取超时数下降。
这类报告的价值在于:别人可以沿着证据复查你的结论,而不是只看到“已优化”三个字。
14. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 如何把“系统很慢”“网络不行”变成可调试现象?
- 日志、指标、追踪、调用栈、抓包分别适合证明什么?
- 为什么调试要按层排查,而不是直接猜根因?
- 如何用二分定位找到问题发生在哪个模块?
- 最小复现为什么能帮助你理解触发条件?
- 正确性 bug 应该如何寻找第一次状态偏离?
- 性能 bug 应该如何先找瓶颈再优化?
- 偶发并发或时序问题如何提高可观察性?
- 为什么每次修改都应该对应一个可验证假设?
如果你以后遇到“程序偶尔错、服务偶尔慢、请求偶尔超时、数据偶尔不一致”,不要先急着修。先把现象写清楚,建立证据链,再一点点缩小范围。能做到这一点,你的调试方式就已经从 guessing 进入 engineering。