三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

从实际项目聊聊Java异常处理的常见误区

从实际项目聊聊Java异常处理的常见误区

你打开一个老项目的日志文件,满屏的Exception堆栈,却没人知道业务到底哪里失败了。这不是段子,这是我接手第三个支付系统时面对的真实场景。异常处理在Java里被当成语法糖一样随手乱抛,却极少有人把它当作架构的一部分来设计。今天我想从几个真实踩过的坑出发,聊聊那些让系统变脆、让排查变难、让同事骂娘的常见误区。

吞异常:编程界最隐蔽的慢性毒药

第一个项目里有个定时任务,跑着跑着突然不执行了。查了半天,发现代码里有个catch块,里面只写了一行注释“// TODO 以后处理”。异常被捕获后什么都没做,任务就像被掐了喉咙的哑巴,静默失败。吞没异常不是容错,是自杀式安静。

更危险的是某些框架的“自动捕获”机制。Spring的@Async方法如果内部异常没抛出,线程池里的异常会被框架吞掉,日志里连个影子都看不到。有一次用户反馈“优惠券没到账”,排查了一整天,最终发现是异步发券代码里catch(Exception e)后只打了个log.error,但log框架配置级别是WARN,error级别根本没输出。你以为记录了日志,实际日志框架可能根本没打开对应级别。

吞异常的深层原因往往是对业务失败的恐惧。怕异常往上抛会影响主流程,所以就地掐死。但正确的做法是:能处理就处理,不能处理就抛出去,或者至少记录完整上下文。最怕的是catch里放个e.printStackTrace(),在微服务环境下,堆栈打印到哪个节点、哪个容器你根本不知道,这等于把线索扔进下水道。

捕获粒度:一场关于颗粒度的战争

有一次代码评审,看到一段代码:

try { orderService.create(order); paymentService.pay(order); stockService.deduct(order); sendMessage(order); } catch (Exception e) { log.error("下单失败", e); }

这种写法把四个独立业务操作捆在一起,任何一个环节出问题,整个事务回滚。但实际业务中,支付失败和库存扣减失败的处理策略完全不同。异常处理的第一原则:按业务边界划分try-catch块,而不是按代码位置。

另一个极端是每个方法内部都try-catch,然后throw new RuntimeException(e)。这会导致异常在每一层被打包、拆包、再打包,堆栈变得又长又臭。有一次排查线上问题,异常堆栈有将近200行,中间至少有5层是包装再包装。你唯一要包装异常的场景,是跨模块传递时需要补充业务上下文。否则,请让异常自然向上传播。

真正的实战经验是:在Service层抛业务异常,在Controller层做统一兜底,在第三方调用处做定制化捕获。颗粒度要精细到“这笔订单的库存锁定失败”和“这笔订单的支付回调验签失败”可以走不同的降级逻辑,而不是笼统的“操作失败”。

异常类型乱用:把Exception当万能筐

我见过一个团队,所有异常都抛new Exception("错误码10001")。结果他们的catch代码全写成了catch (Exception e) { String code = e.getMessage(); }。当业务异常和系统异常混在一个Exception里,什么防御式编程都成了笑话。异常类型本身就是一种通信协议,乱用类型等于在协议里写乱码。

Java的异常体系设计得很清晰:CheckedException用于可预见的业务校验,UncheckedException用于程序缺陷或不可恢复的系统错误。但实际项目里,有人把参数校验失败抛成NullPointerException,有人把数据库连接超时捕获后转成业务异常“库存不足”。这种错位会让上层代码做出一堆错误的判断——库存不足会触发重试机制,数据库超时却可能直接被当作正常业务失败,导致数据不一致。

更常见的误区是用返回值代表异常状态。比如返回null表示失败,返回0表示成功,结果每个调用方都写if(result == null)判断,忘了的话就空指针。异常处理的正道是:业务的失败用例用异常表达,状态码只用于HTTP传输层。别用返回值的“魔法数字”替代异常机制,那是C语言时代的遗产。

finally块里做危险操作

有一次系统发版后内存暴涨,最终定位到是finally块里调用了一个远程服务去释放分布式锁。结果远程服务超时,线程卡在finally里,把连接池占满了。finally块是用来清理资源、释放锁的,不是用来执行新业务、特别是IO操作的。

还有一个经典误区:在finally里直接return,覆盖了try块里的异常。比如:

try { doSomething(); // 抛异常 } finally { returnValue(); // 返回了一个正常值 }

异常被悄无声息地吃掉,调用方以为一切正常,实际上系统已经处于错误状态。最后一道防线里的陷阱,往往最致命。正确做法是:finally只做资源关闭,且关闭操作本身再用try-catch包裹,避免关闭过程中的异常掩盖原始异常。

曾经有个支付对账项目,数据库连接在finally里关闭,结果连接池因为关闭太频繁导致性能下降。后来改成用try-with-resources,代码更简洁,资源管理也更可靠。Java 7开始就应该用try-with-resources替代传统finally关闭,很多老项目还捂着的旧习惯,真的该改了。

日志与异常的错位

异常处理不只是throw和catch,日志是它亲密的战友。但项目里常见的误区是:在catch里写了log.error,又往上抛了异常,上层又log.error,最后一条异常被打印了三次。重复打印日志会让排查者分不清哪个是根因。建议是:异常只在源头打印一次,或者只在最顶层打印一次,不要每层都打。

更隐蔽的问题是异常日志里不包含业务上下文。比如log.error("保存失败", e),失败的是哪条订单?哪个用户?哪个请求ID?全都没有。有一次排查退款失败,日志里全是“退款异常”,但没有订单号、没有金额,只能靠时间戳去数据库反查,效率极低。异常日志里必须包含足够的追踪信息:业务ID、请求ID、关键参数。最好的实践是把MDC里的traceId一起打出来,这样分布式环境下才能串起全链路。

还有团队喜欢在catch后打日志,然后抛出一个新的异常但把cause带上。这没问题,但注意别把敏感信息打到日志里。有一次他们把用户的手机号、身份证号明文打出来了,合规审查直接亮红灯。异常处理要兼具安全视角,日志不是垃圾桶。

框架层面的异常处理误区

Spring的@Transactional默认只在RuntimeException和Error时回滚,检查异常不会触发回滚。很多团队在方法上标注事务,然后内部catch了所有异常,导致事务方法像个漏水的桶——数据一半写进去了,另一半没写,还没人知道。同一个方法里的异常处理必须理解事务的边界。

另一个框架层面的坑是@RestControllerAdvice里接住了所有异常,但返回的错误码和错误信息设计得很粗糙。前端拿到一个“系统繁忙”根本没法处理,用户只能干瞪眼。全局异常处理器要区分业务异常、参数校验异常、鉴权异常、系统异常,每一类返回不同的HTTP状态码和错误信息结构。

还有异步处理的异常陷阱。@Async方法里的异常默认不会传播到调用者线程,除非你在配置里显式设置ErrorHandler。新手项目经常在异步任务里抛了业务异常,结果主线程傻傻地以为成功了,结果数据对不上。异步任务里记得配一个全局的AsyncUncaughtExceptionHandler,或者把异常捕获后转投到一个专门的错误队列。

业务异常的设计:错误码与信息分层

项目做大了,异常处理最考验的是抽象能力。你有没有见过一个枚举类里躺了500多个错误码?有没有见过同一个错误码在不同模块里含义还不一样?业务异常不是一个类、一个枚举就能搞定的,它需要一套分级机制。我的经验是至少分三级:一级是给用户看的提示信息(如“您的订单包含已下架商品”),二级是给开发看的排查信息(如“商品ID=xxx在下单时已下架”),三级是给运维看的系统信息(如“SQL执行超时,连接池耗尽”)。这三者不应该挤在一个字段里。

很多项目的异常对象只有message和code,没有severity级别,没有恢复策略提示。比如库存不足,是否需要重试?是否允许部分发货?这些信息如果能放到异常类里,上层策略就能动态决定走向。异常类应该是业务规则的一部分,而不是一个简单的字符串容器。

还有异常信息里硬编码文案,导致国际化成了噩梦。正确做法是把错误码作为消息key,用ResourceBundle或者模板引擎动态生成。实际项目里见过把中文文案直接拼在异常里的,后来要上英文版,只能一处一处改。从第一天起,异常信息就不要写死任何用户可见的文案。

从一次线上事故看异常处理的连锁反应

最后说一个印象深刻的真实事故。凌晨三点,用户反馈“支付成功后订单一直显示待支付”。我们的支付回调service在catch到验签失败时,直接吞掉异常并记录了log.warn。结果第三方支付平台因为没收到成功响应,连续重发了6次回调,而我们的服务每次都在同一个节点吞掉异常,导致订单状态一直没更新。一个吞异常的小动作,引发了全链路的数据不一致。

事后修复很简单:把验签失败的异常抛出去,让外层重试或者接入人工处理队列。但这次事故提醒我们,异常处理的每一个决策都有代价,吞掉的异常会在别处爆炸。所以现在做设计评审时,我总会问一句话:这个catch之后,系统进入什么状态?如果服务崩溃了,数据怎么补偿?如果外部重试了,你的处理还安全吗?

写代码的时候,我们总是先想着“正常流程怎么走通”,很少有人先想“异常流程怎么保证安全”。但这个世界的真实规则是:正常流程只能带来功能,异常流程才决定系统的信誉。你可以写一百个正确的if-else,但一个错误处理的catch就能毁掉整个系统的可靠性。

Java异常处理从来不是语法问题,是工程判断问题。每一次catch、throw、log,都在塑造系统的命运。把异常当作一等公民来设计,你的项目才经得起真实流量的捶打。

← 返回列表