第 30 章:异常最佳实践
学习目标
- 掌握企业级异常分层设计
- 学会统一错误码规范
- 理解异常与日志的配合
一、企业级异常分层
| 层 | 做什么 |
|---|---|
| Mapper / DAO | 不处理,让框架包装 |
| Service | 业务校验失败 → 抛 BusinessException |
| Controller | 不写 try-catch,保持代码干净 |
| 全局处理器 | 统一捕获,转成标准响应体 |
为什么 Controller 不 try? 每个接口都写 try-catch 会让代码量翻倍且大量重复。交给
@RestControllerAdvice统一兜底(详见第 51 章)。
二、统一错误码设计
java
public enum ErrorCode {
// ① 格式:模块(2位) + 类型(1位) + 序号(2位)
SUCCESS(0, "成功"),
PARAM_INVALID(10001, "参数校验失败"),
UNAUTHORIZED(10401, "未登录"),
FORBIDDEN(10403, "无权限"),
USER_NOT_FOUND(20001, "用户不存在"),
USER_DISABLED(20002, "用户已被禁用"),
PASSWORD_ERROR(20003, "密码错误"),
ORDER_NOT_FOUND(30001, "订单不存在"),
ORDER_PAID(30002, "订单已支付,请勿重复操作"),
SYSTEM_ERROR(50000, "系统繁忙,请稍后重试");
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() { return code; }
public String getMessage() { return message; }
}为什么用枚举而不是常量? 枚举天然做到「错误码 + 消息」绑定,避免 code 和 message 对不上;且能在 IDE 里一键查看所有错误码。
三、业务异常最终版
java
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}
/** 支持覆盖默认消息(补充上下文) */
public BusinessException(ErrorCode errorCode, String detail) {
super(errorCode.getMessage() + ": " + detail);
this.code = errorCode.getCode();
}
public BusinessException(ErrorCode errorCode, Throwable cause) {
super(errorCode.getMessage(), cause);
this.code = errorCode.getCode();
}
/** ① 关键优化:业务异常不需要堆栈,禁用能提升 10 倍性能 */
@Override
public synchronized Throwable fillInStackTrace() {
return this;
}
public int getCode() { return code; }
}为什么重写
fillInStackTrace? 抓取堆栈是异常最耗时的部分(要遍历整个调用栈)。业务异常(如「用户不存在」)我们已经知道原因,不需要堆栈。高并发接口下这能显著降低开销。 ⚠️ 但系统异常必须保留堆栈,否则线上没法排查。
四、断言式抛异常
java
public final class Assert {
public static void notNull(Object obj, ErrorCode code) {
if (obj == null) throw new BusinessException(code);
}
public static void isTrue(boolean condition, ErrorCode code) {
if (!condition) throw new BusinessException(code);
}
private Assert() { }
}对比效果:
java
// ❌ 每次都写 if
User user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
if (user.getStatus() != 1) {
throw new BusinessException(ErrorCode.USER_DISABLED);
}
// ✅ 断言式,一行一个校验
User user = userMapper.selectById(id);
Assert.notNull(user, ErrorCode.USER_NOT_FOUND);
Assert.isTrue(user.getStatus() == 1, ErrorCode.USER_DISABLED);五、异常与日志的配合
java
// ❌ 双重记录:既打日志又抛异常,日志里会出现两条一样的
try {
doSomething();
} catch (Exception e) {
log.error("出错了", e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, e); // ❌ 上层还会再打一次
}
// ✅ 要么处理(打日志+吞掉),要么上抛(不打日志)
try {
doSomething();
} catch (Exception e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, e); // ✅ 交给全局处理器打日志
}日志级别选择:
| 场景 | 级别 |
|---|---|
| 业务异常(用户不存在、余额不足) | WARN,甚至 INFO |
| 系统异常(数据库连不上、NPE) | ERROR |
| 第三方调用失败但有降级 | WARN |
| 循环内的异常 | 聚合后打一条,别刷屏 |
反模式:把业务异常打成
ERROR,会导致告警系统天天误报,最后没人看告警了。
六、常见坑速查
java
// 坑 1:catch 后忘记 return / 继续执行
public void handle() {
try {
validate();
} catch (Exception e) {
log.error("校验失败", e);
// ❌ 没有 return,下面照样执行
}
doBusiness(); // ❌ 校验都失败了还执行
}
// 坑 2:在 finally 里关资源又抛异常,覆盖原始异常
// ✅ 用 try-with-resources,JVM 会把关闭异常挂到 suppressed 上
// 坑 3:捕获了 InterruptedException 却不恢复中断状态
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // ✅ 必须恢复中断标记
throw new BusinessException(ErrorCode.SYSTEM_ERROR, e);
}
// 坑 4:@Transactional 遇到受检异常不回滚
@Transactional(rollbackFor = Exception.class) // ✅ 必须写 rollbackFor
public void save() throws IOException { }七、本章小结
| 要点 | 关键 |
|---|---|
| 分层 | Service 抛,Controller 不 try,全局兜底 |
| 错误码 | 枚举绑定 code + message |
| 业务异常 | 禁用堆栈提升性能 |
| 断言工具 | 减少重复 if |
| 日志 | 抛了就别打,打了就别抛 |
| 事务 | rollbackFor = Exception.class |
动手练习
练习 1:基础题
设计一个电商系统的 ErrorCode 枚举,至少覆盖:用户模块、商品模块、订单模块、支付模块。
练习 2:进阶题
扩展 Assert 工具类,支持消息模板:Assert.notNull(user, ErrorCode.USER_NOT_FOUND, "userId={}", id)。
下一章:第 31 章:IO 流基础 →