# 排错时先找证据，再改代码

原文: https://yishto.com/learn/developer-workflow/debugging-with-evidence/

作者: [Yishto](https://yishto.com/about/)

围绕搜索页持续加载的失败路径，结合复现、网络证据、断点与合成追踪时间线，验证修复是否覆盖边界。

首次发布: 2026-11-10
内容更新: 2026-11-10

资料搜索页一直显示加载中，控制台没有异常，网络面板却显示请求已经返回。此时继续改超时参数未必有用：问题可能在响应字段、状态转换，也可能是旧请求覆盖新请求。排错需要先把可观察的失败放在明确边界里，再用证据筛选假设。

本课使用合成请求与时间线，不接真实监控系统。目标是形成一份能重复失败、解释定位依据、验证修复范围的记录，而不是从一条日志猜出所有原因。

## 先给症状写出复现条件

记录操作顺序、输入、预期、实际结果和环境。对于搜索页，可以写成：输入不存在的标题，点击搜索；预期出现无结果提示，实际持续加载。随后记录是否每次发生、刷新是否改变结果，以及最后一个已知正常版本。输入数据与版本要能让另一位读者复现。

把失败分层：进程无法启动属于运行环境；HTTP 非成功状态属于请求结果；成功响应缺少约定字段属于数据契约；正确数据却显示错误状态属于界面逻辑。它们可能串联发生，但需要逐层检查，不能以“接口成功”证明整个功能成功。

先减小复现范围。保留一个查询、一条必要数据和一条操作路径，删除与故障无关的合成内容。不要在尚未确认输入一致时同时升级依赖、改缓存和重写组件；这样即使症状消失，也不知道哪个变化起作用。

## 网络记录和日志分别证明什么

浏览器 Network 面板可以观察请求地址、方法、状态、响应内容与阶段耗时。确认页面确实发了请求，再检查它返回什么。HTTP 200 与应用层 error 字段可以同时出现，JSON 解析成功也不代表字段类型正确。根据接口契约判断，不只看绿色状态。

Console 中的堆栈能定位异常发生的位置，但异常出现的那一行可能只是错误数据第一次被使用。日志中的时间相近也不等于因果相关。为请求分配关联标识，在服务入口、关键依赖调用和结果映射位置记录必要信息，才能串起同一次操作。

请求头、Cookie、响应体可能包含凭证或个人信息。导出 HAR、复制日志或截图之前，先裁剪和脱敏。调试所需的通常是方法、路径、状态、字段类型、错误码与合成标识，而不是整份真实用户内容。保留证据的同时要控制其传播范围。

## 断点检查变量，假设解释变化

断点暂停执行，让你检查当前作用域、调用栈和局部变量。VS Code 或 Python pdb 的单步进入适合观察被调用函数，单步越过适合观察调用前后的状态。选用哪种操作取决于疑问，而不是每行都停一次。

对于持续加载，可以在响应到达后检查 data 的实际类型，再在状态更新处检查分支是否执行。若代码只有非空结果才设置 success，那么空数组路径就可能遗漏结束状态。把这写成可反驳假设：相同空数组应重复失败，非空数组应走另一分支。

断点会改变时序。竞争问题在暂停后消失，并不能证明修复了竞争；日志过多也可能改变运行节奏。需要用不暂停的重复执行和可控响应顺序测试补充验证。不要把“单步执行正常”作为并发安全结论。

## 用时间线确认测量边界

OpenTelemetry 用 traces 和 spans 表达关联操作；一个 span 通常表示某项工作的时间区间，可包含属性与父子关系。跨服务需要传播上下文，采样会影响哪些操作被保存。找不到 trace 可能是未采集或未传播，不直接说明请求没发生。

下面的合成时间线以同一时间基准、任意单位表达 request、query、render。断言先检查结束不早于开始，再检查子步骤位于请求区间内。它没有调用 OpenTelemetry SDK，也没有验证跨机器时钟同步。

```python
spans = [
    {"name": "request", "start": 0, "end": 50},
    {"name": "query", "start": 8, "end": 30},
    {"name": "render", "start": 32, "end": 45},
]
assert all(s["end"] >= s["start"] for s in spans)
assert spans[1]["end"] - spans[1]["start"] == 22
assert all(0 <= s["start"] <= s["end"] <= 50 for s in spans)
print({s["name"]: s["end"]-s["start"] for s in spans})
```

输出为 `{'request': 50, 'query': 22, 'render': 13}`。query 的 22 来自 30 减 8。两个子步骤持续时间相加为 35，不等于整个请求的 50；剩余区间可能包含未建模的工作。若步骤重叠，简单相加还会重复计算时间，不能称为总延迟。

这个模型只验证给定数据的区间关系。实际系统要区分墙上时钟与适合测量耗时的时钟，还要确认埋点边界。缺少排队或序列化阶段时，不能把未解释耗时直接归为数据库问题。

## 修复只改变已验证的原因

确认遗漏空结果分支后，补全 loading 到 empty 的转换，而不是顺手重写全部状态逻辑。保存修改前后 diff，重新执行原始复现，然后检查非空结果、网络失败、解析失败和连续查询。修复原始输入却引入相邻失败，仍需要继续处理。

对于间歇问题，记录重复次数和可控条件，不用一次成功概括所有情况。日志能证明观察到了什么，测试能证明特定输入满足断言，二者都不能证明所有未来请求安全。将剩余不确定性写成具体待测路径。

避免永久留下高噪声调试输出。保留能解释关键状态的结构化日志，移除临时完整对象打印，并确认错误响应不会泄露内部堆栈。修复后重新检查日志格式，确保将来同类问题仍能定位。

修复前保留一个可恢复版本，失败时可以回到同一输入与代码基线。怀疑数据库结果时，先确认查询参数和返回行数，再检查序列化映射，避免直接用手工改数据消除症状。证据记录应区分已观察事实与仍待验证的解释，方便后续接手者继续实验。

## 练习：写一份可被反驳的排错记录

用合成搜索结果构造空数组、非空数组、无效字段三种响应。记录预期界面状态，先定位最小失败输入，再列出两个竞争假设，为每个假设设计能支持或排除它的观察。只按证据选择一项改动。

验收要求：另一位读者按记录重现原始错误，看到改动前后的差异，且三种响应都有明确终态。再让两次查询逆序完成，检查旧结果不会覆盖新结果。时间线练习则需检测负持续时间和超出父区间的步骤，并明确模型不能证明真实系统的时钟与追踪正确。

## 资料来源与延伸阅读

- [Debugger configuration](https://code.visualstudio.com/docs/debugtest/debugging)
- [Python debugger](https://docs.python.org/3/library/pdb.html)
- [OpenTelemetry concepts](https://opentelemetry.io/docs/concepts/)

资料核验：2026-11-10。教学示例不代表线上实测。

## 相关文章

- [让每次发布都能检查，也能恢复](https://yishto.com/learn/delivery/cicd-monitoring-and-incidents/)
- [AI 应用怎么观测：首字延迟、总耗时与成本](https://yishto.com/learn/production/latency-cost-observability/)
