errors.Join 聚合多个清理错误的返回方案
来源:17golang原创
时间:2026-11-12 16:37:31 302浏览 收藏
在一个函数里关闭文件、响应体、事务或临时资源时,清理动作往往不止一个。若每次都覆盖前一个错误,调用方最后只能看到最后一次关闭失败,前面的原因就丢了。Go 1.20 加入的 errors.Join 可以把多个非 nil 错误合并成一个返回值,并继续支持 errors.Is 和 errors.As 检查。
实用做法是:先把清理阶段产生的非 nil 错误收集到切片,再用errors.Join(errs...)返回。所有元素都是 nil 时它返回 nil;存在错误时返回一个实现Unwrap() []error的聚合错误。
官方资料:https://pkg.go.dev/errors#Join
为什么清理错误不能只保留最后一个
常见的旧写法是让多个 defer 依次给命名返回值赋值。由于 defer 按后进先出执行,最后一次赋值会覆盖前面的错误。这个问题在“主流程成功、清理阶段失败”时尤其容易被忽略:函数表面上返回了错误,但调用方无法知道还有哪些资源没有正确关闭。

聚合的重点不是把错误文本拼成一段日志,而是让返回值保留错误树。这样既能让日志看到多个原因,又能让上层继续按具体错误类型做判断。
先收集清理阶段的非 nil 错误
可以把资源关闭逻辑放进一个小函数,由它负责收集错误。下面的示例模拟文件、响应体和事务三个清理动作;实际项目中把三个变量替换成真实资源即可。
package main
import (
"errors"
"fmt"
)
type closeResource func() error
func closeAll(resources ...closeResource) error {
var cleanupErrs []error
for _, close := range resources {
if err := close(); err != nil {
// 只保存失败的清理动作,成功关闭不应进入返回值。
cleanupErrs = append(cleanupErrs, err)
}
}
// Join 会再次忽略 nil;切片为空时返回 nil。
return errors.Join(cleanupErrs...)
}
func main() {
err := closeAll(
func() error {
// 模拟文件关闭失败。
return errors.New("file close: flush failed")
},
func() error {
// 模拟响应体已经正常关闭。
return nil
},
func() error {
// 模拟事务回滚失败。
return errors.New("tx rollback: connection reset")
},
)
fmt.Println(err)
}
这个函数有三个值得保留的细节:
- 只在关闭动作返回非 nil 时追加,避免把“成功”伪装成错误。
- 按资源关闭的调用顺序收集错误,输出顺序更容易和清理代码对应。
- 统一由
errors.Join处理空切片和多个成员,调用方仍然只接收一个error。
用 errors.Join 组合主流程错误与清理错误
真实业务函数通常还有一个主流程错误。例如写入失败后需要回滚,回滚又可能失败。此时不要让清理错误覆盖主流程错误,而是把两者一起加入聚合列表。
func process() (err error) {
file, err := openFile()
if err != nil {
// 文件都没有打开成功,不需要进入清理阶段。
return err
}
defer func() {
// 关闭文件失败时,把它和主流程错误一起返回。
err = errors.Join(err, file.Close())
}()
if err = writeData(file); err != nil {
// 命名返回值保留写入错误,defer 会在返回前追加关闭结果。
return err
}
return nil
}
这里的关键是 errors.Join(nil, closeErr) 会得到关闭错误,而 errors.Join(mainErr, nil) 会保留主流程错误。两个都为 nil 时最终仍是 nil,因此不需要为“是否只有一个错误”再写一层分支。
如果清理动作不止一个,可以在 defer 中先调用 closeAll,再把结果与命名返回值合并:
defer func() {
cleanupErr := closeAll(file.Close, response.Body.Close, tx.Rollback)
// 主流程和清理阶段都可能失败,统一交给 Join 保留两边的信息。
err = errors.Join(err, cleanupErr)
}()
聚合后仍然可以用 errors.Is 和 errors.As
errors.Join 返回的错误不是只能打印的字符串。官方实现让非 nil 结果提供 Unwrap() []error,因此 errors.Is 可以在聚合结果的成员中查找哨兵错误,errors.As 可以提取某一种具体错误类型。
var (
errFileClosed = errors.New("file close failed")
errRollback = errors.New("rollback failed")
)
joined := errors.Join(errFileClosed, errRollback)
if errors.Is(joined, errRollback) {
// 聚合错误中包含回滚失败,可以触发补偿或告警。
fmt.Println("rollback needs attention")
}
type timeoutError struct{}
func (timeoutError) Error() string { return "cleanup timeout" }
var timeout timeoutError
joined = errors.Join(joined, timeoutError{})
if errors.As(joined, &timeout) {
// As 能从错误树中取出匹配的具体类型。
fmt.Println("cleanup timed out")
}
需要读取全部成员时,可以通过接口断言取得 Unwrap() []error。这适合记录结构化清理结果,但不要依赖标准库内部的具体类型名称;只依赖这个公开的解包方法即可。
type multiUnwrapper interface {
Unwrap() []error
}
if multi, ok := joined.(multiUnwrapper); ok {
for _, item := range multi.Unwrap() {
// 逐个记录成员,保留原始 error 供后续判断。
fmt.Println(item)
}
}
和“只返回最后一个错误”的方案有什么区别

| 处理方式 | 调用方能看到什么 | 适用情况 |
|---|---|---|
| 每次覆盖返回值 | 通常只剩最后一次清理失败 | 前序清理错误确实不需要保留时 |
| 手工拼接字符串 | 文本完整,但失去类型检查 | 仅用于展示日志,不作为程序错误返回值 |
| errors.Join | 多个错误文本与错误树都保留 | 多个清理动作都影响诊断或补偿时 |
errors.Join 也不是所有场景都该使用。如果错误之间存在明确的因果链,例如“读取配置失败,因为文件不存在”,应该使用 %w 包装来表达一条链;只有多个相对独立、需要同时呈现的失败,才适合聚合。另一个边界是错误数量:批量处理数万条记录时,不应无上限地把每一条原始错误都放入内存,应该在业务层先做数量限制或汇总。
落地时记住这三个判断
- 清理动作相互独立且可能同时失败时,先收集非 nil 错误,再调用
errors.Join。 - 主流程已经有错误时,不要用清理错误覆盖它,用
errors.Join(mainErr, cleanupErr)保留两类原因。 - 需要判断具体原因时使用
errors.Is或errors.As,不要解析Error()返回的换行文本。
常见问题
errors.Join 传入 nil 会不会报错?
不会。nil 会被丢弃;当所有参数都是 nil 时,返回值就是 nil。
errors.Join 的输出为什么有多行?
多个成员的错误文本会按参数顺序用换行连接。这个格式适合日志展示,但程序判断仍应使用 errors.Is 或 errors.As。
只有一个错误时还需要 Join 吗?
可以统一调用。单个非 nil 成员会保留其错误文本和可检查性,统一入口能减少清理代码中的条件分支。
errors.Join 能替代 fmt.Errorf("%w") 吗?
不能完全替代。%w 表达一条带上下文的错误链,Join 表达多个并列成员;根据错误之间的关系选择对应方式。
参考资料:https://pkg.go.dev/errors#Join、https://go.dev/src/errors/join.go、https://go.dev/doc/go1.20
-
369 收藏
-
344 收藏
-
278 收藏
-
464 收藏
-
327 收藏
-
373 收藏
-
429 收藏
-
219 收藏
-
226 收藏
-
419 收藏
-
251 收藏
-
395 收藏
-
489 收藏
-
492 收藏
-
279 收藏
-
298 收藏
-
255 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习