Go 错误处理
error 接口、errors 包、错误包装与解包(Go 1.13+)、多错误聚合(多 %w 与 errors.Join,Go 1.20+)、errors.AsType(Go 1.26)、Go 1.27 vet 对 %w 包指针的新检查、panic/recover 机制与最佳实践完整速查。
Go 的错误处理哲学:错误是普通的值,沿返回值通道显式传递,而不是异常——它不会自己"飞"过调用栈。本文以 Go 1.27 为基线;凡标注"实测"的示例与输出串均出自 go1.27.0(windows/amd64)编译运行,示意性片段除外。
1. error 接口
Go 的错误处理基于 error 接口,而不是异常(Exception):
// 标准库中的 error 接口
type error interface {
Error() string
}任何实现了 Error() string 方法的类型都可以作为错误值:
errors.New("something went wrong") // 返回 error
fmt.Errorf("failed: %v", reason) // 返回 error理解 error 的关键在于它是接口:一个 error 变量同时携带"动态类型 + 动态值"两部分,这既是包装/解包体系的基础,也是 typed-nil 陷阱的根源(见第 8.1 节,以及 06-接口与泛型.md 中 nil interface 的讨论)。
2. 创建错误
errors.New 与哨兵错误命名
import "errors"
var ErrNotFound = errors.New("not found") // 哨兵错误(sentinel error)
var ErrTimeout = errors.New("operation timed out")导出的哨兵错误变量统一用 ErrXxx 命名(Err 前缀 + 大驼峰),这是标准库与社区约定:调用方看到 io.EOF、os.ErrNotExist 就知道可以用 errors.Is 去匹配。包内私有的哨兵错误同样保持这个风格,便于日后导出。
fmt.Errorf
err := fmt.Errorf("failed to open %s: %v", filename, reason)
// Go 1.13+:错误包装
err := fmt.Errorf("failed to open %s: %w", filename, originalErr)
// %w = 包装原始错误(保留链)自定义错误类型
type ValidationError struct {
Field string
Value any
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s (got %v)", e.Field, e.Msg, e.Value)
}
// 通常用指针接收者
func validate(name string) error {
if name == "" {
return &ValidationError{Field: "name", Msg: "required"}
}
return nil
}注意:自定义错误类型通常使用指针接收者。如果用值接收者,两个字段全等的错误会
==为 true,这可能不符合预期。标准库errors.New返回的就是指针(*errorString),确保即使消息相同,两次调用返回的错误也不相等。
值接收者 vs 指针接收者
| 场景 | 推荐 | 原因 |
|---|---|---|
| struct 错误类型 | 指针接收者 *T |
防止同值错误相等;避免 nil interface 陷阱 |
type ErrCode string |
值接收者 | 与 const 配合,不可寻址性天然防 nil,效率高 |
// 值接收者——适合 string-based 错误代码
type ErrCode string
func (e ErrCode) Error() string { return string(e) }
const ErrKeyNotFound ErrCode = "key not found" // const 直接可用
var err error = ErrKeyNotFound // OK,实现 error 接口
// 指针接收者——适合 struct 错误类型
type MyError struct {
Code int
Message string
}
func (e *MyError) Error() string { return fmt.Sprintf("[%d] %s", e.Code, e.Message) }深入原理:
const声明的字符串常量不可寻址——如果ErrCode使用指针接收者,var err error = ErrKeyNotFound会编译错误(无法对常量取地址)。因此 string-based 错误码天然适合值接收者。
3. 错误检查惯用法
// 基本的 if err != nil
f, err := os.Open("file.txt")
if err != nil {
return fmt.Errorf("open file: %w", err)
}
defer f.Close()
// map 取值 + 错误判断
if v, ok := m["key"]; !ok {
return ErrNotFound
}
// 忽略错误(需明确)
_ = os.Remove(tempFile) // 显式忽略用 _ = 显式丢弃错误不只是风格问题:errcheck 这类静态检查工具会对未处理的返回错误报警,_ = 相当于写下"我知道这里有错误,我选择不处理"的书面声明。生产项目建议在 CI 里挂 errcheck。
4. 错误包装与解包
Go 1.13 引入 %w 与 Unwrap 协议;Go 1.20 把"单链"扩展为"错误树";Go 1.26 补了泛型版 AsType;Go 1.27 的 vet 加强了对 %w 误用指针的检查。这一节按这个演进脉络讲全。
包装错误
// 方式一:fmt.Errorf + %w(最常用)
if err != nil {
return fmt.Errorf("process user %s: %w", userID, err)
}
// 方式二:自定义类型实现 Unwrap
type WrappedError struct {
Msg string
Err error
}
func (e *WrappedError) Error() string { return e.Msg + ": " + e.Err.Error() }
func (e *WrappedError) Unwrap() error { return e.Err }解包与判断:Is / As / AsType
// errors.Is:判断错误链中是否包含某个哨兵错误
if errors.Is(err, ErrNotFound) { // 沿 Unwrap 链查找
// ...
}
// errors.As:从错误链中提取某个类型的错误
var vErr *ValidationError
if errors.As(err, &vErr) { // 沿 Unwrap 链查找
fmt.Println("invalid field:", vErr.Field)
}
// errors.AsType:泛型版(Go 1.26+),一行替代上面 var + As 两行样板
if vErr, ok := errors.AsType[*ValidationError](err); ok {
fmt.Println("invalid field:", vErr.Field)
}AsType 的类型参数就是 As 第二个参数指针的指向类型,行为完全等价(实测输出 AsType got: u1,见下文多 %w 完整示例)。新旧两种写法都在大量代码里共存,读到老代码认识 As、写新代码可以直接用 AsType。
注意:
errors.Is—— 判断"是不是这个错误"(比较哨兵错误)errors.As/errors.AsType—— 判断"是不是这类错误"(获取错误携带的信息)- 永远不要用
==比较包装过的错误。err == ErrNotFound在包装后为 false。
// 错误
if err == ErrNotFound { ... }
// 正确
if errors.Is(err, ErrNotFound) { ... }多个 %w:错误树(Go 1.20+)
纠错说明:早期手册/资料里"不要同时使用多个
%w,fmt.Errorf不支持"的说法已经作废。该限制只存在于 Go 1.20 之前;Go 1.20 的 release notes 正式引入了多个%w,并约定了Unwrap() []error协议。此后fmt.Errorf中每个%w各包装一个错误,整条错误链变成一棵错误树,errors.Is/errors.As会遍历所有分支。
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
var ErrTimeout = errors.New("timeout")
type NotFoundError struct{ ID string }
func (e *NotFoundError) Error() string { return "not found: " + e.ID }
func main() {
errA := fmt.Errorf("read db: %w", ErrNotFound)
multi := fmt.Errorf("batch failed: %w / %w", errA, ErrTimeout)
fmt.Println(multi)
// 输出: batch failed: read db: not found / timeout
fmt.Println(errors.Is(multi, errA)) // true —— 第一分支
fmt.Println(errors.Is(multi, ErrNotFound)) // true —— 穿透第一分支的链
fmt.Println(errors.Is(multi, ErrTimeout)) // true —— 第二分支
// AsType 同样遍历所有分支(Go 1.26+)
wrapped := fmt.Errorf("get user: %w", &NotFoundError{ID: "u1"})
if v, ok := errors.AsType[*NotFoundError](wrapped); ok {
fmt.Println("AsType got:", v.ID) // AsType got: u1
}
}历史注记:这是 fmt 库能力的变化,不是语言版本开关——Go 1.13 到 1.19 的 fmt.Errorf 一次调用只接受一个 %w,1.20 的 release notes 同时引入了多 %w 与 Unwrap() []error。用 1.20 及以上工具链构建就支持,与 go.mod 里声明的语言版本无关。版本演进细节见 10-版本变迁.md。
包装策略:选 %w 还是 %v
%w(包装):你只是"经过",底层错误对调用方有意义时
例:调用第三方库、调用内部其他服务
return fmt.Errorf("get user: %w", err)
→ 调用方可以用 errors.Is / errors.As 检查底层错误
%v(不包装):你想"隐藏"底层实现细节、改变错误语义时
例:你的函数语义不同,底层错误对调用方无意义
return fmt.Errorf("user not found (id=%s)", id)
→ 调用方不该用 errors.Is 去检查底层的 sql.ErrNoRows注意:
- 不要包装 nil 错误——返回 nil error 时不需要
%w。实测fmt.Errorf("a: %w", nilErr)得到的消息是"a: %!w(<nil>)",链上也没有可解包节点,纯属格式事故- 如果你的包已经暴露了某些哨兵错误,尽量保证
errors.Is可以正常工作
Go 1.27 vet:%w 不要包"错误类型的指针"
Go 1.27 的 vet printf 分析器新增一项检查(notes127):当 %w 的操作数类型是 *E、而 E 本身已实现 error 时会报警。典型场景是 E 用值接收者实现 Error(),调用方却传了指针进 %w:
type E struct{ msg string }
func (e E) Error() string { return e.msg } // 值接收者:E 本身实现 error
func F(p *E) error {
return fmt.Errorf("wrap: %w", p) // Go 1.27 vet 报警
}go1.27.0 实测输出:
vetchk.go:12:27: %w wants operand of error type E, not pointer type *E (defeats errors.Is)为什么这是潜在 bug:链上存的是 *E 而不是 E,调用方用 errors.Is(err, E{...}) 或对 E 的类型断言都会落空,而且几乎没人能一眼看出包装进去的到底是指针还是值。正确写法是要么直接传值 fmt.Errorf("wrap: %w", *p),要么整个代码库统一只用 *E(指针接收者实现 Error())。
注意:该检查只针对"
E本身实现 error 却传*E"。常见的指针接收者错误类型(如上面的&ValidationError{...},只有*T实现 error)传给%w是正确用法,实测 vet 不会报警。
5. panic 与 recover
panic
// panic 会立即停止当前函数执行,沿调用栈向上传播
// 沿途执行各函数的 defer,直到:被 recover 捕获 或 程序崩溃
panic("something went wrong")
panic(fmt.Errorf("fatal: %v", reason))
// 常见触发 panic 的情况
var p *int; fmt.Println(*p) // nil 指针解引用
var m map[string]int; m["x"] = 1 // nil map 写入
close(nilCh) // close nil channel
i := []int{1}[5] // 数组/切片越界
x := 0; y := 1 / x // 除零最佳实践:库函数不要 panic——应该返回 error。
panic仅在程序遇到不可恢复状态时使用(如"配置严重错误"、“逻辑上不可能发生”)。
recover
// recover 只能在 defer 函数中生效
func safeCall() {
defer func() {
if r := recover(); r != nil {
log.Printf("recovered from panic: %v", r)
}
}()
doSomethingThatMightPanic()
}注意:
recover()不是try-catch——它不会让程序"继续执行" panic 之后的代码;recover 之后函数直接返回(返回值用零值或提前设好的值)recover()只在 defer 函数中有效,直接调用recover()返回 nil- goroutine 边界:panic 沿当前 goroutine 的调用栈传播,父 goroutine 的 recover 抓不到子 goroutine 的 panic——每个 goroutine 独立崩溃,谁启动谁负责在启动处设防
panic/recover 的实际场景
// 好的用法:HTTP 框架保护单个请求
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
log.Printf("panic in handler: %v\n%s", r, debug.Stack())
http.Error(w, "Internal Server Error", 500)
}
}()
// handler 逻辑
})
// 滥用(不要这样写):用 panic/recover 模拟 try-catch
// 错误处理应该显式传递 error6. errors.Join 与 Unwrap() []error(Go 1.20)
多 %w 解决"一条消息里并列包装"的场景;errors.Join 解决"收集任意多个错误、一次返回"的场景。两者共享同一套底层协议:Unwrap() []error——任何实现了该方法的错误都会被 errors.Is / errors.As 当作树来遍历(Go 1.20 notes 确立的约定)。
errors.Join
// Go 1.20+:合并多个错误
var errs []error
for _, item := range items {
if err := process(item); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...) // 返回一个包含所有错误的 error三个要点(均实测):
- nil 参数被忽略:
errors.Join(err1, nil, err2)等价于Join(err1, err2);全是 nil(或没有参数)时返回 nil。这让"边收集边 Join"的中间态代码不用先过滤空值。 - 消息用换行连接:
errors.Join(ErrNotFound, nil, ErrTimeout).Error()输出"not found\ntimeout"(两个词之间一个\n)。打印到日志时注意多行错误对采集器的影响。 - Is/As 遍历所有成员:
joined := errors.Join(ErrNotFound, ErrTimeout)
errors.Is(joined, ErrNotFound) // true——任一个成员匹配即可
errors.Is(joined, ErrTimeout) // true自定义聚合错误:Unwrap() []error 协议
不想用 errors.Join 的标准消息格式时,自定义类型只要实现 Unwrap() []error,就能获得同样的树遍历能力:
type MultiError struct{ errs []error }
func (m *MultiError) Error() string { return fmt.Sprintf("%d errors", len(m.errs)) }
func (m *MultiError) Unwrap() []error { return m.errs }
agg := &MultiError{errs: []error{ErrNotFound, &NotFoundError{ID: "u1"}}}
errors.Is(agg, ErrNotFound) // true
var nfe *NotFoundError
errors.As(agg, &nfe) // true, nfe.ID == "u1"(实测输出 agg Is(ErrNotFound): true、agg As(*NotFoundError): true u1。)
注意:一个类型只能二选一——Go 不允许同一类型声明两个同名
Unwrap方法,同时写两种签名直接编译报错(实测:method BothError.Unwrap already declared at ...)。单链用Unwrap() error,聚合用Unwrap() []error。另外自 1.20 起errors.Is的官方描述就是沿"错误树"深度优先遍历(go doc errors.Is),两种 Unwrap 都在遍历范围内。
7. 错误处理策略与设计原则
策略总结
| 策略 | 适用场景 | 示例 |
|---|---|---|
| 直接返回 | 当前层无法处理 | if err != nil { return err } |
| 包装后返回 | 添加上下文信息 | return fmt.Errorf("user %s: %w", id, err) |
| 降级处理 | 有默认值/备选方案 | if err != nil { value = defaultValue } |
| 重试 | 瞬态错误(超时/连接失败) | exponential backoff |
| 日志 + 返回 | 需要记录日志 | log.Printf("warn: %v", err); return err |
错误设计原则
// 好的错误设计
// 1. 错误信息小写开头,不要以标点结尾
errors.New("user not found") // 好
errors.New("User not found.") // 不好
// 2. 使用描述性消息,包含操作对象和上下文
fmt.Errorf("parse config %s: %w", filename, err) // 好
fmt.Errorf("error: %w", err) // 不好(信息太少)
// 3. 暴露可检查的错误
var ErrNotFound = errors.New("not found") // 哨兵错误
type ValidationError struct { ... } // 自定义错误类型
// 4. 不要在错误消息中放用户私有数据(安全/隐私)
fmt.Errorf("user %s login failed", email) // 不好(泄露邮箱)
fmt.Errorf("user login failed") // 好选择哨兵错误还是错误类型,按调用方需要什么来判断:调用方只需要知道"是不是这个情况"(io.EOF),用哨兵错误 + errors.Is;调用方还需要拿错误里的结构化信息(字段名、行号、状态码),用自定义类型 + errors.As/AsType。两者可以混用——自定义类型再实现 Is 或 Unwrap 把哨兵错误挂进链里。
8. 常见陷阱汇总
8.1 typed-nil:nil 指针满足 error 接口
func getError() error {
var p *MyError = nil
return p // 返回的 error != nil!
}
err := getError()
if err != nil {
// 会进入这里!即使 p 本身是 nil
}实测:接口值由"动态类型 + 动态值"组成,把 nil 的
*NotFoundError赋给error后类型部分非空,接口整体就非 nil:
var errIf error = (*NotFoundError)(nil)
fmt.Println(errIf == nil) // false修正:函数返回裸 nil,不要 return p(p 是类型化的 nil 变量)。这是 Go 面试与线上事故的双料常客,根源是接口表示,见 06-接口与泛型.md。
8.2 错误被覆盖
func process() error {
f, err := os.Open("a.txt")
if err != nil {
return err
}
defer f.Close()
// 注意:下面这行使用了 := 声明了新 err
data, err := io.ReadAll(f)
if err != nil {
return err // 返回的是 io.ReadAll 的错误,OK
}
// 但 defer f.Close() 的错误被丢掉了——读文件可接受,写文件建议捕获
return nil
}8.3 哨兵错误使用 == 比较
// 错误:包装后 == 无效
err := fmt.Errorf("wrap: %w", ErrNotFound)
fmt.Println(err == ErrNotFound) // false!
// 正确:使用 errors.Is
fmt.Println(errors.Is(err, ErrNotFound)) // true8.4 panic 与 recover 的误用
// 无效:recover 在非 defer 函数中
recover() // 返回 nil,什么都不做
// 无效:defer 嵌套在另一个函数中
defer func() {
func() {
recover() // 无效!不在 defer 的直接函数中
}()
}()
// 正确:recover 必须在 defer 的直接函数体中
defer func() {
if r := recover(); r != nil {
log.Println(r)
}
}()8.5 错误处理返回后没设置其他返回值为零值
func findUser(id int) (*User, error) {
u, err := db.Query(id)
if err != nil {
return &User{}, err // 不好:返回非零 User(可能被调用方误用)
// return nil, err // 好:错误时返回零值
}
return u, nil
}约定:返回 error 非 nil 时,其他返回值一律给零值,调用方先看 error 再用值。
8.6 fmt.Errorf 将 %w 和 %v 混淆
// %w:包装错误,保留错误链
return fmt.Errorf("read file: %w", err) // err 被包装,errors.Is 可用
// %v:只是格式化,不建立错误链
return fmt.Errorf("read file: %v", err) // err 仅作为字符串,errors.Is 不可用8.7 Go 1.27 vet:%w 包装错误类型的指针
值接收者实现 Error() 的类型 E(E 与 *E 都满足 error),却把 *E 传给 %w——vet 报 %w wants operand of error type E, not pointer type *E (defeats errors.Is)(notes127,实测见第 4 节)。包指针会破坏调用方针对 E 的 errors.Is/类型断言,属于潜在 bug,不是风格问题。
8.8 把"多个 %w"当成非法用法(旧手册遗留)
“一次 fmt.Errorf 只能用一个 %w“是 Go 1.20 之前的旧限制,1.20 notes 起多 %w 与 Unwrap() []error 都是正式能力,errors.Is/As 遍历全部分支(实测见第 4 节)。看到代码里两个 %w 不要"修掉"它——那是特性不是 bug。
9. 实战建议
- 对外 API 层:把内部错误翻译成对调用方有意义的哨兵错误或稳定类型(配
errors.As/AsType可提取),包装链上每个%w加一段"动作 + 对象"上下文,最后一条消息应该能独立读成一句人话。 - 批量操作:循环里收集错误、出口处
errors.Join(errs...);需要自定义聚合消息时实现Unwrap() []error,Is/As 照常工作。 - 边界防御:goroutine 启动处、HTTP/RPC handler 入口、消息消费者入口放 recover;库代码内部用 error 传递,不用 panic 控制流。
- 工具兜底:CI 跑
go vet(1.27 的 printf 检查能抓住%w包指针这类隐蔽 bug)加 errcheck,比 code review 可靠。 - 版本底线:模块声明允许的前提下,按 1.20 的多
%w/Join、1.26 的AsType、1.27 的 vet 增强逐步现代化;各版本能力对照见10-版本变迁.md,并发场景下错误传播与取消的衔接见08-并发编程.md。