Skip to content
Go
Go 错误处理

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
// 错误处理应该显式传递 error

6. 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))  // true

8.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。