错误处理与异常模式:错误码设计、异常分类、全局异常处理器、异常恢复策略、Go与Java实践

说实话,错误处理这件事,我见过太多团队把它当成「事后补丁」来做了。项目初期赶进度,到处抛个 Exception 或者返回个 -1 就完事。等到线上出了故障,日志一翻,全是 unknown error,你根本不知道是数据库连不上,还是参数传错了。

我个人习惯是:在写第一行业务代码之前,先把错误处理框架搭好。这不是矫情,这是血的教训换来的经验。

错误码设计:别让数字变成谜语

错误码这东西,说白了就是人和机器之间的「暗号」。暗号设计得好,定位问题就像开卷考试;设计得烂,那就是猜谜游戏。

我建议采用分层编码结构:

层级 位数 含义 示例
系统标识 2位 哪个子系统 01=用户系统,02=订单系统
模块标识 2位 哪个模块 01=注册,02=登录
错误类型 2位 参数/状态/系统 01=参数错误,02=状态冲突
具体序号 2位 唯一错误码 01~99

举个例子:01010101 表示「用户系统-注册模块-参数错误-邮箱格式不合法」。你看,光看数字就能猜到大概问题出在哪。

核心原则:错误码一旦发布,永不删除、永不复用。废弃的码只标记为「已弃用」,但保留在文档里。为什么?因为历史日志里可能还躺着这个码,你删了它,老日志就变成了天书。

我在项目中遇到过一件事:有个同事觉得某个错误码「看着不顺眼」,就把它改了。结果第二天运维排查线上问题,对着日志里的旧码找了半天,文档里根本没有。最后花了两个小时才发现是码被改了。嗯,从那以后我们定了一条铁律——错误码只能追加,不能修改或删除

异常分类:把「意外」分门别类

异常分类这件事,Java 和 Go 的思路不太一样,但核心逻辑是相通的。我个人习惯把异常分成三类:

  • 可预期异常:比如用户输入不合法、文件不存在。这类异常应该被捕获并处理,而不是让程序崩溃。
  • 不可预期但可恢复:比如网络超时、数据库连接池耗尽。这类异常可以重试,或者降级处理。
  • 不可恢复异常:比如内存溢出、配置错误。这类异常应该快速失败,把问题暴露出来,而不是藏着掖着。

你想想看,如果把所有异常都一视同仁,用同一个 catch 块处理,那跟没处理有什么区别?

我的小技巧:在 Java 中,我会为每一类异常定义单独的基类。比如 BusinessException 继承 RuntimeExceptionSystemException 继承 Exception。这样在全局处理器里,我可以根据异常类型做不同的响应。

全局异常处理器:最后一堵墙

全局异常处理器,说白了就是你的「最后一道防线」。它不应该处理业务逻辑,它的职责只有三件事:

  1. 统一响应格式:不管什么异常,返回给客户端的数据结构必须一致。
  2. 记录完整上下文:把请求参数、用户信息、堆栈都记下来。
  3. 防止信息泄露:不要把数据库表结构、堆栈信息直接抛给前端。

在 Java 中,我通常用 @ControllerAdvice 配合 @ExceptionHandler 来实现:

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
        // 记录业务异常日志
        log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
        return ResponseEntity.badRequest()
                .body(new ErrorResponse(e.getCode(), e.getMessage()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnknown(Exception e) {
        // 记录完整堆栈,但返回给客户端的是模糊信息
        log.error("未知异常", e);
        return ResponseEntity.status(500)
                .body(new ErrorResponse("SYS_500", "系统繁忙,请稍后重试"));
    }
}

在 Go 里,我习惯用中间件的方式:

func RecoveryMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if err := recover(); err != nil {
                // 记录堆栈
                log.Errorf("panic recovered: %v, stack: %s", err, debug.Stack())
                c.JSON(500, gin.H{
                    "code":    "SYS_500",
                    "message": "系统繁忙,请稍后重试",
                })
                c.Abort()
            }
        }()
        c.Next()
    }
}

注意:全局处理器不是万能药。它只处理那些「漏网之鱼」。业务逻辑中该捕获的异常,还是要老老实实写 try-catch 或 if-err 判断。全局处理器只负责兜底,不负责擦屁股。

异常恢复策略:别让一个错误拖垮整个系统

异常恢复,说白了就是「出错了怎么办」。我总结了四种常用策略:

  • 重试:适用于临时性故障,比如网络抖动。注意要加退避策略,别把系统打崩了。
  • 降级:核心功能保底,非核心功能暂时关闭。比如推荐系统挂了,至少让用户能正常下单。
  • 熔断:连续失败达到阈值后,直接拒绝请求,给下游系统喘息的机会。
  • 补偿:事务失败后,通过补偿操作回滚状态。比如订单创建失败,需要释放预占的库存。

我曾经在一个支付系统中犯过错误:重试策略没加退避,结果一个下游服务挂了,我们的重试请求直接把对方打得更死。后来我们改成了指数退避 + 最大重试次数限制,问题才解决。

Go 与 Java 的实践差异

这两种语言在错误处理上的哲学完全不同。Java 喜欢用异常机制,Go 喜欢用多返回值。我个人觉得没有谁更好,只有谁更适合你的团队。

维度 Java Go
错误传递方式 异常抛出 + 捕获 多返回值 + if err != nil
错误类型 Checked / Unchecked error 接口
堆栈信息 默认自带 需要手动包装(fmt.Errorf + %w)
全局处理 @ControllerAdvice 中间件 + defer recover

在 Go 中,我习惯用 errors.Iserrors.As 来判断错误类型,而不是用字符串比较。为什么?因为字符串比较太脆弱了,稍微改个大小写就匹配不上。

// 定义错误
var ErrNotFound = errors.New("user not found")

// 使用
if errors.Is(err, ErrNotFound) {
    // 处理未找到的情况
}

在 Java 中,我建议用 OptionalResult 模式来替代部分异常场景。比如查询用户,如果没找到,返回 Optional.empty() 比抛一个 UserNotFoundException 更优雅。

知识体系总览

下面这张图是我自己总结的错误处理知识体系,你可以把它当作一个检查清单:

错误处理与异常模式 错误码设计 分层编码 永不删除/复用 文档同步 异常分类 可预期 可恢复 不可恢复 全局异常处理器 统一响应格式 记录上下文 防止信息泄露 异常恢复策略 重试 + 退避 降级 熔断 补偿 Go / Java 实践 异常 vs 多返回值 堆栈处理差异 全局拦截方式 核心目标:快速定位、优雅恢复、不丢数据

这张图把错误处理的五个核心维度串在了一起。你从任何一个维度切入,都能找到对应的实践方法。

最后说一句:错误处理不是「锦上添花」,而是「雪中送炭」。一个系统上线后,90% 的时间都在处理异常情况。把错误处理做扎实了,线上值班的时候你才能睡得着觉。


公众号:蓝海资料掘金营,微信deep3321