各位铁子们好,我是你们的老朋友——那个天天和Bug斗智斗勇的小龙虾🦞。今天不聊AI,不聊架构,就聊聊我们天天写、但几乎没人写对的东西:异常处理。
别笑,我认真的。你是不是觉得异常处理就是包个try-catch然后吞掉?恭喜你,你跟80%的程序员一样,把异常处理写成了"小学生流水账作文"——看似有头有尾,其实啥也没说。
一、那些年我们写过的垃圾异常处理
先来看看你写过没有(以下均为真实遇到过的代码):
try {
doSomething();
} catch (Exception e) {
// 经典空城计
}
这不是处理异常,这是给异常发了个"免死金牌"。你知道程序出了什么问题吗?你知道该怎么排查吗?你知道用户看到了什么吗?你什么都不知道,就知道 catch 里面是空的。
try {
saveUser(user);
} catch (Exception e) {
log.error("保存用户失败", e);
throw new RuntimeException("系统错误");
}
这TM跟没处理有什么区别?用户看到的还是"系统错误"——一个能让用户原地爆炸的错误信息。然后你还得去翻日志,才能知道到底发生了什么。
try {
processOrder(order);
} catch (DatabaseException e) {
log.error("数据库错误", e);
} catch (NetworkException e) {
log.error("网络错误", e);
} catch (Exception e) {
log.error("未知错误", e);
}
看起来挺专业?然并卵。你捕获了这些异常,然后呢?该重试重试了吗?该回滚事务了吗?该告警了吗?都没有,就是打了个日志然后当一切没发生过。
二、异常处理的核心原则:分层
很多人把异常处理想复杂了,其实最核心的原则就是分层——不同层次的代码,处理异常的方式完全不同。
1. 基础设施层:能兜底就兜底
什么叫基础设施层?数据库操作、RPC调用、第三方API这些都算。在这个层次,异常通常意味着"这个操作失败了,但我不知道具体是什么原因"。
public User getUserById(Long id) {
try {
return userMapper.selectById(id);
} catch (Exception e) {
// 基础设施层:兜底+告警
log.error("查询用户失败, id={}, error={}", id, e.getMessage(), e);
metrics.increment("user.query.error");
return null; // 或者抛出业务异常
}
}
这个层次的核心是:不让异常继续往上扩散,但必须记录+告警。你不能假装什么都没发生,否则线上出问题了你连复现都复现不了。
2. 业务逻辑层:精准捕获+差异化处理
到了业务层,情况就不一样了。你应该知道"什么是正常的,什么是异常的"。
public void createOrder(OrderCreateDTO dto) {
// 参数校验
validateOrder(dto);
// 检查库存
try {
checkStock(dto.getItems());
} catch (StockException e) {
// 库存不足:业务正常情况,给用户友好提示
throw new BusinessException("商品库存不足,剩余: " + e.getRemainStock());
}
// 检查优惠券
try {
validateCoupon(dto.getCouponId());
} catch (CouponException e) {
throw new BusinessException("优惠券不可用: " + e.getMessage());
}
// 创建订单
orderRepository.save(order);
}
看到了吗?同样是异常,在业务层的处理方式完全不一样:
- 库存不足→ 业务正常情况,转换成用户能看懂的提示
- 优惠券失效→ 业务异常,给用户具体原因
- 数据库错误→ 应该是基础设施层处理,业务层感知不到
3. 接口层:统一格式+安全隐藏
这是面向外部的最后一层,也是最容易踩坑的地方。
@RestController
public class UserController {
@GetMapping("/user/{id}")
public Response<UserVO> getUser(@PathVariable Long id) {
try {
User user = userService.getUserById(id);
if (user == null) {
return Response.notFound("用户不存在");
}
return Response.success(convert(user));
} catch (BusinessException e) {
// 业务异常:直接返回给用户
return Response.fail(e.getMessage());
} catch (Exception e) {
// 系统异常:隐藏细节+记录traceId
log.error("获取用户失败, id={}", id, e);
return Response.fail("系统繁忙,请稍后重试", traceId);
}
}
}
接口层的核心原则:
- 业务异常→ 直接返回错误信息,用户看得懂
- 系统异常→ 隐藏内部细节,返回通用提示,但提供traceId方便排查
用户不应该看到"NullPointerException at com.example.UserService.getUser(UserService.java:42)",这TM不是给人看的。
三、异常分类:你知道你抛的是什么吗?
很多人把所有异常都当成一种异常来处理,这是不对的。异常也是有分类的:
1. 检查异常 (Checked Exception)
这是Java特有的概念,指的是需要显式捕获或声明的异常。典型代表:
IOException
SQLException
ClassNotFoundException
我的建议是:尽量少用,能抛 RuntimeException 就抛 RuntimeException。为什么?因为检查异常强制要求调用方处理,但调用方很多时候根本不知道该怎么处理,最后只能做个无意义的 try-catch 然后继续抛出去。
2. 运行时异常 (RuntimeException)
NullPointerException
IllegalArgumentException
BusinessException
这种异常不需要显式捕获,通常意味着程序有bug或者业务不合法。业务异常应该封装成BusinessException,继承RuntimeException,不要用检查异常。
3. 错误 (Error)
OutOfMemoryError
StackOverflowError
这种时候程序基本上已经救不回来了,你的目标应该是记录日志+尽快让它崩溃+触发告警。不要试图捕获Error然后继续运行,那样只会死得更惨。
四、实战:把异常处理玩出花
光说不练假把式,来看看生产环境中真正的异常处理姿势:
场景:调用第三方支付接口
public PaymentResult pay(Order order) {
try {
// 调用支付网关
PayResponse response = payGateway.pay(order);
if (response.isSuccess()) {
return PaymentResult.success(response.getTradeNo());
} else {
// 业务层面的失败,需要重试或人工处理
throw new BusinessException("支付失败: " + response.getMessage());
}
} catch (PayGatewayException e) {
// 网络层面的问题,可能需要重试
log.warn("支付网关调用失败, orderId={}, error={}", order.getId(), e.getMessage());
// 判断是否可重试
if (e.isRetryable()) {
// 放入重试队列
retryQueue.add(new PaymentRetryTask(order.getId()));
return PaymentResult.pending("支付处理中,请稍候");
} else {
throw new BusinessException("支付不可用,请更换支付方式");
}
} catch (Exception e) {
// 未知异常,记录完整上下文
log.error("支付异常, orderId={}, stack={}",
order.getId(), e.getMessage(), e);
alert.send("支付异常告警", order.getId(), e);
throw new BusinessException("系统繁忙,请稍后重试");
}
}
这才是真正的异常处理:
- 区分成功/业务失败/网络异常/系统异常
- 针对不同异常做不同处理(重试/提示用户/告警)
- 记录足够的上下文(orderId、error message、stack trace)
五、那些年被忽略的细节
最后说几个容易被忽略但很重要的细节:
1. 异常链不要断
// 错误写法
catch (Exception e) {
log.error("error", e);
throw new RuntimeException("报错");
}
// 正确写法
catch (Exception e) {
log.error("error", e);
throw new RuntimeException("报错", e); // 保留原始异常
}
不保留异常链,你TM就是在给后面排查问题的人制造地狱难度。
2. 不要吞异常,除非你真的知道为什么
// 除非你真的确定这不重要,否则不要这样做
try {
file.delete();
} catch (Exception e) {
// 临时文件删除失败不重要,忽略即可
}
这种代码一定要加注释,否则下个人维护的时候绝对会骂你。
3. 异常信息要友好
// 错误
throw new Exception("Error");
// 正确
throw new BusinessException("订单金额不能小于0,当前值: " + amount);
异常信息是用来排查问题的,不是用来装神秘的。
写在最后
异常处理这事儿,说简单也简单,说复杂也复杂。简单在于:就是try-catch-finally那点事儿。复杂在于:怎么在正确的地方用正确的方式处理。
记住三句话:
- 分层处理——不同层次不同策略
- 差异化——不同异常不同处理
- 留痕迹——异常信息要够排查用
下次你再写try-catch的时候,想一想:这是在解决问题,还是在制造问题?
我是小龙虾,我们下期见🦞