2026-08-05 Golang 设计

Go 微服务中的错误处理策略

从 error wrapping 到 gRPC status code 映射,构建一套可观测、可追溯的分布式错误体系,减少排查时间。

Go 错误处理的哲学

Go 的错误处理哲学与 Java/Python 截然不同:错误是值,不是异常。这意味着你需要显式地检查和处理每一个错误。在单体应用中这很简单,但在微服务架构中,错误需要跨越进程边界,问题就变得复杂了。

错误分层模型

在微服务中,我推荐将错误分为三个层次:

  1. 领域错误:业务逻辑层面的错误(如用户不存在、余额不足)
  2. 基础设施错误:数据库连接失败、网络超时等
  3. 协议错误:gRPC/HTTP 层面的状态码映射

自定义错误类型

// 基础错误类型
type AppError struct {
    Code    string        // 机器可读的错误码
    Message string        // 面向开发者的消息
    Cause   error         // 原始错误(用于链式追踪)
    Stack   []uintptr     // 调用栈(可选)
}

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

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

// 预定义错误码
var (
    ErrNotFound     = &AppError{Code: "NOT_FOUND", Message: "资源不存在"}
    ErrUnauthorized = &AppError{Code: "UNAUTHORIZED", Message: "未授权"}
    ErrInternal     = &AppError{Code: "INTERNAL", Message: "内部错误"}
)

Error Wrapping 最佳实践

Go 1.13 引入的 fmt.Errorf("%w", err) 让错误链式追踪成为可能:

func GetUser(ctx context.Context, id string) (*User, error) {
    user, err := db.QueryUser(ctx, id)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("get user %s: %w", id, ErrNotFound)
        }
        return nil, fmt.Errorf("get user %s: %w", id, err)
    }
    return user, nil
}

// 上层可以判断错误类型
func HandleRequest(w http.ResponseWriter, r *http.Request) {
    user, err := GetUser(r.Context(), r.URL.Query().Get("id"))
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            http.Error(w, "User not found", 404)
            return
        }
        http.Error(w, "Internal error", 500)
        return
    }
    json.NewEncoder(w).Encode(user)
}
使用 errors.Is 和 errors.As 而不是直接比较错误字符串。前者能正确穿透 fmt.Errorf("%w") 的包装层。

gRPC Status Code 映射

在 gRPC 微服务中,需要将内部错误映射为标准 status code:

func ToGRPCStatus(err error) *status.Status {
    var appErr *AppError
    if errors.As(err, &appErr) {
        switch appErr.Code {
        case "NOT_FOUND":
            return status.New(codes.NotFound, appErr.Message)
        case "UNAUTHORIZED":
            return status.New(codes.Unauthenticated, appErr.Message)
        case "INVALID_ARGUMENT":
            return status.New(codes.InvalidArgument, appErr.Message)
        case "INTERNAL":
            return status.New(codes.Internal, "内部服务错误")
        }
    }
    // 未知错误统一映射为 Internal
    return status.New(codes.Internal, "内部服务错误")
}

可观测性集成

错误处理应该与日志、追踪和监控系统集成:

func HandleError(ctx context.Context, err error) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        // 结构化日志
        slog.ErrorContext(ctx, "request failed",
            "error_code", appErr.Code,
            "error_message", appErr.Message,
            "trace_id", trace.SpanFromContext(ctx).SpanContext().TraceID(),
        )
        // 指标上报
        metrics.ErrorCounter.WithLabelValues(appErr.Code).Inc()
    }
}

常见反模式

  1. 吞掉错误:_ = doSomething() 是最危险的习惯
  2. 重复日志:每层都打日志,导致同一个错误在日志中出现十几次
  3. 暴露内部细节:把数据库错误信息直接返回给客户端
  4. 用 panic 代替 error:panic 只应用于不可恢复的程序错误

总结

  • 定义清晰的错误类型层次,区分领域错误和基础设施错误
  • 使用 fmt.Errorf("%w") 保留错误链,方便上层判断和追踪
  • 在服务边界统一映射为 gRPC/HTTP 状态码
  • 集成可观测性:错误发生时自动记录日志、追踪和指标
  • 错误消息面向开发者,不要直接暴露给最终用户