09 错误处理

Go 错误处理速查:error 接口、哨兵错误、%w 包装链、errors.Is/As/Join/Unwrap、自定义错误类型、panic 与 error 的边界

09 错误处理

Go 没有异常:错误是普通值,必须显式检查。本页回答:错误怎么造、怎么包、怎么判、什么时候该用 panic。

基线:Go 1.27.1。所有输出为本机实跑结果。


错误处理的三个动作

flowchart LR
    subgraph GEN["① 产生"]
        G1["errors.New<br/>静态消息"]
        G2["fmt.Errorf<br/>带上下文"]
        G3["自定义类型<br/>带结构化字段"]
    end
    subgraph WRAP["② 传递(包装)"]
        W1["%w 建立错误链 🔥"]
        W2["%v 只拼字符串<br/>不建链 ⚠️"]
        W3["errors.Join 合并多个"]
    end
    subgraph CHECK["③ 判断"]
        C1["err == nil"]
        C2["errors.Is<br/>按语义匹配"]
        C3["errors.As<br/>取出具体类型"]
    end
    GEN --> WRAP --> CHECK
动作用什么关键点
产生errors.New / fmt.Errorf / 自定义类型消息小写、不加标点(Go 惯例)
包装%w只有 %w 建立错误链 🔥
判断errors.Is / errors.As永远别用 == 比较包装后的错误 ⚠️

error 接口与三种错误形态

1
2
3
4
// 就这一个方法,整个错误体系的基础
type error interface {
	Error() string
}

形态一:哨兵错误(sentinel error)

包级导出变量,供调用方用 errors.Is 比对:

1
2
3
4
var (
	ErrNotFound = errors.New("not found")
	ErrConflict = errors.New("conflict")
)
特点说明
命名Err 前缀 + 大驼峰 🔥
语义表示「某类可预期的失败」
判断errors.Is(err, pkg.ErrNotFound)
标准库例子io.EOF、sql.ErrNoRows、fs.ErrNotExist
缺点无法携带上下文数据 ⚠️

形态二:包装错误(wrapping)

1
2
3
4
5
6
func find(id int) error {
	if id == 0 {
		return fmt.Errorf("find user %d: %w", id, ErrNotFound)
	}
	return nil
}
动词行为
%w包装,保留错误链,可用 errors.Is/As 穿透 🔥
%v只格式化字符串,链断掉 ⚠️
%s同 %v
多个 %w🆕 1.20 起支持,等价于 errors.Join

形态三:自定义错误类型

需要携带结构化信息时用:

1
2
3
4
5
6
type ValidationError struct {
	Field string
	Msg   string
}

func (e *ValidationError) Error() string { return e.Field + ": " + e.Msg }
1
2
3
4
var ve *ValidationError
if errors.As(err, &ve) {
	fmt.Println("字段校验失败:", ve.Field, ve.Msg)   // 拿到结构化字段 🔥
}

🆕 1.26 起有了泛型版本,更安全也更简洁(推荐新代码使用):

1
2
3
4
5
6
7
8
// 老写法:需要一个预先声明的变量 + 取地址
var ve *ValidationError
if errors.As(err, &ve) { fmt.Println(ve.Field) }

// 🆕 新写法:errors.AsType,类型安全、无需取地址
if ve, ok := errors.AsType[*ValidationError](err); ok {
	fmt.Println("字段校验失败:", ve.Field, ve.Msg)
}
1
2
// 函数签名(本机实测)
//   func AsType[E error](err error) (E, bool)
要素惯例
类型名XxxError 结尾
指针接收者func (e *XxxError) Error(),便于 errors.As
字段只放判断和展示需要的信息
嵌套可加 Unwrap() error 让它参与错误链
1
2
3
4
5
6
7
8
// 让自定义错误也能继续包装下一层
type OpError struct {
	Op  string
	Err error        // 被包装的错误
}

func (e *OpError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *OpError) Unwrap() error { return e.Err }   // 🔥 实现这个才能被 Is/As 穿透

错误链与 Is / As 的判定路径

flowchart TB
    START["errors.Is(err, target)"] --> Q1{"err == target ?"}
    Q1 -- 是 --> YES["返回 true ✅"]
    Q1 -- 否 --> Q2{"err 实现了<br/>Is(error) bool ?"}
    Q2 -- 是 --> USE["调用 err.Is(target)"]
    Q2 -- 否 --> Q3{"err 实现了<br/>Unwrap() error ?"}
    Q3 -- 是 --> NEXT["取下一层,回到开头 🔁"]
    Q3 -- 否 --> Q4{"err 实现了<br/>Unwrap() []error ?"}
    Q4 -- 是 --> EACH["对每个子错误递归检查<br/>(errors.Join 走这条)"]
    Q4 -- 否 --> NO["返回 false ❌"]
1
2
3
4
5
err := find(0)   // find user 0: not found
fmt.Println("err:", err)
fmt.Println("Is ErrNotFound:", errors.Is(err, ErrNotFound))
fmt.Println("Is other:", errors.Is(err, ErrConflict))
fmt.Println("Unwrap == ErrNotFound:", errors.Unwrap(err) == ErrNotFound)
1
2
3
4
err: find user 0: not found
Is ErrNotFound: true
Is other: false
Unwrap == ErrNotFound: true

%v vs %w:一行之差,链断不断

1
2
3
4
plain := fmt.Errorf("plain: %v", ErrNotFound)
wrapped := fmt.Errorf("wrapped: %w", ErrNotFound)
fmt.Println("plain Is:", errors.Is(plain, ErrNotFound))     // false ⚠️
fmt.Println("wrapped Is:", errors.Is(wrapped, ErrNotFound)) // true ✅
1
2
plain Is: false
wrapped Is: true

🛑 这是最常见的错误处理 bug:包装时随手写了 %v,导致上层 errors.Is 永远返回 false,错误分类逻辑静默失效。

多层包装仍然能穿透

1
2
multi := fmt.Errorf("a: %w", fmt.Errorf("b: %w", ErrNotFound))
fmt.Println("multi Is:", errors.Is(multi, ErrNotFound))   // true
1
multi Is: true

手动数链深度:

1
2
3
4
5
6
chain := fmt.Errorf("l1: %w", fmt.Errorf("l2: %w", fmt.Errorf("l3: %w", ErrNotFound)))
depth := 0
for e := chain; e != nil; e = errors.Unwrap(e) {
	depth++
}
fmt.Println("single-chain depth:", depth, "->", chain)
1
single-chain depth: 4 -> l1: l2: l3: not found

errors.Join:合并多个错误 🆕 1.20

1
2
3
j := errors.Join(ErrNotFound, ErrConflict)
fmt.Println("Join Is NotFound:", errors.Is(j, ErrNotFound), "Is Conflict:", errors.Is(j, ErrConflict))
fmt.Println("Join str:", j)
1
2
3
Join Is NotFound: true Is Conflict: true
Join str: not found
conflict

⚠️ errors.Unwrap 不会展开 Join 的结果——这是官方文档明确说明的行为:

1
2
unwrapped := errors.Unwrap(joined)
fmt.Printf("Join Unwrap type: %T value=%v\n", unwrapped, unwrapped)
1
Join Unwrap type: <nil> value=<nil>

原因:Unwrap 只调用 Unwrap() error 形式的方法,而 Join 返回的类型实现的是 Unwrap() []error。判断代码要写:

1
2
3
4
var multi interface{ Unwrap() []error }
if errors.As(joined, &multi) {
	for _, e := range multi.Unwrap() { ... }   // ✅ 手动展开
}

💡 Join 的典型用途:清理阶段收集多个错误、并行任务汇总失败原因:

1
2
3
4
5
6
7
var errs []error
for _, f := range closers {
	if err := f.Close(); err != nil {
		errs = append(errs, err)
	}
}
return errors.Join(errs...)     // nil 元素会被自动忽略 ✅

同一条错误被包装两次 🆕 1.20

1
2
double := fmt.Errorf("x: %w and also %w", ErrNotFound, ErrNotFound)
fmt.Println("double:", double)
1
double: x: not found and also not found

与标准库错误对齐

标准库的哨兵错误值得直接用,而不是自己再造一套:

哨兵来源语义
io.EOFio读到流末尾(不是错误,是正常信号)⚠️
fs.ErrNotExist / os.ErrNotExistio/fs、os文件不存在(两者 Is 互通 ✅)
fs.ErrExistio/fs已存在
fs.ErrPermissionio/fs权限不足
sql.ErrNoRowsdatabase/sql查询无结果
context.Canceledcontext主动取消
context.DeadlineExceededcontext超时
http.ErrServerClosednet/http服务器正常关闭
1
2
3
_, err := os.Open("/definitely/not/here")
fmt.Println("Is ErrNotExist:", errors.Is(err, fs.ErrNotExist))
fmt.Println("Is os.ErrNotExist:", errors.Is(err, os.ErrNotExist))
1
2
Is ErrNotExist: true
Is os.ErrNotExist: true

⚠️ io.EOF 必须用 errors.Is 或 == 判等,但它在 io.ReadAll 等高层 API 里已经被吞掉了——只有自己写 Read 循环才需要处理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 标准读取循环
for {
	n, err := r.Read(buf)
	if n > 0 {
		process(buf[:n])       // ⚠️ 先处理数据,再判错误
	}
	if errors.Is(err, io.EOF) {
		break                  // 正常结束 ✅
	}
	if err != nil {
		return err
	}
}

⚠️ 注意顺序:先处理 n > 0 的数据,再判 err。Read 允许同时返回数据和 io.EOF,先判错会丢最后一块数据。


错误处理的工程惯例

惯例一:错误消息小写、不加标点

1
2
3
4
5
6
7
// ✅ Go 惯例
errors.New("user not found")
fmt.Errorf("open %s: %w", path, err)

// 🛑 不符合惯例
errors.New("User not found.")      // 大写 + 句号
errors.New("failed to open file")  // "failed to" 是冗余噪音 💭

理由:错误会被层层拼接(l1: l2: l3: ...),首字母大写和句号在中间会很难看。

惯例二:包装时补充「在做什么」,而不是重复「出错了」

1
2
3
4
5
// ✅ 补充上下文
return fmt.Errorf("load config %s: %w", path, err)

// 🛑 噪音
return fmt.Errorf("failed to load config: %w", err)   // "failed to" 没信息量

惯例三:错误只处理一次

flowchart TB
    E["底层函数返回 err"] --> Q{"当前层能处理吗?"}
    Q -- "能(有恢复策略)" --> HANDLE["处理掉:重试/兜底/降级<br/>不再向上返回 ✅"]
    Q -- "不能" --> WRAP["包装后向上返回<br/>补充本层上下文 ✅"]
    HANDLE -. "两者都做" .-> BAD["🛑 反模式:既打日志又返回<br/>→ 上层再打一次,日志重复"]
    WRAP -.-> GOOD["✅ 只在最终边界<br/>(main/HTTP handler)打日志"]

惯例四:err 永远是最后一个返回值

1
2
3
4
5
// ✅ Go 惯例
func Do(a, b int) (Result, error)

// 🛑 反例
func Do(a, b int) (error, Result)

惯例五:不要用 _ 静默吞错

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 🛑 除非你有意识地在忽略
f, _ := os.Open(path)      // 万一 f 是 nil,下一行就 panic
defer f.Close()

// ✅
f, err := os.Open(path)
if err != nil {
	return fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()

⚠️ defer f.Close() 的错误也常被忽略。写文件时必须处理:

1
2
3
4
5
defer func() {
	if cerr := f.Close(); cerr != nil && err == nil {
		err = fmt.Errorf("close %s: %w", path, cerr)   // 需要命名返回值
	}
}()

panic vs error:边界在哪

flowchart TB
    Q(["出问题了"]) --> Q1{"这是【程序员错误】<br/>还是【运行时环境问题】?"}
    Q1 -- "程序员错误<br/>越界/空指针/断言失败" --> PANIC["panic ✅<br/>让它崩,暴露 bug"]
    Q1 -- "环境问题<br/>文件不存在/网络超时" --> ERR["返回 error ✅"]
    Q1 -- "不可恢复的初始化失败<br/>配置缺失/端口被占" --> FATAL["log.Fatal / panic<br/>在 main 里"]
    Q1 -- "库函数边界" --> RECOVER["recover 转 error<br/>别让 panic 泄漏 ⚠️"]
场景选择理由
文件不存在、网络失败error可预期、可重试
参数校验失败error调用方问题,应能处理
数组越界、nil 解引用panic(自然发生)是 bug,掩盖它更糟
配置缺失导致无法启动log.Fatal 或 panic起不来比带病运行好
库内部深层 panic边界 recover 转 error保护调用方 ⚠️
用 panic 做流程控制🛑 绝不性能与可读性双输
1
2
3
4
5
6
7
8
9
// 库边界统一兜底(见 06 的 defer 详解)
func (s *Server) Handle(ctx context.Context, req Request) (resp Response, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("panic in handler: %v", r)
		}
	}()
	return s.handle(ctx, req)
}

⚠️ 不要 recover runtime.Error:空指针、越界这类错误说明程序状态已经不可信,继续跑只会产生更隐蔽的 bug。除非在最外层(HTTP 服务器、任务队列 worker)做「一个请求崩了不影响其他请求」的隔离。

panic 的传播边界(配合 06 看)

位置行为
同一 goroutine沿调用栈向上执行 defer,可被 recover
跨 goroutine❌ 不传播,直接崩溃整个进程 ⚠️
init() 里 panic程序启动失败,打印栈并退出
未 recover 的 panic程序退出码 2,打印完整栈

完整范式:一个可用的错误设计

 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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package user

import (
	"errors"
	"fmt"
)

// ① 哨兵错误:调用方可以判定的「类别」
var (
	ErrNotFound = errors.New("user not found")
	ErrConflict = errors.New("user already exists")
)

// ② 自定义类型:需要携带结构化数据时
type ValidationError struct {
	Field  string
	Reason string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("invalid %s: %s", e.Field, e.Reason)
}

// ③ 底层操作:包装并保留链
func (s *Store) Get(id string) (*User, error) {
	u, err := s.db.Query(id)
	if err != nil {
		return nil, fmt.Errorf("query user %s: %w", id, err)
	}
	if u == nil {
		return nil, fmt.Errorf("user %s: %w", id, ErrNotFound)
	}
	return u, nil
}

// ④ 业务层:用 Is/As 分类处理
func (s *Service) Register(u *User) error {
	if u.Email == "" {
		return &ValidationError{Field: "email", Reason: "required"}
	}
	if _, err := s.store.Get(u.ID); err == nil {
		return fmt.Errorf("register %s: %w", u.ID, ErrConflict)
	} else if !errors.Is(err, ErrNotFound) {
		return err        // 只放过「不存在」这一种预期错误 ⚠️
	}
	return s.store.Save(u)
}

// ⑤ 边界层(main / HTTP handler):分类映射 + 打日志
func handle(w http.ResponseWriter, r *http.Request) {
	err := svc.Register(u)
	switch {
	case err == nil:
		w.WriteHeader(http.StatusCreated)
	case errors.Is(err, ErrConflict):
		http.Error(w, err.Error(), http.StatusConflict)
	case errors.Is(err, ErrNotFound):
		http.Error(w, err.Error(), http.StatusNotFound)
	default:
		var ve *ValidationError
		if errors.As(err, &ve) {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		log.Printf("internal error: %v", err)          // 🔥 只在边界打日志
		http.Error(w, "internal error", http.StatusInternalServerError)  // 不泄漏内部细节
	}
}

💭 这个范式值得背下来:哨兵定类别、类型带数据、%w 保链条、边界做映射。


本页陷阱速查

症状实际原因正确做法
errors.Is 始终 false包装时用了 %v 而非 %w改 %w
errors.Unwrap(joined) 返回 nilJoin 实现的是 Unwrap() []error用 errors.As 取 interface{ Unwrap() []error }
用 == 比较错误失败错误被包装过了用 errors.Is
日志里同一错误出现三次每层都打日志又往上返只在边界打一次
io.EOF 被当成错误上报EOF 是正常结束信号errors.Is(err, io.EOF) 时 break
读最后一块数据丢失先判 err 后处理 n先 if n > 0 处理,再判错
错误消息变成 Failed to open: Failed to read每层都加 failed to只补充本层的动作与参数
errors.As 传了非指针第二参数必须是 *Tvar ve *ValidationError; errors.As(err, &ve),或改用 🆕 errors.AsType[*ValidationError](err)
自定义错误没被 Is 穿透没实现 Unwrap()加 func (e *X) Unwrap() error { return e.Err }
recover 掩盖了 nil 解引用 bug吞掉了 runtime.Error只在外层隔离边界 recover
goroutine 里的 panic 导致整个服务挂掉panic 不跨 goroutine每个 goroutine 自己 defer recover
内部错误细节泄漏给客户端直接把 err.Error() 返回边界层映射成通用消息 + 内部日志

📘 官方参考:Go Blog — Working with Errors in Go 1.13、errors 包文档、Go Blog — Error Handling and Go、Go Wiki — Error Handling

➡️ 上一节:08 接口与嵌入 | 下一节:10 泛型

最后修改 September 21, 2026: 更新 (ac821931b)