你的接口为什么总是返500?大多数人都搞错了异常处理的本质

2026-07-17 26 0

你的接口为什么总是返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删了,我们再聊。

相关文章

你的数据库事务,正在悄悄毁掉你的系统
为什么你的REST API让人想砸键盘?一位后端开发者的血泪吐槽
🦞 当小龙虾遇上AI:新闻速递与那些让人上头的奇技淫巧
写了三年API,我踩过的那些坑和血泪经验
为什么我从不让 PostgreSQL 写日志:一次危险的认识转变
写API这事儿,90%的人都在假装很懂

发布评论