用 AI 改一个"看起来很小"的 bug,最后改成了一次调用链重构——记录一次真实的排查过程
背景:项目里有个定时任务,每天凌晨跑一次数据同步,最近半个月偶尔报"记录重复"。用户报的现象很模糊:不是每次都复现,大概三天出一次,日志里只有一行 IntegrityError。第一次看到这个报错,我的第一反应是加个 try/except 吞掉,反正不影响主流程。现在回头看,这个想法本身就错了。
怎么排查的:我先把报错时间点和任务执行时间对齐,发现所有失败的运行都有一个共同点——都在重试发生过之后。也就是说,第一次执行失败触发重试,重试的那次才报重复。顺着这个线索去读代码,发现同步函数是按"批次"写的:外层循环分页拉数据,每页调一次写入函数。写入函数内部用的是 insert,没有任何幂等处理。重试逻辑在更外层的调度器里,它重试的是整个同步任务,而不是失败的那一页。
坑在哪里:真正的坑不是"没有幂等",而是职责放错了地方。调度器只看得到任务的成败,看不到页码,所以它的重试粒度天然就是"整个任务"。而写入函数确实需要幂等,但它拿不到"我是第几页"这个信息,因为参数没往下传。当时的代码是这样的:调度器调 sync(),sync() 循环分页,每页调 save_batch(items),save_batch 直接 insert。重试发生在调度器,幂等需要的东西在 sync 循环里,两者隔了两层。
我先试了最小改动:把 insert 改成"先 select 再 insert"。跑了一天,还是偶尔重复。原因很直白——select 和 insert 之间有窗口,重试的那次可能和上次的写入交错,检查通过但写入撞车。这不是并发量大的场景,所以它只是"偶尔"发生,正好和三天一次的频率对得上。
关键结论:单条 SQL 里做"不存在才插入"才能消灭这个窗口,也就是 ON CONFLICT DO NOTHING 或者等价的 upsert。我把 save_batch 改成用 INSERT ... ON CONFLICT DO NOTHING,按业务键做唯一约束。改完当天跑了三次手动重放,把之前失败的历史批次重跑了一遍,没有一条重复,之前的失败记录也全部补上了。
可复用的方法,我总结了三条:
第一,看到"偶尔"两个字,先去找"重试"和"并发"。偶发错误的绝大部分来源是这两件事,而不是玄学。把所有失败运行的特征做个简单对比,共同点往往一眼就出来了。
第二,重试逻辑的粒度和幂等键必须在同一层。如果你的重试是任务级的,那幂等就得覆盖整个任务;如果是页码级的,幂等才可以是页码级的。层级错配是这类 bug 的根源,改单点不管用。
第三,"先查再写"永远有窗口,只有数据库层面的唯一约束能兜住。应用层的检查只能减少冲突,不能消除冲突。这不是性能优化问题,是正确性问题,用数据库已有的能力去解决,比自己写锁简单可靠得多。
最后补一句:这次改动总共不到十行,但前面花在读调用链上的时间占了八成。如果一开始就改 save_batch 加 try,我大概会把这个 bug 再推三天。