有次线上一个 Go 服务报警,日志里只有冷冰冰的一行:unexpected EOF。没有调用栈、没有出错的请求 ID、没有是哪个下游、更没有当时在处理什么数据。我顺着代码一层层往上扒,发现这个 error 从最底层的网络读取一路被原样 return err 透传到了顶层,中间经过了七八个函数,每一层都只是"接住再扔出去",没有任何一层往上面添过一个字的信息。那个下午我基本是靠猜和加日志重新复现,才定位到是某个下游在特定条件下提前断开了连接。
那次之后我重新理解了 Go 的错误处理。很多人对 Go 的 if err != nil 怨念很深,觉得它啰嗦、是样板代码。但真正的问题往往不是"写了太多 if err != nil",而是每一个 if err != nil 都写得太敷衍——只会原样 return err,既不添上下文,也不区分错误类型,让错误从产生到被处理的整条链路上,信息量始终是零。这篇想讲的不是"怎么少写 error 判断",而是反过来:怎么把错误当成你 API 的一部分认真设计,让它从一个无用的字符串,变成调用方能据此决策、排查时能一眼定位的结构化信息。
先认清:Go 错误处理的几样工具,各管什么
Go 1.13 之后,标准库其实给了一整套处理错误的工具,但很多人只用到了其中最原始的 return err。要把错误用好,先得分清这几样东西各自解决什么问题——它们不是互相替代,而是配合使用的:
| 工具 | 作用 | 典型用法 | 解决的问题 |
|---|---|---|---|
fmt.Errorf + %w |
包装错误并保留原因链 | fmt.Errorf("查询用户失败: %w", err) |
逐层添加上下文,又不丢底层错误 |
| 哨兵错误 sentinel | 表达一个可被识别的固定错误 | var ErrNotFound = errors.New(...) |
调用方判断"是不是某种已知错误" |
errors.Is |
沿原因链匹配某个哨兵错误 | errors.Is(err, ErrNotFound) |
包装之后仍能识别底层错误 |
| 自定义错误类型 | 携带结构化字段的错误 | type APIError struct{ Code int } |
错误需要带数据(状态码、字段名) |
errors.As |
沿原因链提取某个类型的错误 | errors.As(err, &apiErr) |
取出错误里携带的结构化字段 |
这张表里藏着一条主线:错误处理的核心矛盾,是"逐层添加上下文"和"顶层仍能识别底层错误"之间的张力。你想让错误信息在向上传递时越来越丰富(知道是在哪一步、处理什么时出的错),又想让最顶层的调用方还能判断出"这究竟是不是一个'记录不存在'的错误,好决定返回 404 还是 500"。%w 包装配上 errors.Is/As,正是为了同时满足这两个看似冲突的需求。下面从最基础、也最该养成习惯的一步——错误包装——讲起。
第一件事:用 %w 包装错误,给每一层都留下线索
开头那个 unexpected EOF 之所以难查,就是因为每一层都在裸 return err。修正它的第一步,是让错误每经过一层有意义的边界,就被包装一次,添上这一层知道、而下层不知道的上下文。Go 1.13 的 fmt.Errorf 配合 %w 动词就是干这个的——它在错误信息前面拼上你的描述,同时把原始错误"包"在里面,形成一条可追溯的原因链:
// ❌ 裸透传:每一层都原样扔出,顶层拿到的只有最底层那句光秃秃的 "EOF"
func GetUserOrders(ctx context.Context, userID int64) ([]Order, error) {
user, err := repo.FindUser(ctx, userID)
if err != nil {
return nil, err // 出错了你都不知道是查 user 这步挂的
}
orders, err := repo.FindOrders(ctx, user.ID)
if err != nil {
return nil, err // 还是裸扔,顶层无从分辨是哪一步
}
return orders, nil
}
// ✅ 每层用 %w 包装:添上"我在做什么"的上下文,又保留底层原因
func GetUserOrders(ctx context.Context, userID int64) ([]Order, error) {
user, err := repo.FindUser(ctx, userID)
if err != nil {
return nil, fmt.Errorf("查询用户 %d 失败: %w", userID, err)
}
orders, err := repo.FindOrders(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("查询用户 %d 的订单失败: %w", userID, err)
}
return orders, nil
}
// 顶层最终拿到的错误串成了一条链:
// "查询用户 42 的订单失败: 数据库查询超时: unexpected EOF"
// 一眼就能看出:在查 42 号用户订单时、因数据库超时、底层是 EOF
这里的关键是 %w 和 %v 的区别:%v 只是把错误格式化成字符串拼进去,原因链就断了,顶层再也没法用 errors.Is/As 识别底层错误;而 %w 会把原始错误真正"包裹"起来,形成可被程序遍历的链。所以经验法则是:包装错误时默认用 %w,只有当你确实想"切断"原因链、不让调用方看到内部实现细节时,才故意用 %v。包装的内容也有讲究——添的应该是"这一层独有的、有助于定位的上下文"(在处理哪个 ID、哪个步骤),而不是把下层的错误信息重复抄一遍。
第二件事:用哨兵错误 + errors.Is,让调用方能"认出"特定错误
光包装还不够。很多时候顶层不只想看错误信息,还要根据"是哪一种错误"做不同决策——记录不存在该返回 404,权限不足该返回 403,数据库超时该重试。如果错误只是个字符串,调用方就只能去 strings.Contains(err.Error(), "not found") 这样匹配文本,既脆弱又丑陋。正确的做法是定义哨兵错误(sentinel error)——一个导出的、固定的错误值,让调用方用 errors.Is 去识别它:
// ✅ 在包级定义哨兵错误,作为"可被识别的契约"导出给调用方
var (
ErrUserNotFound = errors.New("用户不存在")
ErrUnauthorized = errors.New("无权限")
)
func (r *Repo) FindUser(ctx context.Context, id int64) (*User, error) {
u, err := r.db.Query(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
// 把底层的 sql.ErrNoRows 翻译成本包的语义错误,并包进链里
return nil, fmt.Errorf("查询用户 %d: %w", id, ErrUserNotFound)
}
if err != nil {
return nil, fmt.Errorf("查询用户 %d: %w", id, err)
}
return u, nil
}
// ✅ 调用方:用 errors.Is 沿链匹配,哪怕中间包了好几层也认得出来
user, err := repo.FindUser(ctx, id)
if errors.Is(err, ErrUserNotFound) {
return c.JSON(404, "用户不存在") // 据此决策:返回 404
}
if err != nil {
return c.JSON(500, "服务异常") // 其它错误统一 500
}
errors.Is 的妙处在于它会沿着 %w 串起来的原因链一路往下找,只要链上任何一环 == ErrUserNotFound 就返回 true。这意味着你在底层包装了再多层上下文,顶层依然能准确识别出"这本质上是个用户不存在的错误"。下面这张图就是 errors.Is 在原因链上的匹配过程,也顺带画清了包装链是怎么一层层串起来的:
第三件事:用自定义错误类型 + errors.As,让错误带上数据
哨兵错误解决了"是不是某种错误",但有时调用方还需要从错误里取出数据——比如一个 API 错误要带上 HTTP 状态码,一个校验错误要说清是哪个字段、为什么不合法。这时哨兵值不够用了,该上自定义错误类型:一个实现了 error 接口的结构体,内部携带任意结构化字段,调用方用 errors.As 把它从链里提取出来:
// ✅ 自定义错误类型:携带结构化字段,而不只是一句话
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s 校验失败: %s", e.Field, e.Reason)
}
func ValidateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{Field: "age", Reason: "必须在 0~150 之间"}
}
return nil
}
// ✅ 调用方:用 errors.As 提取出具体类型,拿到里面的字段去做精细处理
var ve *ValidationError
if errors.As(err, &ve) {
// 拿到了结构化信息:可以精确告诉前端是哪个字段错了
return c.JSON(400, map[string]string{"field": ve.Field, "msg": ve.Reason})
}
errors.Is 和 errors.As 是一对,记住它们的分工:errors.Is 回答"是不是这个错误"(比对值),errors.As 回答"链里有没有这种类型的错误,有的话给我提取出来"(比对类型并赋值)。需要带数据的错误一律用自定义类型 + errors.As,只需要"识别身份"的用哨兵 + errors.Is。有一个易错点要提醒:自定义错误类型通常用指针接收者实现 Error(),所以返回时要返回指针(&ValidationError{...}),errors.As 的第二个参数也要传指针的指针(&ve),否则匹配不上。
第四件事:别把错误"处理两次",也别把它悄悄吞掉
还有两个极其高发的反模式,几乎每个 Go 项目里都能找到。一是"又 log 又 return"——在底层把错误打了日志,又把它 return 上去,结果上层也打了一遍,同一个错误在日志里出现五六次,排查时根本分不清是几个错误还是一个错误回声。二是悄悄吞掉错误——用 _ 把 error 丢弃,或者 if err != nil {} 里什么都不做,等于给自己埋雷:
// ❌ 反模式一:又 log 又 return,同一个错误被沿途每一层重复打印
func (s *Service) Do() error {
if err := s.step(); err != nil {
log.Printf("step 失败: %v", err) // 这里打一次
return err // 上层还会再打 → 日志里重复刷屏
}
return nil
}
// ❌ 反模式二:悄悄吞掉错误,出了问题毫无痕迹
data, _ := json.Marshal(payload) // 用 _ 丢弃,序列化失败你永远不知道
f.Close() // 忽略 Close 的错误,可能丢数据没察觉
// ✅ 原则:错误要么处理(并终结它),要么包装上抛(交给上层),二选一不要都做
func (s *Service) Do() error {
if err := s.step(); err != nil {
// 不在这层 log,只添上下文上抛,由最顶层统一记录一次
return fmt.Errorf("执行 step: %w", err)
}
return nil
}
// 确实可忽略的错误,显式注释说明为什么可以忽略,而不是默默 _
if err := f.Close(); err != nil {
log.Printf("关闭文件失败(不影响主流程): %v", err)
}
这里的纪律是:一个错误只应该被"终结处理"一次,而终结处理(记录日志、转换成用户响应)应该发生在调用链的顶层,中间各层只负责包装上下文、向上传递。如果每一层都 log 一次,你得到的不是更多信息,而是同一条信息的噪音回声。至于吞错误,原则更简单:error 不允许被静默丢弃——要么处理,要么上抛,实在确认可以忽略的(极少数),也必须用一行注释写清"为什么这里忽略是安全的",而不是一个无言的 _。
第五件事:分清 panic 和 error 的边界,别用 panic 当错误流
从别的语言转过来的人,常把 panic 当成"抛异常"来用——一遇到错误就 panic,再在上层用 recover 接住,把它当 try/catch 使。这在 Go 里是个明确的反模式。Go 的设计哲学很清楚:error 是给"预期之内、调用方应该处理"的失败用的;panic 只留给"程序已经进入不该发生的状态、再走下去毫无意义"的情况。换句话说,一个文件打不开、一次网络请求超时、一份输入校验不过——这些都是业务逻辑里完全可能发生、且调用方有能力应对的事,必须返回 error;而一个本该被初始化的全局配置是 nil、一个 switch 落到了逻辑上不可能的 default 分支,这才是 panic 的场景。
// ❌ 把 panic 当错误流:调用方根本没法优雅处理,还容易整个进程崩掉
func ParsePort(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
panic("端口解析失败: " + s) // 用户输错一个端口就让程序 panic?
}
return n
}
// ✅ 可预期的失败就老老实实返回 error,把决策权交给调用方
func ParsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("解析端口 %q: %w", s, err)
}
if n < 1 || n > 65535 {
return 0, fmt.Errorf("端口 %d 超出合法范围 1-65535", n)
}
return n, nil
}
// ✅ panic 只用在"不变量被打破、继续运行没有意义"的地方
func mustLoadConfig() *Config {
cfg := loadConfig()
if cfg == nil {
// 配置加载不出来,整个服务根本没法启动,fail fast 是对的
panic("配置加载失败,服务无法启动")
}
return cfg
}
有一个例外值得说:在 goroutine 里 panic 是会直接拖垮整个进程的,因为它没法被另一个 goroutine 的 recover 捕获。所以如果你在写一个库,或者在跑一些可能 panic 的第三方代码、用户回调,需要在每个 goroutine 的入口加一道 recover 兜底,把 panic 转换成 error 返回,而不是让一个孤立的 panic 把整个服务带走。但这是"边界防护",不是日常错误流——业务逻辑里该返回 error 的地方,永远返回 error。
第六件事:并发场景下,用 errgroup 收束多个错误
单线程的错误处理讲清楚了,但真实服务里大量场景是并发的——同时发起好几个下游请求、并行处理一批任务。这时 err != nil 那套就不够用了:多个 goroutine 各自可能出错,你得有个地方把这些错误收集起来、决定整体成败、还要能在第一个出错时取消掉其它还在跑的任务。手搓 sync.WaitGroup + channel + mutex 能做,但又啰嗦又容易出 bug。golang.org/x/sync/errgroup 就是为这个场景准备的标准答案:
// ✅ errgroup:并发跑多个任务,任一出错即取消其余,统一拿到第一个错误
func FetchAll(ctx context.Context, ids []int64) ([]*Data, error) {
g, ctx := errgroup.WithContext(ctx) // ctx 会在任一任务出错时被取消
results := make([]*Data, len(ids))
for i, id := range ids {
i, id := i, id // 经典闭包捕获:每个 goroutine 抓自己的副本
g.Go(func() error {
d, err := fetch(ctx, id)
if err != nil {
// 包装上下文后返回,errgroup 会记住第一个非 nil 错误
return fmt.Errorf("拉取 id=%d: %w", id, err)
}
results[i] = d
return nil // 注意:results[i] 各写各的下标,无需加锁
})
}
if err := g.Wait(); err != nil { // Wait 返回第一个出错任务的 error
return nil, err
}
return results, nil
}
这里有两个关键点。一是 errgroup.WithContext 返回的那个 ctx:任何一个 g.Go 里的函数返回非 nil 错误,这个 ctx 立刻被 cancel,其它正在跑、且正确监听了 ctx 的任务会随之提前退出,不会白白浪费资源继续跑下去。二是对共享数据的写入要么用互不重叠的下标(像上面 results[i] 那样各写各的),要么自己加锁——errgroup 只负责收束错误和取消信号,它不会帮你处理数据竞争。
如果你的需求不是"出错就立刻取消",而是"把所有任务都跑完、再把每个错误汇总起来一起看"(比如批量校验,要一次性告诉用户所有不合法的字段),那 Go 1.20 引入的 errors.Join 正合适——它能把多个错误合并成一个,而且合并后的错误对每一个子错误都能用 errors.Is 命中:
// ✅ errors.Join:把多个错误聚合成一个,常用于"全部跑完再汇总"的批处理
func ValidateForm(f *Form) error {
var errs []error
if f.Name == "" {
errs = append(errs, errors.New("姓名不能为空"))
}
if f.Age < 0 {
errs = append(errs, errors.New("年龄不能为负"))
}
if !validEmail(f.Email) {
errs = append(errs, fmt.Errorf("邮箱 %q 格式非法", f.Email))
}
// errs 为空时 Join 返回 nil;否则返回一个聚合错误,逐行列出全部问题
return errors.Join(errs...)
}
记住这两者的分工:errgroup 是"快速失败 + 取消"的并发原语,errors.Join 是"全部完成 + 汇总"的多错误聚合。前者适合"一个挂了其它就没必要继续"的下游调用,后者适合"每一条都要查、要一次性反馈给用户"的校验类场景。选错了,要么该取消的没取消、白跑一堆任务,要么该全查的提前退出、用户改完一个又冒出一个。
一张图把"这个错误该怎么办"理清楚
前面六件事讲下来,工具不少:%w、哨兵、errors.Is、自定义类型、errors.As、panic、errgroup、errors.Join。真到了写代码当下,每个 if err != nil 里到底该用哪个?其实可以收敛成一棵很短的决策树。下次手在 if err != nil { 后面停顿时,照它走一遍,几乎不会选错:
这棵树的第一个分叉最重要:先问"这是不是预期内的、调用方该处理的失败"。是,就走 error 这条线;不是、是程序自己进了死胡同,才轮到 panic。绝大多数日常代码都走左边的 error 分支,panic 是极少数。往下,"要不要在这层终结"决定了你是 log 还是继续包装上抛;"调用方要不要识别"决定了你是只用 %w 加上下文,还是要额外搭配哨兵(errors.Is)或自定义类型(errors.As)。并发是另一条独立的线,按"快速失败"还是"汇总"二选一。
把这些收口成几条可执行的纪律
这套错误处理经验,最后在我们团队沉淀成了代码评审时逐条对照的清单:
- 跨越有意义的边界就包装:错误每经过一个值得标记的层(一次外部调用、一个业务步骤),就用
fmt.Errorf("在做什么: %w", err)添上这一层独有的上下文,默认用%w保留原因链。 - 包装只加"这层独有"的信息:添的是当前在处理哪个 ID、哪个步骤,而不是把下层的错误文本重复抄一遍——重复的上下文只会让最终那条错误链又臭又长。
- 需要识别身份用哨兵 +
errors.Is:调用方要据"是哪种错误"做分支(404/403/重试),就导出哨兵错误,让它用errors.Is沿链匹配,绝不允许strings.Contains(err.Error(), ...)这种脆弱的字符串匹配。 - 需要带数据用自定义类型 +
errors.As:错误要携带字段(状态码、出错字段名)就定义结构体错误类型,指针接收者实现Error(),返回&XxxError{},调用方用errors.As提取。 - 一个错误只终结处理一次,且在顶层:中间各层只包装上抛,记录日志、转成响应这种"终结动作"统一放在调用链最顶端做一次,杜绝又 log 又 return 的重复噪音。
- error 绝不静默丢弃:不允许
data, _ := ...或空的if err != nil {};实在可忽略的(极少数),也要用一行注释写清"为什么这里忽略是安全的"。 - panic 只留给不变量被打破:可预期的失败一律返回 error;panic 仅用于"再走下去没有意义"的启动期/逻辑不可达场景,且每个 goroutine 入口该有
recover兜底。 - 并发错误用对原语:出错即取消用
errgroup,全跑完汇总用errors.Join,共享数据该加锁的加锁——别用裸 channel + mutex 手搓一套又一套。
几个几乎人人都踩过的认知误区
推广这套规范时,有几个误区出现的频率高到值得专门点破。
第一个误区:"%w 和 %v 不就是格式化占位符吗,随便用哪个都行。" 这是危害最大的一个。%v 只是把错误转成字符串拼进去,原因链当场就断了——顶层再也没法用 errors.Is/As 识别底层是什么错误,你精心定义的哨兵和自定义类型全都白费。所以默认永远用 %w,只有当你刻意要切断链、不暴露内部实现细节给调用方时,才有意识地换成 %v。这俩绝不是可以随手互换的同义词。
第二个误区:"errors.Is 和 errors.As 差不多,记一个就行。" 它们解决的是两个不同的问题。errors.Is 回答"链里有没有这个值"(比对的是哨兵错误那个固定的值);errors.As 回答"链里有没有这个类型,有就把它提取出来赋给我"(比对类型并取出实例,所以第二个参数得传指针的指针)。要"识别身份"用 Is,要"拿到数据"用 As。混用的典型翻车是:想取自定义错误里的字段,却用了 errors.Is 跟一个类型比——根本编译不过或永远不命中。
第三个误区:"在每一层都把错误 log 出来,信息才全。" 恰恰相反。同一个错误如果沿途每层都 log 一次,你的日志里得到的不是"更全的信息",而是同一条错误的五六遍回声,排查时第一件事反而是分辨"这到底是几个错误还是一个错误刷了好几屏"。正确做法是中间层只用 %w 包装上抛,把日志记录这个"终结动作"收敛到最顶层做唯一一次——那一条完整的错误链,信息已经足够全了。
第四个误区:"Go 的 if err != nil 太啰嗦,是语言设计缺陷。" 这是抱怨最多、也最值得反过来想的一个。啰嗦的从来不是 if err != nil 这个结构本身,而是每个 if err != nil 里都只写了 return err 这一句废话——不加上下文、不区分类型,让错误从产生到处理的整条链路上信息量恒为零。当你开始认真对待每一个错误分支:这里该包装什么上下文?调用方需要识别它吗?需要带数据吗?——你会发现 if err != nil 不是噪音,而是 Go 逼着你在每个失败点都显式想清楚"这里出错了该怎么办"的地方。它的"啰嗦"换来的是错误处理的可见和可控,这恰恰是很多用异常的语言里最容易被"一个 try 包住一大片"糊弄过去的东西。
写在最后
回到开头那个只剩一行 unexpected EOF 的下午。后来我们把那条链路上每一层都补上了 %w 包装,再遇到同类问题时,日志里是一条完整的链:"处理订单回调失败: 调用支付网关: 读取响应体: unexpected EOF"——在哪一步、做什么时、底层是什么,一行看尽,不用再靠猜和加日志去复现。从那以后我对 Go 错误处理的理解就变了:错误不是 return err 顺手甩出去的累赘,而是你 API 契约的一部分,值得和函数签名、返回值一样被认真设计。
这件事说到底特别朴素:每写一个 if err != nil,都别急着 return err 了事,停半秒问自己三个问题——这一层该添什么上下文?调用方需要识别这个错误、还是需要它带的数据?这个错误该在这里终结、还是往上抛?把这三个问题在写代码的当下回答掉,你交出去的错误就从一个无用的字符串,变成了下游能据此决策、未来的你能一眼定位的结构化信息。if err != nil 不会变少,但它每一次都会变得有意义——而这,远比"少写几个错误判断"重要得多。
—— 别看了 · 2026