跳到正文

04 错误处理、defer 与 panic ​

一句话:Go 把错误当成普通返回值,让调用方显式决定每一步失败怎么处理;panic 只留给「程序已经不可能正确继续」的场景,recover 是进程或请求边界的最后兜底,不是异常控制流。

适用 Go 1.22+;%w、errors.Is/As 从 1.13 提供,errors.Join 从 1.20 提供。

为什么需要 ​

异常把控制流藏起来:一个调用可能跳到很远,资源释放和错误上下文都难追踪。错误作为值可以包装、比较、聚合、测试,调用方自己决定重试、降级还是返回。代价是每个可能失败的点都要写 if err != nil,漏掉就会把错误吞掉。Go 选择用显式换来可读的失败路径。

核心机制 ​

error 是接口 ​

go
type error interface { Error() string }

任何实现 Error() string 的类型都是 error。errors.New 返回只带消息的错误;需要携带字段时自定义类型,例如 QueryError{Table, Err}。哨兵错误是包级变量,调用方用 errors.Is 判断,不要比较字符串。

包装、Is 与 As ​

go
var ErrNotFound = errors.New("not found")

func find(id int) error {
	if id < 0 {
		return fmt.Errorf("find(%d): %w", id, ErrNotFound)
	}
	return &QueryError{Table: "users", Err: os.ErrPermission}
}
  • %w 保留原错误链,%v 只拼字符串,链会断。
  • errors.Is(err, target) 沿 Unwrap 链比较,能识别包装后的哨兵错误。
  • errors.As(err, &target) 找链上第一个可赋给目标类型的错误,目标必须是指针。
  • errors.Join 把多个错误合成一个,Is/As 会遍历;适合「批量操作收集所有失败」。
  • 自定义错误需要被 Is 识别就实现 Is(target error) bool,需要暴露底层错误就实现 Unwrap() error。

panic 与 recover 的边界 ​

  • panic 后当前函数停止,执行已注册的 defer,然后向上传播;没人 recover 就打印栈并退出进程。
  • recover 只有在 defer 的函数里直接调用才有效,放到嵌套函数里会得到 nil。
  • recover 后不会从 panic 点继续,而是从 defer 所在函数返回;要返回错误,就在 defer 里给命名返回值赋值。
  • 适合 panic:程序 bug(越界、nil 解引用)、不可能状态、启动期初始化失败。不适合:业务错误、输入校验、可重试的 IO。
  • goroutine 里的 panic 不会被其他 goroutine 的 recover 捕获;每个 goroutine 要自己兜底,否则整个进程退出。
  • 不能 recover 的包括运行时 fatal error(并发 map 写、栈溢出)、os.Exit 和部分 cgo 崩溃。

defer 与资源释放 ​

  • defer f.Close() 适合文件、锁、连接、临时目录。写文件时 Close 可能包含 flush 失败,不能忽略:defer func() { err = errors.Join(err, f.Close()) }()。
  • defer 的参数在注册时求值:defer f(x) 用的是当时的 x;想读最终值就包一层闭包。
  • 循环里的 defer 会累积到函数返回才执行,大量循环要用内层函数包住,或显式关闭。

为什么不用异常 ​

Go 的目标是让失败路径在代码里可见,每个 if err != nil 都是一次决策点。异常适合「一层层向上抛、中间不处理」的场景,Go 用 %w 包装达到类似效果,但每一层仍可选择处理或继续包。代价是冗长,用早返回、辅助函数和 errors.Join 控制嵌套,而不是用 panic 偷懒。

最小上手 ​

go
package main

import (
	"errors"
	"fmt"
	"os"
)

var ErrNotFound = errors.New("not found")

type QueryError struct {
	Table string
	Err   error
}

func (e *QueryError) Error() string { return "query " + e.Table + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err }

func find(id int) error {
	if id == 0 {
		return ErrNotFound
	}
	if id < 0 {
		return fmt.Errorf("find(%d): %w", id, ErrNotFound)
	}
	return &QueryError{Table: "users", Err: os.ErrPermission}
}

func safeDiv(a, b int) (n int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("recovered: %v", r)
		}
	}()
	return a / b, nil
}

func main() {
	for _, id := range []int{0, -1, 1} {
		err := find(id)
		fmt.Println(id, errors.Is(err, ErrNotFound))
	}
	var qe *QueryError
	err := find(1)
	fmt.Println(errors.As(err, &qe), qe.Table)
	fmt.Println(safeDiv(4, 2), safeDiv(1, 0))
}

完整版本见 examples/04_errors/main.go。运行:go run examples/04_errors/main.go。

生产动作 ​

  • 错误信息带上下文:说明做什么、对象 ID、关键参数;不要只写 failed。
  • 只在边界记录一次日志:中间层用 %w 包装返回,避免每层都打日志导致重复。
  • 哨兵错误只用于调用方要分支判断的情况;否则用带字段的自定义类型。
  • 比较用 errors.Is/As,不要字符串匹配;展示用 %v,包装用 %w。
  • panic 只在 goroutine 入口、HTTP 中间件、任务框架边界 recover,记录栈并返回错误;不要静默吞掉。
  • 检查:errcheck/staticcheck 找漏掉的 error,go vet 查 printf 参数,测试覆盖错误分支。

边界 ​

  • %w 只能包装一个错误,多个用 errors.Join;包装层太多会让日志重复。
  • 返回 error 时不要返回 typed nil,否则调用方的 err != nil 会误判。
  • recover 不能跨 goroutine;每个 goroutine 自己兜底。
  • 循环里的 defer 会累积;os.Exit 不会执行 defer。
  • panic 不应作为正常控制流;recover 后进程状态可能已经不一致,能重启就重启。
  • 包装后的错误不要用 == 比较;Is 比较的是链上的目标,不是消息文本。

讲清楚 ​

Go 的 error 是一个只要求 Error() string 的接口,失败因此是普通值,调用方必须显式检查。包内定义哨兵错误,跨层用 %w 包装保留链,调用方用 errors.Is 判断「是不是这个错误」,用 errors.As 取出具体类型;多个错误用 errors.Join 合并。panic 只用于程序 bug 或不可能状态,触发后执行 defer 再退出;recover 必须在 defer 里直接调用,通常只放在 goroutine 入口或请求边界。defer 在注册时求值、返回前逆序执行,适合释放资源,但循环里会累积。这套设计的取舍是用冗长换可见:每个失败点都在代码里,不靠异常跳转。

适用 Go 1.22+ | 初稿 2026-10

把一门技术讲透到既懂也会用