跳转到正文
zeno's blog
返回

Go 基础:错误处理与 Errors Are Values

Table of contents

Open Table of contents

TL;DR

Go 将错误视为普通值(error 是一个只含 Error() string 方法的 interface),通过多返回值强制调用者显式处理,而非像 Java/Python 那样用异常机制隐式传播。Go 1.13 引入 error wrapping(%werrors.Iserrors.As),Go 1.20 引入 errors.Join 支持多错误合并。这套机制的核心优势是:控制流可见、错误可编程、无隐式传播。代价是 if err != nil 样板代码多,但 Go 团队已于 2025 年 6 月正式宣布不再追求语法层面的错误处理变更


1. What and Why:Go 的错误处理哲学

”Errors Are Values”

2015 年,Rob Pike 在 Errors are values 博文中提出核心论点:

Errors are values. Values can be programmed, and since errors are values, errors can be programmed.

这意味着 error 不是特殊的控制流构造,而是可以用 Go 的所有常规工具(变量、方法、接口、闭包)来编程的普通值。Pike 用 bufio.ScannererrWriter 模式展示了如何通过抽象消除重复的 if err != nil,而不需要语言层面的特殊语法。

为什么不用 try/catch(异常机制)

Go 团队认为异常机制有几个根本问题:

问题异常机制(Java/Python)Go errors
控制流可见性任何函数调用都可能抛异常,调用者无法从签名看出来(unchecked exceptions)函数签名明确返回 error,不处理就编译不过(unused variable)
隐式传播异常沿调用栈自动传播,中间层可能完全不知道必须显式 return err,每一层都必须做出决定
异常 vs 错误的边界模糊。网络超时是 exception 还是正常情况?清晰。所有预期的失败场景都是 error 值
性能throw/catch 涉及栈展开,代价高error 是普通值,零额外开销
可组合性难以把异常当数据来操作error 可以被累积、转换、存储、传递

不用它会怎样

如果 Go 采用异常机制:

来源:Error handling and GoGo’s Error Handling: Why Explicit Beats Exceptions


2. 核心概念与底层机制

2.1 error interface 的设计

// Go 标准库定义(builtin/builtin.go)
type error interface {
    Error() string
}

为什么只有一个方法?

2.2 值语义 vs 异常语义

Go (值语义):
  result, err := doSomething()
  if err != nil {
      // 你必须在这里决定怎么处理
  }
  // 程序继续

Java (异常语义):
  try {
      Result result = doSomething(); // 可能抛异常,也可能不抛
  } catch (SomeException e) {
      // 可能在几十层调用之上才 catch
  }

关键区别:Go 的错误处理发生在产生错误的那一行,Java 的异常处理可以发生在调用链的任意位置。这不仅是语法差异,是根本不同的心智模型。

2.3 Error Wrapping 链式结构(Go 1.13+)

Go 1.13 引入了 error wrapping 的标准化支持:

原始 error: ErrNotFound
    ↑ Unwrap()
包装层 1: "query user: not found"          (fmt.Errorf("query user: %w", ErrNotFound))
    ↑ Unwrap()
包装层 2: "handle request: query user: not found"  (fmt.Errorf("handle request: %w", err))

核心接口

// 单 error 包装(Go 1.13+)
interface {
    Unwrap() error
}

// 多 error 包装(Go 1.20+)
interface {
    Unwrap() []error
}

%w 动词创建的 error 实现 Unwrap() error,返回被包装的原始 error。errors.Iserrors.As 会递归调用 Unwrap() 沿链向下查找。

2.4 Sentinel Errors vs Custom Error Types vs Opaque Errors

类型定义方式匹配方式适用场景缺点
Sentinel Errorvar ErrNotFound = errors.New("not found")errors.Is(err, ErrNotFound)特定的、可预期的错误条件成为 public API 的一部分,难以修改
Custom Error Typetype *NotFoundError struct{...}errors.As(err, &target)需要携带结构化信息(ID、字段名等)类型也成为 public API
Opaque Errorfmt.Errorf("something failed: %w", err)调用者不关心具体类型,只看 err != nil内部实现细节,不想暴露给调用者调用者无法做精细处理

Dave Cheney 的经验法则(来源:Don’t just check errors, handle them gracefully):

2.5 errors.Iserrors.As 的匹配机制

errors.Is(err, target):沿 Unwrap 链检查是否有 error 等于 target。

匹配逻辑(伪代码):
for err != nil {
    if err == target || err 实现了 Is(target) 且返回 true {
        return true
    }
    if err 实现 Unwrap() []error {  // Go 1.20+: 树形遍历(深度优先前序)
        对每个子 error 递归检查
    } else if err 实现 Unwrap() error {
        err = err.Unwrap()
    } else {
        return false
    }
}

errors.As(err, &target):沿 Unwrap 链检查是否有 error 的类型可以赋值给 target。

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    // pathErr 已被赋值,可以访问 pathErr.Path, pathErr.Op 等字段
}

重要细节:自定义类型可以实现 Is(error) boolAs(any) bool 方法来自定义匹配逻辑。

2.6 errors.Join(Go 1.20+)

errors.Join 将多个 error 合并为一个。返回的 error 实现 Unwrap() []error

err := errors.Join(err1, err2, err3)
// err.Error() => "err1 msg\nerr2 msg\nerr3 msg"(换行分隔)
// errors.Is(err, err1) => true
// errors.Is(err, err2) => true

内部实现

type joinError struct {
    errs []error
}

func (e *joinError) Error() string {
    // 用 \n 连接所有 error 的 Error() 输出
}

func (e *joinError) Unwrap() []error {
    return e.errs
}

注意errors.Unwrap()joinError 返回 nil(因为它实现的是 Unwrap() []error 而非 Unwrap() error)。要获取子错误列表,需要类型断言:

if uw, ok := err.(interface{ Unwrap() []error }); ok {
    for _, e := range uw.Unwrap() {
        // 处理每个子错误
    }
}

来源:errors: add support for wrapping multiple errors

2.7 panic/recover 的定位和使用边界

panic 不是错误处理机制,它是程序 bug 的信号

errorpanic
语义可预期的失败(文件不存在、网络超时)不可恢复的 bug(nil 解引用、数组越界、不可能发生的状态)
控制流显式返回,调用者决定沿调用栈展开,终止 goroutine
跨 goroutine通过返回值或 channel 传递不跨 goroutine(goroutine 中未 recover 的 panic 直接 crash 进程)
使用场景业务逻辑的所有错误初始化失败(MustCompile)、不变式被打破

recover 的使用边界


3. 核心 API 与数据结构

3.1 API 速查表

函数/方法引入版本用途
errors.New(text)Go 1.0创建简单 error
fmt.Errorf("...: %w", err)Go 1.13创建包装了另一个 error 的新 error
errors.Unwrap(err)Go 1.13获取被包装的 error(仅限单 error 包装)
errors.Is(err, target)Go 1.13沿 Unwrap 链匹配 error 值
errors.As(err, &target)Go 1.13沿 Unwrap 链匹配 error 类型
errors.Join(errs...)Go 1.20合并多个 error 为一个

3.2 自定义 Error Type 最佳实践

一个完整的自定义 error type 可以实现以下方法:

// Go 1.21+

type AppError struct {
    Code    string // 机器可读的错误码,如 "USER_NOT_FOUND"
    Message string // 人类可读的描述
    Err     error  // 被包装的底层 error
}

// 必须实现:满足 error interface
func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

// 实现 Unwrap:让 errors.Is/errors.As 能穿透到底层 error
func (e *AppError) Unwrap() error {
    return e.Err
}

// 可选:自定义 Is 逻辑(按 Code 匹配而非指针相等)
func (e *AppError) Is(target error) bool {
    t, ok := target.(*AppError)
    if !ok {
        return false
    }
    return e.Code == t.Code
}

4. 典型使用场景对比

场景推荐方式示例理由
已知的、特定的错误条件
(如 “未找到”、“已存在”)
Sentinel Errorvar ErrNotFound = errors.New("not found")调用者需要用 errors.Is 精确匹配
需要携带结构化信息
(如 HTTP 状态码、字段名)
Custom Error Typetype ValidationError struct{Field, Reason string}调用者需要用 errors.As 提取信息
给 error 添加上下文
(跨层传递时标注来源)
fmt.Errorf + %wfmt.Errorf("query user %d: %w", id, err)保留底层 error 的可检查性,添加调试信息
不想暴露内部实现fmt.Errorf + %v(不 wrap)fmt.Errorf("operation failed: %v", err)切断 Unwrap 链,调用者只知道失败了
batch 操作中多个步骤可能各自失败errors.Joinerr = errors.Join(err, step1(), step2())聚合所有错误,不丢失任何一个
真正不可能发生的状态panicpanic("unreachable: switch default")表示程序 bug,不是业务错误
goroutine 边界保护recoverHTTP middleware、worker wrapper防止单个 goroutine 的 panic 崩溃整个进程

5. 与相关技术的对比

维度Go errorJava ExceptionsRust Result<T, E>Node.js error-first callback
错误表示error interface(值)异常对象(类层次结构)Result<T, E> enum(值)callback(err, data)
传播方式显式 return err隐式栈展开(throw)显式 ? 操作符 / return Err(e)手动传递 err 参数
编译期强制部分(unused variable 报错,但可以 _ = errchecked exceptions 强制(但大多数用 unchecked 绕过)完全强制Result 必须被处理,#[must_use]
类型安全弱(error 是 interface,需要类型断言)强(异常类型层次结构)(泛型 E 类型参数)无(err 是 any)
零值问题nil 表示无错误不抛异常表示成功Ok(T) 表示成功null/undefined 表示无错误
多错误errors.Join(Go 1.20+)suppressed exceptions(不常用)自行实现或 anyhow::Error不内置
性能开销几乎为零(值传递)高(栈展开 + 栈帧捕获)几乎为零(值传递)几乎为零
常见批评if err != nil 样板多异常类型泛滥、checked exception 争议学习曲线陡峭(生命周期 + 泛型)回调地狱(已被 async/await 取代)
调试体验每个错误点都可以打断点需要设置 exception breakpoint每个 ? 都可以打断点每个 callback 都可以打断点

关键洞察


6. 可运行代码示例(Go 1.21+)

6.1 基础错误创建和返回

package main

import (
    "errors"
    "fmt"
    "os"
)

// sentinel error:包级别变量,首字母大写导出
var ErrInvalidAge = errors.New("invalid age")

func validateAge(age int) error {
    if age < 0 || age > 150 {
        return fmt.Errorf("age %d: %w", age, ErrInvalidAge)
    }
    return nil
}

func main() {
    if err := validateAge(-1); err != nil {
        fmt.Println(err) // age -1: invalid age
        if errors.Is(err, ErrInvalidAge) {
            fmt.Println("-> 是年龄校验错误")
        }
    }

    // 标准库的 error 使用示例
    _, err := os.Open("/nonexistent")
    if err != nil {
        fmt.Println(err) // open /nonexistent: no such file or directory
    }
}

6.2 Error Wrapping 链

package main

import (
    "errors"
    "fmt"
)

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

func findInDB(id int) error {
    return fmt.Errorf("record %d: %w", id, ErrNotFound) // 第一层包装
}

func queryUser(id int) error {
    err := findInDB(id)
    if err != nil {
        return fmt.Errorf("query user: %w", err) // 第二层包装
    }
    return nil
}

func handleRequest(userID int) error {
    err := queryUser(userID)
    if err != nil {
        return fmt.Errorf("handle request: %w", err) // 第三层包装
    }
    return nil
}

func main() {
    err := handleRequest(42)
    fmt.Println(err)
    // handle request: query user: record 42: not found

    // errors.Is 穿透整条链
    fmt.Println(errors.Is(err, ErrNotFound)) // true

    // errors.Unwrap 逐层剥离
    fmt.Println(errors.Unwrap(err))
    // query user: record 42: not found
}

6.3 自定义 Error Type 带结构化信息

package main

import (
    "errors"
    "fmt"
)

// ValidationError 携带结构化信息
type ValidationError struct {
    Field   string
    Value   any
    Reason  string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed: field %q value %v: %s", e.Field, e.Value, e.Reason)
}

// NotFoundError 支持 Unwrap
type NotFoundError struct {
    Resource string
    ID       string
    Err      error // 底层错误(如数据库错误)
}

func (e *NotFoundError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("%s %q not found: %v", e.Resource, e.ID, e.Err)
    }
    return fmt.Sprintf("%s %q not found", e.Resource, e.ID)
}

func (e *NotFoundError) Unwrap() error {
    return e.Err
}

func getUser(id string) error {
    // 模拟数据库返回的底层 error
    dbErr := fmt.Errorf("sql: no rows in result set")
    return &NotFoundError{
        Resource: "user",
        ID:       id,
        Err:      dbErr,
    }
}

func main() {
    err := getUser("abc-123")

    // 用 errors.As 提取结构化信息
    var nfErr *NotFoundError
    if errors.As(err, &nfErr) {
        fmt.Printf("资源: %s, ID: %s\n", nfErr.Resource, nfErr.ID)
        // 资源: user, ID: abc-123
    }

    // ValidationError 示例
    vErr := &ValidationError{Field: "email", Value: "not-an-email", Reason: "invalid format"}
    fmt.Println(vErr)
    // validation failed: field "email" value not-an-email: invalid format
}

6.4 errors.Is / errors.As 实战

package main

import (
    "errors"
    "fmt"
    "io/fs"
    "os"
)

func main() {
    // === errors.Is:匹配特定的 error 值 ===
    _, err := os.Open("/nonexistent/file.txt")

    // 检查是否是 "不存在" 错误(穿透 *os.PathError 的 Unwrap 链)
    if errors.Is(err, fs.ErrNotExist) {
        fmt.Println("文件不存在") // ✓
    }

    // 即使被多层包装,errors.Is 依然能穿透
    wrapped := fmt.Errorf("loading config: %w", err)
    fmt.Println(errors.Is(wrapped, fs.ErrNotExist)) // true

    // === errors.As:提取特定类型的 error ===
    var pathErr *fs.PathError
    if errors.As(err, &pathErr) {
        fmt.Printf("操作: %s, 路径: %s\n", pathErr.Op, pathErr.Path)
        // 操作: open, 路径: /nonexistent/file.txt
    }

    // === 自定义 Is 方法 ===
    type CodeError struct {
        Code int
        Msg  string
    }
    // 自定义匹配逻辑:只比较 Code
    errA := &CodeError{Code: 404, Msg: "user not found"}
    errB := &CodeError{Code: 404, Msg: "order not found"}
    // 默认情况下 errA != errB(不同指针)
    // 如果实现了 Is 方法按 Code 匹配,就可以 errors.Is(errA, errB) == true
}

6.5 errors.Join 多错误聚合

package main

import (
    "errors"
    "fmt"
)

var (
    ErrPermission = errors.New("permission denied")
    ErrTimeout    = errors.New("timeout")
)

// cleanup 模拟多步清理,每步都可能失败
func cleanup() error {
    var errs []error

    if err := closeDB(); err != nil {
        errs = append(errs, fmt.Errorf("close db: %w", err))
    }
    if err := closeCache(); err != nil {
        errs = append(errs, fmt.Errorf("close cache: %w", err))
    }
    if err := flushLogs(); err != nil {
        errs = append(errs, fmt.Errorf("flush logs: %w", err))
    }

    return errors.Join(errs...) // nil errs 会被自动过滤;全部 nil 则返回 nil
}

func closeDB() error    { return ErrPermission }
func closeCache() error { return ErrTimeout }
func flushLogs() error  { return nil }

func main() {
    err := cleanup()
    if err != nil {
        fmt.Println("cleanup 错误:")
        fmt.Println(err)
        // close db: permission denied
        // close cache: timeout
    }

    // errors.Is 能穿透 Join 找到每个子错误
    fmt.Println(errors.Is(err, ErrPermission)) // true
    fmt.Println(errors.Is(err, ErrTimeout))    // true

    // 提取子错误列表
    if uw, ok := err.(interface{ Unwrap() []error }); ok {
        for i, e := range uw.Unwrap() {
            fmt.Printf("  子错误 %d: %v\n", i, e)
        }
    }
}

6.6 panic/recover 的正确使用(HTTP Handler 恢复)

package main

import (
    "fmt"
    "log"
    "net/http"
    "runtime/debug"
)

// RecoveryMiddleware 捕获 handler 中的 panic,防止进程崩溃
func RecoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                // 记录完整堆栈,便于定位 bug
                log.Printf("PANIC recovered: %v\n%s", rec, debug.Stack())
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// SafeGo 封装 goroutine 创建,确保 panic 被 recover
func SafeGo(fn func()) {
    go func() {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("goroutine PANIC: %v\n%s", rec, debug.Stack())
            }
        }()
        fn()
    }()
}

func riskyHandler(w http.ResponseWriter, r *http.Request) {
    // 模拟一个 bug
    var m map[string]string
    _ = m["key"] // panic: assignment to entry in nil map — 不会,这只是读取返回零值
    // 换一个真的 panic
    panic("unexpected state: this should never happen")
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/risky", riskyHandler)

    server := &http.Server{
        Addr:    ":8080",
        Handler: RecoveryMiddleware(mux),
    }

    fmt.Println("listening on :8080")
    log.Fatal(server.ListenAndServe())
}

6.7 分层架构中的错误处理实践

package main

import (
    "errors"
    "fmt"
    "log/slog"
    "net/http"
)

// ============ 领域层:定义业务错误 ============

type AppError struct {
    Code       string // 机器可读:"USER_NOT_FOUND", "VALIDATION_FAILED"
    Message    string // 用户可见的消息
    HTTPStatus int    // HTTP 状态码映射
    Err        error  // 底层 error(内部使用,不暴露给客户端)
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

func (e *AppError) Unwrap() error { return e.Err }

func (e *AppError) Is(target error) bool {
    t, ok := target.(*AppError)
    if !ok {
        return false
    }
    return e.Code == t.Code
}

// 预定义错误(sentinel 风格,按 Code 匹配)
var (
    ErrUserNotFound = &AppError{Code: "USER_NOT_FOUND", HTTPStatus: 404}
    ErrValidation   = &AppError{Code: "VALIDATION_FAILED", HTTPStatus: 400}
    ErrInternal     = &AppError{Code: "INTERNAL_ERROR", HTTPStatus: 500}
)

// ============ Repository 层 ============

func findUserByID(id int) (string, error) {
    // 模拟数据库查询
    if id <= 0 {
        return "", fmt.Errorf("invalid user id %d", id)
    }
    if id == 999 {
        return "", &AppError{
            Code:       ErrUserNotFound.Code,
            Message:    fmt.Sprintf("user %d does not exist", id),
            HTTPStatus: ErrUserNotFound.HTTPStatus,
            Err:        nil, // 这里没有底层错误
        }
    }
    return "Alice", nil
}

// ============ Service 层 ============

func getUserName(id int) (string, error) {
    name, err := findUserByID(id)
    if err != nil {
        // 如果已经是 AppError,直接传递(不重复包装)
        var appErr *AppError
        if errors.As(err, &appErr) {
            return "", err
        }
        // 非 AppError 的错误 → 包装为 Internal Error
        return "", &AppError{
            Code:       ErrInternal.Code,
            Message:    "failed to get user",
            HTTPStatus: ErrInternal.HTTPStatus,
            Err:        err,
        }
    }
    return name, nil
}

// ============ Handler 层(HTTP 边界)============

func handleGetUser(w http.ResponseWriter, r *http.Request) {
    name, err := getUserName(999)
    if err != nil {
        // 在 HTTP 边界统一处理错误
        var appErr *AppError
        if errors.As(err, &appErr) {
            // 日志记录完整 error(包含 Unwrap 链),响应只返回安全信息
            slog.Error("request failed",
                "code", appErr.Code,
                "err", appErr.Error(),
                "path", r.URL.Path,
            )
            http.Error(w, fmt.Sprintf(`{"code":"%s","message":"%s"}`, appErr.Code, appErr.Message),
                appErr.HTTPStatus)
            return
        }
        // 未知错误,500
        slog.Error("unexpected error", "err", err, "path", r.URL.Path)
        http.Error(w, `{"code":"INTERNAL_ERROR","message":"unexpected error"}`, 500)
        return
    }
    fmt.Fprintf(w, `{"name":"%s"}`, name)
}

func main() {
    http.HandleFunc("/user", handleGetUser)
    fmt.Println("listening on :8080")
    http.ListenAndServe(":8080", nil)
}

/*
关键原则:
1. Repository 层:翻译数据库错误为领域错误,不返回 sql.ErrNoRows 等基础设施细节
2. Service 层:编排业务逻辑,非 AppError 包装为 Internal Error
3. Handler 层:
   - 这里(且仅在这里)记日志
   - 把 AppError 映射为 HTTP 响应
   - 内部 error 细节不暴露给客户端
*/

7. 常见陷阱(Pitfalls)

陷阱 1:用 == 比较 error 而不是 errors.Is

// ❌ 错误
if err == sql.ErrNoRows {
    // 如果 err 被 fmt.Errorf("%w", ...) 包装过,这里永远不会进入
}

// ✅ 正确
if errors.Is(err, sql.ErrNoRows) {
    // 穿透 Unwrap 链匹配
}

为什么是坑:Go 1.13 之前 == 是唯一方式,大量旧代码和教程仍在用。一旦中间层开始 wrap error,所有 == 比较都会失效。

陷阱 2:Wrap 了 error 但丢失了上下文

// ❌ 没有上下文
return fmt.Errorf("failed: %w", err)

// ❌ 用 %v 而非 %w,切断了 Unwrap 链
return fmt.Errorf("query user %d: %v", id, err)

// ✅ 有上下文 + 保持 Unwrap 链
return fmt.Errorf("query user %d: %w", id, err)

为什么是坑%v 只保留 error 的字符串表示,调用者无法用 errors.Is/errors.As 匹配原始 error。除非你有意切断链(不想暴露内部实现),否则应该用 %w

陷阱 3:panic 被滥用为错误处理

// ❌ 把 panic 当 throw 用
func getUser(id int) *User {
    user, err := db.Find(id)
    if err != nil {
        panic(err) // 调用者根本不知道这里会 panic
    }
    return user
}

// ✅ 正确做法
func getUser(id int) (*User, error) {
    user, err := db.Find(id)
    if err != nil {
        return nil, fmt.Errorf("get user %d: %w", id, err)
    }
    return user, nil
}

为什么是坑:panic 会绕过所有正常的错误处理逻辑,让调用者无法优雅地处理失败。唯一的”接口契约”就是函数签名——如果签名不返回 error,调用者有理由假设它不会失败。

陷阱 4:忽略 error(_ = doSomething()

// ❌ 静默吞掉错误
_ = file.Close()
_ = db.Ping()

// ✅ 至少记日志
if err := file.Close(); err != nil {
    slog.Warn("failed to close file", "err", err)
}

为什么是坑file.Close() 在写入场景下可能触发 flush 失败,意味着数据没有真正持久化。_ = 是”我知道有 error 但我选择忽略”的显式声明——如果你真要这么做,至少加注释说明理由。

陷阱 5:Error message 重复前缀

// ❌ 每一层都加 "failed to"
// 最终: "failed to handle request: failed to query user: failed to find record: not found"
return fmt.Errorf("failed to handle request: %w", err)

// ✅ 只说做了什么,不加 "failed to"
// 最终: "handle request: query user: find record: not found"
return fmt.Errorf("handle request: %w", err)

为什么是坑:error 已经表示失败了(它是 error 类型!)。每层加 “failed to” 纯属冗余,还让 error message 又长又难读。Go 惯例是只描述正在做什么(动词 + 宾语),不描述”失败了”。

来源:Go Code Review Comments 建议 error string 不要大写、不要加标点、不要加 “failed to” 前缀

陷阱 6:在 goroutine 中 panic 但没有 recover

// ❌ 致命:这个 goroutine 的 panic 会杀死整个进程
go func() {
    result := riskyOperation() // 如果这里 panic...
    ch <- result
}()
// 主 goroutine 的 recover 捕获不到子 goroutine 的 panic

// ✅ 每个 goroutine 都要有自己的 recover
go func() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("goroutine panic: %v\n%s", r, debug.Stack())
        }
    }()
    result := riskyOperation()
    ch <- result
}()

为什么是坑:Go 的 recover 只能捕获当前 goroutine 的 panic。net/http 的内置 recovery 只保护 handler 所在的 goroutine,handler 里 go func() 新起的 goroutine 不受保护。生产环境中,一个未 recover 的 goroutine panic 会导致整个进程崩溃

陷阱 7:对 nil interface 和 nil 值的 error 混淆

// ❌ 经典陷阱
func doSomething() error {
    var p *MyError // p 是 nil 的 *MyError 指针
    // ... 一些逻辑,p 没被赋值 ...
    return p // 返回一个"non-nil interface holding a nil pointer"!
}

func main() {
    err := doSomething()
    fmt.Println(err == nil) // false!因为 interface 的 type 字段是 *MyError
}

// ✅ 正确做法
func doSomething() error {
    // ... 一些逻辑 ...
    return nil // 显式返回 nil,不要返回类型化的 nil 指针
}

为什么是坑:Go 的 interface 由 (type, value) 两部分组成。(*MyError)(nil) 赋给 error interface 后,type 部分是 *MyError(非 nil),所以 err != nil 为 true。这可能是 Go 中最隐蔽的陷阱之一。

陷阱 8:在错误的层记日志

// ❌ 每层都记日志 → 同一个错误在日志里出现 3 次
func repo() error {
    err := db.Query(...)
    if err != nil {
        log.Error("db query failed", "err", err) // 第 1 次
        return fmt.Errorf("repo: %w", err)
    }
}
func service() error {
    err := repo()
    if err != nil {
        log.Error("repo failed", "err", err) // 第 2 次
        return fmt.Errorf("service: %w", err)
    }
}
func handler() {
    err := service()
    if err != nil {
        log.Error("service failed", "err", err) // 第 3 次
    }
}

// ✅ 底层 wrap 上下文,只在最顶层(边界)记一次日志

为什么是坑:重复日志浪费存储、干扰排查(搜索到 3 条日志以为是 3 个错误)。正确做法:底层只负责 wrap 上下文,日志在 HTTP handler / CLI entrypoint / worker 的最外层统一记录。


8. 生产环境最佳实践 / Checklist

8.1 错误日志策略

原则说明
只在边界记日志HTTP handler、gRPC interceptor、CLI main、worker loop 的最外层
底层只 wrap 不 logRepository / Service 层用 fmt.Errorf 添加上下文,不调用 logger
结构化日志slog(Go 1.21+ 标准库),输出 JSON 格式,附带 request_id、user_id 等字段
内部 error 不暴露给客户端日志记录完整 error 链,HTTP 响应只返回安全的 message

8.2 错误分类与 HTTP 状态码映射

// 推荐:在领域层定义错误码,在 HTTP 层映射状态码
var statusMap = map[string]int{
    "NOT_FOUND":         404,
    "VALIDATION_FAILED": 400,
    "UNAUTHORIZED":      401,
    "FORBIDDEN":         403,
    "CONFLICT":          409,
    "INTERNAL_ERROR":    500,
}

不要在 repository 或 service 层引入 net/http。状态码映射属于 HTTP 边界的职责。

8.3 结构化错误(API 响应)

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "email format is invalid",
    "details": [{ "field": "email", "reason": "must be a valid email address" }]
  }
}

原则:

8.4 错误监控与报警

8.5 第三方库

适用场景状态
标准库 errors + fmt绝大多数场景推荐首选,Go 1.13+ 功能已足够
cockroachdb/errors分布式系统、需要网络传输 error、PII 过滤、Sentry 集成活跃维护,CockroachDB 生产使用
hashicorp/go-multierrorGo 1.20 之前需要多错误聚合Go 1.20+ 后建议用 errors.Join 替代
github.com/pkg/errors添加 stack trace已归档(archived),不再维护。标准库已覆盖其核心功能

建议:新项目直接用标准库。只有在需要 stack trace 附加、网络 error 传输、PII 过滤等高级功能时才引入第三方库。


9. 社区争议与演进方向

9.1 if err != nil 的冗余问题

Go Developer Survey 连年显示,错误处理的冗余是开发者的头号抱怨。典型的 Go 函数中,错误处理代码占比可达 30-50%。

Rob Pike 的回应(2015):这不是语言的问题,是程序员没有把 error 当值来编程。用 errWriterbufio.Scanner 等模式可以大幅减少样板。

社区的反驳:这些模式只适用于特定场景(连续写入、扫描),大多数业务代码仍然是”调用 → 检查 → 返回”的直线逻辑,无法用这些模式优化。

9.2 被拒绝的提案

提案年份核心思路被拒原因
check/handle2018check 关键字替代 if err != nilhandle 块定义错误处理过于复杂,引入新的控制流概念
try built-in2019try(f()) 内置函数,自动检查并返回 error隐藏返回点、调试困难、~900 条评论压倒性反对
? operator2024借鉴 Rust,f() ? 自动 propagate error改善最大但缺乏广泛共识,用户研究表现尚可

9.3 Go 团队的最终立场(2025 年 6 月)

Go 官方博客 On/No syntactic support for error handling 明确宣布:

For the foreseeable future, the Go team will stop pursuing syntactic language changes for error handling. We will also close all open and incoming proposals that concern themselves primarily with the syntax of error handling, without further investigation.

核心理由

  1. 没有共识:即使 Go 团队内部的资深成员也无法达成一致
  2. 15 年窗口已过:Go 已有 15 年的错误处理实践,新语法会强制改变所有人的写法(不像泛型是可选的)
  3. 收益不够大:IDE 辅助(自动补全、折叠)、LLM 代码生成、cmp.Or 等工具链改进可以缓解痛点
  4. 成本太高:语言变更的维护成本、社区分裂风险、Go 团队资源有限
  5. 现实反馈:Google Cloud Next 2025 上的 Go 用户普遍表示反对语法变更

这意味着if err != nil 就是 Go 错误处理的终极形态。与其抱怨,不如掌握好 error wrapping、自定义 error type、errors.Is/errors.As 这套工具,写出清晰、可维护的错误处理代码。


参考来源


分享这篇文章:

上一篇
现代 C++(一):C++11/14 为什么是现代 C++ 的起点
下一篇
Go 运行时(三):netpoller 如何用 epoll 与 gopark 跑异步 I/O