适用版本:Go 1.21+、Gin 1.9+、Zap 1.26+、OpenTelemetry 1.x
关键词:错误治理、统一响应、异常分层、可观测性、高并发、微服务、工程化
一、引言:为什么很多 Go 服务“能跑”,但一出故障就难查?
在多数团队里,错误处理往往停留在“if err != nil { return err }”这一层。代码没有错,但系统治理并没有真正建立起来。
一个线上接口在高峰期报错时,常见问题通常不是“有没有返回错误”,而是:
- 返回给前端的结构不统一,前端只能写一堆分支兼容。
- 日志中只有
internal error,无法定位是哪一层失败。
- 数据库超时、Redis 超时、第三方失败、业务校验失败全部长得一样。
- 没有 TraceID,调用链一长就查不到故障传播路径。
- 所有异常都打 ERROR、都告警,最终造成告警疲劳。
- 错误处理与业务代码耦合,越写越散,越散越难收敛。
因此,生产环境真正需要的不是“能返回错误”,而是建立一套完整的错误治理体系:
- 错误有分类。
- 响应有协议。
- 日志有上下文。
- 链路可追踪。
- 高并发场景下性能可控。
- 架构层面可以持续扩展,而不是越补越乱。
本文会以 Gin 为载体,构建一套可直接落地的生产级错误处理方案,并重点补齐很多文章容易忽略的几个关键点:
- 错误抽象和架构分层如何设计。
- Gin 中间件顺序为什么会直接影响治理效果。
- 高并发下堆栈捕获、日志写入、错误包装的性能取舍。
- 如何把错误处理与限流、熔断、重试、链路追踪打通。
- 如何让文章里的代码真正具备“工程可用性”。
二、错误治理的目标:不是“少报错”,而是“报对错、查得出、控得住”
我们先明确错误治理的目标。
2.1 业务视角的目标
- 前端能稳定识别错误类型,而不是依赖模糊文案。
- 用户看到的是可理解、可操作的提示,而不是内部细节。
- 业务错误和系统错误必须能区分,否则会误导决策。
2.2 工程视角的目标
- 错误从 Handler 到 Service 到 Repository 的传播路径清晰。
- 错误码、HTTP 状态码、日志级别、告警等级之间能建立映射。
- 线上排障时可以通过 TraceID 快速串起请求、日志、指标、链路。
2.3 架构视角的目标
- 新业务接入时,不需要重复发明一套错误模型。
- 新增中间件、网关、消息消费端、定时任务时,治理模型还能复用。
- 错误处理能承载更高级能力:重试、降级、熔断、审计、风控。
一句话概括:
错误治理不是一个 utils 包,而是服务稳定性架构的一部分。
三、先讲原理:错误到底应该如何分层?
在 Go 服务中,错误至少要从两个维度分类:来源维度和处理维度。
3.1 来源维度:错误来自哪里?
A. 输入类错误
请求参数非法、缺少字段、格式错误、权限不足、签名错误等。
特点:
- 本质上是“请求不满足约束”。
- 通常不需要告警。
- 适合记录为
WARN 或 INFO。
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 拦截器
- 消息消费者
- 定时任务执行器
- 熔断器与重试器
这就是“错误治理架构化”的关键。
四、生产级错误模型设计:不要只定义一个 Code 和 Message
很多文章里的错误结构大概长这样:
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
}
这套模型有几个核心收益:
Message 用于内部统一语义,不一定直接返回给前端。
SafeDetail 用于返回给外部调用方,避免把 SQL、文件路径、连接串等泄露出去。
Cause 保留根因,支持 errors.Is / errors.As。
Kind 让我们可以统一映射 HTTP 状态码、日志级别和告警策略。
Strategy 和 Retryable 为熔断、重试、降级预留能力。
五、错误码体系设计:不要让错误码沦为“数字常量堆”
错误码设计的本质,不是为了“看起来规范”,而是为了支撑协作和治理。
推荐按域划分,而不是简单按“前后端约定几个数”。
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
}
这里有两个关键点:
- 不只写入 Gin Context,也写入
context.Context,便于 Service / Repository 透传。
- 回写 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...)
}
}
这个中间件解决了三件事:
- 所有 Handler 只需
c.Error(err),统一收口。
- 不同错误类型自动映射不同 HTTP 状态码和日志级别。
- 统一把 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 层:错误语义的核心落点
下面以“创建订单”为例,演示生产级业务错误设计。
场景设定
下单流程包含:
- 参数校验
- 查询商品
- 校验库存
- 创建订单
- 扣减库存
- 投递订单创建事件
其中任何一步失败,都不应该返回同一种错误。
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
}
这个例子有几个值得注意的点:
- “商品不存在”是
KindNotFound,不是内部错误。
- “库存不足”是业务错误,不应打成系统 ERROR。
- “数据库失败”属于依赖错误,且可重试。
- “事件投递失败”不一定要把整个请求判失败,可以根据业务决定是否降级。
这就是错误分层真正落到业务代码中的方式。
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
- 将
Code、TraceID 写入 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 / 指标平台
第五阶段:稳定性联动
- 错误对象接入重试、熔断、限流、降级
- 对高频错误做采样和聚合告警
这比一次性大重构更容易成功。
二十、结语:优秀的错误处理,本质上是在设计系统的失败方式
很多人把错误处理理解为“异常情况下补几行代码”,这是远远不够的。
在生产环境中,错误处理真正回答的是三个问题:
- 当系统失败时,用户会看到什么?
- 当系统失败时,工程师能否快速定位?
- 当系统失败时,系统是否还能稳定运行?
因此,好的 Go + Gin 错误治理,不是简单的 return err,也不是几个 middleware 的堆叠,而是一套完整的工程体系:
- 用统一错误模型表达语义
- 用分层设计隔离职责
- 用中间件实现统一收口
- 用可观测性建立排障闭环
- 用高并发优化保证治理本身不成为负担
- 用策略字段打通重试、降级、熔断等稳定性能力
如果你把这套体系搭起来,错误就不再只是“坏消息”,而会成为系统运行状态最准确、最有价值的信号之一。
如果你在实践 Go 高可用架构与微服务时遇到更多问题,欢迎到 云栈社区 与其他开发者交流经验,那里有丰富的后端实战讨论和资源。
附录 A:文中示例的几个实战建议
1. 关于返回文案
- 面向用户的文案简洁、安全、可理解
- 面向内部排障的信息放在日志与 Trace 中
2. 关于错误码文档
- 必须有文档
- 必须由后端、前端、测试共同维护
- 变更必须可追踪
3. 关于高并发系统
- 业务错误尽量轻量化
- 系统错误保留足够上下文
- 高频错误必须做采样与聚合
4. 关于演进路线
- 先统一,再细化
- 先建收口,再谈优化
- 先能定位,再谈优雅
附录 B:一句话设计原则总结
- 业务错误不是系统故障。
- 根因错误要保留,外部细节要脱敏。
- 错误码解决协作,TraceID 解决定位。
- Service 定语义,Repository 定边界。
- 错误治理必须接入日志、指标和链路。
- 高并发下,错误处理也要做性能设计。