1. 第一次看到那段代码时,我被“一个try catch”吓到了
那天我在Review一个订单服务的新接口,代码从Controller一路看到Service,突然停住了。不是因为逻辑多复杂,而是那段try catch写得太干净了,干净到让人怀疑自己是不是一直在用脚写Java。
大多数团队的try catch长什么样?大概是这样的:
java复制try {
orderService.createOrder(orderDTO);
} catch (Exception e) {
log.info("创建订单失败");
return Result.error("创建订单失败");
}
如果运气不好,还能看到更让人血压升高的版本:
java复制try {
orderService.createOrder(orderDTO);
} catch (Exception e) {
e.printStackTrace();
}
而那位从头部电商公司跳槽过来的工程师写的是这样:
java复制public Result<Void> createOrder(CreateOrderDTO dto) {
if (dto == null || dto.getUserId() == null) {
throw new BizException(ErrorCode.PARAM_NOT_NULL);
}
try {
orderService.createOrder(builder.from(dto));
return Result.success();
} catch (BizException e) {
log.warn("[order] create failed, biz error, bizNo={}, msg={}", dto.getBizNo(), e.getMessage());
return Result.error(e.getCode(), e.getMessage());
} catch (RetryableException e) {
log.error("[order] create failed, retryable error, bizNo={}", dto.getBizNo(), e);
return Result.error(ErrorCode.ORDER_CREATE_CONFLICT);
} catch (Exception e) {
log.error("[order] create failed, unknown error, bizNo={}", dto.getBizNo(), e);
return Result.error(ErrorCode.SYSTEM_ERROR);
} finally {
metric.record("order.create", System.currentTimeMillis() - start);
}
}
看到这段代码的一瞬间,我的第一反应是:这也太夸张了吧,写个try catch而已,有必要这么讲究吗?但等我冷静下来,把这段代码拆开看了一遍,才发现自己缺的不是语法知识,而是一整套关于异常处理的思考方式。
这篇文章我就想把这套思考方式彻底聊透。不管你现在用的是Java、Go还是JavaScript,只要写过判空、试错、兜底,就一定用得上看完这篇之后的几个判断准则。我会从最基础的try catch / finally / return执行机制讲起,一直讲到异常体系怎么设计、跟全局异常处理怎么配合、以及我在实际项目里踩过的那些“看起来正常但后患无穷”的坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 优雅的底层:try catch / finally / return 的完整执行链路
如果想写出优雅的try catch,第一步不是学设计模式,而是真正搞懂try、catch、finally、return四者之间的执行顺序。很多同事写了好几年代码,在这上面都有错误认知。最典型的一个问题:try里先return了一个值,finally里又修改了这个值,最后方法返回的是哪个?
2.1 finally 和 return 到底谁先执行
直接说结论:在return表达式执行完之后、方法真正返回之前,会先执行finally块。用一段代码来验证再清楚不过:
java复制public static String testFinally() {
String result = "before";
try {
result = "in-try";
return result;
} finally {
result = "in-finally";
System.out.println("finally executed");
}
}
public static void main(String[] args) {
System.out.println(testFinally());
}
这段代码输出什么?答案是:
code复制finally executed
in-try
为什么?因为return result其实分两步走:先把result当前的值(也就是"in-try")存到临时槽里,然后执行finally,最后才把临时槽里的值返出去。finally里修改result,改的是变量本身,不是那个已经拷贝出来的临时值。
但是,这里有一个让很多人翻车的前提:这个临时值针对的是基本类型和String这种不可变对象。如果return的是引用类型,情况完全不同:
java复制public static List<String> testRefFinally() {
List<String> list = new ArrayList<>();
try {
list.add("try");
return list;
} finally {
list.add("finally");
}
}
这段代码返回的List里既有"try"又有"finally"。因为return保存的是list的引用地址,finally里通过这个引用去修改List内容,修改的是同一块堆内存,所以调用方拿到的是被改动过的集合。
这一点极其重要。日常开发里如果有人在finally里去更新某个缓存、修改某个聚合根的状态,然后又想依赖return返回值做后续判断,非常容易出现“明明返回值是对的,但被调方看到的对象又被改了”的诡异问题。
2.2 finally 里写 return 是最危险的写法
下面这种写法属于我见到一次就要拉一次黑的典型:
java复制public static boolean doSomething() {
try {
int result = riskyOperation();
return result > 0;
} catch (Exception e) {
log.error("failed", e);
return false;
} finally {
return true;
}
}
不管try里发生了什么,不管catch有没有捕获异常,这个方法永远返回true。更恐怖的是,finally里的return会把原本要抛出的异常直接吞掉。比如try块里抛了一个NullPointerException,catch还没来得及处理,finally直接return了,异常就这样无声无息地消失了。调用方看到方法返回true,以为一切正常,实际上系统已经出现了一个没有被记录的错误。
如果你跟我一样要维护那种历史遗留系统,去搜一下项目里finally{return xxx}这种代码,大概率能翻出几处定时任务或者同步逻辑的隐藏bug。这种代码的可怕之处在于,它不是每次都出问题,而是当底层依赖抖动、RPC超时、数据库连接断开的时候,它会把错误悄悄抹掉,让系统看起来一切安好。
为什么高手不这么写?因为finally的定位是“清理现场”,不是“覆盖结果”。你可以在finally里关流、释放锁、清理ThreadLocal,但永远不要在里面return,也不要在finally里抛异常。如果finally里必须做二次确认,那就用另一个独立方法包一层,不要在同一个方法里混杂。
2.3 catch 分支的匹配顺序与多异常处理
catch块的匹配规则很简单:从上往下,命中第一个匹配的异常类型就停下。所以多个catch必须把子类异常写在前面,父类异常写在后面:
java复制try {
// ...
} catch (IllegalArgumentException e) {
// 精确处理参数异常
} catch (BizException e) {
// 处理业务异常
} catch (Exception e) {
// 兜底
}
如果你把catch (Exception e)写在最前面,底下的catch (BizException e)就是永远不可达的代码,编译器还会直接报错。这个细节语法上很简单,但很多人写代码时根本没想过“我要不要把可恢复异常和不可恢复异常分开处理”。
一个更进阶的姿势是Java 7以后的多异常捕获:
java复制try {
// ...
} catch (BizException | RetryableException e) {
// 这两种异常的处理逻辑一致
}
这就是那行代码里把BizException和RetryableException分开catch的底层逻辑思维:不同异常代表不同类型的失败,它们的处理方式注定不一样,所以在catch阶段就得拆开,而不是统一吞掉再打一行一模一样的日志。
3. 从“能抓到异常”到“不会发生异常”:三个层次的防御设计
try catch本身不产生价值,产生价值的是它背后的防御设计。真正的高手写代码,脑子里有一个递进关系:第一层是尽量不让异常发生;第二层是如果发生了,用最小的范围抓住它;第三层才是最坏的兜底。
很多团队把三层顺序搞反了——一上来就在整个方法体外面套一层大try,把几十行代码全包进去。这种写法的防守面积很大,但也意味着排障面积非常大,出了问题你只能知道“某一段代码炸了”,根本定位不到具体是哪行。
3.1 前置校验:最简单的“防呆”设计
回到那段代码,第一眼看上去最优雅的不是catch块,而是方法开头的这一段:
java复制if (dto == null || dto.getUserId() == null) {
throw new BizException(ErrorCode.PARAM_NOT_NULL);
}
这叫fail-fast,快速失败。入参不合法,第一时间抛异常,而不是让脏数据一路传下去,最后在某个深层DAO或远程调用里炸出一个莫名其妙的SQLException,白白浪费半天排查时间。
我在实际项目里见过太多类似这种的代码:
java复制public void createOrder(CreateOrderDTO dto) {
try {
User user = userService.getById(dto.getUserId());
// 这里直接用user.getName()
} catch (NullPointerException e) {
log.error("创建订单失败", e);
}
}
用NPE兜底,等于把判空的职责全部推给异常机制。正确的做法是在源头就校验,而且判断要放在try之外。为什么?因为dto和dto.getUserId()判空本身不需要异常机制介入,if判断效率更高、语义更清晰,更重要的是,校验失败产生的BizException根本不需要走系统异常那套流程。
这背后其实是一个很重要的原则:异常机制是为“意料之外”准备的,不是为“可预期的问题”准备的。入参可能为空是可预期的,所以在入口就把它拦截掉;下游服务可能超时也是可预期的,但你没有更好的处理手段,所以用RetryableException来标记它。
3.2 catch 的粒度:精准打击而不是一网打尽
写try catch最忌讳的是“大锅烩”。一个方法几十行,里面可能在做数据库查询、调用外部接口、写文件、发消息,然后一个catch (Exception e)全包了。看起来兜底很全面,实际上排查问题时你会发现日志里只有一句话:“调用失败”,但到底是哪一步失败?底层依赖有没有抛业务错误?这些信息全丢了。
高手的做法是把catch拆成不同的失败类型:
| 异常类型 | 语义 | 处理方式 |
|---|---|---|
| ParamException | 调用方参数问题 | 返回参数错误提示 |
| BizException | 业务规则不满足 | 返回业务错误码+提示 |
| RetryableException | 临时性故障,可重试 | 提示稍后再试或自动重试 |
| Exception | 未预期的故障 | 返回系统繁忙,完整打日志 |
每一类异常都有明确的处理路径,这样调用方才能根据异常的语义做出正确响应。比如你写一个支付接口,参数缺失告诉你“参数错误”,余额不足告诉你“余额不足”,银行接口超时告诉你“请勿重复提交”。这三种情况都是接口调用失败,但用户需要看到的提示完全不同,后续能否重试的决策也完全不同。
3.3 fail-fast 与 fail-safe:什么时候该“中断”
代码评审时经常争一个问题:这个方法要不要抛出异常?还是吞掉错误返回一个默认值?没有标准答案,核心要看“中断成本”和“错误成本”哪个更高。
拿订单创建来说,如果创建订单时网络抖动导致超时,你吞掉异常然后返回false,用户就会以为下单成功,等到第二天对账才发现单子没进去,甚至存在重复下单的风险。这种场景必须fail-fast,宁可让用户看到失败重试,也不能把错误吞进肚子里。
反过来,如果你在写一个推荐位展示接口,某个推荐算法微服务超时了,你完全可以让推荐位返回空列表,而不是让整个页面都挂掉。这种场景是典型的fail-safe——推荐位挂了不影响主流程,页面还能正常浏览。
所以那个工程师的代码里,catch (RetryableException)专门跳出来处理重试,catch (Exception)作为最后兜底。他没有把所有失败一律看作“不能继续”,而是给每类失败分配了明确的策略。这种思考深度,比单纯写几个catch块值钱太多了。
4. 业务异常与系统异常的分离:异常体系设计是优雅的真正来源
如果你以为优雅的try catch只是靠缩进和对齐堆出来的,那就错了。代码的“优雅”往往是设计的结果,不是语法的作用。那段代码真正值钱的地方,在于他背后那套异常体系和对异常的分级分类。
4.1 一套够用的异常体系应该长什么样
很多项目从头到尾只有一个自定义异常,或者干脆全用RumtimeException + 字符串消息。这样做的后果是:所有业务错误都长一个样,错误码和错误消息靠硬编码散落在各处,根本没法统一管理。
一套基础可用的异常体系,至少包含下面几种:
java复制public class BizException extends RuntimeException {
private final int code;
private final String message;
// 构造器
}
public class ParamException extends BizException {
// 参数异常,自动带一个默认错误码
}
public class SystemException extends RuntimeException {
private final int code;
private final String message;
private final Throwable cause;
// 构造器,必须传cause
}
为什么不直接用new RuntimeException("余额不足")?因为RuntimeException只携带了一个message字符串。当这个异常从Service层一路抛到Controller,再被全局异常处理器捕获时,系统只能知道“这句话说的是余额不足”,却没法知道这个异常属于哪一类、对应的错误码是什么、应该返回给用户什么提示、系统内部是否需要对它进行告警。
有了异常体系之后,整个调用链路就顺畅了:
java复制try {
paymentService.pay(orderId, amount);
} catch (ParamException e) {
return Result.error(e.getCode(), "入参错误:" + e.getMessage());
} catch (BizException e) {
return Result.error(e.getCode(), e.getMessage());
} catch (Exception e) {
log.error("[payment] unexpected error, orderId={}", orderId, e);
return Result.error(ErrorCode.SYSTEM_ERROR, "系统忙,请稍后重试");
}
你看,catch块看起来已经有些优雅了,其中有一半功劳来自异常体系本身的层次分明。
4.2 try catch 如何与全局异常处理配合
很多新人对全局异常处理有误解,以为有了@RestControllerAdvice之类的全局处理器,Service层就不用写try catch了。这是两个不同层面的东西:全局异常处理解决的是“异常如何在出口收敛为统一响应”的问题,而Service层里的try catch解决的是“当前代码块是否要对某种异常做特殊处理”的问题。
正确的分工是这样的:
- 如果当前代码块不关心某类异常如何对外呈现,就让异常自己抛出去,交给全局处理器。
- 如果当前代码块需要根据异常类型做额外处理(写告警、发补偿消息、记录重试状态),那就catch住,处理完该处理的事,再决定是吞掉还是继续抛出。
比如消息消费者的代码:
java复制public void onMessage(Message message) {
try {
orderService.handle(message);
} catch (BizException e) {
// 业务失败,记录失败原因,消息ACK,不能无限重试
log.warn("[consumer] biz error, msgId={}, err={}", message.getId(), e.getMessage());
return;
} catch (Exception e) {
// 未知异常,让消息重试
log.error("[consumer] unhandled error, msgId={}", message.getId(), e);
throw e;
}
}
这里全局异常处理器帮不上任何忙,因为这是异步消费的场景,没有HTTP响应可以返回。你必须自己在不同层级把异常处理掉,再决定消息要不要重新投递。这就是为什么“全让全局异常处理器兜底”的做法在一些团队行得通、在一些团队会出大问题——它只适用于同步接口场景。
4.3 跨服务调用时,异常如何传播
微服务架构下,try catch还承担着另一个职责:异常边界转换。你调用一个下游HTTP接口,对方返回HTTP 500,你的代码把它直接抛成一个RuntimeException往外传,这是不合适的。更好的做法是把下游的失败转换成对当前系统有意义的异常:
java复制public UserInfo getUserInfo(Long userId) {
try {
Response resp = httpClient.get("/api/user/" + userId);
if (resp.isSuccess()) {
return resp.getData();
}
throw new BizException(ErrorCode.USER_SERVICE_ERROR, resp.getMsg());
} catch (BizException e) {
throw e;
} catch (Exception e) {
throw new RetryableException("user service call failed", e);
}
}
这里有一个很重要的知识点:throw new RetryableException("...", e)时,一定把原异常通过cause传进去。原因很简单,日志追查时要看到完整堆栈。如果只传消息不传cause,异常链就断了,报错排查时只能看到“user service call failed”这一句话,根本不知道底层是超时、连接拒绝还是DNS解析失败。
5. 实测中踩过的坑:那些“看起来正常”的错误用法
写了这么多年代码,我在try catch上踩过的坑,比在业务逻辑上踩过的坑多得多。下面这几个问题,是代码评审时我几乎每周都会碰到的经典案例,每一个都有过血淋淋的生产事故。
5.1 空catch吞异常:最隐蔽的“丢了根因”
java复制try {
localCache.refresh();
} catch (Exception e) {
// 忽略,下次刷新再更新
}
看这段代码,你可能会说,缓存刷新失败而已,不影响主流程,忽略掉也合理。但问题是:你连log.warn都没打,如果缓存持续刷新失败,你会发现在线上环境里内存里的数据永远是旧版本,所有人都在用错误的数据做判断。等到用户投诉了,你打开日志一看,什么报错都没有,连一点线索都找不到。
我的建议是:吞异常至少要满足三个条件之一,否则不允许吞。第一,你确认这个异常在当前场景下绝对无影响;第二,你捕获异常后写入了日志或指标;第三,你捕获异常后做了降级处理(比如返回默认值)。一条都不满足,就老老实实把它抛出去。
如果你真要吞,至少写成这样:
java复制} catch (Exception e) {
log.warn("[localCache] refresh failed, will use stale data", e);
metric.increment("local_cache.refresh.failure");
}
5.2 catch里打了一堆日志,却缺上下文
每次看到这种日志我就头大:
code复制ERROR 2025-01-12 10:33:02 Failed to create order
java.lang.NullPointerException: null
光看日志,你能知道是哪个订单创建失败了吗?订单号是多少?用户ID是多少?当然不知道。等真要排查问题时,你只能根据时间点反推、翻链路追踪系统找traceId,运气不好还要去问业务同学最近哪个订单没进来。
这就是那段优雅代码里为什么要拼命打bizNo的原因。日志里带上关键业务ID,相当于给异常标了坐标。我在团队里一直强调:catch块里打的日志,至少要有“是什么操作、影响了谁、错误是什么”三个要素。也就是操作名、订单号/用户ID/消息ID、异常对象。如果团队有traceId体系,别忘了把traceId也打进去。
5.3 catch后return null:把问题拖到更远的代码里
最后这个坑特别经典。有些同学写代码,一旦异常就在catch里返回null,然后上层拿到null以后,要么NPE,要么又做一层判空,判断不出来就继续往上传,层层套娃。
java复制public List<Order> queryOrders(Long userId) {
try {
return orderDao.query(userId);
} catch (Exception e) {
log.error("query error", e);
return null; // 问题从这开始了
}
}
调用方拿到List之后,如果没做null判断,直接orders.size(),就会NPE。如果做了orders == null判断又返回null,问题继续向上传。最后你打开错误监控平台,发现半个调用链上全是跟这个null有关的NPE,源头却是最开始那个被吞掉的数据库异常。
更好的处理思路是:catch的时候想清楚“这个异常我想让上层怎么处理”。如果上层把空列表和异常同等对待也没问题,那你return Collections.emptyList()是可以的。但如果上层必须区分“查询失败”和“结果为空”,就绝对不能return null,应该抛一个带context的异常让上层决定怎么兜底。
5.4 try代码块的范围越大越好吗
这也是个高频争议。有人喜欢一个方法只写一个try,把所有代码包进去,理由是这样能兜住所有意外;有人喜欢只包最危险的几行,理由是局部化了风险。
我的看法是:try块应该覆盖“你认为可能出问题的边界操作”,而不是整个方法体。比如一个方法里既有参数校验、又有缓存读取、又有数据库更新,那么try只需要包住数据库更新那几行就够了。参数校验在前面已经做掉了,它自己有自己的防御;缓存读取如果失败了,它的异常策略跟数据库更新显然不同,硬放在一起catch反而会让处理逻辑变得混乱。
回到文章开头那段代码,它的try块其实也很窄,基本就只包了orderService.createOrder()这次调用。所有前置校验都在try外面完成了。这正是我觉得它优雅的一个重要原因——该防的地方防得很严,不该掺和的地方一点不拖泥带水。
6. 一次代码评审后,我整理出的try catch自查清单
讲了这么多理论,最后分享一份我自己在代码评审时对照使用的清单。每次写完涉及异常处理的代码,拿这份清单过一遍,绝大多数低级问题都能在提交前发现,不用等上线以后靠告警来提示。
基础语法层
- 多个catch是否按异常类型从子类到父类排列
- finally里有没有写return,有没有在finally里抛异常
- 会不会吞掉原本应该向上传递的异常
- 是否捕获了
Throwable或Error(一般不需要,OOM你能处理吗)
设计层面
- 业务异常是否使用了自定义异常类型,还是到处
new RuntimeException("中文提示") - 异常是否能表达出失败的类型(参数错误、业务不满足、临时故障、系统bug)
- 调用方是否需要区分异常类型才能做出正确处理
- 是否对“可恢复异常”和“不可恢复异常”采取了不同策略
可观测层面
- catch块里的日志是否包含关键业务ID(订单号、用户ID、消息ID)
- 日志级别是否合理(业务规则不满足用warn,未知异常用error)
- 是否有监控指标统计异常发生频率
- 跨服务调用时,异常链的cause是否保留了原始堆栈
恢复策略层面
- 消息消费者的异常是否决定了消息重试或进死信
- 数据库操作失败后,是否做了幂等补偿
- Fail-fast和fail-safe的选择是否合理
- 被吞掉的异常是否满足“确认无影响、有日志记录、有降级方案”三个条件之一
这份清单不复杂,但能把大多数团队里常见的异常处理问题覆盖掉。我见过不少团队,立项时连异常体系都没有,开发时全靠随手new Exception,到后期查问题的时候才痛不欲生。如果你所在的项目也是这样,建议从这周开始就动手补一套简单的异常类型和全局处理逻辑,不用一上来就搞很复杂,先把BizException和SystemException这两类区分开,把日志打规范,就能解决80%的问题。
回头看,当初被那段try catch震住,不完全是因为语法技巧有多炫。它更像一记提醒:真正拉开普通工程师和资深工程师差距的,往往不是那些高深复杂的技术框架,而是这种每天都要写、却很少有人认真打磨的基础代码。能把try catch这种随处可见的语法,写成让人愿意停下来看两遍的样子,本身就说明这个人对代码的每个细节都有想法。下次你写try catch的时候,也可以停下来问自己一句:如果三个月后有个同事因为线上问题翻开这段代码,他会感谢我写清楚的每一行日志,还是会对着一个空catch块骂街?
