Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第20章 错误处理

错误处理是 Go 语言工程实践的核心。Go 摒弃了 try/catch 异常机制,把错误当作普通的值来处理,这一设计选择深刻影响了 Go 代码的写作风格、库的 API 设计以及系统可观测性建设。本章系统讲解 error 接口、errors 包的标准能力、错误包装与日志实践,帮助你建立一套可扩展、可观测、可恢复的错误处理体系。相关基础可参考 第6章 函数与方法第7章 接口

error

1. 是什么

error 是 Go 内置的接口类型,定义极其简洁:

type error interface {
    Error() string
}

任何实现了 Error() string 方法的类型都可以作为 error 使用。函数通常通过多返回值把错误显式返回给调用方:

package main

import (
	"errors"
	"fmt"
)

// 自定义 error 类型:携带业务字段,便于调用方判断与处理
type MyError struct {
	Code int
	Msg  string
}

func (e *MyError) Error() string {
	return fmt.Sprintf("code=%d msg=%s", e.Code, e.Msg)
}

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

func main() {
	if _, err := divide(1, 0); err != nil {
		fmt.Println(err)
	}
	e := &MyError{Code: 500, Msg: "internal"}
	fmt.Println(e.Error())
}

2. 为什么这样设计 / 底层要点

Go 之父 Rob Pike 在《Errors are values》中明确指出:错误就是值,应当被正常处理而不是被“抛出“。这种设计与 try/catch 的核心差异:

维度try/catch 异常Go error 显式返回
控制流隐式跳转,可能跨多层栈帧显式逐层返回,调用链清晰
性能抛异常开销大(栈展开)与普通返回值开销一致
强制处理不强制 catch编译器不强制,但 if err != nil 习惯约束
可读性happy path 清晰error 处理代码占比高

底层要点:

  • error 是一个 iface,运行时用 16 字节的结构表示(eface/iface):一个指向类型信息的指针 + 一个指向具体数据的指针。返回 error 实际上返回这两个字。
  • 当返回 nil error 时,两个指针都为 nil,err != nil 判断为 false;但如果函数返回一个“被装进接口的 nil 具体值“(例如 var p *MyError = nil; return nil, p),接口非 nil 而 underlying 是 nil,就会出现“err != nil 但实际没错误“的著名陷阱。
  • 标准库预定义了一批 sentinel error(如 io.EOFsql.ErrNoRowsos.ErrNotExist),它们是包级指针变量,便于调用方用 ==errors.Is 比较。

3. 工程实践与常见坑

  • 不要忽略 error_, _ = ... 是事故源头。用 errcheck linter 强制检查未处理的 error。
  • 不要返回裸字符串errors.New("xxx") 适合简单场景;对外暴露的库应定义有结构的 error 类型或 sentinel,便于调用方判断。
  • nil 接口陷阱
package main

import "fmt"

type MyError struct{ Msg string }

func (e *MyError) Error() string { return e.Msg }

func bad() error {
	var e *MyError = nil // 具体类型为 nil
	return e             // 装入接口后接口 != nil
}

func main() {
	err := bad()
	fmt.Println("err == nil ?", err == nil) // false,但实际没有错误
}

规避方法:直接返回 nil 字面量,或显式 if e == nil { return nil }

  • error 与 panic 的边界:真正不可恢复的编程错误(数组越界、空指针解引用、并发读写 map)由 runtime panic;业务可预期错误一律用 error 返回。库一般不要 panic,除非初始化失败(如 regexp.MustCompile)。
  • error 命名约定:error 变量以 Err 开头(ErrNotFound),error 类型以 Error 结尾(*MyError)。
  • error 应只读:不要让调用方修改 sentinel 的内容,sentinel 用 var ErrXxx = errors.New(...) 定义为包级变量,并避免取地址。

errors.Is

1. 是什么

errors.Is(err, target) bool 沿着错误链(error chain)逐层 Unwrap,判断 err 是否等于 target,或者其底层是否存在等于 target 的错误。它是 Go 1.13 引入的错误比较标准能力。

package main

import (
	"errors"
	"fmt"
	"io"
)

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

func load() error {
	return fmt.Errorf("load config: %w", ErrNotFound)
}

func main() {
	err := load()
	fmt.Println(errors.Is(err, ErrNotFound)) // true
	fmt.Println(errors.Is(err, io.EOF))       // false
}

2. 为什么这样设计 / 底层要点

在 Go 1.13 之前,比较错误只能用 err == io.EOF。但一旦错误被 fmt.Errorf("%w", err) 或自定义类型包装,原来的 == 比较就会失效——因为包装后的 err 是新类型,不再是那个 sentinel 指针。errors.Is 解决了“包装后还能比较“的问题。

底层流程:

  1. err == target,直接返回 true(短路)。
  2. 否则,若 err 实现了 Is(target) bool 方法,调用它(允许自定义匹配逻辑)。
  3. 否则,若 err 实现了 Unwrap() error,对 unwrap 后的结果递归执行上述步骤;errors.Join 产生的错误实现的是 Unwrap() []error,会逐一比较。
  4. 链到底仍不匹配,返回 false。

关键点:sentinel error 用 == 比较,自定义类型可以实现 Is 方法来覆盖默认行为(例如脱敏、模糊匹配)。

3. 工程实践与常见坑

  • 优先用 errors.Is 比较 sentinel:即使现在没有包装,未来加包装也不会破坏调用方。把 if err == io.EOF 改成 if errors.Is(err, io.EOF) 是好习惯。
  • 不要用 errors.Is 比较错误类型errors.Is(err, &MyError{}) 永远 false(指针不同),类型判断用 errors.As
  • 自定义 Is 方法要谨慎:可实现 Is 让一组错误等价,但要保证对称性与一致性,否则调试困难。
  • 降级处理:在 HTTP 服务中,常见模式是把 sentinel 映射成 HTTP 状态码:
package main

import (
	"errors"
	"net/http"
)

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

func statusFor(err error) int {
	switch {
	case errors.Is(err, ErrNotFound):
		return http.StatusNotFound
	default:
		return http.StatusInternalServerError
	}
}

errors.As

1. 是什么

errors.As(err, target) bool 沿着错误链查找第一个能赋值给 *target 所指类型的错误,找到则赋值并返回 true。它用于从包装链中提取特定类型的错误并访问其字段。

package main

import (
	"errors"
	"fmt"
)

type QueryError struct {
	SQL   string
	Cause error
}

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

func runQuery() error {
	return &QueryError{SQL: "SELECT 1", Cause: errors.New("connection reset")}
}

func main() {
	err := runQuery()
	var qe *QueryError
	if errors.As(err, &qe) {
		fmt.Println("sql:", qe.SQL)        // 提取到字段
		fmt.Println("cause:", qe.Cause)
	}
}

2. 为什么这样设计 / 底层要点

errors.As 解决的是“类型断言 + 包装“的组合问题。普通类型断言 if e, ok := err.(*QueryError); ok 只能判断最外层,包装后就失效。errors.As 通过递归 Unwrap 在整条链上做可赋值性检查。

底层流程:

  1. target 必须是非 nil 指针,指向某个接口或实现了 error 的具体类型,否则 panic。
  2. 沿链遍历,对每个错误用反射判断“能否赋值给 *target“,能则赋值返回 true。
  3. 自定义类型可实现 As(target interface{}) bool 覆盖默认行为。

3. 工程实践与常见坑

  • target 必须是指针errors.As(err, qe)(qe 是值)会 panic,必须 errors.As(err, &qe)
  • 优先 errors.Is 判断 sentinel,errors.As 提取结构化信息:两者互补。
  • 不要用 errors.As 做“是否是某类错误“的判断后又忽略字段——那应该用 errors.Is。
  • 链上多个同类型错误:返回第一个匹配,若需要全部,用 errors.Join + 手动遍历。
  • 包装第三方错误:如果你的库包装了 net.OpError 之类,要确认 As 仍能提取,必要时实现 As 方法做转发。

errors.Join

1. 是什么

errors.Join(errs ...error) error(Go 1.20+)将多个 error 合并成一个 error。如果所有入参都是 nil,返回 nil;否则返回一个新 error,其 Error() 把所有非 nil 错误用换行连接,Unwrap() []error 返回所有非 nil 错误,因此能被 errors.Is/errors.As 正确穿透。

package main

import (
	"errors"
	"fmt"
)

func validate(name, email string) error {
	var errs []error
	if name == "" {
		errs = append(errs, errors.New("name is empty"))
	}
	if email == "" {
		errs = append(errs, errors.New("email is empty"))
	}
	return errors.Join(errs...) // 无错误时返回 nil
}

func main() {
	err := validate("", "")
	fmt.Println(err)
}

2. 为什么这样设计 / 底层要点

errors.Join 出现前,社区有 hashicorp/go-multierroruber-go/multierr 等第三方方案,API 各异。标准库提供 errors.Join 统一了多错误语义,并与 errors.Is/errors.As 兼容(通过 Unwrap() []error)。底层实现就是一个 joinError 结构,持有非 nil error 切片。

设计要点:

  • 入参全为 nil → 返回 nil(与 fmt.Errorf 行为不同,后者会得到非 nil 字符串)。
  • Error() 拼接时每条用 \n 分隔,便于人读。
  • Unwrap() []error 让 Is/As 能穿透多错误链。

3. 工程实践与常见坑

  • 并发场景收集错误:用 sync.Mutex + 切片,或 errgroup(见后文)。errors.Join 是合并结果的好工具。
  • 不要把 errors.Join 当字符串拼接:它的语义是“多个独立错误“,不是“包装原因“。要包装单个原因用 fmt.Errorf("%w", err)
  • 遍历 joinError
package main

import (
	"errors"
	"fmt"
)

func main() {
	err := errors.Join(errors.New("a"), errors.New("b"))
	// 方法1:类型断言取 Unwrap() []error
	type unwrapper interface{ Unwrap() []error }
	if u, ok := err.(unwrapper); ok {
		for _, e := range u.Unwrap() {
			fmt.Println("sub:", e)
		}
	}
}
  • 与 errgroup 配合golang.org/x/sync/errgroupWait() 在多个 goroutine 都出错时只返回第一个;若要拿到全部,可用 channel 收集后 errors.Join

包装错误

1. 是什么

包装(wrap)是指在原 error 之外叠加一层上下文(调用位置、参数、阶段名),同时保留原 error 以便 errors.Is/errors.As 穿透。Go 1.13 起标准方式是 fmt.Errorf 配合 %w 动词:

package main

import (
	"errors"
	"fmt"
)

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

func loadFromDB(id int) error {
	return ErrNotFound
}

func getUser(id int) error {
	if err := loadFromDB(id); err != nil {
		return fmt.Errorf("getUser id=%d: %w", id, err)
	}
	return nil
}

func main() {
	err := getUser(42)
	fmt.Println(err)                  // getUser id=42: not found
	fmt.Println(errors.Is(err, ErrNotFound)) // true
}

2. 为什么这样设计 / 底层要点

%w%v 的区别:%w 会把被格式化的 error 作为“原因“挂到返回 error 的 Unwrap 链上;%v 只是把 error 转成字符串拼接,丢失链关系。底层 fmt.Errorf 遇到 %w 会构造一个 wrapError 结构,持有原 error 引用并实现 Unwrap() error

包装带来的好处:

  • 调用链上下文:每层加 funcName args: %w,最终错误信息自带调用栈语义。
  • 不破坏比较:errors.Is/errors.As 仍可穿透到根因。
  • 可控的暴露面:可以选择包装或转换,把内部错误转成对外错误。

Go 1.20+ 还提供 errors.Join 用于多原因;fmt.Errorf 支持多个 %wfmt.Errorf("a=%w b=%w", ea, eb)),生成的错误 Unwrap() []error

3. 工程实践与常见坑

  • 每层包装只加有用的上下文:避免 fmt.Errorf("failed: %w", fmt.Errorf("failed: %w", err)) 这种无信息堆叠。好的上下文是“函数名 + 关键参数“。
  • %w vs %v 的选择:希望调用方能用 Is/As 判断根因 → %w;只是想隐藏实现、对外暴露新错误 → %v(同时转换)。
  • 不要包装后又丢信息:把 *os.PathError 包成 fmt.Errorf("%v", err) 会丢失路径字段,调用方拿不到 Path()
  • 错误转换(边界层):在 RPC/HTTP 边界,把内部 sentinel 转成对外错误码与对外错误,避免泄漏内部细节:
package main

import (
	"errors"
	"fmt"
)

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

type APIError struct {
	Code int
	Msg  string
}

func (e *APIError) Error() string { return fmt.Sprintf("api %d: %s", e.Code, e.Msg) }

func toAPIError(err error) error {
	if errors.Is(err, ErrNotFound) {
		return &APIError{Code: 404, Msg: "resource not found"}
	}
	return &APIError{Code: 500, Msg: "internal error"}
}
  • 包装顺序:最外层是最高层调用,最内层是根因。打印时 outer: middle: root,符合阅读习惯。
  • 避免循环包装:A 包装 B,B 又包装 A 会形成无限链,errors.Is 会递归爆栈;不要让 Unwrap 形成环。

日志

1. 是什么

错误本身只是“发生了什么“,日志才记录“在什么上下文、什么参数、什么时间发生“。Go 标准库提供两套日志工具:

  • log 包:传统行式日志,log.Printflog.Fatal
  • log/slog(Go 1.21+):结构化日志,支持 KV 字段、Level、Handler、Context 传播,是当代 Go 服务的首选。
package main

import (
	"log"
	"log/slog"
	"os"
)

func main() {
	// 传统 log
	log.SetFlags(log.LstdFlags | log.Lshortfile)
	log.Println("service started")

	// 结构化 slog
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
	slog.SetDefault(logger)

	slog.Info("user login", "uid", 42, "ip", "1.2.3.4")
	slog.Error("db query failed", "err", "timeout", "sql", "SELECT 1")
}

2. 为什么这样设计 / 底层要点

  • log/slog 的设计目标是让结构化日志成为标准,避免每个项目重新发明轮子。核心抽象是 HandlerJSONHandler 输出 JSON,TextHandler 输出 key=value 文本,可自定义。
  • 日志级别:Debug < Info < Warn < Error,通过 HandlerOptions.Level 控制。
  • slog.LogAttrs + slog.Attr 是零分配路径,适合热路径。
  • context.Context 传播:slog.InfoContext(ctx, msg, args...) 让 Handler 能读取 traceID 等 context 值,实现日志与链路追踪关联。

错误与日志的关系:error 是程序内传递的值,日志是给人和观测系统看的记录。二者结合的常见做法是“在错误被最终处理(不再向上传播)的位置记录日志“,避免同一错误在多层都被记录造成噪音。

3. 工程实践与常见坑

  • 不要在每个 if err != nil 里都 log:只在“处理错误“的位置记录,传播路径保持 silent 或仅包装。否则同一错误被记 5 次。
  • 结构化字段而非字符串拼接
// 不好:难以解析
slog.Error(fmt.Sprintf("user %d login failed: %v", uid, err))

// 好:结构化
slog.Error("user login failed", "uid", uid, "err", err)
  • 错误对象作为字段slog 会调用 err.Error(),但要避免记录带敏感信息的 error(如含密码的 SQL)。
  • 日志级别纪律:Debug 给开发排错,Info 给运维了解状态,Warn 是可恢复异常,Error 是需要告警的。乱用 Error 会导致告警疲劳。
  • Context 传播 traceID
package main

import (
	"context"
	"log/slog"
	"os"
)

type ctxKey string

const traceIDKey ctxKey = "traceID"

// 自定义 Handler 把 context 里的 traceID 注入每条日志
type traceHandler struct{ slog.Handler }

func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
	if v, ok := ctx.Value(traceIDKey).(string); ok {
		r.Add("traceID", v)
	}
	return h.Handler.Handle(ctx, r)
}

func main() {
	base := slog.NewJSONHandler(os.Stdout, nil)
	h := traceHandler{base}
	slog.SetDefault(slog.New(h))

	ctx := context.WithValue(context.Background(), traceIDKey, "abc-123")
	slog.InfoContext(ctx, "hello")
}
  • Fatal 与 paniclog.Fatal 调用 os.Exit(1),会跳过 defer;服务启动失败可用,请求处理中不要用,会拖死整个进程。
  • 采样与轮转:高 QPS 服务对 Debug 日志做采样(如 lumberjack 轮转 + 自定义采样 Handler),避免磁盘被日志写满。
  • 错误码统一:定义错误码常量并随日志输出,方便在监控平台按错误码聚合。可参考 第26章 可观测性 进一步讨论 metrics、tracing、logging 三位一体。

本章小结

Go 的错误处理哲学是“错误是值“:通过显式返回、逐层处理、按需包装,让控制流清晰可控。本章关键点:

  1. error 是一个单方法接口,nil 接口陷阱源于“接口装入了 nil 具体值“,规避方法是直接返回 nil 字面量。
  2. errors.Is 用于比较 sentinel(沿链 Unwrap),errors.As 用于提取类型化错误字段,二者配合 Unwrap 支持包装穿透。
  3. errors.Join(Go 1.20+)标准化了多错误合并语义,适合校验、并发收集场景。
  4. 包装用 fmt.Errorf("%w", err),每层加有意义上下文;边界层做错误转换避免泄漏内部细节。
  5. 日志用 log/slog 做结构化输出,遵循级别纪律,只在错误最终处理处记录,并通过 context 关联 traceID。

掌握这些,你就能写出“可比较、可追踪、可观测“的 Go 错误处理代码,为后续的 第21章 性能优化第22章 设计模式 打下坚实基础。