Go 错误处理链:错误包装要保留原始上下文 Go 错误处理链错误包装要保留原始上下文一、当错误日志里只剩下一行internal error线上报了一个 500日志里只有一行internal error: request failed。问题出在哪不知道。哪个函数报的不知道。原始错误是什么不知道。同事在五个服务的代码里 grep 了一圈最后定位到是 Redis 写入时connection refused——但中间经过了四层函数调用每一层都把原始错误吞掉了。Go 的错误处理有一个著名的批评到处if err ! nil。但问题不在于写if err ! nil太多而在于写得太敷衍。真正的问题是一层层return err把错误上下文全扔掉了最后只剩下一个孤零零的字符串。基础设施不需要漂亮话。错误处理的本质是让排查问题的人能沿着调用链一路追溯到根因。二、错误包装的三层信息模型一个高质量的错误应该包含三层信息发生了什么、在哪里发生的、为什么发生。flowchart LR A[底层错误br/connection refused] -- B[中间层包装br/redis write failed] B -- C[上层包装br/save user session failed] C -- D[最终错误信息br/每层保留链式上下文] D -- E[使用 errors.Isbr/判断是否为特定错误] D -- F[使用 errors.Asbr/提取特定错误类型] D -- G[使用 %vbr/打印完整调用链]Go 1.13 引入的fmt.Errorf%w和errors.Is、errors.As、errors.Unwrap形成了完整的错误包装体系。但实践中常见三个反模式反模式一直接 return err。调用方拿到原始错误不知道是在哪个环节出的问题。应该用%w加上语义信息。反模式二覆盖原始错误。用%v而不是%w导致errors.Is和errors.As失效。上层无法判断错误的类型。反模式三在错误信息中堆砌太多细节。一行错误信息里塞了时间戳、IP、请求体反而淹没关键信息。细节应该放在结构化日志里错误信息只描述语义。三、生产级错误包装实现package service import ( context database/sql errors fmt ) // 定义业务域错误哨兵值 var ( ErrUserNotFound errors.New(user not found) ErrSessionExpired errors.New(session expired) ErrRateLimited errors.New(rate limited) ErrExternalTimeout errors.New(external service timeout) ) // 错误包装示例 // UserRepository 数据访问层 type UserRepository struct { db *sql.DB } func (r *UserRepository) FindByID(ctx context.Context, id string) (*User, error) { var user User err : r.db.QueryRowContext(ctx, SELECT id, name, email FROM users WHERE id ?, id). Scan(user.ID, user.Name, user.Email) if err ! nil { if errors.Is(err, sql.ErrNoRows) { // 将底层错误转换为业务域错误保留原始错误链 return nil, fmt.Errorf(user %s: %w, id, ErrUserNotFound) } return nil, fmt.Errorf(query user %s: %w, id, err) } return user, nil } // SessionService 会话服务 type SessionService struct { redis RedisClient repo *UserRepository } func (s *SessionService) ValidateSession(ctx context.Context, token string) (*User, error) { userID, err : s.redis.Get(ctx, session:token) if err ! nil { return nil, fmt.Errorf(redis get session: %w, err) } if userID { return nil, ErrSessionExpired } user, err : s.repo.FindByID(ctx, userID) if err ! nil { return nil, fmt.Errorf(find user for session: %w, err) } return user, nil } // Handler HTTP 处理层 type Handler struct { sessionSvc *SessionService } func (h *Handler) GetProfile(ctx context.Context, token string) (*Profile, error) { user, err : h.sessionSvc.ValidateSession(ctx, token) if err ! nil { // 根据错误类型决定 HTTP 状态码 if errors.Is(err, ErrUserNotFound) { return nil, fmt.Errorf(get profile: %w, err) // 返回 404 } if errors.Is(err, ErrSessionExpired) { return nil, fmt.Errorf(get profile: %w, err) // 返回 401 } // 未知错误返回 500但保留完整错误链 return nil, fmt.Errorf(get profile: %w, err) } return buildProfile(user), nil } // 错误消费侧 // ErrorType 错误分类 type ErrorType int const ( ErrorTypeNotFound ErrorType iota ErrorTypeUnauthorized ErrorTypeRateLimit ErrorTypeInternal ErrorTypeTimeout ) // ClassifyError 根据错误链判断错误类型 func ClassifyError(err error) ErrorType { if err nil { return ErrorTypeInternal } // 沿错误链查找匹配的哨兵值 if errors.Is(err, ErrUserNotFound) { return ErrorTypeNotFound } if errors.Is(err, ErrSessionExpired) { return ErrorTypeUnauthorized } if errors.Is(err, ErrRateLimited) { return ErrorTypeRateLimit } if errors.Is(err, ErrExternalTimeout) { return ErrorTypeTimeout } return ErrorTypeInternal } // LoggableError 提取可记录的错误信息 type LoggableError struct { Message string RootCause string ErrorChain []string } // ExtractLoggableError 从错误链提取结构化信息 func ExtractLoggableError(err error) LoggableError { if err nil { return LoggableError{} } le : LoggableError{Message: err.Error()} current : err // 沿错误链向上追溯 for current ! nil { le.ErrorChain append(le.ErrorChain, current.Error()) current errors.Unwrap(current) } if len(le.ErrorChain) 0 { le.RootCause le.ErrorChain[len(le.ErrorChain)-1] } return le } // HTTPStatusCode 将错误类型映射为 HTTP 状态码 func HTTPStatusCode(err error) int { switch ClassifyError(err) { case ErrorTypeNotFound: return 404 case ErrorTypeUnauthorized: return 401 case ErrorTypeRateLimit: return 429 case ErrorTypeTimeout: return 504 default: return 500 } }关键设计决策每层都用fmt.Errorf%w包装。%w保留了错误链errors.Is和errors.As可以穿透多层包装找到原始错误。定义业务域哨兵值。ErrUserNotFound、ErrSessionExpired这些是稳定的 API上层代码通过errors.Is判断错误类型而不是字符串匹配。错误分类独立于 HTTP 层。ClassifyError返回业务错误类型Handler 层再将类型映射为 HTTP 状态码。这样同一个错误类型在 gRPC、消息队列等场景也能复用分类逻辑。ExtractLoggableError提取错误链用于日志。单次日志记录包含完整上下文链排查问题时不再需要辗转多个日志文件。四、错误包装的边界与注意事项包装频率。不是每个if err ! nil都要包装。原则是跨越模块边界时包装同一模块内部可以直接传递。判断标准是——调用方是否需要知道这次错误是在哪个环节发生的。敏感信息泄露。错误信息不能包含用户的原始 Token、密码、身份证号等敏感数据。在安全审计点API Gateway 或日志采集层做脱敏处理而不是在每个fmt.Errorf处判断。性能开销。fmt.Errorf%w的字符串格式化和错误链构建有一定开销但在 I/O 密集的后端服务中完全可以忽略。不需要为了省这点开销牺牲排查效率。第三方库错误。调用数据库驱动、HTTP 客户端等第三方库时它们的错误类型不应直接暴露给上层。在 Repository 或 Client 层包装一次转化为业务域错误。error 和 panic 的边界。Go 的原则是可预期的错误用 error不可恢复的状态用 panic。数据库连接失败是可预期的网络抖动返回 error。空指针解引用是不可恢复的bug应该 panic 并在 recover 中间件中记录。错误链的可观测性集成。推荐将ExtractLoggableError集成到 OpenTelemetry 的 Span 属性中每个 Span 记录各自的错误上下文。配合 Jaeger 或 Grafana Tempo可以沿调用链直接看到每一层的错误状态。五、总结Go 错误处理的核心原则是每层用fmt.Errorf%w包装错误以保留上下文用业务域哨兵值定义错误类型通过errors.Is做类型判断而不是字符串匹配。关键实践跨模块边界时必须包装错误敏感信息在日志采集层脱敏而非每个 fmt.Errorf 处第三方库错误在 Client/Repository 层转化错误分类独立于传输层HTTP/gRPC集成 OpenTelemetry 实现分布式错误追踪。错误链的完整性直接决定线上问题的排查效率。