跳转至

错误处理

学习目标

学完本章后,学习者应该能够:

  1. 理解 Go 中 error 的设计和返回约定。
  2. 正确使用 errors.Iserrors.As 和错误包装。
  3. 区分普通错误、业务错误、系统错误、panic 和 recover。
  4. 能设计后端服务中的错误码和错误边界。

error 类型

Go 的错误是普通值:

type error interface {
    Error() string
}

函数通常把错误作为最后一个返回值:

user, err := repo.GetUser(ctx, id)
if err != nil {
    return err
}

这种显式错误处理让失败路径清楚可见。

错误包装

包装错误时使用 %w,保留错误链:

if err != nil {
    return fmt.Errorf("get user %d: %w", id, err)
}

调用方可以用 errors.Is 判断哨兵错误:

if errors.Is(err, ErrNotFound) {
    return nil
}

也可以用 errors.As 提取特定错误类型:

var appErr *AppError
if errors.As(err, &appErr) {
    return appErr.Code
}

panic 与 recover

panic 表示程序进入异常状态,不应用于普通业务错误。

适合 panic 的场景:

  • 程序启动时配置缺失且无法继续。
  • 违反不可恢复的不变量。
  • 测试中快速失败。

HTTP 服务中可以用中间件 recover,避免单个请求 panic 导致整个进程退出,但 recover 后要记录堆栈。

业务错误与系统错误

业务错误是预期内失败:

  • 用户不存在。
  • 参数不合法。
  • 余额不足。
  • 权限不足。

系统错误是基础设施或程序异常:

  • 数据库连接失败。
  • Redis 超时。
  • 网络错误。
  • JSON 编码失败。

两者应该在日志、错误码和告警上区别对待。

Go 后端实际应用例子

例子一:定义业务错误

type AppError struct {
    Code    string
    Message string
}

func (e *AppError) Error() string {
    return e.Code + ": " + e.Message
}

var ErrUserNotFound = &AppError{
    Code:    "USER_NOT_FOUND",
    Message: "user not found",
}

业务错误可以映射到稳定的 HTTP 状态码和响应体。

例子二:Repository 层保留底层错误

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

包装错误时补充上下文,但不要在每一层重复打日志。通常在边界层统一记录。

常见误区

  • 误区一:错误信息越长越好。

错误要有上下文,但不要泄露敏感信息,也不要重复包装到无法阅读。

  • 误区二:每一层都记录一次错误日志。

这会造成重复日志。通常在请求边界或任务边界统一记录,内部层返回错误即可。

  • 误区三:用 panic 处理业务错误。

业务错误是正常分支,应该显式返回。panic 会破坏控制流,也容易漏掉资源释放和观测信息。

线上问题案例

某服务把数据库唯一键冲突直接返回给前端,响应中包含表名和索引名。后来发现这既不友好,也暴露内部结构。

修复方式是在 repository 层识别唯一键冲突,在 service 层转换成业务错误,例如 API_KEY_NAME_EXISTS,handler 层映射为 409。

实战任务

设计一个 API Key 创建接口的错误处理:

  1. 参数不合法返回 400。
  2. 名称重复返回 409。
  3. 数据库错误返回 500。
  4. 日志中保留底层错误。
  5. 响应中不暴露数据库细节。
参考答案

可以定义业务错误码,例如 INVALID_ARGUMENTAPI_KEY_NAME_EXISTS。repository 层识别数据库唯一键冲突并返回业务可识别错误,service 层继续包装上下文,handler 层用 errors.Iserrors.As 判断错误类型并映射 HTTP 状态码。

日志记录完整错误链,响应只返回稳定错误码和用户可理解信息。数据库连接失败、SQL 语法错误等系统错误统一返回 500,并触发错误率监控。

面试题

1. Go 为什么把 error 设计成普通返回值?

参考答案

Go 把错误设计成普通值,让失败路径显式出现在代码中。调用方必须决定如何处理错误,这有助于写出可读、可控的后端服务。

代价是代码中会有较多 if err != nil,但好处是控制流清晰,不容易隐藏异常路径。

2. %werrors.Iserrors.As 分别有什么作用?

参考答案

%w 用于包装错误并保留错误链。errors.Is 用于判断错误链中是否包含某个目标错误,常用于哨兵错误。errors.As 用于从错误链中提取某个具体错误类型。

它们让我们既能补充上下文,又能让上层保留错误判断能力。

3. panic 适合用在什么场景?

参考答案

panic 适合不可恢复的程序错误,例如启动时关键配置缺失、违反内部不变量,或测试中快速失败。不适合普通业务错误。

Web 服务中可以在中间件 recover,记录堆栈并返回 500,避免单个请求 panic 导致进程退出。