第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 实际上返回这两个字。- 当返回
nilerror 时,两个指针都为 nil,err != nil判断为 false;但如果函数返回一个“被装进接口的 nil 具体值“(例如var p *MyError = nil; return nil, p),接口非 nil 而 underlying 是 nil,就会出现“err != nil 但实际没错误“的著名陷阱。 - 标准库预定义了一批 sentinel error(如
io.EOF、sql.ErrNoRows、os.ErrNotExist),它们是包级指针变量,便于调用方用==或errors.Is比较。
3. 工程实践与常见坑
- 不要忽略 error:
_, _ = ...是事故源头。用errchecklinter 强制检查未处理的 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 解决了“包装后还能比较“的问题。
底层流程:
- 若
err == target,直接返回 true(短路)。 - 否则,若 err 实现了
Is(target) bool方法,调用它(允许自定义匹配逻辑)。 - 否则,若 err 实现了
Unwrap() error,对 unwrap 后的结果递归执行上述步骤;errors.Join产生的错误实现的是Unwrap() []error,会逐一比较。 - 链到底仍不匹配,返回 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 在整条链上做可赋值性检查。
底层流程:
- target 必须是非 nil 指针,指向某个接口或实现了 error 的具体类型,否则 panic。
- 沿链遍历,对每个错误用反射判断“能否赋值给 *target“,能则赋值返回 true。
- 自定义类型可实现
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-multierror、uber-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/errgroup的Wait()在多个 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 支持多个 %w(fmt.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.Printf、log.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的设计目标是让结构化日志成为标准,避免每个项目重新发明轮子。核心抽象是Handler:JSONHandler输出 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 与 panic:
log.Fatal调用os.Exit(1),会跳过 defer;服务启动失败可用,请求处理中不要用,会拖死整个进程。 - 采样与轮转:高 QPS 服务对 Debug 日志做采样(如
lumberjack轮转 + 自定义采样 Handler),避免磁盘被日志写满。 - 错误码统一:定义错误码常量并随日志输出,方便在监控平台按错误码聚合。可参考 第26章 可观测性 进一步讨论 metrics、tracing、logging 三位一体。
本章小结
Go 的错误处理哲学是“错误是值“:通过显式返回、逐层处理、按需包装,让控制流清晰可控。本章关键点:
error是一个单方法接口,nil 接口陷阱源于“接口装入了 nil 具体值“,规避方法是直接返回 nil 字面量。errors.Is用于比较 sentinel(沿链 Unwrap),errors.As用于提取类型化错误字段,二者配合Unwrap支持包装穿透。errors.Join(Go 1.20+)标准化了多错误合并语义,适合校验、并发收集场景。- 包装用
fmt.Errorf("%w", err),每层加有意义上下文;边界层做错误转换避免泄漏内部细节。 - 日志用
log/slog做结构化输出,遵循级别纪律,只在错误最终处理处记录,并通过 context 关联 traceID。
掌握这些,你就能写出“可比较、可追踪、可观测“的 Go 错误处理代码,为后续的 第21章 性能优化 与 第22章 设计模式 打下坚实基础。