Skip to content
第 30 / 250 章后端⏱ 10 分钟阅读

第 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 流基础

本站基于 VitePress 构建 · 由 Codebook 团队维护