您现在的位置是:烟消火灭网 > 探索
程序员修复
烟消火灭网2026-10-17 07:42:24【探索】4人已围观
简介凌晨两点十七分,我盯着屏幕上那行红色的报错信息,手指在键盘上悬了半分钟没落下去。`NullPointerException at com.xxx.service.impl.OrderServiceIm
凌晨两点十七分,程序我盯着屏幕上那行红色的员修报错信息,手指在键盘上悬了半分钟没落下去。程序`NullPointerException at com.xxx.service.impl.OrderServiceImpl.calculatePrice(OrderServiceImpl.java:247)`。员修就这一行,程序把整个支付流程卡死了。员修
白天测试环境跑得好好的程序,预发环境一上就炸。员修运维群里有人@我,程序问是员修不是代码有问题。我没回,程序先把日志捞出来看了一遍。员修日志里订单号、程序用户ID、员修时间戳都对得上,程序唯独到了计算价格那一步,对象突然就空了。可代码逻辑明明写了判空——我翻到247行,盯着那行 `order.getItems().stream()` 看了足足十秒,脑子里闪过一个念头:并发。
这个接口白天QPS不到50,预发环境压测直接拉到800。`order`对象是从缓存里取的,缓存用的是本地Caffeine,过期时间设了30秒。问题就出在这儿——第一个线程拿到order,开始遍历items,遍历到一半,缓存过期了,Caffeine异步刷新把order对象替换成了新的。旧对象被GC回收,但第一个线程还在用引用,`getItems()`返回的list内部数组被清空,stream一跑就NPE。
这种问题很难复现。你本地跑一百遍都没事,因为QPS上不去,缓存根本来不及过期。我去年在上一家公司也遇到过类似的,那次是Guava Cache,场景是用户session刷新,也是并发读写导致对象状态不一致。当时我们加了个`synchronized`块,性能直接掉了一半。这次我学乖了,没急着改代码,先写了个复现脚本,用JMeter模拟并发,把Caffeine的`expireAfterWrite`改成1秒,跑十次能复现三次。
复现了就好办。方案有三个:第一,缓存里存不可变对象,每次刷新生成新实例,旧线程继续用旧的,新线程用新的,互不干扰。第二,加读写锁,读的时候不允许刷新。第三,把`getItems()`改成返回副本。我选了第一个,因为改动最小,而且Caffeine本身就支持`refreshAfterWrite`,配合`buildAsync`可以做到无锁刷新。改完压测跑了一小时,QPS稳在1200,NPE再没出现。
修完这个bug,我靠在椅背上发了会儿呆。程序员修bug,修到最后其实是在修自己对系统的理解。你以为是代码写错了,其实是并发模型没吃透;你以为是缓存配置问题,其实是对象生命周期管理没想清楚。每一次NPE背后,都藏着一个你之前忽略的时序问题。
天亮的时候,运维群里有人发了句“支付恢复了”。我没说话,把复现脚本和修复方案整理成文档,丢进了团队wiki。下次再有人踩这个坑,至少能少熬一个通宵。程序员这行,修复的不只是代码,还有自己那点侥幸心理——总觉得“我写的逻辑没问题”,可系统跑起来,从来不按你写的逻辑走。
很赞哦!(169)







