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. 自测
fmt.Errorf里用%w与%v的差别是什么?对errors.Is有什么影响?- 什么时候该用
errors.Is,什么时候必须用errors.As? - 把
defer+recover转 error 的三个必要条件是什么?代价有哪些? - 子 goroutine 里的 panic 能被主 goroutine 的
recover捕获吗? - 为什么同一条错误不应该既
log又return?正确做法是什么? - 自定义错误类型为什么最好提供行为方法(如
Timeout() bool)而不是只暴露具体类型?