你的接口为什么总是返500?大多数人都搞错了异常处理的本质
昨天线上又报警了。一看,又是那个经典的NPE。代码里某个地方取了个人,person是null,.getName()直接原地升天。
然后你开始排查,发现这个接口捕了Exception,心想:"没事,全捕获了,稳的。"
然后你看到日志里打出来的是:catch (Exception e) { log.error(e); return 500; }
好家伙,500是返了,但鬼知道发生了什么。运维问你是啥问题,你说"好像是空指针",他说"那为什么人的字段是空",你说"业务方没传",他说"那为什么没传你们系统没校验",你说"……"
恭喜你,你遇到的不是技术问题,是认知问题。
先把"catch Exception"这句话从你脑子里删掉
我知道你在想什么。"catch Exception多通用啊,万能药,包治百病。"
兄弟,这是把灭火器灌进嘴里指望它治感冒。
Java的异常体系设计出来是有原因的。RuntimeException(unchecked) vs 受检异常(checked),这不是语言设计者闲得慌搞的分类,而是两种完全不同的人生观:
- 受检异常:这个风险你可以提前知道,所以必须显式处理——要么catch,要么throws。编译器逼你面对。
- 运行时异常:这玩意儿不该发生,发生了说明代码有bug——你不需要每层都声明,捕获它是为了记录和恢复,不是为了让它"看起来被处理了"。
你把RuntimeException catch住,返回一个500,然后继续往下执行——这不叫处理异常,这叫假装没看见前女友给你发消息。
// 经典反面教材,看看你有没有中招
@GetMapping("/user")
public ResponseEntity getUser(Long id) {
try {
User user = userService.findById(id);
return ResponseEntity.ok(user);
} catch (Exception e) {
log.error("获取用户失败", e);
return ResponseEntity.status(500).body("系统错误");
}
}
// 这段代码的隐含意思是:
// "我不知道会发生什么,发生了也不知道怎么办,
// 但我假装处理了,所以我的代码质量没问题" —— 掩耳盗铃.jpg
异常不是用来"处理"的,是用来"分类"的
很多人对异常的理解还停留在:异常发生了 → catch住 → 打印日志 → 返回错误信息。
这个思路从根上就歪了。
异常的本质是通信协议。它不是在说"出错了",而是在说"这个错误属于哪种情况,应该由谁来响应"。
想象一下你公司里的问题反馈机制:
- 饮水机没水了 → 行政部处理(业务异常,可预期)
- 服务器着火了 → 找消防,不归你管(系统异常,不可恢复)
- 代码写错了 → 开发者自己修(bug,不应该被业务层捕获)
你不能让行政部去灭火,也不能让开发者去修饮水机。同样的道理:
// 业务异常:业务校验失败,预期内的
class BusinessException extends RuntimeException {
private final String code;
public BusinessException(String code, String message) {
super(message);
this.code = code;
}
}
// 系统异常:基础设施出了问题
class SystemException extends RuntimeException {
public SystemException(String message, Throwable cause) {
super(message, cause);
}
}
// 业务层——应该捕获业务异常,给用户友好提示
@GetMapping("/order")
public ResponseEntity getOrder(Long id) {
try {
Order order = orderService.findById(id);
return ResponseEntity.ok(order);
} catch (BusinessException e) {
// 业务校验失败:400,告知用户哪里填错了
return ResponseEntity.badRequest()
.body(ApiResult.error(e.getCode(), e.getMessage()));
} catch (SystemException e) {
// 系统故障:503,稍后重试
return ResponseEntity.status(503)
.body(ApiResult.error("SERVICE_UNAVAILABLE", "服务暂时不可用"));
} catch (Exception e) {
// 这个catch才是真正需要你写日志的地方
// 它意味着你没想到会发生这种事
log.error("未预期的错误,请立即排查", e);
return ResponseEntity.status(500)
.body(ApiResult.error("INTERNAL_ERROR", "系统繁忙"));
}
}
注意这里的分层逻辑:不同类型的异常,由不同层级来处理,产出不同的用户可见结果。
全局异常处理器才是你的主战场
如果你每个Controller方法里都写一堆try-catch,我建议你现在就去洗手,因为你的代码正在慢性自杀。
正确的姿势是用@ControllerAdvice做统一异常处理:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity handleBusiness(BusinessException e) {
// 业务异常:通常是用户输入问题,400
log.warn("业务校验失败: {} - {}", e.getCode(), e.getMessage());
return ResponseEntity.badRequest()
.body(ApiResult.error(e.getCode(), e.getMessage()));
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity handleNotFound(ResourceNotFoundException e) {
// 找不到资源:404
return ResponseEntity.status(404)
.body(ApiResult.error("NOT_FOUND", e.getMessage()));
}
@ExceptionHandler(SystemException.class)
public ResponseEntity handleSystem(SystemException e) {
// 系统异常:记录详细堆栈,返给用户一个干净的错误码
log.error("系统异常", e);
return ResponseEntity.status(503)
.body(ApiResult.error("SERVICE_ERROR", "服务异常,请稍后重试"));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity handleValidation(MethodArgumentNotValidException e) {
// 参数校验失败:400 + 具体字段错误
Map<String, String> errors = new HashMap<>();
e.getBindingResult().getFieldErrors().forEach(err ->
errors.put(err.getField(), err.getDefaultMessage())
);
return ResponseEntity.badRequest()
.body(ApiResult.error("VALIDATION_ERROR", errors));
}
@ExceptionHandler(Throwable.class)
public ResponseEntity handleUnknown(Throwable e) {
// 最后一道防线:这个catch到说明代码有bug
// 必须记录完整堆栈,因为这个错误你完全没想到
log.error("未捕获的异常,请立即处理!", e);
return ResponseEntity.status(500)
.body(ApiResult.error("UNKNOWN_ERROR", "系统繁忙"));
}
}
用了这个之后,你的Controller干净得像刚洗完澡:
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/users/{id}")
public ApiResult<User> getUser(@PathVariable Long id) {
// 这里的代码在说什么?只说业务逻辑
// 异常?不存在的,handler已经帮你安排好了
return ApiResult.ok(userService.findById(id));
}
}
记录日志这件事,80%的人做错了
我问过一个写了三年前端的朋友:"你catch异常之后一般怎么处理?"
他说:"log.error(e),然后返回500。"
我又问:"e.getMessage()和e.toString()有什么区别?"
他沉默了两秒,说:"……差不多吧。"
来,敲黑板。异常日志记录有三个层次:
- e.getMessage():人类可读的一句话描述。比如"用户不存在"。不含堆栈,不含原因。
- e.toString():类名 + Message。比上面多一个类名。
- e.printStackTrace() 或 log.error("...", e):完整堆栈。知道在哪行哪列崩的。
你如果log.error(e.getMessage()),出了问题你只能知道"哦,错了",然后继续猜。
正确的记录姿势:
// 只记录Message——信息不足,排查靠猜
log.error("下单失败: " + e.getMessage());
// 记录完整堆栈——这是标准做法
log.error("下单失败,请立即排查", e);
// Message + 额外上下文——这是老司机的做法
log.error("用户ID={}下单失败,订单号={},请立即排查",
userId, orderId, e);
第三种写法为什么是老司机?因为你不需要去日志里手动匹配userId和orderId,日志已经帮你串好了。查问题的时候眼泪少流三升。
最容易被忽视的一点:异常不能吞
这是最严重的问题,也是最常见的。
// 吞掉异常——不要这样做
try {
sendEmail();
} catch (Exception e) {
// 什么都不做,假装成功了
}
// 或者更隐蔽的吞法
try {
doSomething();
} catch (Exception e) {
log.debug("忽略异常", e); // debug级别,生产根本不打印
}
你吞掉异常的那一刻,程序就会处于一个未知状态。邮件没发出去,但用户看到"操作成功"。数据没保存,但系统告诉用户"已保存"。
这种bug有多难排查?等你排查到的时候,你已经忘了当时为什么写这个catch。
如果真遇到那种"无论怎样都不能让它影响主流程"的场景,用一个注释把原因写清楚:
try {
// 发送通知失败不影响主业务,但必须记录
// 后续可通过消息队列重试补偿
sendNotification();
} catch (NotificationException e) {
// 注意:这里故意吞掉,主流程不受影响
// 相关事务已提交,通知失败走MQ补偿
log.warn("通知发送失败,已记录,补偿任务稍后重试. userId={}", userId, e);
}
这个注释的价值是:让三年后的你不用骂三年前的自己。
总结一下
异常处理不是什么高深的东西,但为什么写好它的人凤毛麟角?因为它需要你在一开始就思考清楚:这个系统里有哪些类型的错误?每种错误应该由谁处理?用户看到什么?日志记什么?
这不是try-catch的语法问题,这是错误处理策略的设计问题。
下次再遇到500,先别急着加catch,先问自己三个问题:
- 这个异常我能不能提前校验避免?
- 这个异常应该归类到哪种错误?
- 捕获之后,用户看到了什么,日志记了什么?
如果三个问题你都能回答清楚,500报警会少一半。如果不能——
先把catch Exception删了,我们再聊。