Go 的错误处理没有语言级机制介入:没有异常,没有堆栈自动展开,error 就是实现了 Error() string 接口的普通值,随函数返回值逐层传递。这一设计使控制流显式可见,代价是"如何处理错误"完全依赖工程约定。
约定缺失时,代码中会普遍出现三类问题:用 data, _ := 静默丢弃错误;在每一层 log.Printf 之后再 return,同一条错误被重复记录多遍;以 err == sql.ErrNoRows 判断,中间经过一次包装后永远无法匹配。重复记录的典型后果,是聚合系统将同一条错误拆成数百个独立分组。下文按错误的生命周期分述规范——创建、传播、终结处理,每条规范均针对生产环境中的典型故障模式。
error 必须检查
Rob Pike 的 "Errors are values." 落到实处,是一条 lint 工具必查的规矩:失败时把 error 作为最后一个返回值返回,调用方必须先检查 error,再使用其他返回值。
// ❌ 静默丢弃:文件不存在时 data 为空,后续逻辑全部失真
data, _ := os.ReadFile("config.yaml")
// ❌ 使用了其他返回值却未检查 err
result, err := doSomething()
fmt.Println(result)
// ✅ 先判断 err,再使用其他返回值
data, err := os.ReadFile("config.yaml")
if err != nil {
return fmt.Errorf("reading config: %w", err)
}
data, _ := 是高频错误处理陷阱:配置文件被读成空数据后,程序带着空数据继续执行,问题到下游才暴露,排查成本高。若确有意忽略某个错误——例如某些 Close() 的返回值——不应直接写作 _,而应写成 if err := c.Close(); err != nil { /* 注释说明为何可以忽略 */ },使"忽略"成为代码评审中可见的显式决定。
创建错误
**错误消息小写开头、不带结尾标点、不重复调用方已经知道的上下文。**错误会被逐层包装,每层添加一个前缀,最终拼成一条链;大写开头或句号结尾的消息嵌入错误链后,整条链格式不一致。
// ❌ 大写开头、带标点、重复了包名上下文
return errors.New("Failed to connect to database.")
return fmt.Errorf("UserService: failed to fetch user: %w", err)
// ✅ 小写、简洁,只说本层发生了什么
return errors.New("connection refused")
return fmt.Errorf("fetching user: %w", err)
包装到位后,错误链本身即呈现一条调用路径:creating order: charging card: connecting to payment gateway: connection refused,从根因到业务动作完整可见。
错误创建方式的选择取决于调用方的需求,不应一律使用 fmt.Errorf。下表可直接作为团队规范:
| 场景 | 手段 | 示例 |
|---|---|---|
| 调用方需要匹配某个特定条件 | 哨兵错误(errors.New 包级变量) | var ErrNotFound = errors.New("not found") |
| 调用方需要取出结构化数据 | 自定义错误类型 | type ValidationError struct { Field, Msg string } |
| 纯粹的信息记录,无需匹配 | fmt.Errorf / errors.New | fmt.Errorf("connecting to %s: %w", addr, err) |
| 需要堆栈、用户上下文、结构化属性 | samber/oops | 见后文进阶一节 |
自定义类型持有底层错误时,必须实现 Unwrap(),否则 errors.Is/errors.As 会在此处断链:
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string {
return fmt.Sprintf("query %q: %v", e.Query, e.Err)
}
func (e *QueryError) Unwrap() error { return e.Err }
哨兵错误必须定义为包级变量。errors.Is 沿错误链比较的是实例同一性:调用方引用的 ErrNotFound 与实现方返回的必须是同一个对象。定义在函数体内部的 errors.New 每次执行都产生新值,调用方以包级变量比较时永远不匹配。哨兵错误的命名(ErrNotFound)属于命名规范,细节见 命名与风格规范。
包装与判断
中间层返回错误前,应使用 fmt.Errorf("上下文: %w", err) 添加一层上下文。%w 将原始错误纳入错误链,上层仍能用 errors.Is/errors.As 判断根因;%v 只做字符串拼接,错误链就此中断。
%w 与 %v 的选择存在刻意的设计考量:**模块内部用 %w 建链,公共 API 边界用 %v 切断。**内部保留错误链是为了排查方便;对外切断链路,是防止调用方依赖内部错误类型,把实现细节泄漏成公共契约。
// 内部层:保留错误链
func (r *repo) fetch(id string) error {
return fmt.Errorf("querying database: %w", err)
}
// 公共 API 边界:切断链路,隐藏内部实现
func (s *PublicService) GetItem(id string) error {
err := s.repo.fetch(id)
if err != nil {
return fmt.Errorf("item unavailable: %v", err) // %v:调用方无法 unwrap
}
return nil
}
包装应适度:在每个跨包边界添加一次上下文即可,不应在每个函数中机械包装。同类前缀层层叠加只会稀释错误链,使真正有用的那层信息难以定位——这与"不重复调用方已知上下文"是同一原则。
判断哨兵错误应使用 errors.Is,判断错误类型应使用 errors.As;不应使用 == 和裸类型断言,二者只能匹配最外层错误,中间任何一层包装都会使其失效。
// ❌ 包装后恒为 false
if err == sql.ErrNoRows { ... }
if ve, ok := err.(*ValidationError); ok { ... }
// ✅ 沿整条错误链查找
if errors.Is(err, sql.ErrNoRows) { ... }
var ve *ValidationError
if errors.As(err, &ve) {
log.Printf("validation failed on field %s: %s", ve.Field, ve.Msg)
}
// ✅ Go 1.26+ 更简洁:类型安全,且无需先声明变量
if ve, ok := errors.AsType[*ValidationError](err); ok { ... }
多个相互独立的失败,应使用 errors.Join(Go 1.20+)合并,而不是只报第一个。表单校验是典型场景——调用方需要一次获得所有字段的问题;批量关闭资源同理:
func validateUser(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, errors.New("name is required"))
}
if u.Email == "" {
errs = append(errs, errors.New("email is required"))
}
return errors.Join(errs...) // errs 为空时返回 nil
}
err := errors.Join(ErrNotFound, ErrUnauthorized)
errors.Is(err, ErrNotFound) // true:Join 后 Is/As 依然可用
错误消息里的参数
中间层的消息应带上定位问题的关键参数——哪个用户、哪个查询、哪个文件,fmt.Errorf("getting user %s: %w", id, err) 是典型写法。但在日志/APM 边界,这条规则相反:**消息模板必须稳定,变量数据放进结构化属性。**Datadog、Loki、Sentry 这类聚合工具按消息模板分组;变量直接拼入消息时,每个用户、每个租户都会生成一个新分组,聚合结果失去分析价值。
// ❌ 在日志边界:每个 用户/租户 组合产生一个独立分组
fmt.Errorf("error in %s at line %d of the csv", csvPath, line)
// ✅ 标准库方案:静态消息,变量数据在记录日志时以结构化属性附带
slog.Error("csv parsing failed", "error", err,
"csv_file_path", csvPath, "csv_file_line", line)
// ✅ samber/oops 方案:属性直接绑定在错误上,随错误一起传播
oops.With("csv_file_path", csvPath).With("csv_file_line", line).Errorf("csv parsing error")
静态前缀(如 "fetching user: %w")基数固定,可安全使用。标准库方案的不足是上下文分散:错误逐层向上传播后,真正记录日志的层级可能已无法获取 csvPath 和 line;oops 将属性绑定在错误上,在任何层级记录都保持完整。错误日志的级别、采样、HTTP 中间件,见本系列 可观测性规范。
错误终结在哪一层
一个错误要么记日志,要么向上返回,不得两者同时进行。"先记录再返回"是重复日志的主要来源,聚合工具中同一条错误重复出现十余次,多源于此。
// ❌ 又 log 又 return,调用方处理时必然再记一次
func processOrder(id string) error {
err := chargeCard(id)
if err != nil {
log.Printf("failed to charge card: %v", err)
return fmt.Errorf("charging card: %w", err)
}
return nil
}
// ✅ 中间层只负责包装上下文,是否记录日志留给顶层
func processOrder(id string) error {
err := chargeCard(id)
if err != nil {
return fmt.Errorf("charging card: %w", err)
}
return nil
}
日志统一记录在流程终点:HTTP handler、main、worker 循环等顶层位置。在边界层,一个错误的处理应同时完成三件事——记录日志、转换为面向用户的安全文案、返回恰当的 HTTP 状态码。内部技术细节不应直接暴露给用户:
func handleOrder(w http.ResponseWriter, r *http.Request) {
err := processOrder(r.FormValue("id"))
if err != nil {
slog.Error("order failed", "error", err) // 技术细节进日志
http.Error(w, "internal error", http.StatusInternalServerError) // 用户只见通用文案
return
}
w.WriteHeader(http.StatusOK)
}
panic 不是错误处理
**panic 只留给程序性错误:不可能的条件、被破坏的不变量、初始化期的致命配置。**网络超时、查不到记录这类预期内的失败必须以 error 返回——panic 会使调用方失去常规处理该失败的机会。
// ✅ 可接受:初始化期的程序员错误,快速失败是合理选择
func MustCompileRegex(pattern string) *regexp.Regexp {
re, err := regexp.Compile(pattern)
if err != nil {
panic(fmt.Sprintf("invalid regex %q: %v", pattern, err))
}
return re
}
// ❌ 查不到用户是预期内的失败,不应 panic
func GetUser(id string) *User {
user, err := db.Find(id)
if err != nil {
panic(err)
}
return user
}
每个 goroutine 边界(HTTP handler、worker goroutine)都应使用 defer + recover 作为最终防线,避免单个 panic 终止整个进程;恢复时应把 panic 值和堆栈一起记入日志:
func safeHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if p := recover(); p != nil {
slog.Error("panic recovered",
"panic", p,
"stack", string(debug.Stack()),
)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
进阶:samber/oops
标准 error 传播到生产环境时,信息常常只剩一句 "connection refused":缺少来源位置、受影响的用户与责任归属。samber/oops 在标准 error 之上补充四项能力:自动捕获堆栈、键值对上下文随错误传播、机器可读的错误码、面向用户的安全文案(Public)与技术细节分离。所有 oops 错误都实现标准 error 接口,errors.Is/errors.As 照常可用,可作为 drop-in 替代。
它采用链式 builder 风格,变量数据一律放入 .With(),保持消息模板稳定。两个实用细节:Wrapf 在传入 err 为 nil 时直接返回 nil,无需额外的 nil 判断;Recover 接收 *error,在 defer 中把 panic 转成结构化错误。各架构层用 Wrap/Wrapf 各添加一次上下文即可,不必逐函数嵌套:
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) error {
err := s.db.Insert(ctx, req.Order)
if err != nil {
return oops.
In("order-service"). // 领域/服务归属
Code("order_insert_failed"). // 机器可读错误码
User(req.UserID). // 用户上下文
With("order_id", req.Order.ID). // 自定义属性(低基数消息)
Wrapf(err, "inserting order") // 包装语义等价于 fmt.Errorf + %w
}
return nil
}
最终记录这条错误时,堆栈、user ID、order ID、错误码、完整错误链全部结构化输出,可直接对接 slog/zerolog/logrus;HTTP 边界用 oops.GetPublic(err, "fallback") 取出面向用户的文案。团队从字符串错误升级到结构化错误时,oops 是目前社区完成度较高的选择;samber 组织的其他工具见 函数式工具箱。
速查清单
- 必须先检查 err 再使用其他返回值;不用
_静默丢弃错误,确需忽略时注明原因 - 消息小写开头、不带结尾标点、不重复调用方已知信息
- 需要匹配 → 哨兵错误(包级变量);需要携带数据 → 自定义类型(必须实现
Unwrap);纯记录 →fmt.Errorf - 中间层以
fmt.Errorf("上下文: %w", err)包装;模块内部用%w,公共 API 边界用%v - 判断使用
errors.Is/errors.As(Go 1.26+ 可用errors.AsType),禁用==与裸类型断言 - 多个独立失败使用
errors.Join,Join 后Is/As仍可穿透 - 中间层消息带关键参数;日志/APM 边界消息模板稳定,变量数据进结构化属性
- 一个错误要么记录日志要么向上返回,不得同时进行;日志记录在顶层(handler/main/worker)
- panic 仅用于程序性错误;goroutine 边界以 defer+recover 作为最终防线,记录 panic 值与堆栈
- 生产服务推荐采用 samber/oops:堆栈、错误码、属性、Public 文案