5.Go 错误处理

背景与动机#

Go 把错误处理设计成“显式返回值”而不是“异常机制”。原因很直接:失败路径要和正常路径一样清晰,调用者必须正面处理,而不是把控制流隐式交给异常系统。

这套设计主要解决四个问题:

  • 如何创建一个有稳定语义的错误
  • 错误向上返回时,如何补充上下文但不丢失原始原因
  • 调用方如何可靠判断“是不是某类错误”
  • 遇到真正无法继续执行的异常状态时,如何中断并兜底

因此,Go 日常错误处理的主线是 error 返回值;fmt.Errorf("%w") 负责包装;errors.Is/As 负责识别;panic/recover 只处理正常返回路径之外的异常控制流。

核心原理拆解#

1. error 本质是接口,不是异常#

Go 里 error 只是一个接口:

type error interface {
Error() string
}

这意味着错误不是语言层面的特殊对象。任何实现了 Error() string 的类型,都可以作为错误值返回。

常见创建方式有三类:

  • errors.New("..."):创建简单错误,适合固定语义
  • 自定义错误类型:适合携带结构化信息
  • fmt.Errorf("...: %w", err):在已有错误外补充上下文

2. 错误创建强调“语义归属”#

最靠近失败现场的代码最适合创建原始错误,因为它最清楚失败原因。上层的职责通常不是重造一个新错误,而是补充上下文,例如“哪个阶段失败”“哪个对象处理失败”。

例如:

  • 底层返回 ErrNotFound
  • 中间层包装成 load config failed: %w
  • 顶层再包装成 bootstrap service failed: %w

这样调用栈越往上,定位信息越完整,但底层语义仍然保留。

3. %w%v 的本质区别#

fmt.Errorf 看起来像格式化字符串,但 %w 有额外语义:它会把底层错误挂进新的错误对象里,形成一条可展开的错误链。

  • %w:保留底层错误,可被 errors.Is/As 继续识别
  • %v / %s:只是把错误文本拼进字符串,语义丢失

所以 %w 不是“显示更好”,而是“机器还能继续识别原始错误”。

4. errors.Is 用来判断“是不是这个错误”#

errors.Is(err, target) 会沿错误链逐层检查:

  • 当前错误是否和 target 相等
  • 当前错误是否自定义了 Is(error) bool
  • 如果能 Unwrap(),继续往里找

它解决的是“语义匹配”,不是“最外层值必须完全相等”。

适用场景:

  • 判断是否是 io.EOF
  • 判断是否是自定义哨兵错误
  • 判断包装多层后的错误最终是不是某个底层错误

5. errors.As 用来提取“某种具体错误类型”#

errors.As(err, &target) 也是沿错误链查找,但目标是找出某一层是否能赋值给指定类型。

适用场景:

  • 从包装错误里提取 os.PathError
  • 提取自定义错误类型中的字段
  • 根据不同错误类型走不同分支

As 的第二个参数必须是指针,因为它要把匹配到的错误值写回去。

6. panic/recover 是异常控制流,不是常规错误处理#

panic 不走返回值通道。它会立刻中断当前执行路径,让当前 goroutine 开始栈展开,并按 LIFO 顺序执行 defer

如果某个 defer 里调用了 recover(),且当前确实处在 panic 展开过程中,那么这次 panic 会被截住,程序可以继续往下走。

要点有两个:

  • recover 只能在 defer 中生效
  • recover 只能恢复当前 goroutine 的 panic,不能跨 goroutine

日常业务失败应该返回 error;只有真正违背内部不变量、无法继续执行时,才考虑 panic

关键对比 / 数据结构 / 执行流程#

【标准库 - %w 包装链与 %v 字符串拼接对比】

image.png
image.png

【语言特性 - panic 触发后的栈展开与 recover 截获流程】

image.png
image.png

1. errors.New、自定义类型、fmt.Errorf#

  • errors.New 适合稳定、简单、可比较的错误语义
  • 自定义错误类型适合携带结构化字段
  • fmt.Errorf("%w", err) 适合保留原始原因并补充上下文

2. errors.Iserrors.As#

  • errors.Is:判断“是不是这个错误”
  • errors.As:提取“是不是这种错误类型”

3. error 返回与 panic#

  • error:预期内失败,调用者可决定如何处理
  • panic:程序状态异常,当前路径不该继续执行

4. 表面语法与底层行为#

  • 表面上:fmt.Errorf 像字符串格式化
  • 底层上:%w 会创建支持 Unwrap() 的包装错误
  • 表面上:recover() 像普通函数
  • 底层上:它只有在 panic 栈展开中的 defer 里才有拦截效果

最小可运行代码示例#

package main
import (
"errors"
"fmt"
)
var ErrConfigNotFound = errors.New("config not found")
type ParseError struct {
Line int
}
func (e *ParseError) Error() string {
return fmt.Sprintf("parse error at line %d", e.Line)
}
func loadConfig(name string) error {
switch name {
case "missing":
return ErrConfigNotFound
case "bad":
return &ParseError{Line: 3}
default:
return nil
}
}
func boot(name string) error {
if err := loadConfig(name); err != nil {
return fmt.Errorf("boot %q failed: %w", name, err)
}
return nil
}
func safeRun() {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
panic("unexpected nil state")
}
func main() {
err := boot("missing")
fmt.Println("Is ErrConfigNotFound:", errors.Is(err, ErrConfigNotFound))
err = boot("bad")
var pe *ParseError
if errors.As(err, &pe) {
fmt.Println("ParseError line:", pe.Line)
}
safeRun()
}

这个示例覆盖了四件事:

  • 底层创建原始错误
  • 上层用 %w 补上下文
  • 调用方用 errors.Is/As 做语义判断和类型提取
  • panic 通过 defer + recover 被当前 goroutine 恢复

常见陷阱与错误示例#

1. 用 %v 包装错误,导致错误链丢失#

错误写法:

err := fmt.Errorf("read config failed: %v", ErrConfigNotFound)
fmt.Println(errors.Is(err, ErrConfigNotFound)) // false

正确写法:

err := fmt.Errorf("read config failed: %w", ErrConfigNotFound)
fmt.Println(errors.Is(err, ErrConfigNotFound)) // true

问题本质:%v 只保留文本,%w 才保留语义链路。

2. 返回“动态值为 nil 的具体错误指针”#

错误写法:

type MyError struct{}
func (e *MyError) Error() string { return "x" }
func bad() error {
var e *MyError = nil
return e
}

正确写法:

func good() error {
return nil
}

问题本质:接口是否为 nil,取决于“动态类型”和“动态值”是否都为 nil(*MyError)(nil) 装进接口后,接口本身已经不为 nil

3. 把 panic 当业务错误处理#

错误写法:

func queryUser(id int) string {
if id <= 0 {
panic("invalid id")
}
return "Tom"
}

正确写法:

func queryUser(id int) (string, error) {
if id <= 0 {
return "", errors.New("invalid id")
}
return "Tom", nil
}

问题本质:参数非法、资源不存在、校验失败,都属于预期内失败,应该返回 error,而不是让控制流崩掉。

4. 以为 recover 在任意位置都能用#

错误写法:

func badRecover() {
panic("boom")
_ = recover()
}

正确写法:

func goodRecover() {
defer func() {
if r := recover(); r != nil {
fmt.Println(r)
}
}()
panic("boom")
}

问题本质:recover 必须发生在 panic 展开过程中的 defer 内。

性能影响或设计取舍#

  • errors.New 最轻,适合固定错误语义。
  • fmt.Errorf 会引入格式化成本;如果还用了 %w,会额外创建包装对象,但通常这是值得的,因为可观测性和可维护性更重要。
  • errors.Is/As 需要沿错误链遍历,链越深检查成本越高,不过正常工程里的错误链通常不深,这部分开销很小。
  • 如果错误需要给上层程序做分支判断,就不要只返回字符串;字符串只适合给人看,不适合给机器判定。
  • panic/recover 会打断正常控制流,还伴随栈展开成本,不适合高频业务分支。
  • 哨兵错误适合表达稳定语义;自定义错误类型适合表达稳定语义加结构化信息。前者简单,后者更灵活。

版本说明:

  • errors.Iserrors.Asfmt.Errorf("%w") 是 Go 1.13 引入的标准错误链能力。
  • 本文按 Go 1.22+ 语义理解;不同 Go 版本的内部实现细节可能不同,但对外使用模型一致。

面试高频问题#

1. errors.Is== 的区别是什么?#

== 只能比较当前错误值;errors.Is 会沿包装链向内查找,判断语义上是不是目标错误。

2. errors.As 和类型断言有什么区别?#

类型断言只能看最外层错误;errors.As 会沿错误链继续向内找匹配的具体错误类型。

3. 为什么 fmt.Errorf 包装错误时推荐用 %w#

因为 %w 会保留底层错误,后续 errors.Is/As 才能继续识别原始语义;%v 只会把错误转成文本。

4. 什么场景应该返回 error,什么场景应该 panic#

业务失败、资源不存在、校验失败、网络错误等都应该返回 error;只有明显违反内部不变量、程序状态异常且当前路径不该继续时才用 panic

5. 为什么 recover 只能在 defer 中生效?#

因为它的作用点不是“普通函数调用”,而是“panic 发生后的栈展开过程”;离开这个时机,recover 就拿不到当前 panic。

一句话总结#

Go 错误处理的正确主线是:底层创建原始错误,上层用 %w 补上下文,调用方用 errors.Is/As 做稳定判断,而 panic/recover 只用于真正异常的控制流。

文章目录

文章目录