第23章 错误处理
错误处理是 Go 语言工程实践的核心。Go 摒弃了 try/catch 异常机制,把错误当作普通的值来处理,这一设计选择深刻影响了 Go 代码的写作风格、库的 API 设计以及系统可观测性建设。本章系统讲解
error接口、errors包的标准能力、错误包装与日志实践。相关基础可参考第10章 函数与第7章 Interface。
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 显式返回 |
|---|---|---|
| 控制流 | 隐式跳转,可能跨多层栈帧 | 显式逐层返回,调用链清晰 |
| 成本模型 | 取决于语言与 Runtime,抛出路径常涉及展开/栈信息 | 走普通返回控制流;接口构造、包装和逃逸仍可能有成本 |
| 强制处理 | 不强制 catch | 编译器不强制,但 if err != nil 习惯约束 |
| 可读性 | happy path 清晰 | error 处理代码占比高 |
底层要点:
error是非空接口。当前 64 位 gc 实现通常用两个 machine word 表示(类型/itab 信息与数据 word,合计 16 字节),32 位目标通常为 8 字节;具体布局和 ABI 不是语言保证。- 当返回
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:有意忽略时应确认副作用和失败后果,并让注释或 lint 配置表达决定。
errcheck等工具可发现未检查路径,但不能替代语义判断。 - 只为调用方需要的机器语义建类型或 sentinel:
errors.New适合只展示信息的失败;需要稳定分类、字段或errors.Is/As契约时再导出 sentinel 或结构化类型,避免把内部细节永久固化成 API。 - 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 的边界:调用方可预期并处理的失败返回 error;违反 API 前置条件或破坏内部不变量时某些 API 会 panic(
regexp.MustCompile明确用于常量模式)。库不应让普通数据错误意外 panic,边界层 recover 也不能把损坏状态伪装成成功。 - error 命名约定:error 变量以
Err开头(ErrNotFound),error 类型以Error结尾(*MyError)。 - sentinel 语义应稳定:常用
var ErrXxx = errors.New(...)暴露可匹配身份,文档说明何时返回。不要让错误携带可变共享状态,也不要在新版本随意改变既有errors.Is契约。
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。
关键点:errors.Is 先尝试可比较 target 的相等性,再允许链上错误通过 Is(target) bool 定义浅层匹配。自定义 Is 不应递归调用 target 的 Is 或自行 Unwrap。
3. 工程实践与常见坑
- 优先用 errors.Is 比较 sentinel:即使现在没有包装,未来加包装也不会破坏调用方。把
if err == io.EOF改成if errors.Is(err, io.EOF)是好习惯。 - 不要把 errors.Is 当作类型断言:
errors.Is(err, &MyError{})在没有自定义Is语义时通常不会匹配,因为新建指针不是链中的同一值;实现了Is(error) bool的错误可以改变结果。类型提取应使用errors.As,Go 1.26+ 也可使用errors.AsType。 - 自定义 Is 方法要谨慎:匹配可以有方向性(例如错误值匹配一个较宽的模板 target),不要求数学上的对称;但规则必须稳定、文档化且只检查当前 err 与 target,否则调试困难。
- 降级处理:在 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 提取结构化信息:两者互补。
- Is 与 As 解决的问题不同:
errors.Is判断链中是否存在约定等价的目标值,errors.As/AsType判断并提取可赋给某个类型的错误。即使暂时不读取字段,按类型分支也应使用 As;不要为了改用 Is 临时创建一个指针 target。 - 链上多个同类型错误:返回第一个匹配,若需要全部,用
errors.Join+ 手动遍历。 - 包装第三方错误:如果你的库包装了
net.OpError之类,要确认As仍能提取,必要时实现As方法做转发。
errors.AsType
Go 1.26 的 errors.AsType[E error] 用类型参数返回匹配错误,避免 errors.As 常见的双指针样板:
queryError, ok := errors.AsType[*QueryError](err)
if ok {
fmt.Println(queryError.SQL)
}
它沿用 errors.As 的遍历和自定义 As 语义。需要兼容 Go 1.25 及更早版本的库仍应使用 errors.As;不要仅为语法简化提高模块最低 Go 版本。
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能减少...any参数构造及部分分配,适合在测量后用于热路径;最终是否分配还取决于属性、Handler 和输出目标,标准库不承诺零分配。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),避免磁盘被日志写满。 - 错误码统一:定义错误码常量并随日志输出,方便在监控平台按错误码聚合。可参考第28章 可观测性与安全进一步讨论 metrics、tracing、logging 的分工。
本章小结
Go 的错误处理哲学是“错误是值“:通过显式返回、逐层处理、按需包装,让控制流清晰可控。本章关键点:
error是一个单方法接口,nil 接口陷阱源于“接口装入了 nil 具体值“,规避方法是直接返回 nil 字面量。errors.Is用于比较约定等价的 target,errors.As用于匹配类型;Go 1.26 的errors.AsType提供类型安全的返回形式。errors.Join(Go 1.20+)标准化了多错误合并语义,适合校验、并发收集场景。- 包装用
fmt.Errorf("%w", err),每层加有意义上下文;边界层做错误转换避免泄漏内部细节。 - 日志用
log/slog做结构化输出,遵循级别纪律,只在错误最终处理处记录,并通过 context 关联 traceID。
掌握这些,你就能写出“可比较、可追踪、可观测“的 Go 错误处理代码,为后续的 第24章 性能优化 与 第25章 设计模式 打下坚实基础。