2740 字
约 9 分钟
13
S09-X08 · Go 错误处理:error 包装、哨兵错误与 panic 边界
2024-12-31
2026-11-08

Go 把错误当值,不当控制流。error 是只有一个方法的接口,函数显式返回它,调用方显式检查。省掉栈展开与异常表开销,代价是每处 if err != nil 都要有人负责。真正让代码分岔的不是"检不检查",而是包装方式:%w 还是 %v 决定了上层能否 errors.Is 判出根因;把可恢复错误 panic 掉,等于把调用方变成崩溃现场。


1. error 是接口,不是异常

type error interface {
	Error() string
}

只有这一个方法。任何类型实现它就是一个错误。

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

n, err := div(6, 0)
if err != nil {
	fmt.Println("failed:", err)   // failed: division by zero
	return
}
维度 Go Java
传播方式 返回值 throw 展开栈
强制处理 编译器不强制,靠约定与 linter 受检异常强制 catch/throws
类型体系 一个接口 error Throwable 继承树
开销 返回值 + 可能的堆分配 构造异常需抓栈,成本高
恢复机制 panic/recover,仅同 goroutine catch 任意层
忽略方式 _ = f() 或漏判 catch (Exception e) {}

Go 不用异常表达业务错误,"错误即返回值"就是唯一正常路径;异常只留给编程错误。


2. 错误的三种创建方式

// 1. 哨兵:包级固定值,用于 == / errors.Is 比较
var ErrNotFound = errors.New("not found")

// 2. 动态消息:需要拼接上下文时
func wrap(id string) error {
	return fmt.Errorf("user %s not found", id)
}

// 3. 自定义类型:需要携带结构化字段
type ParseError struct {
	Line int
	Col  int
	Msg  string
}

func (e *ParseError) Error() string {
	return fmt.Sprintf("parse error at %d:%d: %s", e.Line, e.Col, e.Msg)
}
方式 能否 errors.Is 能否携带字段 适用
errors.New 哨兵 能(== 即可) 否 需要判定"是不是这类错误"
fmt.Errorf 带 %w 才能 否 只加上下文,不需程序判定
自定义类型 能(As) 能 需要返回结构化数据

选型判据:调用方要拿这个错误做判断吗? 要判"是不是超时/不存在"→ 哨兵或自定义类型;只让人看懂 → fmt.Errorf。

2.1 自定义类型的最佳实践

type TimeoutError struct {
	Op      string
	Timeout time.Duration
}

func (e *TimeoutError) Error() string {
	return e.Op + " timed out after " + e.Timeout.String()
}

func (e *TimeoutError) Timeout() bool { return true }   // 行为方法,供接口判定

标准库 net.Error 的做法值得抄:用行为接口(Timeout() bool)而非具体类型判定,任何实现该行为的错误都能统一处理。


3. 包装与解包:%w 与 %v

return fmt.Errorf("get user: %w", ErrNotFound)   // 包装,链保留
return fmt.Errorf("get user: %v", ErrNotFound)   // 仅格式化,链断裂
动作 结果 errors.Is(err, ErrNotFound)
%w 包装,保留链 true
%v 仅拼字符串,链断 false
%s 同 %v false
不带动词直接返回 无上下文无链 只有原值能判

这是最高频的失误:为打日志好看写了 %v,结果上游 Is 判断全部失效,只能退化成字符串匹配。

%w 可以多次使用(Go 1.20 起支持多错误包装),对应 errors.Join:

var e1, e2 error
err := fmt.Errorf("a: %w, b: %w", e1, e2)
errors.Is(err, e1)   // true
errors.Is(err, e2)   // true

3.1 errors.Is 与 errors.As 的分工

函数 匹配什么 用法 失败返回
errors.Is(err, target) 哨兵值(== 逐层比较) 目标值直接传 bool
errors.As(err, &target) 类型(逐层断言) 目标类型指针的指针 bool
errors.Unwrap(err) 取下一层 — nil 表示链结束
if errors.Is(err, ErrNotFound) {
	// 语义判断
}

var pe *ParseError
if errors.As(err, &pe) {
	fmt.Println("行", pe.Line)   // 拿到结构化字段
}

判据:能用 Is 就别用 As。Is 表达"语义",对实现细节不敏感;As 一旦绑定具体类型,实现方就不能换成等价类型。要读字段时才用 As。


4. 错误信息的写法规范

一条好错误信息应包含发生了什么 + 在哪里发生 + 关键标识,但不重复上一层的上下文。

规范 好 差
小写开头,无结尾标点 open config: permission denied Open config failed!
不重复调用链信息 load: open a.json: no such file load failed: error loading: open a.json: ...
带上关键标识 user 42: not found not found
不暴露内部路径给用户 由上层转换成用户文案 直接吐 /srv/app/secret.go
用 : 分隔上下文 context: cause context - cause (cause)

包装的标准姿势是每层只加自己这一层的信息:

func load(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("load %s: %w", path, err)   // 只加 path
	}
	defer f.Close()
	return nil
}

err := load("a.json")
// load a.json: open a.json: no such file or directory

5. panic / recover 的边界

panic 是"程序无法继续"的信号,不是错误传播手段。

该 panic 不该 panic
索引越界、nil 解引用(运行时自身行为) 用户输入非法
违反程序不变式(switch 到达不可能分支) 文件不存在
初始化阶段配置缺失(fail fast) 网络超时
库内部明确不可恢复的契约违反 业务校验失败

原则:库不应 panic 给调用方。库作者无法预知调用方的场景能否恢复,唯一可接受的例外是初始化期检测到不可用配置(如 regexp.MustCompile 的非法正则,这类"错误来自程序员而非输入")。

5.1 recover 只能捕获同一 goroutine

defer func() {
	if r := recover(); r != nil {
		fmt.Println("recovered:", r)
	}
}()

go func() {
	panic("子 goroutine 的 panic")   // 主 goroutine 的 recover 抓不到
}()

子 goroutine 的 panic 会直接终止整个进程。所以每个长期运行的 goroutine 都要有自己的 recover:

func safeGo(fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("goroutine panic: %v\n%s", r, debug.Stack())
			}
		}()
		fn()
	}()
}
位置 是否需要 recover
main 顶层 需要,兜底记录
HTTP handler 需要,net/http 已内置,但自定义中间件要加
后台 worker goroutine 必须,否则一个 panic 带走整个进程
库函数 一般不加,加了会隐藏调用方的 bug

6. defer + recover 把 panic 转 error

func safeCall(fn func() error) (err error) {   // 必须命名返回值
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("panic: %v", r)
		}
	}()
	return fn()
}

三个必要条件,缺一不可:

条件 作用
返回值命名 defer 才能改到最终返回值
recover 在 defer 里直接调用 嵌套一层函数调用返回 nil
recover 在同一 goroutine 跨 goroutine 无效

代价也得说清楚:

代价 说明
丢失栈信息 recover() 只给 panic 值,栈要用 debug.Stack() 单独取
语义模糊 编程错误被降级成普通 error,调用方难区分
掩盖 bug 本该崩溃的越界被吞成 error,问题被推到更远处
无法保证状态一致 panic 时已写入的部分状态不会回滚

所以这条路径只适合边界层:HTTP handler、任务调度器、插件执行器——也就是"必须活下来"的地方。业务逻辑里用 recover 包一层是把 bug 藏起来。


7. 错误与日志的分工

一条铁律:错误要么被处理,要么被返回,不要两者都做。既 log 又 return 会造成同一条错误在日志里出现 N 次。

层 该做 不该做
数据访问层 包装并返回 %w 打日志后返回
业务层 判断、转换、包装 逐层重复打日志
边界层(handler/main) 记录一次完整链 + 转成响应 吞掉不返回
// 边界层:唯一记录的地方
if err != nil {
	log.Printf("request failed: %v", err)   // 完整链一次打出
	http.Error(w, "internal error", 500)     // 对外不暴露内部细节
	return
}

需要同时"记录并返回"时,给错误加标记避免重复打日志,或在中间件层统一处理。


8. 排错:五个高频坑

现象 原因 处理
上游 errors.Is 全 false 用了 %v/%s 而非 %w 改 %w
日志里同一条错误出现多次 每层都 log 后 return 只在边界层记录
err 明明非空但代码走正常分支 if 里 := 遮蔽(见 X01) 前置声明 + =
程序莫名退出,日志无栈 子 goroutine panic 未 recover safeGo 包装 + debug.Stack()
内存/句柄缓慢增长 循环内 defer 堆积(见 X04) 抽成小函数

补充一条容易被忽略的:errors.Join 之后 errors.Is 会遍历所有子错误,但 errors.As 只返回第一个匹配项。需要处理全部子错误时要自己 Unwrap() []error 展开(Go 1.20+)。


9. 边界:这些情况本就该换方案

场景 不建议 建议
需要重试 到处 for + err != nil 把重试策略包成一层装饰器
需要错误码给前端 解析 Error() 字符串 自定义类型带 Code(),或 As 取字段
需要上下文取消 自定义哨兵 context.Canceled / DeadlineExceeded + Is
需要聚合多个错误 拼字符串 errors.Join(1.20+)
需要分类统计 字符串匹配 哨兵 + errors.Is,或行为接口

context 的错误已经预定义了两个哨兵,直接 errors.Is(err, context.Canceled) 判断即可,不要自己 New 一个同义词——那会让上游标准判断全部失效。


10. 自测

  1. fmt.Errorf 里用 %w 与 %v 的差别是什么?对 errors.Is 有什么影响?
  2. 什么时候该用 errors.Is,什么时候必须用 errors.As?
  3. 把 defer + recover 转 error 的三个必要条件是什么?代价有哪些?
  4. 子 goroutine 里的 panic 能被主 goroutine 的 recover 捕获吗?
  5. 为什么同一条错误不应该既 log 又 return?正确做法是什么?
  6. 自定义错误类型为什么最好提供行为方法(如 Timeout() bool)而不是只暴露具体类型?
S09-X08 · Go 错误处理:error 包装、哨兵错误与 panic 边界
https://www.clxhxhhr.top/posts/5785/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。