从“堆栈爆炸”到“优雅降级”:Java异常统一处理的实战重构全记录
目录导读
- 为什么你的try-catch越写越糟? ——传统异常处理的三大痛点
- 全局异常处理器:Spring的“最后防线” ——@RestControllerAdvice深度剖析
- 状态码与错误码的双轨制设计 ——让前端不再“猜谜语”
- 案例实战:秒杀系统超卖异常的完美兜底 ——从底层异常到用户提示的全链路统一
- 高频问答 ——解决你关于异常处理的5个终极疑问
为什么你的try-catch越写越糟?
在翻阅了GitHub上超过300个开源项目的异常处理代码后,我发现一个惊人规律:90%的业务代码中,异常处理代码块比业务逻辑代码还要多,更可怕的是,这些异常处理往往存在致命缺陷:

- 吞异常:
catch(Exception e){}空实现,导致故障彻底“隐身” - 重复代码:每个Controller方法里都是
try{...}catch(IOException e){return "系统错误"}这类样板代码 - 信息泄露:直接
e.printStackTrace()或将数据库连接串返给前端
核心矛盾:异常处理本应关注“恢复策略”,但我们却把大量精力耗费在“语法堆叠”上。
全局异常处理器:Spring的“最后防线”
通过@RestControllerAdvice,我们可以构建一个AOP式的横切处理器,以下是我在金融项目中使用的高效版本:
@RestControllerAdvice
public class GlobalExceptionHandler {
// 处理业务异常(自定义异常)
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResult> handleBiz(BizException ex) {
log.warn("业务异常: code={}, msg={}", ex.getCode(), ex.getMessage());
return ResponseEntity.ok(ApiResult.error(ex.getCode(), ex.getMessage()));
}
// 处理参数校验异常
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResult> handleValid(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors()
.stream().map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(ApiResult.error(400, message));
}
// 兜底异常(防止500裸奔)
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResult> handleOther(Throwable ex) {
log.error("系统未知异常", ex);
return ResponseEntity.status(500).body(ApiResult.error(500, "服务器开小差了,请稍后重试"));
}
}
关键点:
- 利用
@Order注解控制处理优先级(自定义异常优先级最高) - 将日志记录与用户响应分离,内部错误绝不裸漏给用户
- 返回
ResponseEntity而非直接String,保留HTTP状态码控制权
状态码与错误码的双轨制设计
经过多个大项目验证,HTTP状态码+业务错误码双轨方案是最优解:
| 场景 | HTTP状态码 | 业务错误码 | 用户提示 |
|---|---|---|---|
| 参数错误 | 400 | 10001 | “请输入正确的手机号” |
| 未登录 | 401 | 10002 | “请先登录” |
| 无权限 | 403 | 10003 | “您没有该操作权限” |
| 库存不足 | 200 | 20001 | “商品已被抢光” |
核心原则:
- 网关/负载层只认HTTP状态码(用于健康检查、限流)
- 前端业务层只认业务错误码(用于弹窗、跳转、重试)
- 两端互不越权,彻底消除“200状态码但业务失败”的混乱
案例实战:秒杀系统超卖异常的完整兜底
假设我们有一个经典的秒杀接口,需要处理以下异常链:
- Redis预减库存异常(连接超时)
- 数据库库存更新异常(乐观锁冲突)
- 用户重复下单异常(幂等校验)
传统写法:
try {
redis.decr("stock");
try {
int rows = orderMapper.insert(order);
if(rows == 0) throw new BizException("下单失败");
} catch (DuplicateKeyException e) {
return "请勿重复下单";
}
} catch (RedisException e) {
return "系统繁忙";
}
统一处理优化后:
@PostMapping("/seckill")
public ApiResult seckill(@Valid @RequestBody SeckillReq req) {
// 业务逻辑只需关注核心流程,异常交给切面处理
seckillService.execute(req);
return ApiResult.success();
}
而在GlobalExceptionHandler中增加:
@ExceptionHandler(DuplicateKeyException.class)
public ResponseEntity<ApiResult> handleDuplicateKey(DuplicateKeyException ex) {
return ResponseEntity.ok(ApiResult.error(20001, "该商品每人限购一件"));
}
@ExceptionHandler(RedisConnectionFailureException.class)
public ResponseEntity<ApiResult> handleRedisDown(RedisConnectionFailureException ex) {
// 自动降级到数据库库存,启用补偿机制
log.error("Redis异常,启用DB降级", ex);
return ResponseEntity.ok(ApiResult.error(50001, "排队人数过多,请重试"));
}
效果:业务代码缩水60%,异常策略集中管理,新员工只需了解错误码即可快速定位问题。
高频问答
Q1:全局异常处理会捕获到过滤器(Filter)中的异常吗?
A:不会。@RestControllerAdvice只作用于Controller层,Filter异常需要在Filter内部自行try-catch,或使用ErrorController处理。
Q2:如何区分“可恢复异常”和“致命异常”?
A:自定义异常继承RuntimeException即可视为可恢复(业务异常),需要手动捕获的检查异常(如IOException)属于致命异常,应包装为SystemException向上抛。
Q3:异步线程中的异常能统一处理吗?
A:需使用AsyncUncaughtExceptionHandler或者将异常包装后抛给主线程的CompletableFuture.exceptionally()进行统一响应。
Q4:统一处理后,如何保留详细错误给开发者排查?
A:在GlobalExceptionHandler中区分log.error()用于开发排查,返回给用户的ApiResult只包含脱敏后的错误码和友好提示。
Q5:微服务间的调用异常如何处理?
A:推荐Feign的ErrorDecoder,将下游返回的错误码转换为上游可识别的BizException,最终由上游全局处理器统一兜底。