Go 错误处理:从 if err != nil 到错误设计

“Go 的错误处理就是到处写 if err != nil"——这是新手认知。生产级 Go 的错误处理是错误设计:错误怎么分类、怎么包装、怎么传递、怎么记录。这篇文章是我从"会写"到"会设计"的完整总结。

一、错误的三个问题

问 问 问 题 题 题 " 网 底 1 c 2 络 3 层 : o : 错 : 错 错 n 错 误 错 误 误 n 误 ( 误 直 没 e 没 可 没 接 有 c 有 重 有 透 上 t 分 试 流 传 下 i 类 ) 向 给 文 o 和 上 n 参 层 数 / r 错 前 e 误 端 f ( u 重 → s 试 e 也 暴 d 没 露 " 用 内 ) 部 — 混 细 — 在 节 一 哪 起 / 个 服 用 务 户 ? 看 哪 不 次 懂 调 用 ? 什 么 参 数 ?

二、错误包装(上下文)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Go 1.13+ 错误包装(%w)
func GetOrder(ctx, id) (*Order, error) {
    order, err := repo.Find(id)
    if err != nil {
        return nil, fmt.Errorf("获取订单失败 id=%s: %w", id, err)
    }
    return order, nil
}

// 排查时:errors.Is / errors.As 逐层解包
// "获取订单失败 id=abc: dial tcp 10.0.0.1:3306: connection refused"
// ↑ 一眼看到:哪个操作、哪个参数、底层原因

规范:

✅ ✅ ❌ ❌ 每 关 只 吞 层 键 透 错 包 参 传 误 装 数 ( : 进 e e f 错 r r m 误 r r t ( ( . 订 没 忽 E 单 有 略 r 上 ) r I 下 — o D 文 — r 、 , 最 f 用 等 坏 ( 户 于 的 " 没 写 操 I 说 法 作 D ) ) + — — 关 排 键 查 参 不 数 用 : 翻 日 % 志 w " , e r r )

三、错误分类(可恢复 vs 不可恢复)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 定义错误类型(分类)
type BizError struct {
    Code    int    // 业务错误码
    Message string // 用户可读
    Retryable bool // 是否可重试
}

var (
    ErrNotFound     = &BizError{40400, "资源不存在", false}
    ErrConflict     = &BizError{40900, "状态冲突", true}
    ErrUpstreamDown = &BizError{50300, "上游服务不可用", true}
)

// 处理策略不同:
//   ErrNotFound → 返回 404(正常业务分支)
//   ErrConflict → 重试/提示用户
//   ErrUpstreamDown → 熔断降级

分类的价值:上层根据分类做不同处理(重试/降级/提示/告警),而不是"所有错误一律 500”。

四、错误流向设计

底 中 上 层 间 层 ( 层 ( r ( h e s a p e n o r d / v l d i e a c r o e ) ) ) : : : 映 原 业 射 始 务 错 错 H 误 误 T 分 T + 类 P ( 上 B 状 下 i 态 文 z 码 包 E 装 r + r o 用 r 户 ) 可 读 信 息
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// handler 层
func GetOrderHandler(c *gin.Context) {
    order, err := svc.GetOrder(c, id)
    if err != nil {
        var be *BizError
        if errors.As(err, &be) {
            c.JSON(be.Code, gin.H{"code": be.Code, "msg": be.Message})
            return
        }
        // 未分类错误:日志 + 通用 500(不暴露细节)
        log.Error("未分类错误", "err", err, "id", id)
        c.JSON(500, gin.H{"code": 50000, "msg": "系统繁忙"})
        return
    }
    c.JSON(200, order)
}

原则:

✅ ✅ ✅ 用 内 未 户 部 分 只 错 类 看 误 错 到 必 误 " 须 兜 可 记 底 读 日 : 信 志 5 息 ( 0 " 带 0 ( 完 不 整 + 暴 上 露 下 日 堆 文 志 栈 ) ( / 别 内 让 部 内 细 部 节 细 ) 节 漏 到 前 端 )

五、错误与日志的配合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 记日志的规范
log.Error("获取订单失败",
    "err", err,            // 完整错误链
    "order_id", id,        // 关键参数
    "trace_id", traceID,   // 链路
    "user_id", uid,        // 用户维度
)

// 关键:ERROR 日志必须"可行动"
//  看了知道:什么问题、影响谁、怎么处理

六、避坑清单

✅ ✅ ✅ ✅ ❌ ❌ ❌ 每 错 分 日 别 别 别 层 误 层 志 吞 裸 用 包 分 流 带 错 透 装 类 向 全 误 传 p ( ( ( 上 ( ( a 操 B 底 下 e 底 n 作 i 层 文 r 层 i z 上 ( r 错 c + E 下 e 误 r 文 r ! 直 控 参 r r = 接 制 数 o → 回 业 r + n 前 务 + 中 i 端 流 + 层 参 l ) ( % 分 数 p w R 类 时 a ) e + 忽 n t → 略 i r t ) c y 上 r a 层 a 只 b 映 c 留 l 射 e 给 e ) _ " ) i 不 d 该 ) 发 生 的 事 " )

七、实战:一个完整的错误示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// repo 层
func (r *Repo) FindOrder(ctx, id) (*Order, error) {
    row := r.db.First(&Order{}, id)
    if errors.Is(row.Error, gorm.ErrRecordNotFound) {
        return nil, fmt.Errorf("订单不存在 id=%s: %w", id, ErrNotFound)
    }
    if row.Error != nil {
        return nil, fmt.Errorf("查询订单失败 id=%s: %w", id, row.Error)
    }
    return &Order{}, nil
}

// 排查体验:
//  前端看到:404 资源不存在
//  日志看到:查询订单失败 id=abc: dial tcp ...(完整链路)
//  运维知道:可以重试 or 查数据库连通性

总结

Go 错误处理的核心认知:

  1. 包装:每层加"操作 + 参数",用 %w 保留链
  2. 分类:BizError + Retryable,上层差异化处理
  3. 流向:底层上下文 → 中层分类 → 上层映射
  4. 日志:错误必须可行动(带全上下文)

“if err != nil” 是语法,错误设计才是能力。 一个项目错误处理好不好,决定了凌晨两点被叫起来排查时是"5 分钟定位"还是"翻一小时日志"。