本文共计11454字,预计阅读时间 10min
整理不易,给我一个免费的赞吧 (*❦ω❦)👍

异常处理

企业级项目中,通常使用自定义业务异常(而不是直接使用 RuntimeException),并且这些自定义异常通常继承自 RuntimeException

🤔 为什么选择自定义 RuntimeException?

这种做法结合了两方面的好处:一是通过“自定义”来明确业务语义,二是通过继承“RuntimeException”来避免代码污染并适应现代框架。

1. 业务语义清晰,便于识别和处理

使用通用的 RuntimeExceptionIllegalArgumentException 等内置异常,无法清晰地表达具体的业务问题。自定义异常可以做到“见名知意”。

  • 精准定位:例如,抛出 InsufficientBalanceException(余额不足)比抛出通用的 RuntimeException 更能让人一眼明白问题所在。
  • 分类处理:可以按业务模块(如用户、订单、支付)定义不同的异常,便于在全局异常处理器中进行分类响应。例如,前端可以根据不同的异常类型给出不同的提示。

2. 避免代码污染,提升可读性

RuntimeException 是非受检异常(Unchecked Exception),编译器不要求强制捕获或声明。

  • 减少模板代码:如果继承自 Exception(受检异常),每个方法签名都必须添加 throws,每一层调用都可能需要 try-catch,这会使得业务代码充斥着大量的异常处理模板,严重干扰核心业务逻辑的阅读。
  • 异常直达处理器:业务异常通常需要在最外层(如Controller层)进行统一拦截和处理,返回标准化的错误格式。RuntimeException 可以直接穿透中间的业务层和服务层,直达全局异常处理器(如 Spring 的 @ControllerAdvice),非常契合这种分层架构。

3. 与主流框架无缝集成

现代企业级开发框架,如 Spring Boot,其核心设计理念就是使用运行时异常。

  • 事务管理:Spring 的声明式事务(@Transactional)默认只在遇到 RuntimeException 及其子类时才会回滚。如果使用受检异常,需要额外配置 rollbackFor,增加了复杂性。
  • 全局异常处理:Spring 提供的 @ControllerAdvice@ExceptionHandler 能够非常方便地捕获和处理所有运行时异常,统一返回 JSON 格式的错误响应。

优秀的自定义业务异常设计

一个设计良好的自定义业务异常通常包含以下信息,以便于前端展示和后端排查:

  • 错误码 (Code):一个唯一的字符串或数字代码(如 "ORDER_NOT_FOUND"1001),用于程序识别和国际化处理。
  • 错误消息 (Message):一段面向用户或开发者的可读提示信息(如 "订单不存在"),可以包含占位符。
  • 详细信息 (Details):一个可选的 Map,用于携带与错误相关的业务上下文数据,如订单号、用户ID、请求ID等,方便快速定位问题。

示例:

java
public class BusinessException extends RuntimeException {
    private final String code;
    private final Map<String, Object> details;

    public BusinessException(String code, String message, Map<String, Object> details) {
        super(message);
        this.code = code;
        this.details = Collections.unmodifiableMap(details);
    }

    // getter 方法...
}

使用方式:

java
// 在业务逻辑中抛出
if (balance < amount) {
    Map<String, Object> details = Map.of("currentBalance", balance, "requestedAmount", amount);
    throw new BusinessException("INSUFFICIENT_BALANCE", "余额不足", details);
}

总结:

方案 是否推荐 原因
自定义 RuntimeException ✅ 推荐 语义清晰,代码简洁,与 Spring 等主流框架集成度高,事务回滚默认生效。
自定义 Exception ❌ 不推荐 属于受检异常,强制要求层层声明和捕获,会严重污染业务代码,违背现代Java开发实践。
使用内置 RuntimeException ⚠️ 不推荐 语义模糊,难以区分是程序Bug还是业务失败,不利于精准的异常处理

代码示例

Result

java
package top.hazenix.result;

import lombok.Data;
import java.io.Serializable;

/**
 * 后端统一返回结果
 * @param <T>
 */
@Data
public class Result<T> implements Serializable {

    private String code; // 错误码
    private String msg; // 错误信息
    private T data; // 数据
    private String requestId; // 用于追踪请求链路

    public static <T> Result<T> success() {
        Result<T> result = new Result<T>();
        result.code = "200";
        return result;
    }

    public static <T> Result<T> success(T object) {
        Result<T> result = new Result<T>();
        result.data = object;
        result.code = "200";
        return result;
    }

    public static <T> Result<T> error(String code, String msg) {
        Result result = new Result();
        result.msg = msg;
        result.code = code;
        return result;
    }

}

BusinessException

业务异常,和程序异常区分开

java
/**
 * 业务异常
 */
@Getter
@NoArgsConstructor
public class BusinessException extends RuntimeException {

    private String code; // 业务错误码
    private Object data; // 可以携带相关的业务数据

    // 不携带业务数据
    public BusinessException(String code, String message) {
        this(code, message, null);
    }

    // 携带业务数据
    public BusinessException(String code, String message, Object data) {
        super(message);
        this.code = code;
        this.data = data;
    }
    
}

接下来直接使用 BaseException 还是 新建继承类 ?

大部分场景用 BusinessException,只有需要被 catch 单独处理时,才新建类。

1.直接使用 BaseException(大部分场景)

当你抛出异常仅仅是为了终止流程并返回一个错误码给前端时,直接使用 BaseException 是最简洁、最推荐的做法。

案例:

  • 参数校验失败(如:throw new BaseException("PARAM_ERROR", "用户ID不能为空");
  • 业务状态不满足(如:throw new BaseException("ORDER_PAID", "订单已支付,不能重复操作");
  • 资源未找到(如:throw new BaseException("USER_NOT_FOUND", "用户不存在");

优点:

  • 代码极简:不需要为每一个业务错误都创建一个 Java 文件。
  • 维护方便:所有的错误码集中管理(通常在枚举类中),修改错误信息或码值非常方便。
  • 统一拦截:全局异常处理器可以统一捕获 BaseException,直接提取 codemessage 返回 JSON。

2.新建自定义异常类(10% 的场景)

当你需要在代码逻辑层面对某种特定异常进行“差异化处理”时,必须新建类。

案例:

  • 需要被特定 catch 块捕获:

    • 例如,你在处理一个复杂订单流程,如果抛出的是 InventoryException(库存不足),你需要回滚库存;如果是 PaymentException(支付失败),你需要取消预授权。这时,你必须用 instanceofcatch 块来区分,必须新建类。
  • 需要触发特定的 AOP 切面或拦截器:

    • 例如,你想对所有“权限不足”的异常打一个特殊的埋点,或者记录特殊的审计日志。通过新建 PermissionDeniedException,你可以写一个专门的切面来处理它,而不会影响其他业务异常。
  • 需要携带特殊的、结构化的数据:

    • 例如,你的退款异常需要强制携带 refundIdfailReasonCode 这两个字段,而普通异常只需要 message。这时,继承 BaseException 并添加特有字段是合理的。

常见自定义异常

AccountNotFoundException

java
/**
 * 账号不存在异常
 */
public class AccountNotFoundException extends BaseException {
}

GlobalExceptionHandler

java
package top.hazenix.handler;

import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.ResponseStatus;
import top.hazenix.constant.ErrorCode;
import top.hazenix.exception.BussinessException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import top.hazenix.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

/**
 * 全局异常处理器,处理项目中抛出的业务异常
 */
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 捕获业务异常
     * @param e
     * @return
     */
    @ExceptionHandler(BussinessException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result handleBussinessException(BussinessException e, HttpServletRequest request){
        String requestId = getRequestId();

        // 记录 Warn 级别日志,包含请求URL、错误码和错误信息
        log.warn("业务异常 | RequestId: {} | URI: {} | Code: {} | Error: {}",
                requestId, request.getRequestURI(), e.getCode(), e.getMessage());

        // 返回给前端:使用业务异常自带的 code (字符串错误码) 和 message
        Result<Object> result = Result.error(e.getCode(), e.getMessage());
        result.setRequestId(requestId);
        result.setData(e.getData()); // 如果有附加数据也返回
        return result;
    }

    // 用于在日志和返回中标识请求
    private String getRequestId() {
        return UUID.randomUUID().toString().substring(0, 8);
    }

    /**
     * 处理参数校验异常 (Spring Validated)
     * 当 @Valid 注解校验失败时触发
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e, HttpServletRequest request) {
        String requestId = getRequestId();
        log.warn("参数校验异常 | RequestId: {} | URI: {} | Code: {} | Error: {}", 
                requestId, request.getRequestURI(), ErrorCode.A00001, e.getMessage());

        // 提取具体的字段错误信息
        Map<String, String> errors = new HashMap<>();
        for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
            errors.put(fieldError.getField(), fieldError.getDefaultMessage());
        }

        Result<Map<String, String>> result = Result.error(ErrorCode.A00001, "参数验证失败");
        result.setRequestId(requestId);
        result.setData(errors);
        return result;
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result exceptionHandler(Exception e, HttpServletRequest request){
        String requestId = getRequestId();

        // ERROR 级别日志,记录完整堆栈,用于排查Bug
        log.error("系统内部错误 | RequestId: {} | URI: {} | Code: {} | IP: {}",
                requestId, request.getRequestURI(), "B00001", getClientIpAddress(request), e);

        // 响应给前端:隐藏具体细节,只提示"系统繁忙"
        Result<String> result = Result.error("B00001", "系统繁忙,请稍后再试");
        result.setRequestId(requestId);
        return result;
    }

    /**
     * 获取客户端真实IP地址(考虑了反向代理的情况)
     */
    private String getClientIpAddress(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty() && !"unknown".equalsIgnoreCase(xForwardedFor)) {
            return xForwardedFor.split(",")[0].trim();
        }
        String xRealIp = request.getHeader("X-Real-IP");
        if (xRealIp != null && !xRealIp.isEmpty() && !"unknown".equalsIgnoreCase(xRealIp)) {
            return xRealIp;
        }
        return request.getRemoteAddr();
    }



}

业务错误码

什么是业务错误码

HTTP 状态码(Status Code)负责“通信层面”的结果,而业务错误码(Business Error Code)负责“逻辑层面”的结果。
当请求成功到达服务器(HTTP 状态码为 2xx),业务逻辑仍可能因各种原因失败(如用户余额不足、库存不足)。此时,业务错误码就成为精准描述失败原因的核心载体。它的本质是回答两个根本问题:

  • 谁的错?(错误来源是客户端、服务端,还是第三方依赖?)
  • 错在哪?(具体是参数校验失败、权限不足,还是数据冲突?)

一个错误码能标识来源和原因只是基础。真正优秀的业务错误码需要满足三个关键标准:

  1. 能够快速知晓错误来源

李雷负责 A 服务,韩梅梅负责 B 服务。韩梅梅发现服务 B 出现了一个错误码,韩梅梅能够快速定位这是服务 A 的内部业务异常造成的问题,这个时候韩梅梅就可以拿着错误码找到李雷。李雷拿过来错误码一看,一下就能知道这是上游 Polly 负责的应用阿尔法出了错

  1. 易于记忆和对比

  2. 能够脱离文档和系统平台达到线下轻量沟通的目的(这个要求比较高)

业务错误码 和 HTTP 状态码的区别
维度 HTTP 状态码 (HTTP Status Code) 业务错误码 (Business Error Code)
层级 协议层 (网络通信) 应用层 (业务逻辑)
制定者 HTTP 协议标准 (RFC 规范),不能随意自定义 你自己 (根据业务需求自定义)
作用 告诉客户端:请求是否被服务器接收、理解、接受。 告诉客户端:业务逻辑执行的具体结果(如:余额不足、库存不够)。
粒度 粗粒度400 代表所有客户端错误,无法区分是参数缺失还是格式不对。 细粒度。可以定义 1001=密码错误,1002=账户被冻结。
位置 在 HTTP 响应头 (Response Header) 中。 在 HTTP 响应体 (Response Body) 的 JSON 数据中。

想象你给餐厅打电话点外卖(发起 HTTP 请求):

  1. HTTP 状态码:相当于

    电话接通的情况

    • 200 OK:电话打通了,服务员接听了。
    • 404 Not Found:你拨错了号码,或者餐厅不存在。
    • 500 Internal Error:电话通了,但服务员突然晕倒了,没人理你。
  2. 业务错误码:相当于

    点餐的具体结果

    • 场景:电话打通了(HTTP 状态码是 200),但你想吃的“辣子鸡”卖完了。
    • 结果:服务员告诉你“亲,没货了”。这个“没货了”就是业务错误码

所以电话打通了 (HTTP 200),不代表你一定能吃到饭(业务可能失败)

错误码的必要性

  • 只用 HTTP 状态码,前端无法区分具体的业务失败原因;只用业务错误码,后端又违背了 Web 协议规范

    确实,前端可以通过解析 message 来判断失败原因,但这在企业级开发中并不被视为一种可靠或规范的做法

    为什么要使用“业务错误码(code)” ⬇️

    1. 语言本地化(国际化)问题

    这是最直接的硬伤。

    • 场景:你的应用需要支持中文、英文、甚至日文。
    • 仅靠 Message:后端返回的错误信息可能是动态的。例如,中文返回“余额不足”,英文返回“Insufficient balance”。前端必须写死逻辑来匹配这些字符串,这极其脆弱且无法维护。
    • 使用 Code:无论语言如何切换,错误码是不变的。前端只需要根据 code=1005 这个数字,去自己本地的国际化文件中查找对应的文案即可。

    2. 语义模糊与歧义

    错误信息是给人看的,往往是自然语言,容易产生歧义;而错误码是给机器识别的,必须是唯一的。

    • 场景:后端返回错误信息:“用户不存在或密码错误”。
    • 前端困境前端无法通过这段文字判断到底是“用户不存在”还是“密码错误”如果是“用户不存在”,前端可能想引导用户去注册;如果是“密码错误”,前端可能想引导用户去重置密码字符串匹配无法解决这种业务逻辑分支。
    • 使用 Code:后端可以分别返回 code=2001(用户不存在)和 code=2002(密码错误)。前端可以精准地执行不同的 UI 逻辑

    3. 数据验证与正则匹配成本

    依赖字符串匹配是软件工程中的大忌。

    • 维护成本:假设后端修改了文案,把“参数错误”改成了“请求参数有误”。前端所有依赖“参数错误”这个字符串的判断逻辑瞬间全部失效。
    • 安全性:在某些安全要求高的接口中,为了防止黑客扫描,后端可能会故意隐藏具体的错误原因,统一返回“系统错误”。此时,如果前端逻辑依赖具体的字符串,就会导致无法处理的情况。
  • 研发视角(可观测性):错误码是监控系统的核心指标。
    比如:A类错误突增代表运营活动火爆或遭攻击;B类错误突增代表代码上线出了Bug;C类错误突增代表依赖的云服务不稳定

  • 前端视角(交互控制): 依据错误码进行下一步交互
    10001=>弹窗提示; 10002-> 跳转登录;10003->开启倒计时重试

  • 客服/运营视角(沟通语言)
    用户截图报错“错误码:C0412”,客服通过知识库得知是“银行支付通道拥堵”,能立即给出标准话术

错误码的输出路径

面向日志输出

  • 服务内传递,最终是输出到日志。

    用来快速溯源找到问题;用来形成监控大盘

  • 域内服务间,比如同时大麦电商之间的系统,最终目的是输出到日志。

面向外部传递

  • 域内向域外
  • 服务端传递到前端
  • OpenAPI 错误码
  • 内部不同域之间

错误码规范

每家公司错误码风格都不尽相同,有使用纯数字的,有使用纯英文的,也有使用字母和数字组合的

以下是A-BB-CC模型

A-BB-CCC模型

  • 错误码为字符串类型,共 5 位,分成两个部分:错误产生来源+四位数字编号
  • 错误码不能直接输出给用户作为提示信息使用

第一段:错误来源 (Type/Source) - 1位(A)

决定报警级别和处理策略

  • A / 1 (User Error) - 用户端错误
    • 定义:用户发起的请求有问题,系统逻辑无误。如:参数必填项为空、格式错误、未登录、余额不足、版本过低。
    • 策略:不报警(仅在监控大盘展示趋势),是正常业务逻辑的一部分
    • 处理:前端给用户友好提示
  • B / 2 (System Error) - 系统执行错误
    • 定义:用户请求正确,但系统内部崩了。如:NullPointException、数组越界、数据库死锁、连接池耗尽、配置加载失败
    • 策略:P0 / P1 级核心报警。这意味着代码质量问题或基础设施故障,研发必须立即介入
  • C / 3 (Third-party Error) - 第三方调用错误
    • 定义:系统跟本身无误,依赖的下游(RPC服务)或外部API(如微信支付、阿里云短信)报错或超时
    • 策略:关注级报警。通常结合熔断与降级机制处理。运维人员需确认是网络抖动还是服务商故障。

第二段:业务域 / 服务标识(Domain) - 2位(BB)

由架构组统一分配,映射具体的微服务或业务线。快速定位问题出在哪个服务

  • 分配原则
    • 00 通用/基础架构 (Gateway, Middleware)
    • 01 用户中心 (User Service)
    • 02 商品中心 (Product Service)
    • 03 订单中心 (Order Service)
    • 04 交易/支付 (Payment Service)
    • 05 营销/优惠卷 (Promotion Service)

如果微服务超过99个,可扩展为3位,或改用16进制表示

第三段:具体错误编号(Case) - 3/4位(CCC)

自增数字,标识具体的业务场景

  • 分层设计建议
    • 001~099:该服务内的通用错误(如:用户中心-通用参数错误)
    • 100~099:具体场景错误(如:用户中心-手机号注册-格式错误)

示例

  • A01102
    • A: 用户搞错了
    • 01: 用户中心
    • 102: 用户名已存在
    • 结果:前端弹窗提示“用户名已被占用”,无需后端排查
  • B03005
    • B: 系统bug
    • 03: 订单中心
    • 005: 数据库写入失败(可能是死锁)
    • 结果:触发电话报警给订单组研发,立即查看日志
  • C04201
    • C: 第三方挂了
    • 04: 支付中心
    • 201: 支付宝接口响应超时
    • 结果: 触发钉钉报警,支付组确认是否需要切流到微信支付

参考

错误码分场景设计指南日志与外部API实践-开发者社区-阿里云

https://www.bilibili.com/video/BV1ugvdBoEC2/?vd_source=67ef3bb4c8d68a96408acdaa865b1313