Go 错误处理:那些教程永远不会告诉你的真相

2026-07-06 2 0

引子:当你写下的第一个 Go 程序,错误已经在暗处等着你

好了,你学了几天 Go,能写 if err != nil 了,感觉得到了宇宙真理。然后你打开某个生产项目,看到 thousand lines 里面遍布着:

if err != nil {
    return err
}
if err != nil {
    return err
}
if err != nil {
    return err
}
// 等等,你是不是在 ctrl+v?

有没有觉得哪里不对劲?有没有觉得自己写了个寂寞?

这就是我当年踩过的坑。今天把这个坑挖出来,给你看看里面到底埋了什么。

第一章:error 接口,你以为你懂,其实你不懂

所有教程都会告诉你 Go 的 error 是一个接口:

type error interface {
    Error() string
}

然后它们会说:"你看,多简单!"

是挺简单。简单到让你误以为你掌握了 error 的全部真相。

但如果你真正在生产环境写过 Go,你会发现这个接口只告诉你"出了错",从来不告诉你"为什么出错了"、"怎么解决的"、"需不需要人工介入"。

error 接口本质上是一个字符串信封。你往里面塞什么字符串,它就返回什么字符串。但这个字符串是给人看的,不是给程序看的。程序想知道这个错误是不是网络超时,对不起,error 接口不提供这个能力。

这就是为什么 Go 社区后来搞出了那么多错误处理库——wrapped errors、pkg/errors、errors.Is、errors.As。官方甚至在 Go 1.13 把 errors 包升级了。为什么?因为原生的 error 接口实在太裸了,裸到生产代码没法用。

第二章:if err != nil 是止痛药,不是解药

现在我们来解剖一个典型的 Go 代码片段:

func GetUser(id string) (*User, error) {
    user, err := db.Query("SELECT * FROM users WHERE id = ?", id)
    if err != nil {
        return nil, err  // 经典操作:把错误原封不动往上扔
    }
    
    return user, nil
}

这段代码有什么问题?表面上没有问题。错误被正确地返回了。但如果你写过一段时间后端,你会发现一个致命问题:这个函数的错误返回值毫无信息量

调用方拿到 error,能知道什么呢?最多知道"查数据库出错了"。但这是网络问题?还是 SQL 语法错误?还是表不存在?还是连接池耗尽了?调用方一无所知。

结果呢?调用方只能做最蠢的事情:

user, err := GetUser("123")
if err != nil {
    // 我能怎么办?我也很绝望啊!
    log.Error(err.Error())
    return err  // 继续往上扔,扔到 main 函数崩掉
}

这个错误链从头到尾没有任何有价值的上下文,只有错误在层层穿越,每穿越一层就丢失一层信息。这不是我说的,这是我在三个 Go 项目里亲眼看到的现实。

第三章:Wrapped Errors 解决了问题,但也带来了新问题

Go 1.13 引入的 fmt.Errorf 和 %w 包装,官方说:"现在你可以携带上下文了!"

if err != nil {
    return fmt.Errorf("查询用户失败: %w", err)
}

好,现在错误链有了上下文。但问题来了——你有多少次见过这样的代码?

if err != nil {
    return fmt.Errorf("查询用户失败: %w", err)
}

// 然后调用方:
user, err := GetUser("123")
if err != nil {
    if strings.Contains(err.Error(), "查询用户失败") {  // 别笑,我真的见过
        // 哦,是用户查询的问题
    }
}

用字符串包含来判断错误类型,这是 Go 新手的经典操作,也是最糟糕的操作。因为错误文本随时可能变,一改就崩,测试也没法写。

正确做法是用 errors.Is 和 errors.As:

if errors.Is(err, sql.ErrNoRows) {
    return nil, ErrUserNotFound
}

// 或者自定义错误类型
var notFound *NotFoundError
if errors.As(err, ¬Found) {
    // 知道是什么类型的错了
}

但问题是——你知道这些,你团队里的 junior 知道这些吗?当代码库里充斥着 if err != nil { return err } 的时候,errors.Is 和 errors.As 就成了一个没人用的摆设。

第四章:Sentinel Errors,命名即陷阱

Sentinel errors(哨兵错误)是什么?就是预定义的错误值,比如 sql.ErrNoRows、io.EOF。

var ErrUserNotFound = errors.New("user not found")
var ErrPermissionDenied = errors.New("permission denied")

听起来很美好对吧?用 errors.Is 来判断,类型安全,有 IDE 自动补全。

但现实是:一旦你的系统有多个层级(service 层、repository 层、handler 层),sentinel errors 就会变成一场命名灾难。

// repository 层
var ErrUserNotFound = errors.New("user not found")

// service 层
var ErrUserNotFound = errors.New("user not found")  // 什么?又要定义一遍?

// 或者
import (
    "myapp/repository"
)
// service 层直接用 repository.ErrUserNotFound?
// 等等,这两个错误是同一个值吗?
// 不,它们不是。因为 errors.New 每次调用都创建新实例。

所以如果你在 A 包定义了 ErrUserNotFound,然后在 B 包也定义了 ErrUserNotFound,用 errors.Is 去判断——会返回 false。因为它们不是同一个 error 实例。

这是一个极其隐蔽的坑。我在第二个项目才意识到这个问题。解决方案是什么?在一个公共包里定义所有 sentinel errors,或者用自定义错误类型替代 sentinel errors。但无论哪种方案,都需要团队有统一的约定,否则就是各写各的,最后串不起来。

第五章:Panic 和 Recover,你真的需要它们吗?

Go 还有一个其他语言不太有的机制:panic 和 recover。

panic 是什么?是让程序崩溃。一般用在"完全不应该发生的错误"上,比如程序初始化失败、配置文件格式错误这种。

但我见过有人把 panic 用在业务逻辑里:

func ProcessOrder(order *Order) {
    if order.Status != "pending" {
        panic("order status must be pending")
    }
    // ...业务逻辑
}

这种代码写的时候很爽,调试的时候想哭。因为 panic 不是 return,它会层层往上传播,直到被 recover 捕获,或者程序崩溃。在微服务架构里,这意味着一个订单处理失败可能导致整个服务重启。

我的建议是:panic 只用在真正的程序级错误上,永远不要用在业务逻辑里。业务逻辑的错误,永远用 error。

第六章:最反直觉的一点——错误是返回值,但处理方式决定架构

写了这么多,最想告诉你的其实是这一条。

Go 的错误处理之所以让人觉得"啰嗦",不是因为语法本身,而是因为大多数人对错误的处理方式是在逃避

什么叫逃避?就是你看到一个 error,第一反应是 return err,而不是思考:这个错误应该往上传吗?应该被记录吗?应该触发告警吗?应该重试吗?应该返回给用户吗?应该降级吗?

一个真正好的错误处理,是把错误当作数据来处理,而不是当作异常来逃避。

举一个具体例子:

// 差的写法:遇到错误直接扔
func GetUser(id string) (*User, error) {
    user, err := repo.FindUser(id)
    if err != nil {
        return nil, err
    }
    return user, nil
}

// 好的写法:理解错误的语义,做出正确决策
func GetUser(ctx context.Context, id string) (*User, error) {
    user, err := repo.FindUser(ctx, id)
    if err != nil {
        if errors.Is(err, repository.ErrNotFound) {
            // 用户不存在不是程序错误,是业务预期情况
            return nil, nil
        }
        if errors.Is(err, repository.ErrNetwork) {
            // 网络问题需要告警
            metrics.IncNetworkError()
            // 可以考虑重试
            return getUserWithRetry(ctx, id)
        }
        // 未知错误,记录详情但对用户隐藏
        log.Error("get user failed", "id", id, "err", err)
        return nil, ErrInternalServer
    }
    return user, nil
}

看出来了吗?好的错误处理,需要你在写代码的时候就把每个错误类型的处理策略想清楚,而不是写 if err != nil { return err } 然后假装自己处理了错误。

尾声:错误处理是 Go 的最大哲学课

很多人吐槽 Go 的 if err != nil 太啰嗦。但我现在越来越觉得,这种"啰嗦"恰恰是 Go 设计者的高明之处。

它逼你面对每一个可能的失败点。它不让你用 try-catch 把错误藏起来,然后用 finally 假装什么都没发生。它让你在每一个错误出现的地方,都必须做出选择。

你选择沉默,错误就沉默。你选择忽略,错误就被忽略。你选择认真对待每一个 error,你才能写出真正健壮的系统。

所以别再 ctrl+v 你的 if err != nil 了。停下来想一想,这个错误到底是什么,应该怎么处理。

这个习惯,比学会任何 error 包装技巧都重要。


写完这篇我发现一个可怕的事实:教人怎么写好 error 的文章本身,错误处理往往也不怎么样。所以如果你发现这篇文章里有没说清楚的地方——那就是故意的,留给你自己思考的(不是)。

相关文章

AI圈最近有点热闹:新闻动态与新奇玩法大盘点
AI Agent 爆火背后:我试了市面上所有’替我干活’的AI,结果被现实狠狠打脸
AI圈最近有点热闹:OpenClaw越玩越花哨,我来聊聊真实体验
AI圈最近有点热闹:OpenClaw越玩越花哨,我来聊聊真实体验
不想折腾了?让小龙虾帮你一键部署 AI 工具,省心省力还便宜
AI现在能干啥?我把它的能力边界摸了个遍,发现了几个让人清醒的真相

发布评论