第 7 章

错误处理:error、panic 与 recover

错误处理:error、panic 与 recover

在所有编程语言中,错误处理的设计最能反映语言的哲学。Java 选择了 checked exception——强制调用者声明可能抛出的异常;Rust 选择了 Result<T, E>——用类型系统编码错误可能性;而 Go 选择了最朴素的方式:函数返回一个 error 值,调用者显式检查

这不是妥协,而是深思熟虑的设计决策。Go 的创造者 Rob Pike 在 2015 年 GopherCon 演讲中说过:"Errors are values." 错误是值,不是异常,不是控制流跳转——它们只是普通的值,可以编程处理。这句话看似简单,却定义了 Go 语言错误处理的整个范式。

本章将从最基础的 error 接口出发,逐步深入到错误包装、错误链查找、Go 错误处理与其他语言的哲学对比,最后讨论 panic/recover 的正确使用场景。

Level 1:你需要知道的

error 接口

Go 语言中,error 是一个内置接口,定义在 builtin 包中:

type error interface {
    Error() string
}

任何实现了 Error() string 方法的类型都是一个 error。这是 Go 语言中最简单的接口之一——只有一个方法。这种极简设计意味着:

  1. 任何类型都可以成为错误——只要实现 Error() 方法
  2. 错误是普通值——可以存储、传递、比较、组合
  3. 没有特殊的语法机制——不需要 try/catch/finally

创建错误的三种方式

方式一:errors.New

最简单的创建方式,适用于静态错误消息:

import "errors"

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

errors.New 返回一个内部类型 *errorString 的指针,该类型的定义极其简单:

// 标准库 errors 包的实际实现
type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

注意这里返回的是指针 *errorString,而不是值 errorString。为什么?因为两个内容相同的 errorString 值应该是不同的错误——每次 errors.New("same message") 调用都应该产生一个独立的错误实例。使用指针确保了即使消息相同,用 == 比较时也不相等。

方式二:fmt.Errorf

当需要动态格式化错误消息时使用:

func openFile(path string) (*File, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("failed to open config file %s: %w", path, err)
    }
    return f, nil
}

fmt.Errorf 本质上是先用 fmt.Sprintf 格式化字符串,然后根据是否使用 %w 动词决定返回普通错误还是包装错误(wrapping error,后面 Level 2 详细讲)。

方式三:自定义错误类型

当需要携带更多上下文信息时,定义自己的错误类型:

type ValidationError struct {
    Field   string
    Value   interface{}
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on field %q (value: %v): %s",
        e.Field, e.Value, e.Message)
}

func validateAge(age int) error {
    if age < 0 || age > 150 {
        return &ValidationError{
            Field:   "age",
            Value:   age,
            Message: "must be between 0 and 150",
        }
    }
    return nil
}

自定义错误类型的优势在于调用者可以通过类型断言获取详细信息:

if err := validateAge(-1); err != nil {
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("字段 %s 校验失败\n", ve.Field)
    }
}

if err != nil 模式

Go 代码中最常见的模式:

result, err := someFunction()
if err != nil {
    return fmt.Errorf("context: %w", err)
}
// 正常使用 result

这个模式的核心思想是:在错误发生的地方立即处理它。不存在错误沿着调用栈自动传播的魔法——如果你不检查 err,它就被丢弃了。

一个完整的实际示例:

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("reading config file: %w", err)
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parsing config JSON: %w", err)
    }

    if err := cfg.Validate(); err != nil {
        return nil, fmt.Errorf("validating config: %w", err)
    }

    return &cfg, nil
}

每一步操作都可能失败,每一步都显式检查。这就是 Go 的风格——没有隐藏的控制流,所有错误路径都清晰可见。

常见错误与修复

错误 1:忽略错误

// 错误:忽略了 Close 的错误
file, _ := os.Open("data.txt")
defer file.Close()

// 正确:至少记录错误
file, err := os.Open("data.txt")
if err != nil {
    return err
}
defer func() {
    if cerr := file.Close(); cerr != nil {
        log.Printf("warning: failed to close file: %v", cerr)
    }
}()

错误 2:在 nil 检查前使用返回值

// 错误:result 可能是零值
result, err := compute()
fmt.Println(result.Value) // 如果 err != nil,这里可能 panic
if err != nil {
    return err
}

// 正确:先检查错误
result, err := compute()
if err != nil {
    return err
}
fmt.Println(result.Value)

错误 3:用字符串比较判断错误

// 错误:脆弱的字符串比较
if err.Error() == "file not found" {
    // ...
}

// 正确:使用 errors.Is 或 errors.As
if errors.Is(err, os.ErrNotExist) {
    // ...
}

错误 4:返回 interface 类型的 nil

// 错误:这个函数永远不返回 nil!
func getError() error {
    var p *MyError = nil
    return p // 返回的是 (*MyError)(nil),不是 nil error
}

// 正确:直接返回 nil
func getError() error {
    return nil
}

这是 Go 接口机制导致的经典陷阱:一个 error 接口值包含两部分——类型指针和值指针。当类型指针非 nil 时,即使值指针是 nil,接口值也不等于 nil。

错误处理的基本原则

  1. 只处理一次错误——要么返回它,要么记录它,不要两者都做
  2. 添加上下文——裸露的 return err 丢失了调用栈信息
  3. 在包边界转译错误——不要暴露内部实现细节
  4. nil 是有效的 error 值——表示"没有错误"
// 反模式:既记录又返回
func bad() error {
    err := doSomething()
    if err != nil {
        log.Printf("error: %v", err) // 记录了
        return err                    // 又返回了——上层可能再记录一次
    }
    return nil
}

// 正确:只做一件事
func good() error {
    err := doSomething()
    if err != nil {
        return fmt.Errorf("doing something: %w", err) // 只返回
    }
    return nil
}

Level 2:它是怎么运行的

错误包装(Error Wrapping)

Go 1.13(2019 年 9 月发布)引入了错误包装机制,通过 fmt.Errorf%w 动词实现:

originalErr := errors.New("connection refused")
wrappedErr := fmt.Errorf("connecting to database: %w", originalErr)

%w 动词告诉 fmt.Errorf:不只是把错误消息拼接进去,还要保留对原始错误的引用。返回的错误实现了 Unwrap() error 方法:

// fmt 包内部大致实现
type wrapError struct {
    msg string
    err error
}

func (e *wrapError) Error() string {
    return e.msg
}

func (e *wrapError) Unwrap() error {
    return e.err
}

对比 %v(不包装,只格式化消息)和 %w(包装,保留引用):

err := os.ErrNotExist

// %v:只格式化消息,丢失原始错误引用
e1 := fmt.Errorf("file problem: %v", err)
fmt.Println(errors.Is(e1, os.ErrNotExist)) // false

// %w:保留原始错误引用
e2 := fmt.Errorf("file problem: %w", err)
fmt.Println(errors.Is(e2, os.ErrNotExist)) // true

errors.Is 和 errors.As

Go 1.13 同时引入了两个关键函数,用于在错误链(error chain)中查找特定错误。

errors.Is:值匹配

errors.Is(err, target) 沿着错误链检查是否有某个错误等于 target:

func errors.Is(err, target error) bool

它的查找过程:

  1. 检查 err == target
  2. 如果 err 实现了 Is(error) bool 方法,调用它
  3. 如果 err 实现了 Unwrap() error,递归检查 Unwrap() 返回的错误
  4. 如果 err 实现了 Unwrap() []error(Go 1.20+),递归检查每个子错误
// 实际使用
err := fmt.Errorf("open config: %w",
    fmt.Errorf("reading file: %w", os.ErrNotExist))

// 即使包了两层,仍然能找到
fmt.Println(errors.Is(err, os.ErrNotExist)) // true

errors.As:类型匹配

errors.As(err, target) 沿着错误链查找第一个可以赋值给 target 的错误:

func errors.As(err error, target interface{}) bool
type NotFoundError struct {
    Name string
}

func (e *NotFoundError) Error() string {
    return e.Name + ": not found"
}

// 在错误链中查找特定类型
err := fmt.Errorf("loading user: %w", &NotFoundError{Name: "alice"})

var nfe *NotFoundError
if errors.As(err, &nfe) {
    fmt.Println(nfe.Name) // "alice"
}

注意 target 必须是指向错误类型的指针的指针(**NotFoundError),因为 errors.As 需要通过它设置值。

自定义 Is 和 Unwrap

你的错误类型可以自定义匹配逻辑:

type TimeoutError struct {
    Duration time.Duration
    Op       string
}

func (e *TimeoutError) Error() string {
    return fmt.Sprintf("%s timed out after %v", e.Op, e.Duration)
}

// 自定义 Is:只要都是 TimeoutError 就算匹配
func (e *TimeoutError) Is(target error) bool {
    _, ok := target.(*TimeoutError)
    return ok
}

三种错误策略

Go 社区总结了三种主要的错误定义策略,由 Dave Cheney 在 2016 年博文 "Don't just check errors, handle them gracefully" 中系统阐述:

1. Sentinel Error(哨兵错误)

预定义的全局错误变量,用于表示特定的、已知的错误条件:

// 标准库中的 sentinel errors
var (
    ErrNotExist  = errors.New("file does not exist")
    ErrPermission = errors.New("permission denied")
    ErrClosed    = errors.New("use of closed connection")
)

// io 包
var EOF = errors.New("EOF")

使用 errors.Is 检查:

data, err := io.ReadAll(reader)
if errors.Is(err, io.EOF) {
    // 正常结束
}
if errors.Is(err, os.ErrNotExist) {
    // 文件不存在
}

优点:简单、高效、可用 errors.Is 匹配。 缺点:成为包的公共 API 的一部分,一旦暴露就难以修改;不能携带上下文信息。

命名惯例Err 前缀 + 描述性名称,如 ErrNotFoundErrInvalidInput

2. 类型错误(Error Types)

自定义结构体实现 error 接口,可以携带丰富的上下文:

type PathError struct {
    Op   string // "open", "read", "write"
    Path string // 文件路径
    Err  error  // 底层错误
}

func (e *PathError) Error() string {
    return e.Op + " " + e.Path + ": " + e.Err.Error()
}

func (e *PathError) Unwrap() error {
    return e.Err
}

使用 errors.As 检查:

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    fmt.Printf("操作 %s 失败,路径: %s\n", pathErr.Op, pathErr.Path)
}

优点:携带结构化上下文、可用 errors.As 提取信息。 缺点:暴露内部类型作为公共 API、增加包的 API 表面积。

3. Opaque Error(不透明错误)

只暴露行为(通过接口),不暴露具体类型或值:

// 只定义行为接口
type temporary interface {
    Temporary() bool
}

type timeout interface {
    Timeout() bool
}

// 检查行为而不是具体类型
func IsTemporary(err error) bool {
    var t temporary
    return errors.As(err, &t) && t.Temporary()
}

func IsTimeout(err error) bool {
    var t timeout
    return errors.As(err, &t) && t.Timeout()
}

优点:最大程度解耦——调用者不依赖特定错误类型或变量。 缺点:灵活性有时让人困惑,Go 社区对此模式意见不一。

错误链的数据结构

当你反复用 %w 包装错误时,形成一个链表结构:

wrappedErr3 → wrappedErr2 → wrappedErr1 → originalErr
     |              |              |            |
  Unwrap()      Unwrap()      Unwrap()       nil

errors.Iserrors.As 本质上是对这个链表的线性遍历(在 Go 1.20 之后,支持 Unwrap() []error 返回多个子错误,形成树结构,遍历变成深度优先搜索)。

Go 1.20 引入了多错误包装:

// Go 1.20+ : errors.Join 合并多个错误
err := errors.Join(err1, err2, err3)

// 或者自定义类型实现 Unwrap() []error
type multiError struct {
    errs []error
}

func (e *multiError) Unwrap() []error {
    return e.errs
}

性能考量

错误处理的性能影响在热路径中值得关注:

// Benchmark: 创建错误的开销
// errors.New:     ~40ns (分配 errorString + 字符串)
// fmt.Errorf %v:  ~200ns (格式化 + 分配)
// fmt.Errorf %w:  ~250ns (格式化 + 分配 + wrapError)
// 自定义类型:      取决于字段数量

对于高性能路径,可以预分配 sentinel error 避免重复创建:

// 好:预定义,零分配检查
var ErrBufferFull = errors.New("buffer full")

func write(data []byte) error {
    if len(buf) + len(data) > cap(buf) {
        return ErrBufferFull // 零分配
    }
    // ...
}

// 差:每次调用都分配
func write(data []byte) error {
    if len(buf) + len(data) > cap(buf) {
        return fmt.Errorf("buffer full, need %d bytes", len(data)) // 每次分配
    }
    // ...
}

defer 中的错误处理

一个常见的进阶模式——用命名返回值在 defer 中处理错误:

func writeToFile(path string, data []byte) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return fmt.Errorf("creating file: %w", err)
    }
    defer func() {
        cerr := f.Close()
        if err == nil {
            err = cerr // 只在没有其他错误时才用 Close 的错误
        }
    }()

    _, err = f.Write(data)
    if err != nil {
        return fmt.Errorf("writing data: %w", err)
    }
    return nil
}

这个模式通过命名返回值 err 让 defer 函数能够修改函数的返回值。f.Close() 对于写操作尤其重要——它会 flush 缓冲区到磁盘,如果 flush 失败(比如磁盘满了),忽略这个错误意味着数据丢失。

Level 3:规范怎么定义的

Go 错误处理的设计哲学

Go 的错误处理设计深受几个原则影响,这些原则可以追溯到 Go 语言的创造者——Rob Pike、Ken Thompson 和 Robert Griesemer——在 Plan 9 操作系统和 C 语言中的数十年经验。

来源一:C 语言的返回码传统

C 语言中,错误通过返回值传递:

FILE *f = fopen("data.txt", "r");
if (f == NULL) {
    perror("fopen failed");
    return -1;
}

Go 继承了这种显式性,但做了改进:

来源二:Plan 9 的错误字符串

Ken Thompson 和 Rob Pike 在 Plan 9 操作系统中使用字符串传递错误信息。Go 的 error 接口本质上就是一个能返回错误描述字符串的东西,这直接延续了 Plan 9 的理念。

与 Java 异常的对比

Java 的 checked exception 机制(1996 年 James Gosling 设计):

public void readFile(String path) throws IOException, ParseException {
    // ...
}

Java 异常的问题(Go 团队视角)

  1. 隐藏的控制流:异常可以在任何时刻跳转到任意 catch 块,调用者无法通过阅读代码确定哪行可能抛出异常
  2. catch-all 反模式:开发者厌倦了处理 checked exception,写出 catch (Exception e) {}
  3. 异常层级爆炸:Java 标准库有数百个异常类,很多使用场景不明确
  4. 性能开销:构造异常对象需要捕获调用栈,即使异常永远不会被抛出

Rob Pike 在 2012 年 "Go at Google" 论文中写道:

"We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional."

翻译:我们认为把异常耦合到控制结构(如 try-catch-finally 模式)会导致代码错综复杂。它还倾向于鼓励程序员把太多普通错误(如打开文件失败)标记为"异常"。

与 Rust Result<T, E> 的对比

Rust 的 Result<T, E>(受 Haskell Either 类型启发):

fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("division by zero"))
    } else {
        Ok(a / b)
    }
}

// 使用 ? 运算符传播错误
fn process() -> Result<(), Box<dyn Error>> {
    let result = divide(10.0, 0.0)?;
    Ok(())
}

Rust vs Go 错误处理对比

维度 Go Rust
错误表示 接口值(运行时多态) 泛型枚举(编译时多态)
传播语法 if err != nil { return err } ? 运算符
编译器强制 不强制(可以 _ 忽略) 强制(Result 必须使用,否则 warning)
错误组合 errors.Join(Go 1.20+) anyhowthiserror crate
模式匹配 errors.Is / errors.As match 表达式
性能 接口装箱有间接开销 零成本抽象(枚举内联)

Go 的选择为什么合理?

Go 的设计目标是简单性和可读性(simplicity and readability),面向大型团队的工程实践。Go 团队认为:

  1. 显式 > 隐式if err != nil 虽然冗长,但每个错误路径清晰可见
  2. 简单 > 精巧:不需要学习 monad、? 运算符等概念
  3. 统一 > 灵活:一种错误处理风格,所有人写出相似的代码
  4. 工程 > 理论:在 Google 级别的代码库中,可读性比简洁性更重要

Go 1.13 错误包装提案的历史

2019 年,Go 1.13 引入的错误包装机制源自 Marcel van Lohuizen 的提案(issue #29934)。在此之前,社区广泛使用第三方包如 github.com/pkg/errors(Dave Cheney 开发,2016 年)。

pkg/errors 提供的核心功能:

// 包装错误并记录调用栈
err = errors.Wrap(err, "reading config")

// 获取原始错误
cause := errors.Cause(err)

// 输出带调用栈的错误
fmt.Printf("%+v", err)

Go 标准库最终借鉴了包装思想,但做了简化:

Go 2 错误处理草案

2018 年 8 月,Go 团队发布了 Go 2 Draft Designs,其中包含错误处理的 check/handle 提案(Russ Cox 撰写):

// 提案语法(从未实现)
func CopyFile(src, dst string) error {
    handle err {
        return fmt.Errorf("copy %s %s: %w", src, dst, err)
    }

    r := check os.Open(src)
    defer r.Close()

    w := check os.Create(dst)
    handle err {
        w.Close()
        os.Remove(dst)
    }

    check io.Copy(w, r)
    check w.Close()
    return nil
}

这个提案最终被搁置(2023 年正式关闭)。社区投票和讨论显示,大多数 Go 开发者更愿意保持 if err != nil 的显式风格,而不是引入新的控制流关键字。原因包括:

  1. 可读性if err != nil 虽然冗长但清晰
  2. 可 grep 性:搜索 if err != nil 就能找到所有错误处理点
  3. 一致性:不增加学习负担
  4. 灵活性if err != nil 块中可以做任何事(日志、指标、重试、清理)

error 接口的规范定义

Go 语言规范(The Go Programming Language Specification)对 error 的定义:

The predeclared type error is defined as

type error interface {
    Error() string
}

It is the conventional interface for representing an error condition, with the nil value representing no error.

关键点:规范只说 nil 代表无错误,没有规定非 nil error 的任何其他语义。这种最小化规范给了社区最大的灵活性。

errors 包的演进时间线

版本 年份 变更
Go 1.0 2012 errors.Newerror 接口
2016 github.com/pkg/errors 社区流行
Go 1.13 2019 fmt.Errorf %werrors.Iserrors.Aserrors.Unwrap
Go 1.20 2023 errors.JoinUnwrap() []error 多错误树

Level 4:边界与陷阱

panic/recover 的正确使用场景

panicrecover 是 Go 的异常机制——但它们被刻意设计为不常用的工具。

panic 的行为

func main() {
    fmt.Println("start")
    panic("something went wrong")
    fmt.Println("end") // 永远不会执行
}

当 panic 发生时:

  1. 当前函数立即停止执行
  2. 所有已注册的 defer 函数按 LIFO 顺序执行
  3. 控制权传给调用者,调用者的 defer 也执行
  4. 一直向上传播直到 goroutine 栈顶
  5. 如果没有 recover,程序崩溃并打印调用栈

recover 的行为

func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recovered: %v", r)
        }
    }()
    return a / b, nil // 如果 b == 0,runtime panic: integer divide by zero
}

recover() 只在 defer 函数中有效。如果在非 defer 上下文调用,或者当前 goroutine 没有 panic,它返回 nil。

何时使用 panic

合法场景 1:程序初始化阶段的不可恢复错误

func init() {
    if os.Getenv("DATABASE_URL") == "" {
        panic("DATABASE_URL environment variable not set")
    }
}

// 或者标准库的 regexp.MustCompile
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)

Must* 系列函数的惯例:如果编译时常量不合法,说明代码本身有 bug,panic 是正确反应。

合法场景 2:标准库内部的边界保护

// 切片越界时 runtime 自动 panic
s := []int{1, 2, 3}
_ = s[10] // panic: runtime error: index out of range [10] with length 3

合法场景 3:不可能到达的代码路径

func direction(d int) string {
    switch d {
    case 0:
        return "north"
    case 1:
        return "south"
    case 2:
        return "east"
    case 3:
        return "west"
    default:
        panic(fmt.Sprintf("invalid direction: %d", d))
    }
}

panic 的反模式

反模式 1:用 panic 代替 error 返回

// 错误!不要这样做
func findUser(id int) *User {
    user, err := db.Query(id)
    if err != nil {
        panic(err) // 应该返回 error
    }
    return user
}

反模式 2:用 panic/recover 做控制流

// 错误!这不是 try/catch
func process(items []Item) (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("processing failed: %v", r)
        }
    }()
    for _, item := range items {
        if !item.Valid() {
            panic("invalid item") // 应该 return error
        }
    }
    return nil
}

这种模式的问题:

反模式 3:跨 goroutine recover

// recover 无法捕获其他 goroutine 的 panic
func main() {
    defer func() {
        recover() // 无法捕获子 goroutine 的 panic
    }()
    go func() {
        panic("boom") // 程序直接崩溃
    }()
    time.Sleep(time.Second)
}

每个 goroutine 必须自己负责 recover,panic 不会跨 goroutine 传播。

recover 的合法使用:HTTP Handler 保护

func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                // 记录错误和调用栈
                buf := make([]byte, 4096)
                n := runtime.Stack(buf, false)
                log.Printf("panic recovered: %v\n%s", err, buf[:n])

                // 返回 500 而不是崩溃整个服务
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

这是 recover 最经典的合法使用——防止一个请求的 panic 杀死整个服务进程。Go 标准库的 net/http 包也内建了类似的 recover 机制。

面试题解析

题目 1:以下代码输出什么?

func main() {
    fmt.Println(f())
}

func f() (result int) {
    defer func() {
        if r := recover(); r != nil {
            result = -1
        }
    }()
    panic("oops")
    return 0
}

答案:输出 -1。panic 触发 defer,recover 捕获 panic,修改命名返回值 result 为 -1。

题目 2:error 接口的 nil 陷阱

type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }

func getErr(fail bool) error {
    var err *MyError
    if fail {
        err = &MyError{"failed"}
    }
    return err // 陷阱!
}

func main() {
    err := getErr(false)
    fmt.Println(err == nil) // 输出什么?
}

答案:输出 falsegetErr(false) 返回的是 (*MyError)(nil)——接口的动态类型是 *MyError(非 nil),即使动态值是 nil。正确做法:

func getErr(fail bool) error {
    if fail {
        return &MyError{"failed"}
    }
    return nil // 直接返回 nil
}

题目 3:errors.Is 的递归匹配

var ErrBase = errors.New("base")
err1 := fmt.Errorf("layer 1: %w", ErrBase)
err2 := fmt.Errorf("layer 2: %w", err1)
err3 := fmt.Errorf("layer 3: %w", err2)

fmt.Println(errors.Is(err3, ErrBase)) // ?
fmt.Println(errors.Is(err3, err1))    // ?
fmt.Println(errors.Is(err1, err3))    // ?

答案truetruefalseerrors.Is 只能向下(Unwrap 方向)查找,不能反向。

题目 4:panic 发生时 defer 的执行顺序

func main() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")
    panic("crash")
}

答案:输出 3、2、1,然后打印 panic 信息。defer 按 LIFO(后进先出)顺序执行。

真实 Bug 案例

案例 1:Kubernetes 的 error wrapping 兼容性事故

2020 年,Kubernetes 在升级错误处理时,将一些返回值从裸 error 改为用 fmt.Errorf("%w", err) 包装。这导致下游代码中用 == 比较 sentinel error 的检查失效——因为包装后的错误不再等于原始 sentinel error。

修复方案:将所有 err == SomeError 改为 errors.Is(err, SomeError)

教训:引入错误包装时,必须同时更新所有错误检查代码。

案例 2:recover 中的二次 panic

defer func() {
    if r := recover(); r != nil {
        // 如果 log.Fatal 内部又 panic,没有第二次 recover
        log.Fatalf("panic: %v", r) // log.Fatalf 调用 os.Exit,不是 panic
    }
}()

更危险的情况:

defer func() {
    if r := recover(); r != nil {
        panic(fmt.Sprintf("recovered: %v", r)) // 二次 panic!
    }
}()

二次 panic 不会被同一个 defer 中的 recover 捕获——它会继续向上传播。

案例 3:goroutine 泄漏源于忽略错误

func processAsync(ctx context.Context) {
    ch := make(chan result)
    go func() {
        res, err := longOperation()
        if err != nil {
            return // 没有向 ch 发送 —— 主 goroutine 永远阻塞!
        }
        ch <- res
    }()
    select {
    case r := <-ch:
        handle(r)
    case <-ctx.Done():
        return // context 取消了,但子 goroutine 可能还在运行
    }
}

修复:确保所有路径都向 channel 发送(或关闭 channel),并使用 context 控制子 goroutine 生命周期。

错误处理最佳实践总结

  1. 使用 %w 包装错误——保留错误链,方便上游用 errors.Is/As 检查
  2. 在包边界转译错误——不暴露内部实现(比如不暴露底层用了 Redis 还是 MySQL)
  3. 预定义 sentinel error——对于稳定的、API 级别的错误条件
  4. 自定义错误类型——当需要携带结构化上下文信息时
  5. 不要用 panic 做正常控制流——panic 只用于不可恢复的错误和编程 bug
  6. 每个 goroutine 要有 recover 保护——至少顶层 goroutine 需要
  7. 不要既记录又返回错误——选择一种处理方式
  8. 测试错误路径——不只测试 happy path,错误路径的行为也要验证
本章评分
4.6  / 5  (61 评分)

💬 留言讨论