Go 微服务中的错误处理策略
从 error wrapping 到 gRPC status code 映射,构建一套可观测、可追溯的分布式错误体系,减少排查时间。
Go 错误处理的哲学
Go 的错误处理哲学与 Java/Python 截然不同:错误是值,不是异常。这意味着你需要显式地检查和处理每一个错误。在单体应用中这很简单,但在微服务架构中,错误需要跨越进程边界,问题就变得复杂了。
错误分层模型
在微服务中,我推荐将错误分为三个层次:
- 领域错误:业务逻辑层面的错误(如用户不存在、余额不足)
- 基础设施错误:数据库连接失败、网络超时等
- 协议错误: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()
}
}
常见反模式
- 吞掉错误:
_ = doSomething()是最危险的习惯 - 重复日志:每层都打日志,导致同一个错误在日志中出现十几次
- 暴露内部细节:把数据库错误信息直接返回给客户端
- 用 panic 代替 error:panic 只应用于不可恢复的程序错误
总结
- 定义清晰的错误类型层次,区分领域错误和基础设施错误
- 使用
fmt.Errorf("%w")保留错误链,方便上层判断和追踪 - 在服务边界统一映射为 gRPC/HTTP 状态码
- 集成可观测性:错误发生时自动记录日志、追踪和指标
- 错误消息面向开发者,不要直接暴露给最终用户