Go:错误处理为什么不能只会 `if err != nil`
学 Go 学到错误处理时,最容易得出一个过于粗糙的印象:
- Go 没有异常
- 所以就是一路写
if err != nil - 代码虽然丑一点,但也就这样了
这句话只说对了一半。
Go 的确经常写 if err != nil,但真正难的地方从来不是这一行判断,而是下面这些问题:
- 这个错误到底该在哪里产生
- 这个错误该不该往上抛
- 往上抛时要不要补上下文
- 调用方应该按什么维度判断错误
- 什么情况该重试,什么情况该终止
- 什么地方可以降级,什么地方绝对不能吞
如果这些边界没建立起来,代码很快就会变成另一种失控:
- 错误日志很多,但看不出哪一层出的
- 返回的都是
fmt.Errorf("failed") - 上层根本分不清是参数错误、网络错误还是状态错误
- 为了“简单”,最后到处
panic - 或者另一种极端,到处
if err != nil { return nil }
这一篇就围绕一个实际小场景来讲:做一个批量任务执行器的错误处理骨架。
这个执行器要做几件事:
- 读取任务定义
- 校验任务参数
- 调用下游执行
- 汇总执行结果
- 对部分错误做重试
- 对关键错误立刻终止
这个场景不大,但足够把 Go 错误处理里最容易混乱的部分一次串起来。
一、这篇文章要解决什么问题
读完这一篇,应该能独立回答这些问题:
if err != nil到底只是语法动作,还是错误处理的全部- Go 项目里常见的几类错误应该怎么区分
- 错误返回、包装、分类和判断分别解决什么问题
- 什么时候该继续向上返回,什么时候该就地处理
- 什么时候不该用
panic - 怎么给错误处理补最小测试
如果这些问题能答清,后面再写并发、HTTP 服务、任务调度,代码会稳很多。
二、先看这个小项目里的错误链路
假设现在有一个最小任务执行器,输入一批任务,输出执行摘要。
每个任务至少包含:
IDNameRunnerRetry
执行流程大概是:
- 先校验任务是否合法
- 再调用具体执行器
- 执行失败时记录原因
- 某些可重试错误重试一次
- 最终返回汇总结果
这里的错误至少会来自三层:
输入层
例如任务名为空、重试次数非法执行层
例如网络超时、下游返回失败码、依赖资源不存在汇总层
例如结果写入失败、结果结构不完整
如果一开始就把这三层全部混成一种 error 字符串,后面再想加重试、告警和统计就会很吃力。
三、先给一个最小可运行示例
先只做最小骨架,不急着补重试和分类。
executor.go:
1 | package executor |
调用方:
1 | package main |
输出:
1 | task name can not be empty |
这个例子已经说明一件事:
if err != nil只是接住错误- 真正重要的是
Run返回的错误有没有信息量
四、先把一句话结论说清楚
Go 错误处理最核心的目标不是“把错误写出来”,而是:
让调用方能做出正确决策。
所谓正确决策,至少包括这些:
- 记录日志
- 返回给上层
- 转成业务错误
- 重试
- 降级
- 停止执行
如果一个错误值只能让人知道“失败了”,却不能支持这些决策,那错误处理就是不完整的。
五、错误处理里最容易混的四件事
这里最容易把下面四件事混成一团:
- 产生错误
- 包装错误
- 判断错误
- 处理错误
它们不是一回事。
1. 产生错误
例如参数非法时直接返回:
1 | return fmt.Errorf("task name can not be empty") |
2. 包装错误
例如底层执行失败时补一层上下文:
1 | return fmt.Errorf("run task %s: %w", task.ID, err) |
3. 判断错误
例如上层需要知道这是不是“可重试错误”:
1 | if errors.Is(err, ErrTemporary) { |
4. 处理错误
例如重试、记录日志、终止流程。
如果这四层没有分开,代码很快就会变成:
- 底层既生成错误又打印日志
- 中层既包装错误又私自吞掉错误
- 上层既不知道根因也没法分类
六、常见但低效的错误写法有哪些
先看几个高频坏味道。
1. 只有字符串,没有上下文
1 | return fmt.Errorf("run failed") |
问题是:
- 失败的是哪一个任务
- 在哪一层失败
- 参数是什么
都不知道。
2. 每层都重新造一个新错误,根因丢了
1 | if err != nil { |
这样做以后,上层再也拿不到原始错误。
3. 打日志又返回错误,重复噪声很多
1 | if err != nil { |
如果每层都这么做,最终日志会重复三到五遍。
4. 不分场景直接 panic
1 | if err != nil { |
这在 demo 里很短,放进服务或执行器里通常就是放大事故。
七、先给错误分层:输入错误、临时错误、最终错误
对这个任务执行器来说,最先该做的不是发明很多抽象,而是把错误分层。
可以先分三类:
输入错误
例如字段缺失、格式非法、配置有误
这类错误一般不该重试。临时错误
例如网络抖动、依赖服务超时
这类错误可能值得重试。最终错误
例如状态已经非法、业务条件不满足
这类错误要立即失败。
先有这个分类,后面的重试和汇总才有基础。
八、用哨兵错误解决“能不能判断这类错误”
先看最小做法。
1 | package executor |
然后在具体逻辑里包装:
1 | if task.Name == "" { |
或者:
1 | return fmt.Errorf("%w: upstream timeout", ErrTemporary) |
调用方就可以这样判断:
1 | if errors.Is(err, ErrInvalidTask) { |
这一步解决的不是“写法更高级”,而是让上层开始有分类能力。
九、包装错误不是为了好看,而是为了把上下文带上来
看下面这个执行链:
validateTaskcallRunnerrunOncerunWithRetry
如果底层只返回:
1 | return ErrTemporary |
上层知道它是临时错误,但不知道是哪个任务、哪次执行、哪个 runner。
更完整的写法通常是:
1 | return fmt.Errorf("run task %s with runner %s: %w", task.ID, task.Runner, err) |
这样上层既能保留根因,又能看到上下文。
调用方打印出来的错误可能像这样:
1 | run task task-101 with runner http-check: temporary failure: upstream timeout |
这里真正有价值的是两层信息同时存在:
temporary failuretask-101 with runner http-check
十、什么时候该用自定义错误类型
哨兵错误能解决“这一类错误是什么”,但有时还不够。
例如现在要记录哪个任务失败、失败码是多少、是否允许重试。
这时可以考虑自定义错误类型:
1 | package executor |
如果调用方只想拿结构信息:
1 | var runErr RunError |
这类做法更适合:
- 错误里确实有结构化字段
- 调用方真的需要字段判断
- 这些字段会被日志、告警或汇总使用
如果只是想传一句错误消息,就没必要上自定义类型。
十一、一个更完整的执行器版本
先把前面的分层和包装串起来。
internal/executor/executor.go:
1 | package executor |
一个模拟 runner:
1 | package executor |
调用方:
1 | package main |
这时上层已经能分清:
- 参数问题
- 临时故障
- 其它不可恢复故障
十二、为什么“只会 if err != nil”会让重试逻辑很快失控
假设没有错误分类,代码容易写成这样:
1 | if err != nil { |
这个写法的直接问题是:
- 参数错误也被重试
- 权限错误也被重试
- 资源不存在也被重试
结果就是:
- 没意义的重试把时间耗掉
- 日志量暴涨
- 真正该快速失败的问题被拖延
所以重试前至少要回答:
- 这是不是临时错误
- 当前次数是否还允许继续重试
- 重试会不会造成额外副作用
只有 if err != nil,回答不了这三个问题。
十三、什么时候该就地处理,什么时候该往上返回
这是 Go 错误处理最容易失控的分界线之一。
适合就地处理的情况
- 当前层知道怎么恢复
- 恢复后的行为边界清楚
- 上层不需要关心这次细节
例如一次临时 DNS 失败,当前层明确只需要 sleep 后重试一次。
适合向上返回的情况
- 当前层没法决定是否继续
- 当前层不知道业务后果
- 上层需要做告警、回滚、终止流程
例如任务执行失败是否影响整批发布,这通常不是底层执行器该决定的。
一个简单判断方式是:
如果当前层没有充分信息做业务决策,就不要擅自吞掉错误。
十四、什么时候不该打日志
第一版代码很容易在每层都写:
1 | if err != nil { |
问题是最终日志会变成:
- 底层打一遍
- 中层打一遍
- 上层打一遍
一条错误出现三遍,信息量并没有增加。
更常见的边界是:
- 底层负责构造和返回错误
- 接近入口或边界层时再统一记录日志
也就是说:
- 错误值负责携带信息
- 边界层负责决定怎么记录
十五、什么时候可以用 panic
panic 不是完全不能用,但它的边界要很清楚。
更适合 panic 的情况通常是:
- 明确不应该发生的程序员错误
- 初始化阶段的致命配置错误
- 已经无法继续保证程序正确性
不适合 panic 的情况通常是:
- 普通输入校验错误
- 常规网络故障
- 下游服务超时
- 某个任务执行失败
对这个任务执行器来说,大多数运行期错误都应该用 error 返回,而不是直接 panic。
十六、一个更接近真实现场的完整案例
假设现在有一批巡检任务:
check-order-apicheck-user-apicheck-billing-api
批量执行时出现三种现象:
check-order-api参数缺失check-user-api网络超时check-billing-api权限被拒绝
如果错误处理只有一句:
1 | if err != nil { |
上层最后只能知道“有任务失败了”。
但值班时真正需要知道的是:
- 哪个任务失败
- 为什么失败
- 哪个可以重试
- 哪个应该立刻提单
- 哪个说明配置已经错了
这时一套更完整的处理链通常是:
- 参数错误包装成
ErrInvalidTask - 超时错误包装成
ErrTemporary - 权限错误直接返回最终失败
- 批量汇总层根据
errors.Is做分类统计
例如汇总代码:
1 | type Summary struct { |
这样最终报告就不只是“失败了 3 个”,而是能拆出具体类型。
这就是工程里错误处理的真正价值:
不是让代码显得规范,而是让处理动作能分层落地。
十七、怎么给错误处理补最小测试
错误处理如果只靠手跑,很容易留下两个误区:
- 以为错误能返回出来就算对
- 以为字符串长得差不多就算对
至少应该测三件事:
- 错误是否真的返回
- 错误是否保留了根因
- 上层能否用
errors.Is或errors.As判断
示例测试:
1 | package executor |
执行:
1 | go test ./... |
这组测试虽然很小,但已经守住了错误处理里最关键的一层契约。
十八、一个常见排错场景:为什么上层判断不到底层错误
例如上层写了:
1 | if errors.Is(err, ErrTemporary) { |
结果永远进不来。
高频原因通常有三个:
中间层没有用
%w包装
错误根因在某一层被截断了。每层都重新
errors.New(...)
结果虽然文字一样,但不是同一个错误值。本来需要
errors.As,却误用了errors.Is
排查顺序通常是:
- 先看底层错误怎么生成
- 再看中间层是否用
%w - 再看上层用的是
Is还是As
这类问题一旦会查,后面写 HTTP 服务和并发任务时会省很多时间。
十九、这篇文章的边界在哪里
这一篇重点讲的是错误处理骨架:
- 错误分层
- 错误包装
- 错误分类
- 处理边界
先不展开这些更大的话题:
- 并发执行里的错误聚合
context取消和错误传播- HTTP 场景里的状态码映射
- 更完整的 observability 设计
这些内容更适合放在后面的并发、标准库和 HTTP 服务文章里继续展开。
二十、一个实际练习
可以直接把这一篇改造成一个完整练习。
练习目标:做一个最小批量任务执行器。
要求:
- 至少定义
ErrInvalidTask和ErrTemporary - 参数错误不要重试
- 临时错误允许重试一次
- 每层补必要上下文,但不要在每层都打日志
- 用
errors.Is验证错误分类 - 故意写一个忘记
%w的版本,再自己修回来
如果这个练习能独立做完,说明错误处理已经不只是“会写判断”,而是开始有工程边界了。
二十一、这篇文章学完以后,下一步应该补什么
这一篇解决的是:
- Go 错误处理为什么不只是语法动作
- 错误值、包装、分类和边界怎么配合
接下来最适合继续补的是:
- goroutine、channel、context 应该怎么配合
- 并发场景下错误应该怎么收集和取消
- 标准库里的
io、net/http、json怎么把错误继续向上传
因为到这一步,单线程的小工具已经能比较稳地处理失败路径了。
后面真正会复杂起来的,是多个执行单元一起跑时,错误该怎么传播和收口。
二十二、结语
Go 错误处理最容易被误解成一种机械动作:
- 调函数
- 判断
err - 返回
但真正决定代码质量的,从来不是这一行判断本身,而是背后的边界有没有立住:
- 错误有没有分类
- 根因有没有保留
- 上下文有没有补齐
- 谁负责记录日志
- 谁负责重试
- 谁负责终止
只要这些边界清楚,if err != nil 就不会显得笨重。
它会变成一条非常直接的工程信号:当前这一步失败了,下一步该由谁来做正确处理。