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.Newfmt.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 文案