Go 的错误处理看起来很朴素:函数返回 error,调用方判断 if err != nil。这种设计非常直接,也很符合 Go 的风格。但真正写后端服务时,问题并不在“知不知道怎么判断错误”,而在“这个错误能不能被理解、被追踪、被分类、被恢复”。
一个好的错误处理体系,不只是把错误传上去,而是要让调用链上每一层都知道:出了什么问题,问题发生在哪,应该怎么记录,最终又该如何反馈给调用方。
为什么简单返回字符串远远不够
因为后端里的错误通常不是独立事件,而是一条调用链上的结果。
一个接口失败,可能来自参数校验、配置缺失、数据库超时、下游返回异常、序列化失败或者内部状态错误。如果你只在最底层返回一个普通字符串,到了上层很可能已经失去了上下文。
这会导致两类问题。
第一,调用方不知道如何区分错误类型。
第二,排查时不知道错误到底起源于哪一层。
Go 错误处理真正要解决什么
第一,要保留错误上下文。
第二,要区分业务错误和系统错误。
第三,要保证错误在日志里能被看懂,在响应里又不会泄露不该暴露的细节。
也就是说,错误处理不是“把 err 往上 return”这么简单,而是一套信息传递和边界控制机制。
业务错误和系统错误为什么要分开
因为它们面对的处理策略完全不一样。
业务错误更像预期内失败,例如参数不合法、资源不存在、状态不允许操作。这类错误往往应该转成明确的业务响应。
系统错误更像非预期失败,例如数据库连接失败、超时、依赖服务异常、内部 panic。这类错误更需要被完整记录,并由统一错误处理逻辑兜底。
如果你把这两类错误全部混在一起,前端拿到的响应会混乱,日志里的优先级也会失真。
为什么错误上下文特别重要
举个最简单的例子,底层只返回一句 timeout,你很难知道是调用哪个下游超时了;但如果返回的是“query user profile timeout from account service”,排查方向就立刻清晰很多。
所以在 Go 里,错误处理一个很重要的习惯就是:不要吞上下文,也不要无脑重写到只剩一句模糊信息。更合理的方式是沿调用链补充上下文,让最终错误信息能反映路径。
panic 和 recover 应该怎么理解
很多初学者会把 panic 当普通错误处理来用,这通常不是好习惯。
在工程化服务里,普通业务失败和普通系统失败更适合通过 error 显式返回;panic 更适合表示程序已经进入不正常状态,例如空指针、越界、严重不变量破坏。
而 recover 的价值,通常不在业务层,而在服务边界。例如 HTTP 中间件里统一兜底,防止单次请求把整个进程拖垮。
错误处理和日志怎么配合
一个常见误区是每一层都打同一条错误日志。这样最后日志里会充满重复信息。
更合理的做法通常是:错误沿调用链返回,由最合适的边界层统一记录关键日志。例如在 handler 或统一中间件处记录请求级错误,同时底层只补充上下文而不过度重复打印。
这样既能保留错误路径,也能避免日志噪声。
一个常见反例
1 | if err != nil { |
这类写法的问题是把原始错误细节直接抹掉了。你只知道“查失败了”,但不知道是超时、连接失败、扫描失败还是数据为空。
更合理的思路是什么
更合理的思路是让错误保留来源,并在上层按场景做分类处理。业务层可以增加业务含义,边界层再决定返回给客户端什么信息、记录什么日志。这样错误既不会失控扩散,也不会在关键节点失去定位能力。
一个值得自己做的小练习
给你的 Go 服务设计三类错误。
- 参数错误
- 资源不存在
- 下游调用失败
要求你不要让三种错误都直接返回 500,而是分别设计不同的处理方式。同时想一想:日志里该记录什么,响应里又该暴露什么。只要你做过这个练习,就会明显感受到错误处理和“单纯 return err”不是一回事。
小结
Go 的错误处理之所以看起来简单,是因为语言把复杂性留给了工程实践。真正决定服务质量的,不是会不会写 if err != nil,而是你能不能让错误在整个调用链里既保留上下文、又分清责任、还能被统一记录和优雅返回。错误处理做得好,服务就更可观察、更可维护,也更不容易在出问题时彻底失去方向。