速读#
两个故事讲的是同一个偏差:明明最直接的线索就在眼前,却绕去查更高级、更体面的可能。调试的第一优先级不是炫技,而是逐字读报错、比对环境差异;间歇性问题则先想办法稳定复现。
先摆两条规律#
调试领域有个被反复验证的大致规律:一份代码在本地环境能正常跑、换到线上或另一台机器就出错,问题落在环境层(变量、依赖版本、配置文件、文件路径、网络)的概率,远高于落在逻辑层的概率。原因是逻辑错误通常与运行位置无关——同样的输入走同样的代码路径,在哪都会错;而环境差异(.env 是否就绪、解释器版本、容器网络、时区 locale)只在切换环境时才暴露。所以这个信号一旦出现,排查的优先顺序本应是:逐字读报错信息→比对环境差异→查连通性→最后才怀疑代码逻辑。
间歇性 bug 又是另一种规律。它难,不是因为复杂,是因为不可复现——盯着跑它不犯、一转身它就崩,你没法对它动手。这类 bug 的处理顺序也不是先读代码,而是先想办法让它稳定复现:一旦能按需触发,就等于修了一半,因为复现链路通常就指向了真凶(并发、文件锁、竞态、外部依赖的时序)。无法复现的 bug 没法被真正修复,只能被「碰运气改一下试试」。
这两个规律底下是同一个朴素的认知:线索离你越近、越朴素,命中率越高;越高深、越体面的猜测,越容易是把人带偏的叙事。
情人节前夜#
第一个故事在情人节前夜。写 Spring Boot 接口,一个 @PathVariable 参数死活传不进方法,Postman 打过去永远返回 null。我对着屏幕干瞪了三个小时。
那三个小时里的排查路径是:反复确认路由写法、反复核对参数名、甚至怀疑是 Spring 版本的 bug——就是没去看一眼「变量名和路径占位符对上了没」。
咖啡馆下午#
第二个故事在一个本该轻松的周末下午。抱电脑去咖啡馆,被一个 bug 钉在那两个半小时——FastAPI 接口本地跑得好好的,部署到 VPS 上一访问就 502,uvicorn 日志只有一句 KeyError: 'NOTIFY_WEBHOOK'。
日志明明白白写着那行 KeyError。但因为本地跑没问题,我下意识认定「不可能是这么简单的事」,开始往别的方向钻:怀疑 Nginx 配置,反复对照 proxy_pass,改了又改,重启三四次——其实 502 是网关根本连不上后端,该问的是「后端进程为什么没起来」,不是转发规则写没写对;怀疑 Python 版本差异,本地 3.11 服务器 3.12,花二十分钟对比 changelog,完全无关;怀疑 Docker 网络,进容器 curl 一通证明网络是通的,又是无关。
转机都在离开屏幕之后#
第一个故事的转机发生在洗澡时——离开屏幕,脑子突然冒出「变量名和路径占位符对上了吗」。冲回电脑一看:路径 {userId},参数名 id,没加 @PathVariable("userId")。改一行,通了。
第二个故事的转机,是去柜台续了杯水、站着喝完回来的那两分钟。回到座位后我才第一次认真看了那行 KeyError,然后发现:本地 .env 有 NOTIFY_WEBHOOK、被 gitignore 排除,服务器上压根没这个文件。代码里直接 os.environ['NOTIFY_WEBHOOK'] 取值没给默认值,启动就崩。修复 30 秒。
栽过两次之后我刻的两条排查优先级#
栽过两次,我形成了两条很具体的排查纪律。
第一条:本地能跑、线上不行,问题几乎一定在环境层,不在逻辑层。 这个信号出现时,别先怀疑代码,把环境差异(.env、版本、配置)排前面。
| 优先级 | 检查什么 | 命中率 |
|---|---|---|
| 1 | 认真读报错信息,逐字 | 极高 |
| 2 | 对比本地和线上环境差异(.env、版本、配置) | 高 |
| 3 | 检查网络连通性(端口、DNS) | 中 |
| 4 | 才轮到怀疑代码逻辑 | 相对低 |
第二个故事里,我要是第一秒逐字读那行 KeyError,两个半小时变 30 秒。
第二条:能复现的 bug 才是好 bug。 前阵子 FastAPI 接口每隔二三十次请求才返回一次 500,盯着跑它不犯、一转身它就崩。遇到间歇性 bug,第一件事不是读代码,是想办法让它稳定复现。那次我在关键路径加满 print,最后定位是并发下 SQLite 文件锁冲突——单线程永远不触发,多开几个请求才偶尔撞上。能复现就等于修了一半。
我犯的是同一个偏差:跳过最直接的线索#
两个故事表面毫无关联,一个 Spring 一个 FastAPI,一个深夜一个咖啡馆。但把排查路径摆一起,底子是同一个错:明摆着的线索就在眼前——一行 KeyError、一个路径占位符——我都没在最该看的时候看它,而是去查更「高级」的可能。Nginx、Python 版本、Spring bug、Docker 网络,这些方向体面、像那么回事,但都不是那一行报错指向的地方。
心理上有个很微妙的偏差在起作用:「这么简单的错不可能是我犯的」。于是下意识往复杂的方向钻,结果恰恰就是那个简单的错。真正让我浪费两个半小时的,不是 bug 难,是方向错了。
而两个转机机制完全一样:离开屏幕的几分钟,大脑从「在已有思路里反复打转」的死循环里跳出来了。我管这叫「热水器驱动开发」——卡超过 40 分钟强制起身,不是放弃,是给潜意识腾地方。它后来救了我不止一次。
别绕开朴素事实#
这两次还让我顺带想通一件更普遍的事:人有一种用更复杂的解释掩盖简单错误的倾向,它不只出在 debug 上。
代码里写 // TODO: 临时方案,记得改 的地方,往往会一直跑到项目下线。我有一段硬编码配置,标着「等有空改成读配置文件」,三个月过去今天还在生产跑着。后来我想通:别骗自己,临时的就是永久的——要么现在好好写,要么坦然接受它一直在,但别给自己留「以后会改」这个心理安慰,那比 bug 本身更有害。
这和「跳过最直接线索去查高级可能」是同一种心理结构:用一个更体面、更省事的叙事,回避眼前那个朴素的事实。 这一层想通,是这两次 debug 给我超过 bug 本身的收获——它治了我对「复杂叙事」的本能偏好,提醒我遇到事先看眼前那行报错,再决定要不要往深里钻。
这些是经验,不是定律#
把它们说成「永远有效」就又犯了同一个毛病——用一个高级叙事盖住具体情境。所以边界写清楚:
- 离开屏幕救的是「思路打转」这类卡法;逻辑本身不理解,起身也没用,得去补理解。
- 优先级表是「本地能跑线上不行」这个信号下的流程,不是所有 bug 的万能顺序。
- 「临时即永久」是提醒自己对临时诚实,不是说所有临时方案都该立刻改成正式的——有些临时是真临时,诚实标注即可。
写代码这行,耐心比聪明重要。聪明让你写得快,耐心让你改得完。这两次我学到的其实是更朴素的一句:别绕开最直接那条路。
