Go:错误处理为什么不能只会 `if err != nil`

学 Go 学到错误处理时,最容易得出一个过于粗糙的印象:

  • Go 没有异常
  • 所以就是一路写 if err != nil
  • 代码虽然丑一点,但也就这样了

这句话只说对了一半。

Go 的确经常写 if err != nil,但真正难的地方从来不是这一行判断,而是下面这些问题:

  • 这个错误到底该在哪里产生
  • 这个错误该不该往上抛
  • 往上抛时要不要补上下文
  • 调用方应该按什么维度判断错误
  • 什么情况该重试,什么情况该终止
  • 什么地方可以降级,什么地方绝对不能吞

如果这些边界没建立起来,代码很快就会变成另一种失控:

  • 错误日志很多,但看不出哪一层出的
  • 返回的都是 fmt.Errorf("failed")
  • 上层根本分不清是参数错误、网络错误还是状态错误
  • 为了“简单”,最后到处 panic
  • 或者另一种极端,到处 if err != nil { return nil }

这一篇就围绕一个实际小场景来讲:做一个批量任务执行器的错误处理骨架

这个执行器要做几件事:

  1. 读取任务定义
  2. 校验任务参数
  3. 调用下游执行
  4. 汇总执行结果
  5. 对部分错误做重试
  6. 对关键错误立刻终止

这个场景不大,但足够把 Go 错误处理里最容易混乱的部分一次串起来。

一、这篇文章要解决什么问题

读完这一篇,应该能独立回答这些问题:

  1. if err != nil 到底只是语法动作,还是错误处理的全部
  2. Go 项目里常见的几类错误应该怎么区分
  3. 错误返回、包装、分类和判断分别解决什么问题
  4. 什么时候该继续向上返回,什么时候该就地处理
  5. 什么时候不该用 panic
  6. 怎么给错误处理补最小测试

如果这些问题能答清,后面再写并发、HTTP 服务、任务调度,代码会稳很多。

二、先看这个小项目里的错误链路

假设现在有一个最小任务执行器,输入一批任务,输出执行摘要。

每个任务至少包含:

  • ID
  • Name
  • Runner
  • Retry

执行流程大概是:

  1. 先校验任务是否合法
  2. 再调用具体执行器
  3. 执行失败时记录原因
  4. 某些可重试错误重试一次
  5. 最终返回汇总结果

这里的错误至少会来自三层:

  1. 输入层
    例如任务名为空、重试次数非法

  2. 执行层
    例如网络超时、下游返回失败码、依赖资源不存在

  3. 汇总层
    例如结果写入失败、结果结构不完整

如果一开始就把这三层全部混成一种 error 字符串,后面再想加重试、告警和统计就会很吃力。

三、先给一个最小可运行示例

先只做最小骨架,不急着补重试和分类。

executor.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package executor

import "fmt"

type Task struct {
ID string
Name string
Retry int
}

func Run(task Task) error {
if task.ID == "" {
return fmt.Errorf("task id can not be empty")
}
if task.Name == "" {
return fmt.Errorf("task name can not be empty")
}
if task.Retry < 0 {
return fmt.Errorf("retry can not be negative")
}
return nil
}

调用方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
"log"

"task_runner/internal/executor"
)

func main() {
task := executor.Task{
ID: "task-101",
Name: "",
Retry: 1,
}

if err := executor.Run(task); err != nil {
log.Fatal(err)
}

fmt.Println("done")
}

输出:

1
task name can not be empty

这个例子已经说明一件事:

  • if err != nil 只是接住错误
  • 真正重要的是 Run 返回的错误有没有信息量

四、先把一句话结论说清楚

Go 错误处理最核心的目标不是“把错误写出来”,而是:

让调用方能做出正确决策。

所谓正确决策,至少包括这些:

  • 记录日志
  • 返回给上层
  • 转成业务错误
  • 重试
  • 降级
  • 停止执行

如果一个错误值只能让人知道“失败了”,却不能支持这些决策,那错误处理就是不完整的。

五、错误处理里最容易混的四件事

这里最容易把下面四件事混成一团:

  1. 产生错误
  2. 包装错误
  3. 判断错误
  4. 处理错误

它们不是一回事。

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
2
3
if errors.Is(err, ErrTemporary) {
...
}

4. 处理错误

例如重试、记录日志、终止流程。

如果这四层没有分开,代码很快就会变成:

  • 底层既生成错误又打印日志
  • 中层既包装错误又私自吞掉错误
  • 上层既不知道根因也没法分类

六、常见但低效的错误写法有哪些

先看几个高频坏味道。

1. 只有字符串,没有上下文

1
return fmt.Errorf("run failed")

问题是:

  • 失败的是哪一个任务
  • 在哪一层失败
  • 参数是什么

都不知道。

2. 每层都重新造一个新错误,根因丢了

1
2
3
if err != nil {
return fmt.Errorf("execute task failed")
}

这样做以后,上层再也拿不到原始错误。

3. 打日志又返回错误,重复噪声很多

1
2
3
4
if err != nil {
log.Printf("run task failed: %v", err)
return err
}

如果每层都这么做,最终日志会重复三到五遍。

4. 不分场景直接 panic

1
2
3
if err != nil {
panic(err)
}

这在 demo 里很短,放进服务或执行器里通常就是放大事故。

七、先给错误分层:输入错误、临时错误、最终错误

对这个任务执行器来说,最先该做的不是发明很多抽象,而是把错误分层。

可以先分三类:

  1. 输入错误
    例如字段缺失、格式非法、配置有误
    这类错误一般不该重试。

  2. 临时错误
    例如网络抖动、依赖服务超时
    这类错误可能值得重试。

  3. 最终错误
    例如状态已经非法、业务条件不满足
    这类错误要立即失败。

先有这个分类,后面的重试和汇总才有基础。

八、用哨兵错误解决“能不能判断这类错误”

先看最小做法。

1
2
3
4
5
6
7
8
package executor

import "errors"

var (
ErrInvalidTask = errors.New("invalid task")
ErrTemporary = errors.New("temporary failure")
)

然后在具体逻辑里包装:

1
2
3
if task.Name == "" {
return fmt.Errorf("%w: task name can not be empty", ErrInvalidTask)
}

或者:

1
return fmt.Errorf("%w: upstream timeout", ErrTemporary)

调用方就可以这样判断:

1
2
3
4
5
6
7
if errors.Is(err, ErrInvalidTask) {
// 直接返回,不重试
}

if errors.Is(err, ErrTemporary) {
// 允许重试
}

这一步解决的不是“写法更高级”,而是让上层开始有分类能力。

九、包装错误不是为了好看,而是为了把上下文带上来

看下面这个执行链:

  • validateTask
  • callRunner
  • runOnce
  • runWithRetry

如果底层只返回:

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 failure
  • task-101 with runner http-check

十、什么时候该用自定义错误类型

哨兵错误能解决“这一类错误是什么”,但有时还不够。

例如现在要记录哪个任务失败、失败码是多少、是否允许重试。

这时可以考虑自定义错误类型:

1
2
3
4
5
6
7
8
9
10
11
12
package executor

type RunError struct {
TaskID string
Runner string
Retryable bool
Reason string
}

func (e RunError) Error() string {
return "task " + e.TaskID + " runner " + e.Runner + ": " + e.Reason
}

如果调用方只想拿结构信息:

1
2
3
4
5
6
var runErr RunError
if errors.As(err, &runErr) {
if runErr.Retryable {
...
}
}

这类做法更适合:

  • 错误里确实有结构化字段
  • 调用方真的需要字段判断
  • 这些字段会被日志、告警或汇总使用

如果只是想传一句错误消息,就没必要上自定义类型。

十一、一个更完整的执行器版本

先把前面的分层和包装串起来。

internal/executor/executor.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package executor

import (
"errors"
"fmt"
)

var (
ErrInvalidTask = errors.New("invalid task")
ErrTemporary = errors.New("temporary failure")
)

type Task struct {
ID string
Name string
Runner string
Retry int
}

type Runner interface {
Run(Task) error
}

func Execute(task Task, runner Runner) error {
if task.ID == "" {
return fmt.Errorf("%w: task id can not be empty", ErrInvalidTask)
}
if task.Name == "" {
return fmt.Errorf("%w: task name can not be empty", ErrInvalidTask)
}
if task.Runner == "" {
return fmt.Errorf("%w: runner can not be empty", ErrInvalidTask)
}
if task.Retry < 0 {
return fmt.Errorf("%w: retry can not be negative", ErrInvalidTask)
}

err := runner.Run(task)
if err != nil {
return fmt.Errorf("execute task %s: %w", task.ID, err)
}

return nil
}

一个模拟 runner:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package executor

import "fmt"

type HTTPRunner struct{}

func (r HTTPRunner) Run(task Task) error {
if task.Name == "timeout-task" {
return fmt.Errorf("%w: upstream timeout", ErrTemporary)
}
if task.Name == "forbidden-task" {
return fmt.Errorf("permission denied")
}
return nil
}

调用方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
"errors"
"log"

"task_runner/internal/executor"
)

func main() {
task := executor.Task{
ID: "task-101",
Name: "timeout-task",
Runner: "http-check",
Retry: 1,
}

err := executor.Execute(task, executor.HTTPRunner{})
if err != nil {
if errors.Is(err, executor.ErrTemporary) {
log.Printf("temporary error, can retry: %v", err)
return
}
log.Fatal(err)
}
}

这时上层已经能分清:

  • 参数问题
  • 临时故障
  • 其它不可恢复故障

十二、为什么“只会 if err != nil”会让重试逻辑很快失控

假设没有错误分类,代码容易写成这样:

1
2
3
4
if err != nil {
retry++
continue
}

这个写法的直接问题是:

  • 参数错误也被重试
  • 权限错误也被重试
  • 资源不存在也被重试

结果就是:

  • 没意义的重试把时间耗掉
  • 日志量暴涨
  • 真正该快速失败的问题被拖延

所以重试前至少要回答:

  1. 这是不是临时错误
  2. 当前次数是否还允许继续重试
  3. 重试会不会造成额外副作用

只有 if err != nil,回答不了这三个问题。

十三、什么时候该就地处理,什么时候该往上返回

这是 Go 错误处理最容易失控的分界线之一。

适合就地处理的情况

  • 当前层知道怎么恢复
  • 恢复后的行为边界清楚
  • 上层不需要关心这次细节

例如一次临时 DNS 失败,当前层明确只需要 sleep 后重试一次。

适合向上返回的情况

  • 当前层没法决定是否继续
  • 当前层不知道业务后果
  • 上层需要做告警、回滚、终止流程

例如任务执行失败是否影响整批发布,这通常不是底层执行器该决定的。

一个简单判断方式是:

如果当前层没有充分信息做业务决策,就不要擅自吞掉错误。

十四、什么时候不该打日志

第一版代码很容易在每层都写:

1
2
3
4
if err != nil {
log.Printf("failed: %v", err)
return err
}

问题是最终日志会变成:

  • 底层打一遍
  • 中层打一遍
  • 上层打一遍

一条错误出现三遍,信息量并没有增加。

更常见的边界是:

  • 底层负责构造和返回错误
  • 接近入口或边界层时再统一记录日志

也就是说:

  • 错误值负责携带信息
  • 边界层负责决定怎么记录

十五、什么时候可以用 panic

panic 不是完全不能用,但它的边界要很清楚。

更适合 panic 的情况通常是:

  • 明确不应该发生的程序员错误
  • 初始化阶段的致命配置错误
  • 已经无法继续保证程序正确性

不适合 panic 的情况通常是:

  • 普通输入校验错误
  • 常规网络故障
  • 下游服务超时
  • 某个任务执行失败

对这个任务执行器来说,大多数运行期错误都应该用 error 返回,而不是直接 panic

十六、一个更接近真实现场的完整案例

假设现在有一批巡检任务:

  • check-order-api
  • check-user-api
  • check-billing-api

批量执行时出现三种现象:

  1. check-order-api 参数缺失
  2. check-user-api 网络超时
  3. check-billing-api 权限被拒绝

如果错误处理只有一句:

1
2
3
if err != nil {
return err
}

上层最后只能知道“有任务失败了”。

但值班时真正需要知道的是:

  • 哪个任务失败
  • 为什么失败
  • 哪个可以重试
  • 哪个应该立刻提单
  • 哪个说明配置已经错了

这时一套更完整的处理链通常是:

  1. 参数错误包装成 ErrInvalidTask
  2. 超时错误包装成 ErrTemporary
  3. 权限错误直接返回最终失败
  4. 批量汇总层根据 errors.Is 做分类统计

例如汇总代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Summary struct {
SuccessCount int
RetryableCount int
InvalidCount int
FailedCount int
}

func Collect(summary *Summary, err error) {
if err == nil {
summary.SuccessCount++
return
}
if errors.Is(err, ErrTemporary) {
summary.RetryableCount++
return
}
if errors.Is(err, ErrInvalidTask) {
summary.InvalidCount++
return
}
summary.FailedCount++
}

这样最终报告就不只是“失败了 3 个”,而是能拆出具体类型。

这就是工程里错误处理的真正价值:
不是让代码显得规范,而是让处理动作能分层落地。

十七、怎么给错误处理补最小测试

错误处理如果只靠手跑,很容易留下两个误区:

  • 以为错误能返回出来就算对
  • 以为字符串长得差不多就算对

至少应该测三件事:

  1. 错误是否真的返回
  2. 错误是否保留了根因
  3. 上层能否用 errors.Iserrors.As 判断

示例测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package executor

import (
"errors"
"testing"
)

type fakeRunner struct {
err error
}

func (r fakeRunner) Run(Task) error {
return r.err
}

func TestExecuteInvalidTask(t *testing.T) {
err := Execute(Task{}, fakeRunner{})
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrInvalidTask) {
t.Fatalf("expected ErrInvalidTask, got %v", err)
}
}

func TestExecuteTemporaryError(t *testing.T) {
runner := fakeRunner{
err: ErrTemporary,
}
err := Execute(Task{
ID: "task-101",
Name: "timeout-task",
Runner: "http-check",
}, runner)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrTemporary) {
t.Fatalf("expected ErrTemporary, got %v", err)
}
}

执行:

1
go test ./...

这组测试虽然很小,但已经守住了错误处理里最关键的一层契约。

十八、一个常见排错场景:为什么上层判断不到底层错误

例如上层写了:

1
2
3
if errors.Is(err, ErrTemporary) {
...
}

结果永远进不来。

高频原因通常有三个:

  1. 中间层没有用 %w 包装
    错误根因在某一层被截断了。

  2. 每层都重新 errors.New(...)
    结果虽然文字一样,但不是同一个错误值。

  3. 本来需要 errors.As,却误用了 errors.Is

排查顺序通常是:

  1. 先看底层错误怎么生成
  2. 再看中间层是否用 %w
  3. 再看上层用的是 Is 还是 As

这类问题一旦会查,后面写 HTTP 服务和并发任务时会省很多时间。

十九、这篇文章的边界在哪里

这一篇重点讲的是错误处理骨架:

  • 错误分层
  • 错误包装
  • 错误分类
  • 处理边界

先不展开这些更大的话题:

  • 并发执行里的错误聚合
  • context 取消和错误传播
  • HTTP 场景里的状态码映射
  • 更完整的 observability 设计

这些内容更适合放在后面的并发、标准库和 HTTP 服务文章里继续展开。

二十、一个实际练习

可以直接把这一篇改造成一个完整练习。

练习目标:做一个最小批量任务执行器。

要求:

  1. 至少定义 ErrInvalidTaskErrTemporary
  2. 参数错误不要重试
  3. 临时错误允许重试一次
  4. 每层补必要上下文,但不要在每层都打日志
  5. errors.Is 验证错误分类
  6. 故意写一个忘记 %w 的版本,再自己修回来

如果这个练习能独立做完,说明错误处理已经不只是“会写判断”,而是开始有工程边界了。

二十一、这篇文章学完以后,下一步应该补什么

这一篇解决的是:

  • Go 错误处理为什么不只是语法动作
  • 错误值、包装、分类和边界怎么配合

接下来最适合继续补的是:

  1. goroutine、channel、context 应该怎么配合
  2. 并发场景下错误应该怎么收集和取消
  3. 标准库里的 ionet/httpjson 怎么把错误继续向上传

因为到这一步,单线程的小工具已经能比较稳地处理失败路径了。
后面真正会复杂起来的,是多个执行单元一起跑时,错误该怎么传播和收口。

二十二、结语

Go 错误处理最容易被误解成一种机械动作:

  • 调函数
  • 判断 err
  • 返回

但真正决定代码质量的,从来不是这一行判断本身,而是背后的边界有没有立住:

  • 错误有没有分类
  • 根因有没有保留
  • 上下文有没有补齐
  • 谁负责记录日志
  • 谁负责重试
  • 谁负责终止

只要这些边界清楚,if err != nil 就不会显得笨重。
它会变成一条非常直接的工程信号:当前这一步失败了,下一步该由谁来做正确处理。