错误处理与异常模式:错误码设计、异常分类、全局异常处理器、异常恢复策略、Go与Java实践
说实话,错误处理这件事,我见过太多团队把它当成「事后补丁」来做了。项目初期赶进度,到处抛个 Exception 或者返回个 -1 就完事。等到线上出了故障,日志一翻,全是 unknown error,你根本不知道是数据库连不上,还是参数传错了。
我个人习惯是:在写第一行业务代码之前,先把错误处理框架搭好。这不是矫情,这是血的教训换来的经验。
错误码设计:别让数字变成谜语
错误码这东西,说白了就是人和机器之间的「暗号」。暗号设计得好,定位问题就像开卷考试;设计得烂,那就是猜谜游戏。
我建议采用分层编码结构:
| 层级 | 位数 | 含义 | 示例 |
|---|---|---|---|
| 系统标识 | 2位 | 哪个子系统 | 01=用户系统,02=订单系统 |
| 模块标识 | 2位 | 哪个模块 | 01=注册,02=登录 |
| 错误类型 | 2位 | 参数/状态/系统 | 01=参数错误,02=状态冲突 |
| 具体序号 | 2位 | 唯一错误码 | 01~99 |
举个例子:01010101 表示「用户系统-注册模块-参数错误-邮箱格式不合法」。你看,光看数字就能猜到大概问题出在哪。
核心原则:错误码一旦发布,永不删除、永不复用。废弃的码只标记为「已弃用」,但保留在文档里。为什么?因为历史日志里可能还躺着这个码,你删了它,老日志就变成了天书。
我在项目中遇到过一件事:有个同事觉得某个错误码「看着不顺眼」,就把它改了。结果第二天运维排查线上问题,对着日志里的旧码找了半天,文档里根本没有。最后花了两个小时才发现是码被改了。嗯,从那以后我们定了一条铁律——错误码只能追加,不能修改或删除。
异常分类:把「意外」分门别类
异常分类这件事,Java 和 Go 的思路不太一样,但核心逻辑是相通的。我个人习惯把异常分成三类:
- 可预期异常:比如用户输入不合法、文件不存在。这类异常应该被捕获并处理,而不是让程序崩溃。
- 不可预期但可恢复:比如网络超时、数据库连接池耗尽。这类异常可以重试,或者降级处理。
- 不可恢复异常:比如内存溢出、配置错误。这类异常应该快速失败,把问题暴露出来,而不是藏着掖着。
你想想看,如果把所有异常都一视同仁,用同一个 catch 块处理,那跟没处理有什么区别?
我的小技巧:在 Java 中,我会为每一类异常定义单独的基类。比如 BusinessException 继承 RuntimeException,SystemException 继承 Exception。这样在全局处理器里,我可以根据异常类型做不同的响应。
全局异常处理器:最后一堵墙
全局异常处理器,说白了就是你的「最后一道防线」。它不应该处理业务逻辑,它的职责只有三件事:
- 统一响应格式:不管什么异常,返回给客户端的数据结构必须一致。
- 记录完整上下文:把请求参数、用户信息、堆栈都记下来。
- 防止信息泄露:不要把数据库表结构、堆栈信息直接抛给前端。
在 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.Is 和 errors.As 来判断错误类型,而不是用字符串比较。为什么?因为字符串比较太脆弱了,稍微改个大小写就匹配不上。
// 定义错误
var ErrNotFound = errors.New("user not found")
// 使用
if errors.Is(err, ErrNotFound) {
// 处理未找到的情况
}
在 Java 中,我建议用 Optional 或 Result 模式来替代部分异常场景。比如查询用户,如果没找到,返回 Optional.empty() 比抛一个 UserNotFoundException 更优雅。
知识体系总览
下面这张图是我自己总结的错误处理知识体系,你可以把它当作一个检查清单:
这张图把错误处理的五个核心维度串在了一起。你从任何一个维度切入,都能找到对应的实践方法。
最后说一句:错误处理不是「锦上添花」,而是「雪中送炭」。一个系统上线后,90% 的时间都在处理异常情况。把错误处理做扎实了,线上值班的时候你才能睡得着觉。
公众号:蓝海资料掘金营,微信deep3321