Go 并发批处理为什么不能只返回第一个错误:errors.Join 与任务结果汇总怎么选
来源:17golang原创
时间:2026-07-26 09:23:24 278浏览 收藏
批量同步订单、刷新缓存或是并发调用多个下游服务时,不少人为了省事写的代码碰到第一个错误就直接返回。这样写起来逻辑简单直观,但日志里只会留下第一个失败的订单,等你把这个问题排查完,后面第二第三个隐藏的错误还得重新跑一遍才能发现。Go 1.20 引入的 errors.Join 能把多个错误都保留下来,不过它并不能完全替代专门的任务结果汇总逻辑:要不要聚合错误、要不要保留任务编号,完全取决于调用方后续要不要做重试、要不要展示失败明细给用户。
只关心“这批任务是不是失败了”的时候用
errors.Join聚合错误;需要给用户展示具体哪几项失败时,先汇总带任务标识的全量结果,再把错误交给errors.Join处理,别用错误字符串来存结构化数据。
要点速览
errors.Join(nil, err)只会收纳非 nil 错误,所有传入参数都是 nil 时最终返回 nil。errors.Is和errors.As可以直接穿透 Join 生成的多错误树,别再用字符串比对来判断错误类型。- 批处理接口一般需要返回“成功数、失败任务清单、可重试错误”三类信息,单个 error 类型装不下这些内容。
- 并发写入结果切片时要么按索引位置归位要么加互斥锁,别为了聚合错误反而引入新的数据竞争问题。
先把“批次整体失败”和“具体哪项失败”两个需求分开
假设一个批次里有 12 个任务,每个任务都调用 syncOne。如果处理函数的返回值只有 func SyncBatch(ctx context.Context, ids []int64) error,调用方最容易形成的认知是:返回 nil 就代表整批全成功,返回非 nil 就代表整批都失败。
这个逻辑放在定时任务、流水线门禁场景里完全够用,但放到后台管理页面就不好用了。管理页还得展示失败的订单号、失败原因,还要给用户提供重试入口。把这些字段硬拼进一段长错误文本里,后续既难解析,日志也容易丢上下文。
梳理下来就能得到明确的实现约束:
- 只需要收到一个失败信号:留首错或者聚合多个错误都可以。
- 需要拿到完整的失败清单:用自定义结果结构体存任务标识、运行状态和原始错误。
- 需要按错误类型做重试:错误本身必须支持
errors.Is或者errors.As,不能只靠错误文案来判断。

errors.Join 的最小写法与判断顺序
先看一个不带并发逻辑的最简示例。errors.Join 会返回一个包裹了多个根因的错误,格式化输出时默认用换行做分隔;更重要的是它实现了标准库的多错误展开接口,所以原生的错误判断函数都可以正常使用。
package main
import (
"errors"
"fmt"
)
var ErrRateLimited = errors.New("rate limited")
var ErrBadPayload = errors.New("bad payload")
type jsonError struct{ Field string }
func main() {
err := errors.Join(ErrRateLimited, fmt.Errorf("item 42: %w", ErrBadPayload))
fmt.Println(errors.Is(err, ErrRateLimited)) // true
fmt.Println(errors.Is(err, ErrBadPayload)) // true
var target *jsonError
if errors.As(err, &target) {
fmt.Println(target.Field)
}
}
实际项目里可以按这个优先级判断:先用 errors.Is 匹配固定哨兵错误,再用 errors.As 取出带自定义字段的错误类型,最后才把错误文本打印到日志或者返回给上层。别用 strings.Contains(err.Error(), "rate") 代替类型判断,哪天错误文案改了,重试策略就会悄无声息地失效。
nil 是这个接口最容易被忽略的边界情况
errors.Join() 和 errors.Join(nil, nil) 会直接返回 nil;只要入参里有一个非 nil 错误,返回值就一定是非 nil 的。并发收集错误的时候可以直接把每个任务返回的错误扔进切片,但最后一定要检查切片里是不是真的有有效错误,别把空批次误判成执行失败。
三种批处理方案,跟着调用方的实际需求选就行
方案一:只保留第一个遇到的错误
首错方案的实现成本最低,适合任务失败后整个批次都要回滚,或者剩下的任务继续跑没有业务价值的场景。它的缺点也很明显:并发任务可能同时抛出错误,第一个写入结果的 goroutine 对应的问题不一定是最值得优先处理的。
var first error
for _, id := range ids {
if err := syncOne(ctx, id); err != nil {
first = err
break
}
}
return first
如果所有任务已经是并发启动的,这段逻辑还会直接丢掉其他任务抛出的失败原因。只有“碰到第一个错误就立刻停止”确实是业务明确要求的规则时,再选用这个方案。
方案二:只聚合所有错误
只需要给定时任务返回一个整体失败信号的场景下,可以把每个任务的非 nil 错误收集完之后调用 errors.Join。下面的写法按任务下标往切片里写结果,避免多个 goroutine 同时 append 引发的数据竞争。
errs := make([]error, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
wg.Add(1)
go func(i int, id int64) {
defer wg.Done()
errs[i] = fmt.Errorf("task %d: %w", id, syncOne(ctx, id))
}(i, id)
}
wg.Wait()
nonNil := make([]error, 0, len(errs))
for _, err := range errs {
if err != nil {
nonNil = append(nonNil, err)
}
}
return errors.Join(nonNil...)
示例里还有个细节要注意:不能无条件给每个结果都包裹 nil,不然会生成类似“task 7:
方案三:结果结构体搭配聚合错误
面向用户的后台接口更适合返回结构化结果。错误聚合只用来做批次级别的失败判断,每个任务的具体状态都交给 Items 来保存。
type ItemResult struct {
ID int64
Err error
Retryable bool
}
type BatchResult struct {
Items []ItemResult
Err error
}
// Items 负责展示和重试,Err 负责给调度器判断批次是否失败。
func finish(items []ItemResult) BatchResult {
errs := make([]error, 0, len(items))
for _, item := range items {
if item.Err != nil {
errs = append(errs, fmt.Errorf("item %d: %w", item.ID, item.Err))
}
}
return BatchResult{Items: items, Err: errors.Join(errs...)}
}
这种写法的边界处理最稳妥:前端页面直接读取 Items 做展示,调度器读取 Err 判断整批状态,重试器读取 Retryable 做定向重试。别从错误字符串里硬猜这个任务能不能重试。

并发收集结果时,三个坑比 errors.Join 本身更值得留意
别在多个 goroutine 里直接对共享切片做 append
多 goroutine 共享切片的 append 操作必须加同步保护。最简单的处理方式是预先分配好固定长度的切片,按任务下标直接往对应位置写数据;如果任务数量是动态变化的,就用互斥锁保护 append 逻辑,或者单独开一个结果通道集中收集所有返回值。
错误上下文要加在离错误现场最近的地方
fmt.Errorf("item %d: %w", id, err) 比等所有任务跑完之后再统一拼接错误信息更容易定位问题。每一层逻辑只补充自己当前能拿到的上下文,不要重复套很多层相同的任务编号。
聚合错误本身替代不了超时和取消机制
errors.Join 只负责把多个错误打包好传递出去,它不会自动停下还在后台运行的任务。如果批次有固定的截止时间,还是得用 context.WithTimeout,还要保证 syncOne 能把 ctx 正确传递到下游的 HTTP、数据库或者队列调用逻辑里。
写单元测试验证错误树和批次结果
写测试用例的时候别只判断最终返回的 err.Error() 是不是非 nil。专门校验哨兵错误的匹配结果能确认错误包装逻辑没有被破坏,逐个检查结果项的内容能确认任务编号没有错位。
func TestFinish(t *testing.T) {
got := finish([]ItemResult{
{ID: 7, Err: ErrRateLimited, Retryable: true},
{ID: 8},
{ID: 9, Err: ErrBadPayload},
})
if got.Err == nil {
t.Fatal("want batch error")
}
if !errors.Is(got.Err, ErrRateLimited) {
t.Fatal("rate-limit cause was lost")
}
if !errors.Is(got.Err, ErrBadPayload) {
t.Fatal("payload cause was lost")
}
if got.Items[0].ID != 7 || !got.Items[0].Retryable {
t.Fatal("item result changed")
}
}
记得补一条空批次测试用例:所有任务都执行成功的时候,BatchResult.Err 必须返回 nil;没有任何待处理任务的时候,也不要手动构造一个“无错误”的假对象返回。
常见问题
errors.Join 能不能替代第三方多错误处理库?
如果你只需要标准库提供的错误聚合、Is 和 As 能力,它基本可以覆盖需求。如果还需要并发生命周期管理、限流或者首错自动取消这类能力,还是要搭配专门的并发控制方案来实现。
怎么判断 Join 生成的多错误里是不是包含某一类错误?
直接调用标准库的 errors.Is 或者 errors.As 就行。别手动拆分 Error() 返回的换行文本,那个只是用于展示的格式,不属于稳定的公开接口。
批量接口应该返回聚合错误还是完整失败列表?
如果调用方是调度器只关心整批任务的成败,返回聚合错误就够了;如果终端用户需要重试指定的个别任务,就返回完整的失败列表,还可以额外附带聚合错误作为批次整体状态。
落地检查清单
- 先确认调用方只需要整批总状态,还是需要拿到每个任务的失败明细。
- 固定哨兵错误用
errors.Is判断,带自定义字段的错误用errors.As解析。 - 并发收集结果优先按下标位置归位写入,避免操作共享切片触发竞态问题。
- 用
go test -race ./...跑通错误收集逻辑的全用例,再单独验证超时和取消的分支路径。
-
405 收藏
-
384 收藏
-
112 收藏
-
193 收藏
-
391 收藏
-
488 收藏
-
Golang · Go问答 | 1天前 | net/http · Go问答 · HTTP重试 · 请求体 · 接口稳定性 · net/http Go HTTP重试 Request.GetBody Request.Clone 请求体复用273 收藏
-
Golang · Go问答 | 1天前 | go · 安全 · net/http · HTTP重定向 · 请求头 · 请求头 Authorization 安全边界 CheckRedirect Go HTTP重定向268 收藏
-
Golang · Go问答 | 1天前 | 错误处理 · go · SQL · database/sql · 线上排查 · SCAN 查询结果 database/sql Go问答 rows.Next Rows.Err292 收藏
-
Golang · Go问答 | 1天前 | 超时 · 错误处理 · go · Context · errors.Is Go context deadline exceeded WithCancelCause309 收藏
-
385 收藏
-
286 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习