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

发布时间:2026/8/9 9:11:34
从实际项目聊聊Java异常处理的常见误区
你打开一个老项目的日志文件满屏的Exception堆栈却没人知道业务到底哪里失败了。这不是段子这是我接手第三个支付系统时面对的真实场景。异常处理在Java里被当成语法糖一样随手乱抛却极少有人把它当作架构的一部分来设计。今天我想从几个真实踩过的坑出发聊聊那些让系统变脆、让排查变难、让同事骂娘的常见误区。吞异常编程界最隐蔽的慢性毒药第一个项目里有个定时任务跑着跑着突然不执行了。查了半天发现代码里有个catch块里面只写了一行注释“// TODO 以后处理”。异常被捕获后什么都没做任务就像被掐了喉咙的哑巴静默失败。吞没异常不是容错是自杀式安静。更危险的是某些框架的“自动捕获”机制。Spring的Async方法如果内部异常没抛出线程池里的异常会被框架吞掉日志里连个影子都看不到。有一次用户反馈“优惠券没到账”排查了一整天最终发现是异步发券代码里catch(Exception e)后只打了个log.error但log框架配置级别是WARNerror级别根本没输出。你以为记录了日志实际日志框架可能根本没打开对应级别。吞异常的深层原因往往是对业务失败的恐惧。怕异常往上抛会影响主流程所以就地掐死。但正确的做法是能处理就处理不能处理就抛出去或者至少记录完整上下文。最怕的是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多个错误码有没有见过同一个错误码在不同模块里含义还不一样业务异常不是一个类、一个枚举就能搞定的它需要一套分级机制。我的经验是至少分三级一级是给用户看的提示信息如“您的订单包含已下架商品”二级是给开发看的排查信息如“商品IDxxx在下单时已下架”三级是给运维看的系统信息如“SQL执行超时连接池耗尽”。这三者不应该挤在一个字段里。很多项目的异常对象只有message和code没有severity级别没有恢复策略提示。比如库存不足是否需要重试是否允许部分发货这些信息如果能放到异常类里上层策略就能动态决定走向。异常类应该是业务规则的一部分而不是一个简单的字符串容器。还有异常信息里硬编码文案导致国际化成了噩梦。正确做法是把错误码作为消息key用ResourceBundle或者模板引擎动态生成。实际项目里见过把中文文案直接拼在异常里的后来要上英文版只能一处一处改。从第一天起异常信息就不要写死任何用户可见的文案。从一次线上事故看异常处理的连锁反应最后说一个印象深刻的真实事故。凌晨三点用户反馈“支付成功后订单一直显示待支付”。我们的支付回调service在catch到验签失败时直接吞掉异常并记录了log.warn。结果第三方支付平台因为没收到成功响应连续重发了6次回调而我们的服务每次都在同一个节点吞掉异常导致订单状态一直没更新。一个吞异常的小动作引发了全链路的数据不一致。事后修复很简单把验签失败的异常抛出去让外层重试或者接入人工处理队列。但这次事故提醒我们异常处理的每一个决策都有代价吞掉的异常会在别处爆炸。所以现在做设计评审时我总会问一句话这个catch之后系统进入什么状态如果服务崩溃了数据怎么补偿如果外部重试了你的处理还安全吗写代码的时候我们总是先想着“正常流程怎么走通”很少有人先想“异常流程怎么保证安全”。但这个世界的真实规则是正常流程只能带来功能异常流程才决定系统的信誉。你可以写一百个正确的if-else但一个错误处理的catch就能毁掉整个系统的可靠性。Java异常处理从来不是语法问题是工程判断问题。每一次catch、throw、log都在塑造系统的命运。把异常当作一等公民来设计你的项目才经得起真实流量的捶打。