Go 错误处理:error、defer、panic 与 recover

Go 将错误视为普通值。调用方显式检查错误,能够清楚看到失败路径、补充上下文并决定是否重试、降级或终止请求。

一套稳定的错误处理策略需要回答四个问题:错误在哪里产生、如何保留原因、由谁记录日志,以及何时才允许 panic。

error 的基本用法

error 是只有一个方法的接口:

type error interface {
	Error() string
}

使用 errors.New 创建固定错误,使用 fmt.Errorf 创建带变量信息的错误。

func ParsePort(raw string) (int, error) {
	port, err := strconv.Atoi(raw)
	if err != nil {
		return 0, fmt.Errorf("parse port %q: %w", raw, err)
	}
	if port < 1 || port > 65535 {
		return 0, fmt.Errorf("port out of range: %d", port)
	}
	return port, nil
}

错误文本通常使用小写开头且不加句号,因为上层可能继续包装它。

包装错误并保留原因

%w 会保留原始错误链,调用方可以使用 errors.Iserrors.As 判断原因。%v 只会格式化文本,无法参与错误链判断。

var ErrUserNotFound = errors.New("user not found")

func (r *Repository) FindUser(ctx context.Context, id int64) (User, error) {
	user, err := r.query(ctx, id)
	if err != nil {
		return User{}, fmt.Errorf("find user %d: %w", id, err)
	}
	return user, nil
}

调用方不要比较错误字符串:

if errors.Is(err, ErrUserNotFound) {
	// 返回 404 或执行替代逻辑
}

字符串可能变化,错误链仍能保持稳定语义。

哨兵错误与自定义错误类型

哨兵错误适合调用方只关心类别的情况:

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

需要携带结构化信息时,定义错误类型:

type ValidationError struct {
	Field   string
	Message string
}

func (e *ValidationError) Error() string {
	return e.Field + ": " + e.Message
}

通过 errors.As 提取类型:

var validationErr *ValidationError
if errors.As(err, &validationErr) {
	fmt.Println(validationErr.Field)
}

对外暴露的哨兵错误和错误类型会成为包的 API。只有调用方确实需要分支处理时才公开它们。

errors.Join:合并多个失败

清理多个资源或并行任务可能同时失败。Go 1.20 起可以使用 errors.Join

func closeAll(closers ...io.Closer) error {
	var errs []error
	for _, closer := range closers {
		if err := closer.Close(); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...)
}

合并后的错误仍支持 errors.Iserrors.As

用提前返回保持主流程清晰

Go 代码通常先处理失败路径,再继续主流程:

func Register(ctx context.Context, store UserStore, input Input) (User, error) {
	if err := input.Validate(); err != nil {
		return User{}, fmt.Errorf("validate input: %w", err)
	}

	user, err := NewUser(input)
	if err != nil {
		return User{}, fmt.Errorf("create user: %w", err)
	}

	if err := store.Save(ctx, user); err != nil {
		return User{}, fmt.Errorf("save user: %w", err)
	}

	return user, nil
}

避免连续多层 if err == nil。提前返回能让成功路径保持线性。

defer:可靠释放资源

defer 在当前函数返回前执行,多个 defer 按后进先出顺序运行。

func ReadConfig(path string) ([]byte, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("open config: %w", err)
	}
	defer file.Close()

	data, err := io.ReadAll(file)
	if err != nil {
		return nil, fmt.Errorf("read config: %w", err)
	}
	return data, nil
}

需要处理 Close 错误时,可以使用命名返回值:

func WriteFile(path string, data []byte) (err error) {
	file, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("create file: %w", err)
	}

	defer func() {
		if closeErr := file.Close(); closeErr != nil {
			err = errors.Join(err, fmt.Errorf("close file: %w", closeErr))
		}
	}()

	if _, err := file.Write(data); err != nil {
		return fmt.Errorf("write file: %w", err)
	}
	return nil
}

defer 参数何时求值

defer 语句的函数参数会在注册时求值:

func example() {
	value := 1
	defer fmt.Println(value)
	value = 2
}

输出为 1。如果 defer 使用闭包并读取外部变量,则在执行时获取值。

不要在无限循环中堆积 defer

defer 在函数返回时才执行。处理大量循环项时,应把单次操作提取为函数:

func processFile(path string) error {
	file, err := os.Open(path)
	if err != nil {
		return err
	}
	defer file.Close()
	return consume(file)
}

panic 的合理边界

panic 会停止当前函数的正常流程并沿调用栈展开。它适合程序无法继续满足内部不变量的情况,而不适合普通输入错误、网络失败或数据库错误。

可以考虑 panic 的场景:

  • 应用启动时关键配置缺失,继续运行没有意义。
  • 包初始化中的静态模板或正则表达式必定正确,否则属于程序缺陷。
  • 检测到理论上不可能出现的内部状态。

不应 panic 的场景:

  • 用户提交了无效参数。
  • 下游服务超时。
  • 文件不存在或权限不足。
  • 数据库返回无记录。

库代码通常应返回错误,让应用决定策略。

recover:在边界处隔离崩溃

recover 只有在同一 Goroutine 的 defer 中调用才有效。

func Recovery(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if recovered := recover(); recovered != nil {
				log.Printf("panic: %v\n%s", recovered, debug.Stack())
				http.Error(w, "internal server error", http.StatusInternalServerError)
			}
		}()

		next.ServeHTTP(w, r)
	})
}

恢复后应记录堆栈并返回明确失败,不能静默忽略 panic。recover 也不能替代正常错误处理。

新启动的 Goroutine 需要自己的恢复边界:

go func() {
	defer func() {
		if recovered := recover(); recovered != nil {
			log.Printf("worker panic: %v\n%s", recovered, debug.Stack())
		}
	}()
	runWorker()
}()

错误应该在哪里记录日志

同一个错误不要在每一层都记录,否则一次故障会产生大量重复日志。常见策略是:

  1. 底层函数补充操作上下文并返回错误。
  2. 业务层将底层错误转换为稳定的领域错误。
  3. HTTP、RPC、任务消费者等系统边界统一记录日志和指标。
  4. 面向客户端返回安全信息,不泄露 SQL、文件路径或内部地址。
func writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, ErrNotFound):
		http.Error(w, "resource not found", http.StatusNotFound)
	case errors.Is(err, ErrConflict):
		http.Error(w, "resource conflict", http.StatusConflict)
	default:
		http.Error(w, "internal server error", http.StatusInternalServerError)
	}
}

测试错误行为

测试应验证错误类别,而不是绑定完整错误文本。

func TestFindUser_NotFound(t *testing.T) {
	_, err := findUser(context.Background(), 404)
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}

对于自定义类型,使用 errors.As 验证结构化字段。

实践检查清单

  1. 每个错误是否包含失败动作和必要标识,但不泄露敏感信息。
  2. 包装错误时是否使用 %w 保留原因。
  3. 调用方是否使用 errors.Iserrors.As,而不是比较字符串。
  4. 文件、响应体、事务等资源是否通过 defer 正确释放。
  5. 同一个错误是否只在合适的系统边界记录一次。
  6. panic 是否只用于不可恢复的程序错误。
  7. HTTP、RPC 和后台任务入口是否具备恢复与堆栈记录机制。
  8. 测试是否覆盖关键错误类别和资源清理路径。

好的错误处理不是让错误消失,而是保留足够上下文,让上层能够做出正确决策,同时保证资源始终被释放、服务边界不会因单个请求而崩溃。