Go的错误处理:那些我曾经觉得很蠢、后来发现自己才是蠢的那个设计

2026-04-29 11 0

干Go语言这些年,如果有人问我哪个设计最想让我口吐芬芳,我一定会说是错误处理。满屏的 if err != nil ,写得我怀疑人生。我曾经无数次吐槽:这不就是让程序员多写十倍代码吗?直到有一天,我线上出了bug,追查了三个小时才找到根因——就是因为错误没有正确wrap。我才意识到,Go设计这套错误处理,是有它的道理的。只是我当年太年轻,看不懂。

今天来好好聊聊Go错误处理的几个核心概念,以及我踩过的那些坑。全文硬核,建议配水服用。

1. errors.Is 和 errors.As:你还在用 == 比较错误?

先问个问题:下面这段代码,最终会打印出什么?

err1 := fmt.Errorf("context: %w", ErrPermission)
err2 := fmt.Errorf("handler: %w", err1)

if err2 == ErrPermission {
    fmt.Println("相等")
} else {
    fmt.Println("不相等")
}

答案是:不相等。因为err2是一个全新的error对象,它的值跟ErrPermission完全不同。

很多新手会犯这个错误:用 == 或者 != 来比较两个error是否相同。这是错的,因为error是接口,两个接口比较的是地址,不是它们包装的内容。

正确的方式是用 errors.Is :

if errors.Is(err2, ErrPermission) {
    fmt.Println("找到了!")
}

errors.Is 会沿着错误链向上追溯,检查 Unwrap() 返回的错误是否匹配目标。这是Go错误处理的核心设计之一——错误是可以被"解包"的。

2. %w vs %v:损失了一个宇宙的错误

Go 1.13引入了错误包装,用法和坑都集中在两个格式化动词上: %w 和 %v 。

先看 %v ——这是大多数人随手一写的方式:

err := fmt.Errorf("get user: %v", ErrUserNotFound)

这样做的问题是: err 只是一个包含字符串的新error, ErrUserNotFound 的信息完全丢失了。你无法用 errors.Is 来判断这个错误是否源于 ErrUserNotFound 。

正确姿势是用 %w :

err := fmt.Errorf("get user: %w", ErrUserNotFound)

加了 %w 之后, errors.Is(err, ErrUserNotFound) 就会返回 true 了。

血的教训:我当年用一个 %v 坑了自己整整三个小时。线上用户反馈"领不了优惠券",我追日志追了半天,发现下游服务返回了一个被 %v 包装过的错误,导致我的 errors.Is 判断完全失效。改成 %w 之后,世界清净了。

3. 自定义错误类型:让错误携带上下文

光有 sentinel errors(预定义错误)是不够的。想想看:你在用户模块里定义了一个 ErrUserNotFound,然后在订单模块里也定义了一个 ErrUserNotFound——这两个可不是一回事,无法通过 errors.Is 区分。

这时候需要自定义错误类型。举一个我真实用过的场景:

type ValidationError struct {
    Field   string
    Message string
    Cause   error
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed on %q: %s", e.Field, e.Message)
}

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

关键是这个 Unwrap() 方法。它实现了 errors.Is 和 errors.As 的自动查找——Go会在错误链上递归调用 Unwrap() 直到找到匹配的错误。

然后你可以这样用:

err := validateUser(req)
if err != nil {
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("字段 %s 校验失败: %s\n", ve.Field, ve.Message)
    }
}

errors.As 类似于 errors.Is ,但它可以顺着错误链找到目标类型的错误,然后赋值给你指定的变量。这样你就能在只知道部分上下文的情况下,获取完整的错误信息。

4. goroutine 里的错误:沉默的杀手

这是很多人忽视的一个大坑。

func fetchAll(ids []int) error {
    for _, id := range ids {
        go func(userID int) {
            err := fetchUser(userID)
            if err != nil {
                // 这个错误去哪了?
                log.Printf("fetch user %d failed: %v", userID, err)
            }
        }(id)
    }
    return nil // 这里总是返回nil,因为err是内部变量
}

goroutine 里的错误无法自动传播到外层函数。你在 for 循环里启动了 N 个goroutine,但 fetchAll 永远返回 nil ——错误全在 goroutine 内部被 log.Printf 吞掉了。

正确做法:用 channel 收集错误,或者用 errgroup :

func fetchAll(ids []int) error {
    eg, ctx := errgroup.WithContext(context.Background())
    
    for _, id := range ids {
        userID := id
        eg.Go(func() error {
            select {
            case <-ctx.Done():
                return ctx.Err()
            default:
                return fetchUser(userID)
            }
        })
    }
    
    return eg.Wait() // 等待所有goroutine,返回第一个非nil的错误
}

errgroup 是官方推荐的做法。它会等待所有goroutine完成,并返回第一个发生的错误。如果你有多个错误需要收集,它也支持返回错误列表。

5. 实战经验:我的错误处理最佳实践

经过这些年的踩坑,我总结出几条实战经验:

第一,永远用 %w 而不是 %v 。别偷懒,多敲两个字符,省得以后debug到怀疑人生。

第二,给每个错误链加上下文。错误信息要能说明白"在哪个环节出的问题":fmt.Errorf("process order: %w", err) 就比直接返回 err 好得多。

第三,区分可恢复错误和不可恢复错误。ValidationError 这种是客户端问题,应该返回给用户看;系统错误(如数据库连接失败)则应该记录日志并返回通用错误信息给客户端。

第四,在程序入口统一处理错误。不要在每个层级都 log.Printf + return err ,在最高层统一做日志记录和响应转换。

func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %+v\n", err)
        os.Exit(1)
    }
}

注意这里用的是 %+v 而不是 %s 或 %v 。 %+v 会打印完整的错误链,包括所有被包装的错误信息。这个技巧记下来,非常实用。

最后说两句

Go的错误处理确实不如 try-catch 优雅。但它的设计哲学是显式优于隐式——你必须正视每一个可能的错误,而不是把它catch住然后假装没发生过。

当年我嫌弃它繁琐,现在我感谢它让我少踩了无数坑。你对Go的错误处理有什么看法?欢迎评论区聊聊。

相关文章

懒得折腾?让小龙虾帮你一键部署AI工具,省心省力还省钱
懒得折腾?让小龙虾帮你一键部署AI工具,省心省力还省钱
为什么你的API总被吐槽?血泪教训总结的RESTful设计避坑指南
限流:那些你以为懂了但其实没懂的算法,今天我帮你扒光了他们裤子
AI 为什么会一本正经地胡说八道?
让你的API从能用变优雅:RESTful设计实战经验谈

发布评论