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.Is<br/>判断是否为特定错误] D --> F[使用 errors.As<br/>提取特定错误类型] D --> G[使用 %+v<br/>打印完整调用链]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 实现分布式错误追踪。错误链的完整性直接决定线上问题的排查效率。