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 告诉你某一步发生了什么状态变化。

例如一个服务变慢:

  1. 指标显示 P99 延迟从 100ms 涨到 3s。
  2. 追踪显示慢在读取文件阶段。
  3. 日志显示读取时频繁等待锁。
  4. 线程快照显示多个执行流都在等同一把锁。

这样才形成证据链。

如果系统没有观测点,调试会变成盲人摸象。你只能猜,或者不断改代码加打印。

好的程序应该在关键边界留下证据:

  • 输入是什么。
  • 状态从什么变成什么。
  • 调用了哪个外部资源。
  • 等待了多久。
  • 失败原因是什么。
  • 请求或任务的唯一标识是什么。

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解析慢、解析到错误地址解析稳定且地址符合预期
TCPconnect 超时、连接被拒绝、重传高connect 快且稳定
TLS证书错误、握手失败、协议不兼容握手成功且耗时正常
应用入口无入口日志、网关拒绝服务端收到请求
业务处理慢查询、锁等待、异常栈业务处理耗时正常
响应传输服务端已发,客户端未收完整客户端收到完整响应
客户端解析解析异常、状态转换错原始响应正确且解析正常

如果某层既没有支持证据,也没有反证,就不要急着下结论。它只是“还没被验证”。

证据链模板:最小可协作证据包

一次严肃排查至少要留下一个最小证据包。它不用很长,但必须让别人能复核你的结论。

现象:
用户或系统看到的具体失败是什么?

范围:
影响哪些输入、环境、时间段、比例?

预期:
正常状态应该是什么?

证据:

  1. 支持问题存在的证据
  2. 已经排除的方向和反证
  3. 尚未验证的关键假设

时间线:
变更、报警、第一次出现、恢复动作

当前结论:
最可能的层次和原因,不超过一句

下一步:
需要采集哪份证据,或做哪个低风险验证

写证据包时要避免三种句子:

坏句子问题
“应该是某层问题”没有证据
“看起来正常”没说看了什么
“偶尔失败”没有比例、时间、输入范围

更好的句子是:

代码块JAVA · 2 行收起展开
10:00-10:201200 次请求中,约 8% 在 connect 阶段超过 3s;
DNS 耗时和解析结果稳定,目标端口没有拒绝连接;

下一步需要采集失败样本的 SYN 重传和路由路径。

这类记录能让排查从个人直觉变成团队可复核的工程过程。

6. 二分定位:把范围切半

二分定位适合任何长链路:

输入 -> A -> B -> C -> D -> 输出

如果输出错了,不要从 A 到 D 全部乱看。先切中间:

B 的输出是否正确?

如果 B 正确,问题在 C/D。
如果 B 错误,问题在 A/B。

继续切:

A 的输出是否正确?

这种方法适用于:

  • 数据处理管线。
  • 编译/构建流程。
  • 网络请求链路。
  • 状态转换流程。
  • 文件读写流程。
  • 性能瓶颈定位。

二分定位的关键是找到“可观察边界”。

模块输入是什么?
模块输出是什么?
中间状态能不能打印、断点、记录、抓取?

没有边界,二分就做不起来。因此良好的抽象和观测性会直接提高可调试性。

7. 最小复现:逼自己理解触发条件

最小复现是调试里最有价值的工具之一。

它要满足四个要求:

要求含义
最小去掉无关条件
稳定尽量每次都能触发
可观察能看到失败结果
可解释能说明触发条件

最小复现不是为了“写给别人看”才做的。它能逼你回答:

  1. 哪些条件是必要的?
  2. 哪些条件只是噪声?
  3. 问题和输入规模有关吗?
  4. 问题和顺序有关吗?
  5. 问题和时间、并发、缓存有关吗?

比如一个排序函数在大输入下变慢。你可以逐步去掉:

  • 无关字段。
  • 无关业务流程。
  • 外部服务调用。
  • UI 或展示层。
  • 非必要配置。

最后只留下:

输入数组 -> 排序函数 -> 耗时异常

如果最小复现还能触发,问题范围就很小。
如果不能触发,说明被去掉的某个条件参与了问题。

8. 正确性排查:找第一次状态偏离

正确性问题问的是:

为什么结果错了?

排查顺序建议是:

  1. 输入是什么?
  2. 预期输出是什么?
  3. 实际输出是什么?
  4. 中间状态从哪里开始偏离?
  5. 哪个操作破坏了不变量?
  6. 是否有边界输入?
  7. 是否有并发交错?
  8. 是否有表示转换或精度问题?

正确性排查的核心是:

找到第一次偏离预期的位置。

例子:

输入正确
解析后正确
归一化后错误
后续计算都基于错误状态

那根因大概率在归一化阶段,而不是最后输出阶段。

不变量在这里非常重要。

队列长度不能为负
金额不能凭空增加
已关闭连接不能继续发送数据
文件 header 声明长度要和实际内容匹配

如果你能找到“不变量第一次被破坏”的地方,bug 通常已经接近答案。

9. 性能排查:先找瓶颈,不要盲目优化

性能问题问的是:

时间或资源花在哪里?

不要在不知道瓶颈的情况下优化局部代码。

先按资源问:

资源观察问题
CPU是否算得太多,是否频繁上下文切换
内存是否分配过多,是否频繁回收
磁盘 IO是否读写太多,是否等待磁盘
网络 IO是否等待响应,是否丢包或重传
是否大量时间花在等待临界区
队列是否生产快于消费,任务堆积
缓存是否命中率低,是否频繁失效

性能排查常用路径:

确认现象
-> 看整体指标
-> 找最忙或最慢的资源
-> 定位到具体阶段
-> 定位到具体操作
-> 再决定优化手段

例如:

响应慢
-> CPU 不忙
-> 大量请求在等待 IO
-> 追踪显示慢在读取远程资源
-> 日志显示缓存频繁未命中
-> 优化方向是缓存和数据访问路径

如果 CPU 本来就不忙,盲目优化计算代码可能没有意义。

10. 偶发问题:把不确定性变成可观察

偶发问题难,因为它不稳定。

常见来源:

来源例子
并发交错某种线程顺序才触发
时间窗口超时、重试、定时器竞争
缓存状态命中和未命中路径不同
资源压力高负载下才出现
初始化顺序某些组件先后不同
外部依赖第三方服务偶尔慢或失败

偶发问题的策略不是“等它再出现”。要提高可观察性:

  • 给请求或任务加唯一 ID。
  • 记录状态转换前后。
  • 记录关键输入和边界值。
  • 记录线程、进程、机器、时间和版本。
  • 记录重试次数、超时值、队列长度。
  • 保留失败现场的快照或最小输入。

目标是:

下次出现时,系统能留下足够证据。

偶发问题也适合做压力复现:

增加并发
缩短时间窗口
重复运行
随机化顺序
模拟慢 IO
模拟失败和超时

这不是为了制造混乱,而是为了让隐藏的时序条件更容易暴露。

11. 修改前先建立假设,修改后验证假设

调试中的一次代码修改应该对应一个明确假设。

假设:
问题是解析阶段把空输入当成合法输入。

修改:
在解析阶段拒绝空输入。

验证:
旧失败输入不再通过解析。
正常输入仍然通过。
边界输入有明确行为。

如果你同时改十个地方,问题消失了,也很难知道到底哪个改动有效。

可靠调试节奏:

  1. 写下当前现象。
  2. 提出一个可验证假设。
  3. 找证据支持或反驳。
  4. 做最小修改。
  5. 重新运行能证明问题的检查。
  6. 确认没有破坏相邻行为。

这和科学实验非常像:一次只改变少量变量,否则结论不可靠。

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. 学完本章你能解决什么问题

学完这一章,你应该能解决或开始分析这些问题:

  1. 如何把“系统很慢”“网络不行”变成可调试现象?
  2. 日志、指标、追踪、调用栈、抓包分别适合证明什么?
  3. 为什么调试要按层排查,而不是直接猜根因?
  4. 如何用二分定位找到问题发生在哪个模块?
  5. 最小复现为什么能帮助你理解触发条件?
  6. 正确性 bug 应该如何寻找第一次状态偏离?
  7. 性能 bug 应该如何先找瓶颈再优化?
  8. 偶发并发或时序问题如何提高可观察性?
  9. 为什么每次修改都应该对应一个可验证假设?

如果你以后遇到“程序偶尔错、服务偶尔慢、请求偶尔超时、数据偶尔不一致”,不要先急着修。先把现象写清楚,建立证据链,再一点点缩小范围。能做到这一点,你的调试方式就已经从 guessing 进入 engineering。

延伸阅读