错误处理: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 语言中最简单的接口之一——只有一个方法。这种极简设计意味着:
- 任何类型都可以成为错误——只要实现
Error()方法 - 错误是普通值——可以存储、传递、比较、组合
- 没有特殊的语法机制——不需要
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。
错误处理的基本原则
- 只处理一次错误——要么返回它,要么记录它,不要两者都做
- 添加上下文——裸露的
return err丢失了调用栈信息 - 在包边界转译错误——不要暴露内部实现细节
- 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
它的查找过程:
- 检查
err == target - 如果 err 实现了
Is(error) bool方法,调用它 - 如果 err 实现了
Unwrap() error,递归检查Unwrap()返回的错误 - 如果 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 前缀 + 描述性名称,如 ErrNotFound、ErrInvalidInput。
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.Is 和 errors.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 继承了这种显式性,但做了改进:
- C 返回
-1或NULL,语义不清晰——Go 返回独立的error值 - C 的
errno是全局变量,线程不安全——Go 的 error 是局部值 - C 没有强制检查返回值——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 团队视角):
- 隐藏的控制流:异常可以在任何时刻跳转到任意 catch 块,调用者无法通过阅读代码确定哪行可能抛出异常
- catch-all 反模式:开发者厌倦了处理 checked exception,写出
catch (Exception e) {} - 异常层级爆炸:Java 标准库有数百个异常类,很多使用场景不明确
- 性能开销:构造异常对象需要捕获调用栈,即使异常永远不会被抛出
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+) |
anyhow、thiserror crate |
| 模式匹配 | errors.Is / errors.As |
match 表达式 |
| 性能 | 接口装箱有间接开销 | 零成本抽象(枚举内联) |
Go 的选择为什么合理?
Go 的设计目标是简单性和可读性(simplicity and readability),面向大型团队的工程实践。Go 团队认为:
- 显式 > 隐式:
if err != nil虽然冗长,但每个错误路径清晰可见 - 简单 > 精巧:不需要学习 monad、
?运算符等概念 - 统一 > 灵活:一种错误处理风格,所有人写出相似的代码
- 工程 > 理论:在 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 标准库最终借鉴了包装思想,但做了简化:
- 只保留
Unwrap()机制,不自动记录调用栈(调用栈记录开销大且通常不必要) - 用
%w动词而不是独立的Wrap函数 - 用
errors.Is/As替代errors.Cause的直接比较
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 的显式风格,而不是引入新的控制流关键字。原因包括:
- 可读性:
if err != nil虽然冗长但清晰 - 可 grep 性:搜索
if err != nil就能找到所有错误处理点 - 一致性:不增加学习负担
- 灵活性:
if err != nil块中可以做任何事(日志、指标、重试、清理)
error 接口的规范定义
Go 语言规范(The Go Programming Language Specification)对 error 的定义:
The predeclared type
erroris defined astype 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.New、error 接口 |
| — | 2016 | github.com/pkg/errors 社区流行 |
| Go 1.13 | 2019 | fmt.Errorf %w、errors.Is、errors.As、errors.Unwrap |
| Go 1.20 | 2023 | errors.Join、Unwrap() []error 多错误树 |
Level 4:边界与陷阱
panic/recover 的正确使用场景
panic 和 recover 是 Go 的异常机制——但它们被刻意设计为不常用的工具。
panic 的行为
func main() {
fmt.Println("start")
panic("something went wrong")
fmt.Println("end") // 永远不会执行
}
当 panic 发生时:
- 当前函数立即停止执行
- 所有已注册的 defer 函数按 LIFO 顺序执行
- 控制权传给调用者,调用者的 defer 也执行
- 一直向上传播直到 goroutine 栈顶
- 如果没有 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
}
这种模式的问题:
- 性能差——panic 需要展开调用栈
- 可读性差——读者不知道哪里可能 panic
- 不安全——如果 defer 中的 recover 被意外移除,程序就崩溃了
反模式 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) // 输出什么?
}
答案:输出 false。getErr(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)) // ?
答案:true、true、false。errors.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 生命周期。
错误处理最佳实践总结
- 使用
%w包装错误——保留错误链,方便上游用errors.Is/As检查 - 在包边界转译错误——不暴露内部实现(比如不暴露底层用了 Redis 还是 MySQL)
- 预定义 sentinel error——对于稳定的、API 级别的错误条件
- 自定义错误类型——当需要携带结构化上下文信息时
- 不要用 panic 做正常控制流——panic 只用于不可恢复的错误和编程 bug
- 每个 goroutine 要有 recover 保护——至少顶层 goroutine 需要
- 不要既记录又返回错误——选择一种处理方式
- 测试错误路径——不只测试 happy path,错误路径的行为也要验证