Golang错误处理在微服务中的实践经验

Go error不可跨服务传播,需用结构化ErrorResponse;勿用fmt.Errorf包装远程错误;日志须用.Err(err)保留链路;自定义错误必须实现Unwrap()和Error()以支持errors.Is/As判定。

Go 的 error 类型不适合跨服务传播

微服务间通信依赖序列化(如 JSON、Protobuf),而 Go 原生 error 是接口类型,无法直接编码。常见错误是把 err 直接塞进 HTTP 响应体或 gRPC 返回值,结果得到空对象或 panic。

  • HTTP 场景下,json.Marshal(err) 总是返回 null,因为 error 接口没有导出字段
  • gRPC 中若在 proto 定义外硬塞 err.Error(),会丢失堆栈、码、上下文等关键信息
  • 正确做法:统一用结构化错误响应,例如定义 ErrorResponse{Code: "INVALID_INPUT", Message: "...", Details: map[string]interface{}{}}

不要用 fmt.Errorf 包装远程调用错误

对下游服务的 HTTP/gRPC 调用失败时,原错误往往已含状态码、超时标识、重试建议等语义。用 fmt.Errorf("failed to call X: %w", err) 会抹掉这些信息,只剩字符串描述。

  • errors.Is(err, context.DeadlineExceeded) 在包装后失效 —— %w 不传递底层类型断言能力
  • 推荐用专用错误转换函数,例如:
    func ToServiceError(err error) *ServiceError {
        if errors.Is(err, context.DeadlineExceeded) {
            return &ServiceError{Code: "TIMEOUT", HTTPStatus: 408}
        }
        if httpErr, ok := err.(*url.Error); ok && httpErr.Err != nil {
            return &ServiceError{Code: "CONNECTION_FAILED", Cause: httpErr.Err}
        }
        return &ServiceError{Code: "UNKNOWN", Raw: err}
    }
  • 所有出站请求的错误必须经过此层转换,确保上游能做策略判断(比如只对 TIMEOUT 重试)

日志中的错误不能只打 err.Error()

微服务排查依赖链路追踪和集中日志。仅记录 err.Error() 会让问题定位退化成“猜谜”——没有堆栈、没有调用路径、没有原始错误类型。

  • 使用 log.With().Err(err).Msg("failed to process order")(如 zerolog/zap),它会自动展开 Unwrap() 链并保留字段
  • 避免 log.Printf("error: %v", err) —— 它忽略所有额外字段,且不格式化嵌套错误
  • 对关键错误(如支付失败),强制附加 trace ID 和 span ID:
    logger.Error().Str("trace_id", traceID).Str("span_id", spanID).Err(err).Msg("payment declined")

自定义错误类型必须实现 Unwrap()Error()

Go 1.13 引入的错误链机制依赖 Unwrap(),但很多团队写的 ServiceError 只实现了 Error(),导致 errors.Is()errors.As() 失效。

  • 必须返回非 nil 的 error:如果当前错误封装了底层错误,Unwrap() 应返回它;否则返回 nil
  • 不要在 Error() 中拼接 Unwrap().Error() —— 这会导致重复打印,且破坏错误链遍历
  • 示例:
    type ServiceError struct {
        Code    string
        Message string
        Cause   error
    }
    
    func (e *ServiceError) Error() string { return e.Message }
    func (e *ServiceError) Unwrap() error { return e.Cause }
实际落地中最容易被忽略的是错误类型的可判定性:不是所有错误都需要自定义,但一旦定义,就必须让 errors.Is(err, MyTimeout) 成立,否则熔断、重试、告警策略全都会失效。