找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2898

积分

0

好友

390

主题
发表于 5 天前 | 查看: 27| 回复: 0

适用版本:Go 1.21+、Gin 1.9+、Zap 1.26+、OpenTelemetry 1.x
关键词:错误治理、统一响应、异常分层、可观测性、高并发、微服务、工程化

一、引言:为什么很多 Go 服务“能跑”,但一出故障就难查?

在多数团队里,错误处理往往停留在“if err != nil { return err }”这一层。代码没有错,但系统治理并没有真正建立起来。

一个线上接口在高峰期报错时,常见问题通常不是“有没有返回错误”,而是:

  • 返回给前端的结构不统一,前端只能写一堆分支兼容。
  • 日志中只有 internal error,无法定位是哪一层失败。
  • 数据库超时、Redis 超时、第三方失败、业务校验失败全部长得一样。
  • 没有 TraceID,调用链一长就查不到故障传播路径。
  • 所有异常都打 ERROR、都告警,最终造成告警疲劳。
  • 错误处理与业务代码耦合,越写越散,越散越难收敛。

因此,生产环境真正需要的不是“能返回错误”,而是建立一套完整的错误治理体系:

  1. 错误有分类。
  2. 响应有协议。
  3. 日志有上下文。
  4. 链路可追踪。
  5. 高并发场景下性能可控。
  6. 架构层面可以持续扩展,而不是越补越乱。

本文会以 Gin 为载体,构建一套可直接落地的生产级错误处理方案,并重点补齐很多文章容易忽略的几个关键点:

  • 错误抽象和架构分层如何设计。
  • Gin 中间件顺序为什么会直接影响治理效果。
  • 高并发下堆栈捕获、日志写入、错误包装的性能取舍。
  • 如何把错误处理与限流、熔断、重试、链路追踪打通。
  • 如何让文章里的代码真正具备“工程可用性”。

二、错误治理的目标:不是“少报错”,而是“报对错、查得出、控得住”

我们先明确错误治理的目标。

2.1 业务视角的目标

  • 前端能稳定识别错误类型,而不是依赖模糊文案。
  • 用户看到的是可理解、可操作的提示,而不是内部细节。
  • 业务错误和系统错误必须能区分,否则会误导决策。

2.2 工程视角的目标

  • 错误从 Handler 到 Service 到 Repository 的传播路径清晰。
  • 错误码、HTTP 状态码、日志级别、告警等级之间能建立映射。
  • 线上排障时可以通过 TraceID 快速串起请求、日志、指标、链路。

2.3 架构视角的目标

  • 新业务接入时,不需要重复发明一套错误模型。
  • 新增中间件、网关、消息消费端、定时任务时,治理模型还能复用。
  • 错误处理能承载更高级能力:重试、降级、熔断、审计、风控。

一句话概括:

错误治理不是一个 utils 包,而是服务稳定性架构的一部分。

三、先讲原理:错误到底应该如何分层?

Go 服务中,错误至少要从两个维度分类:来源维度和处理维度。

3.1 来源维度:错误来自哪里?

A. 输入类错误

请求参数非法、缺少字段、格式错误、权限不足、签名错误等。

特点:

  • 本质上是“请求不满足约束”。
  • 通常不需要告警。
  • 适合记录为 WARNINFO

B. 业务类错误

例如余额不足、库存不足、订单状态非法、优惠券过期。

特点:

  • 属于业务规则的一部分。
  • 不应该被记录为系统异常。
  • 往往需要稳定错误码给前端和调用方识别。

C. 依赖类错误

例如 MySQL 超时、Redis 连接失败、消息队列写入失败、调用第三方支付超时。

特点:

  • 可能可重试。
  • 可能需要熔断、降级。
  • 需要重点观察错误率和延迟。

D. 系统类错误

例如 panic、空指针、数组越界、配置缺失、线程资源耗尽。

特点:

  • 通常属于代码缺陷或运行环境异常。
  • 应该进入 ERROR 级别日志和告警体系。

3.2 处理维度:遇到错误后应该怎么做?

错误处理不应该只有“返回”这一种动作,还应抽象出处理策略。

type Strategy string

const (
    StrategyReturn   Strategy = "return"   // 直接返回
    StrategyRetry    Strategy = "retry"    // 重试
    StrategyFallback Strategy = "fallback" // 降级
    StrategyIgnore   Strategy = "ignore"   // 忽略
    StrategyPanic    Strategy = "panic"    // 升级为致命异常
)

这个抽象看似简单,但非常重要。因为一旦错误对象里携带了“建议处理策略”,后续你就能把它接到:

  • HTTP 接口
  • gRPC 拦截器
  • 消息消费者
  • 定时任务执行器
  • 熔断器与重试器

这就是“错误治理架构化”的关键。

四、生产级错误模型设计:不要只定义一个 CodeMessage

很多文章里的错误结构大概长这样:

type AppError struct {
    Code    int
    Message string
}

这在 Demo 层面可以,但在线上治理里远远不够。

一个更实用的错误模型,至少应该包含:

  • 面向调用方的业务错误码
  • 对用户展示的安全消息
  • 对内部排障的根因错误
  • 错误分类
  • 是否可重试
  • 建议处理策略
  • 结构化元数据
  • 可选堆栈

下面给出一个更完整的生产级实现。

package apperr

import (
    "errors"
    "fmt"
    "runtime"
    "strings"
)

type Kind string

const (
    KindInvalidArgument Kind = "invalid_argument"
    KindUnauthorized    Kind = "unauthorized"
    KindForbidden       Kind = "forbidden"
    KindNotFound        Kind = "not_found"
    KindConflict        Kind = "conflict"
    KindBusiness        Kind = "business"
    KindDependency      Kind = "dependency"
    KindInternal        Kind = "internal"
)

type Strategy string

const (
    StrategyReturn   Strategy = "return"
    StrategyRetry    Strategy = "retry"
    StrategyFallback Strategy = "fallback"
    StrategyFailFast Strategy = "fail_fast"
)

type Error struct {
    Code       int
    Kind       Kind
    Message    string
    SafeDetail string
    Cause      error
    Op         string
    Retryable  bool
    Strategy   Strategy
    Meta       map[string]any
    stack      []uintptr
}

func (e *Error) Error() string {
    if e == nil {
        return "<nil>"
    }

    b := strings.Builder{}
    b.WriteString(fmt.Sprintf("code=%d kind=%s msg=%s", e.Code, e.Kind, e.Message))
    if e.Op != "" {
        b.WriteString(" op=" + e.Op)
    }
    if e.Cause != nil {
        b.WriteString(" cause=" + e.Cause.Error())
    }
    return b.String()
}

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

func (e *Error) StackTrace() string {
    if len(e.stack) == 0 {
        return ""
    }

    frames := runtime.CallersFrames(e.stack)
    var b strings.Builder
    for {
        frame, more := frames.Next()
        b.WriteString(frame.Function)
        b.WriteString("\n\t")
        b.WriteString(frame.File)
        b.WriteString(fmt.Sprintf(":%d\n", frame.Line))
        if !more {
            break
        }
    }
    return b.String()
}

type Option func(*Error)

func WithCause(err error) Option {
    return func(e *Error) { e.Cause = err }
}

func WithOp(op string) Option {
    return func(e *Error) { e.Op = op }
}

func WithRetryable(v bool) Option {
    return func(e *Error) { e.Retryable = v }
}

func WithStrategy(s Strategy) Option {
    return func(e *Error) { e.Strategy = s }
}

func WithMeta(key string, value any) Option {
    return func(e *Error) {
        if e.Meta == nil {
            e.Meta = make(map[string]any)
        }
        e.Meta[key] = value
    }
}

func WithSafeDetail(detail string) Option {
    return func(e *Error) { e.SafeDetail = detail }
}

func New(code int, kind Kind, message string, opts ...Option) *Error {
    err := &Error{
        Code:     code,
        Kind:     kind,
        Message:  message,
        Strategy: StrategyReturn,
    }

    for _, opt := range opts {
        opt(err)
    }

    const depth = 32
    pcs := make([]uintptr, depth)
    n := runtime.Callers(2, pcs)
    err.stack = pcs[:n]

    return err
}

func IsKind(err error, kind Kind) bool {
    var app *Error
    if !errors.As(err, &app) {
        return false
    }
    return app.Kind == kind
}

这套模型有几个核心收益:

  1. Message 用于内部统一语义,不一定直接返回给前端。
  2. SafeDetail 用于返回给外部调用方,避免把 SQL、文件路径、连接串等泄露出去。
  3. Cause 保留根因,支持 errors.Is / errors.As
  4. Kind 让我们可以统一映射 HTTP 状态码、日志级别和告警策略。
  5. StrategyRetryable 为熔断、重试、降级预留能力。

五、错误码体系设计:不要让错误码沦为“数字常量堆”

错误码设计的本质,不是为了“看起来规范”,而是为了支撑协作和治理。

推荐按域划分,而不是简单按“前后端约定几个数”。

package errno

const (
    OK = 0
)

const (
    InvalidArgument = 100001
    Unauthorized    = 100002
    Forbidden       = 100003
    NotFound        = 100004
    Conflict        = 100005
    TooManyRequests = 100006
)

const (
    OrderStockInsufficient = 200001
    OrderAlreadyPaid       = 200002
    OrderClosed            = 200003
    CouponExpired          = 200004
)

const (
    DBUnavailable      = 300001
    CacheUnavailable   = 300002
    MQUnavailable      = 300003
    UpstreamTimeout    = 300004
    UpstreamBadRequest = 300005
)

const (
    Internal = 500001
    Panic    = 500002
)

推荐遵守几个原则:

  • 前两位或前三位体现错误域,方便按模块聚合统计。
  • 同一错误码永远表达同一语义,不能今天是“余额不足”,明天改成“库存不足”。
  • 错误码文档必须能被产品、前端、测试读懂。
  • 尽量不要让“文案”成为判断逻辑依据,调用方应基于 code 判断。

六、统一响应协议设计:把错误治理从“字符串”升级为“契约”

很多系统失败的根源不是“错误没处理”,而是“协议不稳定”。

建议统一响应格式如下:

package response

type Body struct {
    Code      int         `json:"code"`
    Message   string      `json:"message"`
    Data      any         `json:"data,omitempty"`
    TraceID   string      `json:"trace_id,omitempty"`
    RequestID string      `json:"request_id,omitempty"`
}

6.1 为什么还需要业务码,HTTP 状态码不够吗?

HTTP 状态码解决的是“传输层语义”,而业务码解决的是“应用层语义”。

举例:

  • 两个请求都可能是 400 Bad Request
  • 但一个是参数错误,一个是优惠券失效
  • 前端和调用方真正关心的是业务差异

因此生产环境通常采用“双层语义”:

  • HTTP 状态码表达协议层结果
  • 业务错误码表达业务层结果

6.2 HTTP 状态码映射建议

错误类型 推荐 HTTP 状态码 说明
参数错误 400 请求格式或参数不合法
未登录 401 身份认证失败
无权限 403 已认证但没有权限
资源不存在 404 查询目标不存在
状态冲突 409 资源状态与操作冲突
限流 429 请求过多
依赖超时 503 / 504 下游不可用或超时
内部错误 500 系统级异常

对于“业务失败是否返回 200”,我的建议是:

  • 面向 Web 前端、已有历史包袱的系统,可以兼容使用 200 + code != 0
  • 面向新系统、开放平台、内部 API 平台,优先使用语义化 HTTP 状态码

也就是说,不要把“返回 200”当成先进设计,它更多是历史兼容方案。

七、Gin 中间件链路设计:顺序不对,治理效果会直接失真

在 Gin 中,错误治理效果很大程度取决于中间件顺序。

推荐顺序如下:

RequestID / TraceID
    -> AccessLog
    -> Recover
    -> Timeout
    -> Auth
    -> Biz Handler
    -> ErrorHandler

更准确地说,ErrorHandler 应该作为“外层兜底收口中间件”。

7.1 为什么顺序重要?

因为你需要保证:

  • 任何错误发生前,请求上下文里已经有 TraceID。
  • panic 被 recover 后,仍能拿到 TraceID 和请求信息。
  • AccessLog 能记录最终状态码,而不是只记录进入时状态。
  • ErrorHandler 能在所有业务逻辑执行后统一收口。

如果顺序错了,常见后果是:

  • panic 日志没有 TraceID
  • 统一错误响应失效
  • AccessLog 记录不到真正状态码
  • 某些中间件提前写响应,导致后续无法统一处理

八、生产级 Gin 错误处理中间件实现

下面给出一套更接近生产环境的实现。

8.1 TraceID / RequestID 中间件

package middleware

import (
    "context"

    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
)

const (
    HeaderTraceID   = "X-Trace-ID"
    HeaderRequestID = "X-Request-ID"

    CtxTraceIDKey   = "trace_id"
    CtxRequestIDKey = "request_id"
)

func RequestIdentity() gin.HandlerFunc {
    return func(c *gin.Context) {
        traceID := c.GetHeader(HeaderTraceID)
        if traceID == "" {
            traceID = uuid.NewString()
        }

        requestID := c.GetHeader(HeaderRequestID)
        if requestID == "" {
            requestID = uuid.NewString()
        }

        c.Set(CtxTraceIDKey, traceID)
        c.Set(CtxRequestIDKey, requestID)
        c.Writer.Header().Set(HeaderTraceID, traceID)
        c.Writer.Header().Set(HeaderRequestID, requestID)

        ctx := context.WithValue(c.Request.Context(), CtxTraceIDKey, traceID)
        ctx = context.WithValue(ctx, CtxRequestIDKey, requestID)
        c.Request = c.Request.WithContext(ctx)

        c.Next()
    }
}

func TraceID(c *gin.Context) string {
    v, _ := c.Get(CtxTraceIDKey)
    s, _ := v.(string)
    return s
}

func RequestID(c *gin.Context) string {
    v, _ := c.Get(CtxRequestIDKey)
    s, _ := v.(string)
    return s
}

这里有两个关键点:

  1. 不只写入 Gin Context,也写入 context.Context,便于 Service / Repository 透传。
  2. 回写 Header,方便网关、调用方、压测工具、日志平台统一检索。

8.2 Panic Recover 中间件

package middleware

import (
    "net/http"
    "runtime/debug"

    "github.com/gin-gonic/gin"
    "go.uber.org/zap"

    "your-project/internal/apperr"
    "your-project/internal/errno"
    "your-project/internal/response"
)

func Recover(log *zap.Logger, exposeDebug bool) gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if rec := recover(); rec != nil {
                fields := []zap.Field{
                    zap.Any("panic", rec),
                    zap.String("trace_id", TraceID(c)),
                    zap.String("request_id", RequestID(c)),
                    zap.String("method", c.Request.Method),
                    zap.String("path", c.Request.URL.Path),
                }

                if exposeDebug {
                    fields = append(fields, zap.ByteString("stack", debug.Stack()))
                }

                log.Error("panic recovered", fields...)

                c.AbortWithStatusJSON(http.StatusInternalServerError, response.Body{
                    Code:      errno.Panic,
                    Message:   "服务器开小差了,请稍后重试",
                    TraceID:   TraceID(c),
                    RequestID: RequestID(c),
                })
            }
        }()

        c.Next()
    }
}

设计说明:

  • panic 只应该出现在少数不可预期故障里,它不属于业务错误。
  • 是否返回堆栈给前端一般不建议开放,哪怕是测试环境也建议只记录日志。
  • Recover 的目标是“兜底保活”,而不是代替正常错误流转。

8.3 统一错误收口中间件

package middleware

import (
    "errors"
    "net/http"

    "github.com/gin-gonic/gin"
    "go.uber.org/zap"

    "your-project/internal/apperr"
    "your-project/internal/errno"
    "your-project/internal/response"
)

func ErrorHandler(log *zap.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next()

        if len(c.Errors) == 0 || c.Writer.Written() {
            return
        }

        err := c.Errors.Last().Err
        status, body, fields := convertError(c, err)
        logByStatus(log, status, fields...)
        c.AbortWithStatusJSON(status, body)
    }
}

func convertError(c *gin.Context, err error) (int, response.Body, []zap.Field) {
    fields := []zap.Field{
        zap.Error(err),
        zap.String("trace_id", TraceID(c)),
        zap.String("request_id", RequestID(c)),
        zap.String("method", c.Request.Method),
        zap.String("path", c.Request.URL.Path),
    }

    var ae *apperr.Error
    if errors.As(err, &ae) {
        body := response.Body{
            Code:      ae.Code,
            Message:   clientMessage(ae),
            TraceID:   TraceID(c),
            RequestID: RequestID(c),
        }

        if len(ae.Meta) > 0 {
            fields = append(fields, zap.Any("meta", ae.Meta))
        }
        fields = append(fields,
            zap.Int("code", ae.Code),
            zap.String("kind", string(ae.Kind)),
            zap.String("op", ae.Op),
            zap.Bool("retryable", ae.Retryable),
            zap.String("strategy", string(ae.Strategy)),
        )

        return httpStatus(ae), body, fields
    }

    body := response.Body{
        Code:      errno.Internal,
        Message:   "系统繁忙,请稍后再试",
        TraceID:   TraceID(c),
        RequestID: RequestID(c),
    }
    return http.StatusInternalServerError, body, fields
}

func clientMessage(err *apperr.Error) string {
    if err.SafeDetail != "" {
        return err.SafeDetail
    }
    return err.Message
}

func httpStatus(err *apperr.Error) int {
    switch err.Kind {
    case apperr.KindInvalidArgument:
        return http.StatusBadRequest
    case apperr.KindUnauthorized:
        return http.StatusUnauthorized
    case apperr.KindForbidden:
        return http.StatusForbidden
    case apperr.KindNotFound:
        return http.StatusNotFound
    case apperr.KindConflict, apperr.KindBusiness:
        return http.StatusConflict
    case apperr.KindDependency:
        if err.Retryable {
            return http.StatusServiceUnavailable
        }
        return http.StatusBadGateway
    default:
        return http.StatusInternalServerError
    }
}

func logByStatus(log *zap.Logger, status int, fields ...zap.Field) {
    switch {
    case status >= 500:
        log.Error("request failed", fields...)
    case status >= 400:
        log.Warn("request rejected", fields...)
    default:
        log.Info("request handled with business error", fields...)
    }
}

这个中间件解决了三件事:

  1. 所有 Handler 只需 c.Error(err),统一收口。
  2. 不同错误类型自动映射不同 HTTP 状态码和日志级别。
  3. 统一把 TraceID / RequestID 注入响应和日志。

九、请求日志中间件:日志不是越多越好,而是越“可检索”越好

很多系统日志很多,但定位仍然慢,原因是结构化字段不足。

推荐 AccessLog 至少记录:

  • trace_id
  • request_id
  • method
  • path
  • status
  • latency_ms
  • client_ip
  • user_agent
  • biz_code
package middleware

import (
    "time"

    "github.com/gin-gonic/gin"
    "go.uber.org/zap"
)

func AccessLog(log *zap.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        path := c.Request.URL.Path
        rawQuery := c.Request.URL.RawQuery

        c.Next()

        log.Info("http access",
            zap.String("trace_id", TraceID(c)),
            zap.String("request_id", RequestID(c)),
            zap.String("method", c.Request.Method),
            zap.String("path", path),
            zap.String("query", rawQuery),
            zap.Int("status", c.Writer.Status()),
            zap.String("client_ip", c.ClientIP()),
            zap.String("ua", c.Request.UserAgent()),
            zap.Duration("latency", time.Since(start)),
        )
    }
}

工程建议:

  • 访问日志与错误日志分流,避免查询噪音。
  • 高频 4xx 场景可做采样,否则恶意流量会冲爆日志系统。
  • 日志字段名全局统一,后续做 Loki / ELK / ClickHouse 查询会省很多时间。

十、业务代码如何落地:Handler、Service、Repository 分层实战

错误治理成败的关键,不只在中间件,更在业务层“怎么返回错”。

推荐原则:

  • Handler 负责协议转换,不做复杂业务判断。
  • Service 负责业务规则和错误语义。
  • Repository 负责依赖错误包装,不直接暴露底层细节。

10.1 Handler 层:只做参数与协议,不做领域决策

package handler

import (
    "net/http"

    "github.com/gin-gonic/gin"

    "your-project/internal/service"
)

type OrderHandler struct {
    svc *service.OrderService
}

type CreateOrderRequest struct {
    UserID    int64  `json:"user_id" binding:"required,min=1"`
    ProductID int64  `json:"product_id" binding:"required,min=1"`
    Quantity  int32  `json:"quantity" binding:"required,min=1,max=100"`
}

func NewOrderHandler(svc *service.OrderService) *OrderHandler {
    return &OrderHandler{svc: svc}
}

func (h *OrderHandler) Create(c *gin.Context) {
    var req CreateOrderRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.Error(service.ErrInvalidCreateOrderRequest(err))
        return
    }

    order, err := h.svc.Create(c.Request.Context(), service.CreateOrderInput{
        UserID:    req.UserID,
        ProductID: req.ProductID,
        Quantity:  req.Quantity,
    })
    if err != nil {
        c.Error(err)
        return
    }

    c.JSON(http.StatusCreated, gin.H{
        "code":       0,
        "message":    "success",
        "data":       order,
        "trace_id":   c.GetString("trace_id"),
        "request_id": c.GetString("request_id"),
    })
}

这里有一个很重要的边界意识:

参数不合法这件事,虽然发生在 Handler,但错误语义仍然应该纳入统一错误模型,而不是随便返回一个字符串。

10.2 Service 层:错误语义的核心落点

下面以“创建订单”为例,演示生产级业务错误设计。

场景设定

下单流程包含:

  1. 参数校验
  2. 查询商品
  3. 校验库存
  4. 创建订单
  5. 扣减库存
  6. 投递订单创建事件

其中任何一步失败,都不应该返回同一种错误。

package service

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
    "time"

    "your-project/internal/apperr"
    "your-project/internal/errno"
    "your-project/internal/model"
    "your-project/internal/repository"
)

type OrderRepo interface {
    GetProduct(ctx context.Context, productID int64) (*model.Product, error)
    CreateOrderTx(ctx context.Context, arg repository.CreateOrderTxArg) (*model.Order, error)
}

type EventPublisher interface {
    PublishOrderCreated(ctx context.Context, orderID int64) error
}

type OrderService struct {
    repo      OrderRepo
    publisher EventPublisher
}

type CreateOrderInput struct {
    UserID    int64
    ProductID int64
    Quantity  int32
}

func NewOrderService(repo OrderRepo, publisher EventPublisher) *OrderService {
    return &OrderService{repo: repo, publisher: publisher}
}

func ErrInvalidCreateOrderRequest(cause error) error {
    return apperr.New(
        errno.InvalidArgument,
        apperr.KindInvalidArgument,
        "请求参数不合法",
        apperr.WithCause(cause),
        apperr.WithSafeDetail("请求参数错误"),
        apperr.WithOp("OrderHandler.Create.BindJSON"),
    )
}

func (s *OrderService) Create(ctx context.Context, in CreateOrderInput) (*model.Order, error) {
    if in.UserID <= 0 || in.ProductID <= 0 || in.Quantity <= 0 {
        return nil, apperr.New(
            errno.InvalidArgument,
            apperr.KindInvalidArgument,
            "下单参数不合法",
            apperr.WithSafeDetail("下单参数不合法"),
            apperr.WithOp("OrderService.Create.Validate"),
            apperr.WithMeta("user_id", in.UserID),
            apperr.WithMeta("product_id", in.ProductID),
            apperr.WithMeta("quantity", in.Quantity),
        )
    }

    product, err := s.repo.GetProduct(ctx, in.ProductID)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, apperr.New(
                errno.NotFound,
                apperr.KindNotFound,
                "商品不存在",
                apperr.WithCause(err),
                apperr.WithSafeDetail("商品不存在"),
                apperr.WithOp("OrderService.Create.GetProduct"),
                apperr.WithMeta("product_id", in.ProductID),
            )
        }
        return nil, apperr.New(
            errno.DBUnavailable,
            apperr.KindDependency,
            "查询商品失败",
            apperr.WithCause(err),
            apperr.WithSafeDetail("服务暂时不可用,请稍后再试"),
            apperr.WithRetryable(true),
            apperr.WithStrategy(apperr.StrategyRetry),
            apperr.WithOp("OrderService.Create.GetProduct"),
        )
    }

    if !product.Enabled {
        return nil, apperr.New(
            errno.Conflict,
            apperr.KindBusiness,
            "商品已下架",
            apperr.WithSafeDetail("商品暂不可购买"),
            apperr.WithOp("OrderService.Create.CheckEnabled"),
            apperr.WithMeta("product_id", product.ID),
        )
    }

    if product.Stock < in.Quantity {
        return nil, apperr.New(
            errno.OrderStockInsufficient,
            apperr.KindBusiness,
            "库存不足",
            apperr.WithSafeDetail("库存不足"),
            apperr.WithOp("OrderService.Create.CheckStock"),
            apperr.WithMeta("stock", product.Stock),
            apperr.WithMeta("quantity", in.Quantity),
        )
    }

    order, err := s.repo.CreateOrderTx(ctx, repository.CreateOrderTxArg{
        UserID:    in.UserID,
        ProductID: in.ProductID,
        Quantity:  in.Quantity,
        Amount:    product.Price * int64(in.Quantity),
        ExpireAt:  time.Now().Add(15 * time.Minute),
    })
    if err != nil {
        return nil, apperr.New(
            errno.DBUnavailable,
            apperr.KindDependency,
            "创建订单事务失败",
            apperr.WithCause(err),
            apperr.WithSafeDetail("下单失败,请稍后重试"),
            apperr.WithRetryable(true),
            apperr.WithStrategy(apperr.StrategyRetry),
            apperr.WithOp("OrderService.Create.CreateOrderTx"),
        )
    }

    if err := s.publisher.PublishOrderCreated(ctx, order.ID); err != nil {
        return order, apperr.New(
            errno.MQUnavailable,
            apperr.KindDependency,
            "订单事件投递失败",
            apperr.WithCause(err),
            apperr.WithSafeDetail("订单已创建,通知稍后补发"),
            apperr.WithRetryable(true),
            apperr.WithStrategy(apperr.StrategyFallback),
            apperr.WithOp("OrderService.Create.PublishOrderCreated"),
            apperr.WithMeta("order_id", order.ID),
        )
    }

    return order, nil
}

这个例子有几个值得注意的点:

  1. “商品不存在”是 KindNotFound,不是内部错误。
  2. “库存不足”是业务错误,不应打成系统 ERROR。
  3. “数据库失败”属于依赖错误,且可重试。
  4. “事件投递失败”不一定要把整个请求判失败,可以根据业务决定是否降级。

这就是错误分层真正落到业务代码中的方式。

10.3 Repository 层:不要把数据库细节直接泄漏出去

Repository 的职责不是“消灭错误”,而是“约束依赖错误边界”。

package repository

import (
    "context"
    "database/sql"
    "fmt"
)

type CreateOrderTxArg struct {
    UserID    int64
    ProductID int64
    Quantity  int32
    Amount    int64
    ExpireAt  time.Time
}

type Repo struct {
    db *sql.DB
}

func (r *Repo) GetProduct(ctx context.Context, productID int64) (*model.Product, error) {
    const query = `
        SELECT id, price, stock, enabled
        FROM product
        WHERE id = ?`

    var p model.Product
    err := r.db.QueryRowContext(ctx, query, productID).Scan(
        &p.ID, &p.Price, &p.Stock, &p.Enabled,
    )
    if err != nil {
        return nil, fmt.Errorf("query product: %w", err)
    }
    return &p, nil
}

为什么这里不直接构造 apperr.Error

因为 Repository 更适合作为“依赖实现层”,它对上暴露的是依赖失败事实,而具体要解释成“数据库不可用”“商品不存在”还是“服务暂不可用”,应由更懂业务语义的 Service 来决定。

这个分层非常关键。

十一、高并发与可扩展设计:错误处理写得优雅,不代表扛得住流量

这部分是很多文章缺失的重点。

高并发场景下,错误治理本身也会成为系统开销来源。如果不做设计,错误处理可能从“辅助系统”变成“性能负担”。

11.1 堆栈捕获成本控制

runtime.Callers 不是免费的。在异常率高时,每个错误都抓完整堆栈,会带来明显 CPU 和内存开销。

建议:

  • 业务错误默认不抓堆栈,或按采样抓。
  • 系统错误、panic、未知错误抓完整堆栈。
  • 高频输入错误只保留关键信息,不抓堆栈。

可以这样设计:

type StackPolicy interface {
    NeedStack(kind apperr.Kind, code int) bool
}

type DefaultStackPolicy struct{}

func (DefaultStackPolicy) NeedStack(kind apperr.Kind, code int) bool {
    switch kind {
    case apperr.KindInternal, apperr.KindDependency:
        return true
    default:
        return false
    }
}

如果系统是超大流量入口,进一步建议加采样:

func ShouldSampleStack(rate int) bool {
    if rate <= 0 {
        return false
    }
    return rand.Intn(100) < rate
}

例如:

  • 业务错误堆栈采样率 1%
  • 依赖错误 20%
  • panic 100%

这比“一刀切全抓”更符合生产现实。

11.2 日志风暴治理

错误一多,日志系统先挂,这是很典型的线上问题。

建议从三个层面控制:

A. 分类打级别

  • 参数错误:INFO/WARN
  • 业务错误:WARN
  • 依赖错误:ERROR
  • panic:ERROR/FATAL 视情况

B. 做采样

Zap 支持采样配置,建议对高频重复错误采样,避免磁盘和网络写放大。

C. 聚合告警

不要对每条错误都告警,而是按以下维度聚合:

  • 某接口 5xx 错误率
  • 某依赖超时率
  • 某错误码分钟级突增
  • 某租户或某机房异常集中

错误治理如果不接告警聚合,最终只会产生噪音。

11.3 超时、重试、熔断与错误模型联动

真正成熟的错误治理,不是只“记录错误”,而是能驱动恢复动作。

例如:

  • Retryable = true 的依赖错误,可进入指数退避重试。
  • StrategyFallback 的错误,可走缓存或兜底数据。
  • KindDependency 且错误率过高,可触发熔断。

示例:

func CallWithRetry(ctx context.Context, fn func(context.Context) error) error {
    var lastErr error
    for i := 0; i < 3; i++ {
        if err := fn(ctx); err != nil {
            lastErr = err
            var ae *apperr.Error
            if errors.As(err, &ae) && ae.Retryable {
                time.Sleep(time.Duration(i+1) * 50 * time.Millisecond)
                continue
            }
            return err
        }
        return nil
    }
    return lastErr
}

再进一步,接入熔断器时就更自然:

if errors.As(err, &ae) && ae.Kind == apperr.KindDependency {
    breaker.MarkFailed("inventory-service")
}

也就是说,错误模型不仅服务于“响应”,还服务于“稳定性控制面”。

11.4 池化与对象复用

如果你的接口量非常高,频繁构造错误对象、日志字段和响应对象,也会带来分配压力。

可以做的优化包括:

  • 使用 sync.Pool 复用高频临时对象
  • 结构化日志字段尽量避免重复构造超大 map
  • 高频路径避免无意义的 fmt.Sprintf
  • 大量 4xx 错误场景优先减少分配,而不是追求“堆栈完整”

但要注意:

只有在性能分析确认瓶颈后再做池化,过度优化会让代码复杂度大幅上升。

十二、链路追踪与可观测性:错误不接观测,治理只完成了一半

12.1 TraceID 不等于链路追踪,但 TraceID 是最低成本入口

即使暂时没有完整 OpenTelemetry,也建议先统一 TraceID。

原因很简单:

  • 日志检索靠它
  • 压测回放靠它
  • 客服排障靠它
  • 多服务串查也靠它

12.2 与 OpenTelemetry 集成思路

在有条件的情况下,建议把错误写入当前 Span:

func RecordSpanError(ctx context.Context, err error) {
    span := trace.SpanFromContext(ctx)
    if !span.IsRecording() || err == nil {
        return
    }

    span.RecordError(err)
    span.SetStatus(codes.Error, err.Error())
}

进一步可以在错误对象中补充:

  • trace_id
  • span_id
  • upstream_service
  • downstream_service
  • dependency

这样你就能在一个错误事件里同时看到:

  • 接口请求
  • 依赖耗时
  • 错误码
  • 日志上下文
  • 分布式追踪链路

这才是完整的排障闭环。

十三、真实案例:订单服务在秒杀场景下的错误治理设计

为了让文章更贴近实际,我们看一个典型高并发场景。

13.1 场景

秒杀接口 POST /api/v1/orders 在高峰期会面临:

  • 瞬时流量暴涨
  • 商品热点库存竞争
  • 下游 Redis / DB 压力飙升
  • 大量重复请求
  • 少量用户恶意刷接口

13.2 如果没有错误治理,常见结果

  • 所有失败都返回 下单失败
  • 日志里全是重复 ERROR
  • 前端无法区分“没抢到”和“系统故障”
  • 排障时不知道是库存冲突、数据库锁等待还是 Redis 超时

13.3 正确的错误分层

A. 用户未登录

  • 错误类型:认证错误
  • HTTP 状态码:401
  • 业务码:100002
  • 是否告警:否

B. 商品售罄

  • 错误类型:业务错误
  • HTTP 状态码:409
  • 业务码:200001
  • 是否告警:否

C. 库存服务超时

  • 错误类型:依赖错误
  • HTTP 状态码:503
  • 业务码:300004
  • 是否告警:是
  • 是否重试:视接口幂等性决定

D. 下单逻辑 panic

  • 错误类型:系统错误
  • HTTP 状态码:500
  • 业务码:500002
  • 是否告警:是

13.4 秒杀场景下的工程策略

  • 业务错误不打 ERROR,避免把正常售罄当事故。
  • 库存冲突优先走原子扣减或乐观锁,而不是靠报错兜底。
  • 重试必须建立在幂等键基础上,否则会产生重复订单。
  • 对下游超时错误启用熔断和降级,保护主链路。
  • 对恶意参数或刷单流量启用限流,减少无效错误。

这说明:错误治理不是“报错美化”,而是稳定性架构的一部分。

十四、文章里最容易写错的几个点

14.1 误区一:所有错误都返回 200

这会导致:

  • 网关和监控很难基于 HTTP 指标判断异常
  • 第三方调用方很难按协议处理
  • 运维视角缺乏直观信号

兼容旧系统可以接受,但新系统不建议默认这么做。

14.2 误区二:所有错误都记录堆栈

高并发下成本很高,而且业务错误堆栈价值有限。

14.3 误区三:所有错误都打印原始 err.Error()

这可能泄露:

  • SQL 语句
  • 表名字段名
  • 路径信息
  • 第三方返回明细
  • 用户敏感数据

一定要区分“内部消息”和“外部安全消息”。

14.4 误区四:在 Repository 层做全部错误语义决策

Repository 知道数据库失败,但未必知道这是否应该被表达为“资源不存在”“服务不可用”还是“状态冲突”。

这类语义决策更适合放在 Service 层。

14.5 误区五:只做错误码,不做观测

没有 TraceID、日志字段、Span 记录、指标聚合,错误码只能解决“分类”,解决不了“定位”。

十五、测试体系怎么建:只测 happy path 不叫工程化

错误治理必须被测试覆盖,否则上线后很容易失真。

推荐至少覆盖以下四层测试。

15.1 单元测试:验证错误语义

测试目标:

  • errors.Is / errors.As 是否可用
  • 错误码、类型、策略是否正确
  • 安全消息是否按预期返回
func TestAppError_Unwrap(t *testing.T) {
    root := sql.ErrConnDone
    err := apperr.New(
        errno.DBUnavailable,
        apperr.KindDependency,
        "数据库不可用",
        apperr.WithCause(root),
        apperr.WithRetryable(true),
    )

    if !errors.Is(err, root) {
        t.Fatal("errors.Is should match root cause")
    }
}

15.2 中间件测试:验证响应收口

重点测:

  • c.Error(err) 是否被正确转换为统一响应
  • 不同错误类型的 HTTP 状态码是否符合预期
  • TraceID 是否在响应中回传

15.3 集成测试:验证依赖失败链路

例如:

  • 数据库超时
  • Redis 不可用
  • 下游 502 / 504
  • 消息投递失败

这类测试的价值非常高,因为它直接验证错误传播链路是否被正确包装。

15.4 压测与故障注入

推荐工具:

  • wrk
  • vegeta
  • k6
  • chaos 工具或自研故障注入开关

重点观察:

  • 5xx 错误率
  • P95 / P99 延迟
  • 日志写入量
  • CPU / 内存变化
  • 错误激增时是否出现二次故障

错误治理系统自身也必须接受压测。

十六、推荐的完整工程目录

一个更适合落地的目录结构如下:

project/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── apperr/
│   │   └── error.go
│   ├── errno/
│   │   └── code.go
│   ├── handler/
│   │   └── order_handler.go
│   ├── service/
│   │   └── order_service.go
│   ├── repository/
│   │   └── order_repo.go
│   ├── middleware/
│   │   ├── request_identity.go
│   │   ├── recover.go
│   │   ├── error_handler.go
│   │   ├── access_log.go
│   │   ├── timeout.go
│   │   └── rate_limit.go
│   ├── response/
│   │   └── body.go
│   ├── telemetry/
│   │   └── tracing.go
│   └── bootstrap/
│       └── http.go
├── test/
│   ├── unit/
│   ├── integration/
│   └── benchmark/
└── docs/
    └── error-code.md

这个目录的核心思想是:

  • 错误抽象独立出来,不跟某个业务耦合。
  • 中间件收口放在统一目录,方便 HTTP 框架层演进。
  • docs/error-code.md 必须存在,治理不是代码私产。

十七、主程序装配示例

下面给出一个可直接参考的 Gin 装配方式。

package main

import (
    "time"

    "github.com/gin-gonic/gin"
    "go.uber.org/zap"

    "your-project/internal/handler"
    "your-project/internal/middleware"
    "your-project/internal/service"
)

func buildHTTPServer(log *zap.Logger, orderHandler *handler.OrderHandler) *gin.Engine {
    gin.SetMode(gin.ReleaseMode)

    r := gin.New()
    r.Use(
        middleware.RequestIdentity(),
        middleware.AccessLog(log),
        middleware.Recover(log, false),
        middleware.Timeout(2*time.Second),
        middleware.ErrorHandler(log),
    )

    api := r.Group("/api/v1")
    {
        api.POST("/orders", orderHandler.Create)
    }

    r.GET("/healthz", func(c *gin.Context) {
        c.JSON(200, gin.H{"status": "ok"})
    })

    return r
}

这里再强调一次:

  • RequestIdentity 要尽可能靠前。
  • ErrorHandler 作为统一收口,必须在业务执行链上起作用。
  • Timeout 很关键,否则慢依赖会把错误治理拖死。

十八、如果系统不只是 HTTP,还要怎么扩展?

一套好的错误治理模型应该能跨协议复用。

18.1 gRPC

做法:

  • apperr.Error 作为内部统一错误对象
  • 在 gRPC interceptor 中映射为 status.Status
  • CodeTraceID 写入 metadata 或 details

18.2 消息消费

做法:

  • Retryable = true 的依赖错误进入重试队列
  • KindBusiness 的错误直接 ACK,避免无意义重试
  • KindInternal 进入死信或人工介入流程

18.3 Cron / Job

做法:

  • 用同一套错误分类决定是否重试、是否中断、是否告警
  • 保留 Op 字段,帮助定位是哪个 Job 阶段失败

这意味着:

你的错误模型不该只服务 Gin,而应该服务整个服务端系统。

十九、落地清单:团队从 0 到 1 推进错误治理怎么做

如果你要在团队里推动这件事,建议按下面顺序推进。

第一阶段:统一协议

  • 定义统一响应结构
  • 定义错误码规范
  • 定义错误分类和 HTTP 状态码映射

第二阶段:统一收口

  • 落地 Gin 错误处理中间件
  • 落地 Recover 中间件
  • 落地 TraceID / RequestID

第三阶段:分层治理

  • Handler 只做参数与协议
  • Service 负责错误语义
  • Repository 负责依赖失败边界

第四阶段:接入观测

  • 统一日志字段
  • 接入 TraceID 检索
  • 接入 OpenTelemetry / 指标平台

第五阶段:稳定性联动

  • 错误对象接入重试、熔断、限流、降级
  • 对高频错误做采样和聚合告警

这比一次性大重构更容易成功。

二十、结语:优秀的错误处理,本质上是在设计系统的失败方式

很多人把错误处理理解为“异常情况下补几行代码”,这是远远不够的。

在生产环境中,错误处理真正回答的是三个问题:

  1. 当系统失败时,用户会看到什么?
  2. 当系统失败时,工程师能否快速定位?
  3. 当系统失败时,系统是否还能稳定运行?

因此,好的 Go + Gin 错误治理,不是简单的 return err,也不是几个 middleware 的堆叠,而是一套完整的工程体系:

  • 用统一错误模型表达语义
  • 用分层设计隔离职责
  • 用中间件实现统一收口
  • 用可观测性建立排障闭环
  • 用高并发优化保证治理本身不成为负担
  • 用策略字段打通重试、降级、熔断等稳定性能力

如果你把这套体系搭起来,错误就不再只是“坏消息”,而会成为系统运行状态最准确、最有价值的信号之一。

如果你在实践 Go 高可用架构与微服务时遇到更多问题,欢迎到 云栈社区 与其他开发者交流经验,那里有丰富的后端实战讨论和资源。

附录 A:文中示例的几个实战建议

1. 关于返回文案

  • 面向用户的文案简洁、安全、可理解
  • 面向内部排障的信息放在日志与 Trace 中

2. 关于错误码文档

  • 必须有文档
  • 必须由后端、前端、测试共同维护
  • 变更必须可追踪

3. 关于高并发系统

  • 业务错误尽量轻量化
  • 系统错误保留足够上下文
  • 高频错误必须做采样与聚合

4. 关于演进路线

  • 先统一,再细化
  • 先建收口,再谈优化
  • 先能定位,再谈优雅

附录 B:一句话设计原则总结

  • 业务错误不是系统故障。
  • 根因错误要保留,外部细节要脱敏。
  • 错误码解决协作,TraceID 解决定位。
  • Service 定语义,Repository 定边界。
  • 错误治理必须接入日志、指标和链路。
  • 高并发下,错误处理也要做性能设计。



上一篇:生产级日志架构设计:Filebeat+Redis+Logstash+Elasticsearch应对千万级并发
下一篇:当AI Agent只回复“Done”:如何用DuckDB实现OpenClaw全链路观测与10秒排障
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-7 18:52 , Processed in 0.661922 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表