一、错误处理为什么这么难

写程序这件事,业务逻辑再复杂,只要捋清楚需求,总能写出来。但错误处理不一样,它出现在每一个角落,你永远不知道会在哪一步翻车。很多刚接触 Go 的朋友,习惯把错误当成“异常”来看,一遇到不对就 panic,或者干脆忽略不处理。结果到了生产环境,日志里只有一行“nil pointer dereference”,完全不知道是哪个接口、哪个用户、哪次请求触发的。这种状态,就是错误处理没设计好的典型症状。

咱们做一个后端服务,每天要处理成千上万个请求,任何一个请求中间都可能出错。数据库连不上、第三方接口超时、参数没校验通过、缓存没命中,这些都能变成 errors。如果在代码里没有好好地把它们“传下去”,而是随手丢给上层,最后你只能面对一堆毫无意义的消息干瞪眼。下面咱们的示例统一用 Go 语言,这是每个 Go 工程师每天都会打交道的技术栈。Go 的错误处理看起来简单,但想做好,一点也不简单。

二、Go 语言错误处理的基本盘

2.1 error 是一个接口

Go 里错误是一个内置接口,只要实现了 Error() string 方法,就能当错误用。这个设计让错误可以很轻量,但也给了我们很大的发挥空间。你可以创建一个最简单的错误,也可以用一个复杂的结构体来承载各种上下文。对于多数刚起步的代码,能定义一个错误变量,就已经很满足了。

package main

import (
    "errors"
    "fmt"
)

// 定义两个基础错误变量
var ErrNotFound = errors.New("记录不存在")
var ErrTimeOut  = errors.New("操作超时")

func main() {
    // 直接返回错误变量
    err := doSomething()
    if err != nil {
        fmt.Println(err)
    }
}

func doSomething() error {
    // 这里我们直接返回一个预定义错误
    return ErrNotFound
}

这种预定义错误变量的方式很常见,但问题是错误信息只有一句话。一旦系统复杂起来,这句话根本不够用,排查问题的时候你会想去撞墙。

2.2 错误字符串的烦恼

很多项目里,错误是用字符串拼出来的,比如 “order create failed: product id:x”。这种做法的好处是直白,坏处是没法被程序识别。你想根据错误类型做不同的重试逻辑?对不起,只能字符串匹配,跟用雷区探雷一样可怕。而且同一个错误可能在不同地方被拼出不同的格式,排查的时候全靠肉眼。更要命的是,一旦项目里出现几个不同风格的错误文案,你都不知道该相信谁。

三、生产中遇到的真问题

假设咱们开发一个订单服务,用户下单时涉及库存、优惠券、支付三个子系统。任何一个环节出错,都需要上报并记录日志。如果每个子系统的错误都是裸字符串,那么最终日志可能这样:

2025-03-01 10:00:01 ERROR 库存扣减失败
2025-03-01 10:00:02 ERROR 优惠券使用失败

然后你就要开始猜:是哪个订单?哪个仓库?哪个优惠券?为什么失败?是网络超时还是余额不足?这种日志基本没价值,因为缺少链路信息。

生产环境不是本地调试,你不能再看一眼变量值。一个请求会穿越好几个服务,我们需要的是层层包裹的错误,每一步都追加“我在哪、做了什么、看到了什么”,最后形成一个完整的错误链。这样拿到日志的一瞬间,就能顺着链条找到根因。想象一下,你被叫去排查事故,打开日志看到一句“连接失败”,你会怎么想?连接谁?哪个 IP?什么时候?但是如果你看到“订单服务调用支付网关超时,订单号 20250301001,重试次数 2,最近一次错误:tcp connect timeout”,你几乎不需要思考就能定位。这就是错误链路设计带来的差异。

四、打造一条清晰可见的错误链路

要设计清晰的错误链路,核心思路就是:在错误向上传递的时候,不断给它增加上下文。Go 标准库提供了非常顺手的工具,我们不用自己造轮子。

4.1 给错误穿上“外套”:fmt.Errorf 与 %w

Go 1.13 之后,Errorf 支持了 %w,这是错误包装的基础玩法。%w 能把原来的错误“包”进一个新的错误里,上层既能看到新的描述,也能通过 Unwrap 慢慢拆出最里面的根因。

package main

import (
    "fmt"
)

// 模拟底层数据库查询
func queryUser(id int) error {
    // 这里假装发生了底层错误
    return fmt.Errorf("数据库连接池已耗尽")
}

// 业务层调用
func getUser(id int) error {
    err := queryUser(id)
    if err != nil {
        // 用 %w 包装,既保留原错误,又增加业务上下文
        return fmt.Errorf("查询用户 %d 失败: %w", id, err)
    }
    return nil
}

func main() {
    err := getUser(123)
    if err != nil {
        // 打印时能看到完整的链路
        fmt.Println(err)
    }
}

运行结果会显示出“查询用户 123 失败: 数据库连接池已耗尽”,排查问题的信息量就上来了。%w 的好处是,上层可以通过 errors.Unwrap 一层层解开,找到最里层的根因。

4.2 建立错误码体系

光有描述还不够,最好给错误配上稳定不变的错误码。错误码是给程序看的,错误描述是给人看的。这样,日志系统可以按错误码聚合告警,客户端也可以根据错误码做对应的交互提示。

package main

import (
    "fmt"
)

// 定义错误码常量
const (
    ErrCodeNotFound = 10001 // 资源不存在
    ErrCodeInternal = 10002 // 内部异常
)

// AppError 是携带错误码的自定义错误
type AppError struct {
    Code    int    // 错误码
    Message string // 用户可读信息
    Op      string // 出错的业务操作
    Err     error  // 底层错误
}

// Error 实现 error 接口
func (e *AppError) Error() string {
    return fmt.Sprintf("[%d] %s (操作: %s 底层: %v)", e.Code, e.Message, e.Op, e.Err)
}

func main() {
    err := placeOrder()
    if err != nil {
        // 打印错误链
        fmt.Println(err)
    }
}

func placeOrder() error {
    err := deductStock()
    if err != nil {
        return &AppError{
            Code:    ErrCodeInternal,
            Message: "扣减库存失败",
            Op:      "order.placeOrder",
            Err:     err,
        }
    }
    return nil
}

func deductStock() error {
    // 模拟返回一个底层错误
    return fmt.Errorf("库存服务返回 500 超时")
}

这里 AppError 结构体就像物流包裹的“外箱”,里面装着错误码、操作名和原始错误。这样你既能用错误码做自动化告警,又能看到完整链路。错误码的定义也不是随便写几个数字。最好按照业务模块划分区间,比如用户服务从 10000 开始,订单服务从 20000 开始。这样就算错误码跨服务传递,也能一眼看出是哪个模块出的问题。而且错误码一旦确定,就不要随意修改,否则下游依赖你错误码的系统会莫名其妙。

4.3 给错误加上更细致的上下文

有些时候,描述还不够,我们还需要关键信息,比如用户 ID、订单号、请求 ID。把这些信息一起塞进来,排查问题会非常舒服。我们可以扩展上面 AppError,也可以直接用 fmt.Errorf 配合格式化参数。

package main

import (
    "fmt"
)

func main() {
    err := processOrder("10086", "SKU-888", 3)
    if err != nil {
        fmt.Println(err)
    }
}

func processOrder(orderID string, sku string, count int) error {
    // 调用底层扣减接口
    err := callInventory(sku, count)
    if err != nil {
        // 把订单ID、SKU、数量都包进错误里
        return fmt.Errorf("订单 %s 扣减库存失败 (sku=%s, 数量=%d): %w", orderID, sku, count, err)
    }
    return nil
}

func callInventory(sku string, count int) error {
    // 模拟一个底层错误,比如网络错误
    return fmt.Errorf("连接库存服务超时")
}

这样的错误信息,一眼就能定位到具体订单、具体商品、具体数量,不用再去翻关联表。注意,日志里不要放密码、手机号这些敏感信息,错误链里需要的是业务上下文,不是个人隐私。

五、进阶:用 errors.Is 和 errors.As 做分支处理

错误链设计好了,怎么判断错误类型呢?标准库给了 errors.Is 和 errors.As 两个函数。

  • errors.Is 用来判断错误是否匹配某个目标错误。
  • errors.As 用来把错误链中的某一层转换为具体类型。

看个例子:

package main

import (
    "errors"
    "fmt"
)

// 定义基础错误
var ErrStorage = errors.New("存储层故障")

// DefineError 是携带额外信息的错误类型
type DefineError struct {
    Op  string // 具体操作名
    Err error  // 原始错误
}

// Error 实现 error 接口
func (d *DefineError) Error() string {
    return fmt.Sprintf("操作 %s 失败: %v", d.Op, d.Err)
}

// Unwrap 让 errors.Is/As 能深入错误链
func (d *DefineError) Unwrap() error {
    return d.Err
}

func main() {
    err := topFunc()
    if errors.Is(err, ErrStorage) {
        fmt.Println("识别到存储层故障,可以进行重试")
    }

    var dErr *DefineError
    if errors.As(err, &dErr) {
        fmt.Printf("出错的操作是: %s\n", dErr.Op)
    }
}

func topFunc() error {
    // 内层包装
    errA := fmt.Errorf("查询订单表: %w", ErrStorage)
    // 外层包装
    errB := fmt.Errorf("处理订单: %w", errA)
    // 还可以用自定义类型包装
    return &DefineError{Op: "order.split", Err: errB}
}

这样,程序就可以根据错误类型做不同处理。比如遇到存储层故障就等待重试,遇到业务规则错误就返回给用户提示信息。这比比对字符串靠谱一万倍。实际开发中,如果你在持久层遇到 sql.ErrNoRows,就可以通过 errors.Is 判断,然后转换成“资源不存在”的业务错误码返回给上层。

六、应用场景与优缺点

6.1 应用场景

这套错误链路设计最适合互联网后端服务,尤其是微服务架构。无论是 HTTP API 层、RPC 层还是定时任务,只要错误需要跨函数、跨模块、跨服务传播,就能用到。具体场景包括:

  • 订单服务:多系统协作,错误需要定位到具体订单和环节。
  • 用户中心:各种校验和存储交互,需要区分参数错误、鉴权错误、数据库错误。
  • 数据同步任务:需要记录失败记录和重试策略,对错误码和上下文要求高。

还有一个容易被忽略的场景:异步队列。消费者从队列里拿出消息去处理,如果处理失败,不能直接把消息丢掉,需要记录错误并合理重试。如果错误里没有消息 ID 和业务上下文,重试机制很难设计。比如一个消息被消费了三次都失败,你至少得知道它处理的是哪个订单吧。

6.2 优点

  • 排查速度快:日志信息完整,不需要重复打点就能知道调用链。
  • 可编程性强:错误码和类型让流程可以进行分支判断。
  • 代码结构清晰:错误处理逻辑不散落,每一层只关心自己有没有上下文要追加。
  • 对监控友好:错误码可以连入监控系统,出现高频错误码自动报警。

6.3 缺点

  • 需要多写一些代码,包装错误本身是有成本的。
  • 如果过度包装,错误链会很长,反而影响阅读。
  • 错误码体系需要统一维护,否则容易失控。
  • 需要团队达成共识,如果每个人包装风格不一致,最后还是会产生一堆“格式混乱的西装”。

七、注意事项

设计错误链路时,有几个坑要避开。

第一,不要把错误吞掉。很多人喜欢来个 _ = err 或者只打印不返回。上了生产环境,错误没有传到上层,就等于什么都没发生。千万要杜绝“反正不关键,错就错吧”的念头。

第二,不要太早把 error 转成字符串。一旦转成字符串,后面的 errors.Is 和 errors.As 就没用了。尽量保持 error 类型直到最外层再打印。

第三,不要重复包装。已经包装过的错误,再包装时如果手滑用了 %v,会丢失原始错误链,导致 errors.Is 失效。记住,想要保留链子就用 %w。

第四,错误码不要随便定义。建议同一个服务内部统一维护一份错误码清单,并且对应文档。线上报错时,根据错误码就能找到释义和解决方案。如果错误码不够用或含义模糊,尽早申请新增,不要“复用”那些八字不合的码。

第五,注意敏感信息。日志里不要夹杂用户密码、手机号等个人隐私。错误链中需要上下文,但也要做脱敏处理。比如身份证号只保留前几位,密码一律不记录。

第六,要给最外层的中间件统一拦劫错误。不要在几十个 handler 里各写各的打印逻辑,用一个集中错误处理的地方,把错误链整体输出到日志,再根据错误码决定返回给客户端什么样的信息。

八、文章总结

错误处理是工程问题,不是语法问题。把错误当成普通值来通盘设计,而不是应付式地 if err != nil,才能真正提升线上故障定位效率。用 fmt.Errorf 加上 %w 做包装,用自定义结构体携带错误码和操作名,再用 errors.Is 和 errors.As 做分支逻辑,这套组合拳足够应对绝大多数生产环境场景。关键是养成习惯:每次写错误处理时,多问自己一句,这个错误到最终日志里,能让人一眼看懂吗?如果答案不是肯定的,就赶紧补上下文吧。真正的错误处理高手,不是把代码写得多么炫酷,而是让后来排查问题的人少掉几根头发。