一、 先说痛点:这些异常处理方式,你是不是很熟悉?
先约定一个简单的电商下单链路:
Controller 收下单请求 → Service 做各种校验(库存、余额、风控…) → 远程调用支付/库存等系统 → 结果返回给前端
1–3 年经验的同学,在这条链路里常见的写法大概是:
- try-catch 到处都是:一层套一层,
catch (Exception e) {}一把梭,逻辑全被包在大括号里,主流程根本看不清。 - 异常信息模糊:远程调用失败、余额不足、库存不足,全都变成“下单失败”或
e.printStackTrace()。 - 只抛不管:Controller 直接
throws Exception,上层只看到 500,没人知道怎么兜底。 - Checked / Unchecked 随缘用:要么全
RuntimeException,要么方法签名写满throws Exception。
下面我们就围绕“电商下单”这一条主线,用 4 个实战方案,把异常从底层到前端串起来: 技术异常 → 业务异常 → 全局异常处理器 → 前端响应。
二、方案一:在下单链路中划清 Checked / Unchecked 边界
1. 电商场景里的 Checked / Unchecked
- Checked 异常:继承
Exception,编译器强制处理,适合“外部系统调用失败”,如支付、库存接口超时。 - Unchecked 异常:继承
RuntimeException,多用于“编程错误/不该发生的状态”,如数量为负、订单状态非法。
2. 坏写法 vs 好写法(调用支付示例)
| 对比项 | 坏写法 | 好写法 |
|---|---|---|
| 异常类型 | 统一 throws Exception | 技术异常在边界层转成语义清晰的 Checked 业务异常 |
| 调用方感受 | 不知道要不要处理 | 一眼能看出“这是支付失败,需要显式处理” |
| 可维护性 | 日志乱、栈乱 | 按“外部依赖失败”归类,便于监控和重试 |
坏写法:所有异常混成一锅粥
public class PaymentService {
public void pay(Long orderId) throws Exception {
// 调远程支付
String result = paymentClient.request(orderId);
if (!"SUCCESS".equals(result)) {
throw new Exception("支付失败");
}
}
}
调用方完全不知道是网络问题还是业务失败,自然也不知道要不要重试、要不要提示用户余额不足。
好写法:边界层转换 + 编程错误用 RuntimeException
// 业务可理解的 Checked 异常:支付失败
public class PaymentFailedException extends Exception {
public PaymentFailedException(String message) {
super(message);
}
}
// 支付客户端:技术异常 → 业务异常(Checked)
public class PaymentClient {
public String request(Long orderId) throws PaymentFailedException {
try {
String result = httpClient.post("/pay", orderId);
if (!"SUCCESS".equals(result)) {
throw new PaymentFailedException("支付结果失败");
}
return result;
} catch (IOException e) {
throw new PaymentFailedException("支付服务不可用,请稍后重试");
}
}
}
// 订单服务:显式处理支付失败
public class OrderService {
public void payOrder(Long orderId) {
try {
paymentClient.request(orderId);
} catch (PaymentFailedException e) {
// 记录日志,转换为统一的业务异常给上层
throw new BusinessException("PAYMENT_FAILED", e.getMessage());
}
}
// 编程错误用 RuntimeException 表达
public void validateQuantity(Integer quantity) {
if (quantity == null || quantity <= 0) {
throw new IllegalArgumentException("购买数量必须为正数");
}
}
}
小结:
外部依赖失败(IO/HTTP)→ 边界层转成 Checked 异常;
业务层再转成 BusinessException;
编程错误(参数非法)→ 直接 RuntimeException,这类问题通常靠测试/监控尽早暴露。
三、方案二:用自定义业务异常,把“下单失败”说清楚
在下单场景里,常见业务失败有:余额不足、库存不足、风控拦截。
如果写成 return null 或 throw new RuntimeException("下单失败"),调用方根本没法区分。
1. 坏写法 vs 好写法(余额不足示例)
| 对比项 | 坏写法 | 好写法 |
|---|---|---|
| 表达方式 | return null / 错误码 | 明确的业务异常类,如 InsufficientBalanceException |
| 调用方体验 | 容易忘记判空 / 判码 | 用异常强提醒,必须处理或交给全局异常 |
| 日志/排查 | 只能看到 NPE 或统一报错 | 从异常名+消息就能看出“余额不足” |
2. 自定义业务异常类(保留原有 BusinessException 思路)
// 通用业务异常
public class BusinessException extends RuntimeException {
private final String code;
public BusinessException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() {
return code;
}
}
// 更具体的业务异常:余额不足
public class InsufficientBalanceException extends BusinessException {
public InsufficientBalanceException(Long userId, BigDecimal currentBalance, BigDecimal needAmount) {
super("INSUFFICIENT_BALANCE",
String.format("用户[%d]余额不足,当前:%s,需要:%s",
userId, currentBalance, needAmount));
}
}
3. 服务层在下单时使用
public class OrderService {
public void placeOrder(Long userId, Long productId, BigDecimal amount) {
BigDecimal balance = accountService.getBalance(userId);
if (balance.compareTo(amount) < 0) {
// 直接抛出业务语义清晰的异常
throw new InsufficientBalanceException(userId, balance, amount);
}
// 其他下单逻辑:扣库存、锁单、生成订单等
}
}
相比“返回 null / 错误码”:
- 不会悄悄失败:异常没被捕获就会往上冒,必然有地方兜底;而
null很容易被忽略,最后变成莫名其妙的 NPE。 - 业务语义直接暴露:从异常类就能看出“余额不足”“库存不足”,日志和监控可以按异常类型统计。
- 职责更清晰:服务层只管“发现规则不满足就抛异常”,怎么展示给前端交给全局异常处理器。
四、方案三:全局异常处理器,让前端拿到稳定的错误响应
1. 坏写法 vs 好写法(Controller 层)
| 对比项 | 坏写法 | 好写法 |
|---|---|---|
| 控制器结构 | 每个方法写 try-catch | 控制器只写业务流程,异常交给全局处理器 |
| 重复度 | 大量复制粘贴捕获日志代码 | 异常处理集中在一个类 |
| 前端体验 | 有的返回 JSON,有的 500,有的 HTML | 响应结构统一:code + message + data |
2. 统一返回对象(保留原有 ApiResponse 思路)
public class ApiResponse<T> {
private String code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> resp = new ApiResponse<>();
resp.code = "SUCCESS";
resp.message = "OK";
resp.data = data;
return resp;
}
public static <T> ApiResponse<T> failure(String code, String message) {
ApiResponse<T> resp = new ApiResponse<>();
resp.code = code;
resp.message = message;
return resp;
}
// getter / setter 省略
}
3. 全局异常处理器(补充字段错误提取)
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.util.stream.Collectors;
@Slf4j
@RestControllerAdvice // 等价于 @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {
// 处理业务异常(下单失败、余额不足等)
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
log.warn("业务异常:code={}, message={}", ex.getCode(), ex.getMessage());
return ApiResponse.failure(ex.getCode(), ex.getMessage());
}
// 处理参数校验异常(数量、收货地址等字段校验失败)
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleValidationException(Exception ex) {
String message = "参数校验失败";
if (ex instanceof MethodArgumentNotValidException) {
message = ((MethodArgumentNotValidException) ex).getBindingResult()
.getFieldErrors()
.stream()
.map(this::formatFieldError)
.collect(Collectors.joining("; "));
} else if (ex instanceof BindException) {
message = ((BindException) ex).getBindingResult()
.getFieldErrors()
.stream()
.map(this::formatFieldError)
.collect(Collectors.joining("; "));
}
log.warn("参数校验异常:{}", message, ex);
return ApiResponse.failure("VALIDATION_FAILED", message);
}
private String formatFieldError(FieldError error) {
return String.format("%s %s (实际值:%s)",
error.getField(),
error.getDefaultMessage(),
error.getRejectedValue());
}
// 兜底的系统异常
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleException(Exception ex) {
log.error("系统异常", ex);
// 对外只暴露通用提示,避免泄露内部信息
return ApiResponse.failure("INTERNAL_ERROR", "系统开小差了,请稍后再试");
}
}
4. 一句话“文字流程图”
支付 HTTP 超时(IOException 等技术异常)
→ 在PaymentClient转成PaymentFailedException(Checked)
→ 在OrderService转成BusinessException("PAYMENT_FAILED", …)
→ 被GlobalExceptionHandler.handleBusinessException捕获
→ 返回{"code":"PAYMENT_FAILED","message":"支付服务不可用,请稍后重试"}给前端
前端只需要根据 code 做不同提示/跳转,不关心后端内部细节。
五、方案四:日志要“能查问题”,而不是“陪跑”
1. 坏写法 vs 好写法(处理订单示例)
| 对比项 | 坏写法 | 好写法 |
|---|---|---|
| 日志内容 | e.printStackTrace() 或压根不打 | 明确业务场景 + 关键标识(userId、orderId) |
| 查问题体验 | 线上看不到 / 看不懂 | 从一条日志就能大致知道发生了什么 |
| 对外信息 | 可能把堆栈原样返回前端 | 对内详细,对外简洁安全 |
坏写法:吞掉异常或只打印堆栈
try {
doSomething();
} catch (Exception e) {
e.printStackTrace(); // 打印到控制台,线上日志系统未必采集到
// 或者更糟糕:什么都不做
}
这样的结果是:问题发生了你也不知道,用户只是反馈“偶尔有错误”,日志里却什么线索都没有。
好写法:有上下文的日志 + 配合 BusinessException
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class OrderService {
public void processOrder(Long userId, Long orderId) {
try {
doProcess(orderId);
} catch (Exception e) {
// 日志中记录关键上下文,方便排查
log.error("处理订单失败,userId={}, orderId={}", userId, orderId, e);
// 向上抛给全局异常处理器,由它决定返回什么给前端
throw new BusinessException("ORDER_PROCESS_FAILED", "订单处理失败,请稍后重试");
}
}
}
原则小结:
- 不吞异常:记录日志 or 抛给上层,别默默吃掉。
- 日志写给“人”看:谁在干什么、对哪个对象操作、结果如何。
- 对外响应只给必要信息,不直接把堆栈抛给用户。
六、异常处理避坑清单(现象 + 危害 + 修复)
| 坑点 | 典型现象 | 危害 | 修复方案 |
|---|---|---|---|
到处 catch (Exception) | 任何问题都变成“下单失败” | 无法快速定位问题 | 按业务异常/参数异常/系统异常分别捕获 |
用 null 表示失败 | 调用方忘记判空,NPE | 调试成本高 | 失败用业务异常表示,不再用 null |
| 业务异常返回 500 | 前端误以为服务器挂了 | 用户体验差,告警污染 | 业务异常统一用 4xx,如 400 |
| 不打日志或乱打 | 日志没有 userId/orderId | 排查困难 | 在关键链路统一格式日志 |
| 暴露完整堆栈给前端 | 前端直接看到 SQL/路径 | 安全风险 | 堆栈只打日志,对外给通用提示 |
| 所有异常都 RuntimeException | 方法签名看不出会失败 | 可读性差 | 外部依赖失败用 Checked,内部再包成业务异常 |
| 每个 Controller 自己处理异常 | 复制粘贴大量 try-catch | 难以统一改动 | 用 @RestControllerAdvice 统一收口 |
七、不同规模项目怎么选
-
小型单体应用(人少、接口少)
- 至少有:
BusinessException + ApiResponse + GlobalExceptionHandler三件套。 - 不用一上来就定义很多子类异常,先保证“都能返回统一 JSON”。
- 至少有:
-
中型电商/业务系统
- 为核心域(订单、支付、库存)各定义少量关键业务异常,如
InsufficientBalanceException、InventoryNotEnoughException。 - 严格使用统一的
ApiResponse和GlobalExceptionHandler,便于前端对接和统一监控。
- 为核心域(订单、支付、库存)各定义少量关键业务异常,如
-
大型分布式系统
- 异常处理要和错误码规范、链路追踪(TraceId)、重试/补偿机制一起设计。
- 业务异常需要能直接喂给监控/告警系统,按异常类型出报表。
八、新手落地“三步法”
-
先搭全局异常处理骨架
- 写好
ApiResponse、BusinessException、GlobalExceptionHandler,保证“任何异常都能有一个统一 JSON 返回”。
- 写好
-
新代码统一用“好写法”
- 新增接口、改需求时:用自定义业务异常 + 全局异常处理器,不再在 Controller 堆
try-catch。
- 新增接口、改需求时:用自定义业务异常 + 全局异常处理器,不再在 Controller 堆
-
逐步改造老代码
- 挑一条关键链路(比如“下单”),从底层客户端开始:
- 技术异常 → Checked;
- 服务层 → 业务异常;
- Controller → 去掉多余
try-catch,交给全局处理器。
- 挑一条关键链路(比如“下单”),从底层客户端开始:
九、异常处理最佳实践速查表
| 场景 | 异常类型 | 处理方式 | 日志规范 |
|---|---|---|---|
| 请求参数不合法(负数、必填为空) | Validation 异常 / IllegalArgumentException | 由全局异常处理器返回 VALIDATION_FAILED | 级别 WARN,记录字段名+值 |
| 余额不足、库存不足等业务失败 | 自定义业务异常(继承 BusinessException) | 由全局异常处理器返回对应业务错误码 | WARN,记录 userId、orderId、商品信息 |
| 调用支付/库存超时 | 在客户端转为 Checked(如 PaymentFailedException)再包成 BusinessException | 返回业务错误码(如 PAYMENT_FAILED) | ERROR,记录外部接口名、耗时、请求参数 |
| 不可预期的系统错误(NPE 等) | RuntimeException | 兜底捕获,返回 INTERNAL_ERROR | ERROR,完整堆栈 + 关键上下文 |
| Controller 内部显式抛出的业务异常 | BusinessException | 全局异常处理器统一转换为 JSON | WARN,控制台和日志系统一致 |
| 批处理/定时任务 | 自定义任务异常或直接 RuntimeException | 任务框架日志 + 告警 | 必须有任务名、批次号、处理条数 |
十、小结:从“顺手 try-catch”到“有设计的异常处理”
回顾一下这条“电商下单”的异常链路:
- 合理划分 Checked / Unchecked:IO、网络这类外部依赖失败,用 Checked 异常在边界层转换为业务可理解的异常;编程错误用 RuntimeException 表达“不该发生”。
- 用自定义业务异常表达业务语义:余额不足、库存不足、风控拦截,都有自己明确的异常类,而不是一把梭的“下单失败”。
- 通过全局异常处理器统一收口:让 Controller 只关心业务流程,异常由
@RestControllerAdvice在一个入口统一转换为标准响应。 - 为异常提供有用信息与日志:既不吞异常,也不过度暴露内部细节,用恰到好处的上下文帮助排查问题。
真正成熟的代码,不是“哪里报错就顺手包个 try-catch”,而是一开始就设计好异常边界和处理策略。
从下一次改“下单”这类核心流程开始,不妨按文中的思路梳理你的异常处理,你会发现:代码更干净,问题更好查,团队同事也更愿意接你的坑。