try-catch这个坑,你踩了多少次?
事情是这样的,那天凌晨三点,我的手机响了。不是美女约我,是报警。
核心服务挂了。看了一眼监控,发现数据库连接数瞬间爆表。再看日志,密密麻麻的 timeout 报错,一条接一条,跟弹幕似的。
我第一反应是:数据库扛不住了?加配置?扩容?还是被人DDoS了?
结果查了一个小时,发现真正的罪魁祸首,是一个看起来人畜无害的 try-catch。
就那么几行代码,藏在几百个文件里,差点让我头秃。
事情是这样的
我们有个定时任务,每五分钟跑一次,扫描订单表,把超时的订单状态更新一下。代码大概长这样:
public void processTimeoutOrders() { List<Order> orders = orderMapper.findTimeoutOrders(); for (Order order : orders) { try { updateOrderStatus(order); sendNotification(order); } catch (Exception e) { log.error("处理订单失败: {}", order.getId(), e); } } }
看起来很合理对吧?单个订单失败不影响其他订单,还有日志记录。教科书级别的异常处理,完美。
但问题在于,sendNotification 里面调了个外部接口,偶尔会超时。超时就抛异常,然后被 catch 住,打个日志,完事。
然后呢?没有然后了。订单状态没更新,但日志打了。下一个五分钟,这个订单又被查出来,再跑一遍,再失败,再打日志。
一个订单,半小时能给你打几百条 error 日志。运维一看日志量,还以为系统被DDoS了。
更要命的是,因为 sendNotification 抛了异常,后面的订单处理全停了。但因为在循环里面,异常被吞了,所以从外表看,好像系统还在正常运行,就是某些订单莫名其妙没处理。
这种 bug 最恶心了。不崩,但数据就是不对。客服被客户骂,运维被领导问,开发者在家里睡觉不知道自己写的代码正在生产环境挖坑。
你的try-catch在干什么?
事后我反思这个问题,发现一个很扎心的事实:
大多数人的 try-catch 不是在处理异常,而是在掩盖异常。
catch (Exception e) { log.error("出错了", e); } —— 这大概是世界上最常见的代码异味。我见过无数次了,每次看到都想问:你打这条日志是给谁看的?给领导看系统出过问题?给自己看心理安慰?
你 catch 了这个异常,然后呢?打了一条日志,然后假装问题不存在了。
下一次定时任务跑过来,这条记录还在。下下次,还在。你不是在解决问题,你是在积累债务。利滚利那种。
总有一天,会有人拿着账单来找你。那个订单怎么超时了三天还没处理?客服说客户打了好几个电话。为什么没人知道?因为异常被你的 try-catch 悄悄吞掉了。
你以为你保护了系统,其实你只是把问题藏起来了。藏到某个你意想不到的时间点,以一种你意想不到的方式爆发。
三种真正有用的异常处理模式
我后来总结了一下,真正有价值的异常处理,其实就三种。没有第四种了,别整那些花里胡哨的。
第一种:重试。 如果这个操作可以重试,而且暂时性故障(比如网络抖动、数据库忙、外部服务响应慢)导致的,那就重试。但要注意:重试要有上限,要有退避策略,不能死循环。
@Retryable(value = Exception.class, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)) public void updateOrderStatus(Order order) { // 业务逻辑 }
重试个两三次还失败?就别重试了,说明这不是暂时性问题,继续重试只会把系统拖垮。很多人在重试策略上容易犯的错是:重试次数太多,或者没有退避,直接疯狂重试,把对方服务打垮。
第二种:补偿。 如果这次操作失败了,你需要知道哪些工作已经完成,然后去做对应的补偿。比如更新了状态但发通知失败了,要不要回滚状态?要不要发到死信队列稍后重试?
public void processOrder(Order order) { try { updateOrderStatus(order); } catch (Exception e) { compensationQueue.add(new CompensationTask(order, "UPDATE_STATUS")); throw e; } try { sendNotification(order); } catch (Exception e) { compensationQueue.add(new CompensationTask(order, "SEND_NOTIFICATION")); throw e; } }
这样至少你能知道,哪一步成功了,哪一步失败了。该补偿的补偿,该重试的重试。这才是真正的容错,而不是在日志里写一句"操作失败"然后假装什么都没发生。
第三种:暴露。 如果这个异常你不应该在这里处理,就让它往上抛,抛到真正能处理的地方去。或者直接告警,让人介入。别在自己不知道怎么处理的地方把异常吃掉了。
try { doSomething(); } catch (SpecificBusinessException e) { // 这种异常我知道是什么,可以处理 handleBusinessError(e); } catch (Exception e) { // 这种异常我不知道是什么,不该我处理 throw e; }
很多人觉得 catch 范围越大越安全,其实恰恰相反。你 catch 得越宽,你其实越不知道怎么处理。catch (Exception) 是最偷懒的做法,也是最危险的做法。
还有一种更阴的坑
上面的问题已经够坑了,但还有更阴的。
有时候你 catch 了异常,但 catch 里面的代码本身也会抛异常。然后你的原始异常就丢了。堆栈信息被覆盖,最原始的错误原因再也查不到了。
try { doSomething(); } catch (Exception e) { log.error("操作失败: {}", params, e); // 如果这里格式化日志的时候出问题,你就永远失去 e 了 sendAlert(); // 如果 sendAlert 本身有问题呢?两个异常叠在一起 }
我见过有人 catch 住异常,然后调用一个会抛空指针的方法,原始异常直接没了。查 bug 的时候对着一个 NullPointerException 怀疑人生,完全不知道真正的原因是什么。
这种情况,推荐用一个包含了原始异常和上下文的专用异常类:
try { doSomething(); } catch (Exception e) { throw new BusinessException("处理订单失败,订单ID: " + orderId, e); }
把上下文信息和原始异常都包进去,这样排查问题的时候,你至少知道是哪个订单出的事,而不是对着一堆 null 抓瞎。
我后来养成了一个习惯:如果 catch 里面只是打日志,那我就加一行 throw new RuntimeException("context", e)。不是真的要抛出去,是确保上下文信息不会丢。虽然有点dirty,但关键时刻能救命。
说点得罪人的话
很多人写 try-catch 的心态是:先别崩,运行起来再说。
这种心态,短期看是保护了自己,长期看是坑了团队。代码跑起来了是没什么问题,但数据可能已经悄悄坏了。
一条被吞掉的异常,可能当时看起来风平浪静,但等你发现的时候,已经是一堆莫名其妙的数据和一堆没人看得懂的日志了。
好的异常处理不是让代码不报错,而是让报错之后还能救回来。
下次写 try-catch 之前,先问自己三个问题:
- 这个异常我要怎么处理?重试、补偿、还是暴露?
- 处理之后,程序的状态还是正确的吗?
- 如果我不知道怎么处理,是不是就不该 catch?
想清楚这三个问题,你的 try-catch 才算没白写。想不清楚的话,宁可让它往上抛,也别偷偷吞掉。
至于我那个凌晨三点的故障?最后怎么解决的?
很简单:告警加上异常订单的人工处理队列。catch 住之后,不是打个日志完事,而是进入一个待处理流程,第二天上班一看,有十几条积压,人工处理一下,该重试的重试,该取消的取消。
技术债还了,终于能睡个好觉了。
希望你们的 try-catch,不要成为下一个凌晨三点的噩梦。
也希望你们的监控系统,不要只在凌晨三点才告诉你出事了。白天能发现的问题,干嘛非要等到半夜?