上个月我接手了一个老项目的bug,现象非常简单:服务跑八个小时左右就会慢慢卡死,但重启后一切正常。一开始我怀疑是内存泄漏,上去就打开了 VisualVM,GC 日志也开了,还加了十几个监控点。结果呢?两个小时过去,内存曲线好看得像心电图,一点问题都没有。你懂那种感觉吗——你明明知道系统在流血,但所有仪器都说它很健康。后来我换了工具,用 jcmd 手动 dump 堆,用 MAT 分析,仍然一无所获。那时候我陷入了典型的调试陷阱:因为现象像内存问题,我就一直往内存方向找,而忽略了真正的线索——时间。
说实话,干调试久了会养成一种条件反射:看到空指针就查 null,看到超时就查网络,看到 CPU 高就查循环。这种思维像肌肉记忆,快但是浅。真正坑人的 bug 往往不在你熟悉的那条路上。这次我换了个笨办法:把所有能拿到的现象列在一张草稿纸上,不管有没有用。比如:故障固定在 8 小时左右发生,不是 3 小时也不是 12 小时;第一个告警来自线程池的拒绝策略,而不是响应时间;内存曲线没有任何波动,但线程数在凌晨两点有个小尖峰。我用笔把这些点连起来,突然发现一个被我忽略的事实——每天凌晨两点有个定时任务,叫 optimizeCache。它不会占用太多内存,但它会触发一次全局锁,而那个锁的另一端连接着数据库连接池。8 小时正好是连接池里 sleep 连接被回收的周期,两者叠加,造成了一个极低概率的死锁。
说到这,我想把调试过程拆成三步,因为我发现大部分教程只会教你怎么用断点、怎么查日志,却没人教你怎么组织证据。第一步,记录现象,必须写到纸上,不许用记事本。为什么?因为纸上的信息无法被搜索,你的大脑就不会走“关键字匹配”的老路,而是被迫做空间关联。第二步,定义时间线。把从正常到异常之间的所有事件,包括定时任务、用户请求、外呼接口,全部按分钟标出来。第三步,做减法。每个现象背后至少有俩可能原因,用排除法干掉那些无法被证据支持的原因——注意,是证据支持,不是直觉支持。我见过太多同行用“我觉得这里不太对”作为排除理由,那叫赌博。
这次调试还有个额外的感悟:工具越高级,反而越容易让思维变懒。我认识一个搞嵌入式的老前辈,他说他们排查问题从来只看寄存器现场和串口打印,连 IDE 的调试器都很少用。当时我不理解,觉得那是原始人采猎。直到这次经历,我才体会到他话里的意思——当你依赖可视化工具的时候,你会被工具设计者预设的角度框住。VisualVM 只给你看它认为重要的,而一张白纸什么都敢画。当然,我不是说断点和 profiler 没用,我每天都用,但当你试了多轮工具都找不到方向的时候,就应该停下来,把工具全部关掉,回到源头做一次单纯的观察。
如果你想试这个方法,我给你一个最小的练习场景。下次遇到一个“偶发”bug,不要立刻打日志,先忍住。清空你的编辑器,打开一个纯文本文件,回答自己四个问题:这个 bug 在什么条件下一定出现?什么条件下从来不出现?它出现时系统里哪些进程/线程/文件的状态发生了变化?第一个发生变化的是什么?这四个问题里哪怕你能准确回答其中一个,就已经超过一半的开发者了。大多数人都是先猜一个原因,然后加日志验证,错了就换个原因继续猜——运气好十分钟,运气差就像我一样折腾三天。我不是说自己从此不再试错了,但现在每次动手改代码之前,我会先问问自己:我手里的证据链够不够长?如果断了一环,那刚才的所有努力都只是安慰剂。