用错误组并发调用多个依赖并在首错时收敛
来源:17golang原创
时间:2026-10-07 06:07:38 422浏览 收藏
当一个接口需要同时读取用户、库存和推荐三个独立依赖时,最直接的性能优化是把串行等待改成并发等待。但仅仅写三个 go func() 不够:任何一个依赖失败后,其他调用必须尽快取消;所有 goroutine 必须在函数返回前结束;错误要保留来源;批量扇出还要有限制。
golang.org/x/sync/errgroup 正好把这四件事放到一个结构里:Go 启动返回 error 的任务,WithContext 在首个非空错误出现时取消派生 Context,Wait 等待所有任务返回并交付第一个错误,SetLimit 则限制组内活跃 goroutine 数量。
Go 官方地址:https://go.dev/
errgroup 官方文档:https://pkg.go.dev/golang.org/x/sync/errgroup
先算清串行调用的延迟基线
假设首页聚合接口有三个互不依赖的后端调用,延迟预算分别是 80ms、120ms 和 200ms。串行执行的理想等待时间接近三者之和,也就是 400ms;并发执行的理想关键路径接近最慢依赖,也就是 200ms。这里是预算推导,不是生产压测结果,真实耗时还包括网络、连接池、序列化、调度和服务端排队。
| 依赖 | 示例延迟预算 | 串行时的影响 | 并发时的影响 |
|---|---|---|---|
| 用户资料 | 80ms | 累加到总耗时 | 与其他依赖重叠 |
| 库存 | 120ms | 累加到总耗时 | 与其他依赖重叠 |
| 推荐 | 200ms | 累加到总耗时 | 通常形成关键路径 |
| 理想总等待 | — | 约 400ms | 约 200ms |
只有三个调用相互独立时,才可以这样改。如果库存查询依赖用户资料中的区域字段,二者就不能放进同一并发层。并发减少的是可重叠等待时间,不会减少总工作量,反而可能让后端在同一时刻承受更多请求。
用 errgroup 建立首错取消边界

下面是首页聚合的核心写法。最容易写错的地方是忽略 WithContext 返回的新 ctx,继续把原始 parent 传给依赖。那样虽然 Wait 能返回错误,但首错取消无法传播到底层请求。
package home
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
)
type Profile struct {
Name string
}
type Inventory struct {
Available int
}
type Recommendation struct {
Items []string
}
type Page struct {
Profile Profile
Inventory Inventory
Recommendation Recommendation
}
type ProfileClient interface {
Get(context.Context, string) (Profile, error)
}
type InventoryClient interface {
Get(context.Context, string) (Inventory, error)
}
type RecommendationClient interface {
Get(context.Context, string) (Recommendation, error)
}
type Service struct {
profiles ProfileClient
stocks InventoryClient
recs RecommendationClient
}
func (s *Service) Load(parent context.Context, userID string) (Page, error) {
// 派生 ctx 会在首个任务返回非空错误或 Wait 返回时取消。
g, ctx := errgroup.WithContext(parent)
var profile Profile
var inventory Inventory
var recommendation Recommendation
g.Go(func() error {
value, err := s.profiles.Get(ctx, userID)
if err != nil {
return fmt.Errorf("读取用户资料: %w", err)
}
// 每个 goroutine 只写自己的结果变量,避免共享写竞争。
profile = value
return nil
})
g.Go(func() error {
value, err := s.stocks.Get(ctx, userID)
if err != nil {
return fmt.Errorf("读取库存: %w", err)
}
inventory = value
return nil
})
g.Go(func() error {
value, err := s.recs.Get(ctx, userID)
if err != nil {
return fmt.Errorf("读取推荐: %w", err)
}
recommendation = value
return nil
})
// Wait 会等待所有 Go 函数返回,再交付第一个非空错误。
if err := g.Wait(); err != nil {
return Page{}, err
}
return Page{
Profile: profile,
Inventory: inventory,
Recommendation: recommendation,
}, nil
}
三个 goroutine 分别写三个不同变量,主 goroutine 在 Wait 返回后才读取它们,因此不需要额外互斥锁。如果多个任务写同一个 map、向同一个 slice 追加,或更新同一个结构字段,就必须使用 mutex、结果 channel,或者预先按索引分配互不重叠的槽位。
首错取消为什么还要等待全部任务
官方文档规定,WithContext 返回的派生 Context 会在第一个 Go 函数返回非空错误,或第一次 Wait 返回时取消。取消只是发出信号,不是强制杀死 goroutine。依赖函数只有实际观察 ctx.Done(),或者调用支持 Context 的 HTTP、数据库和 RPC 方法,才会尽快退出。
Wait 不会在看到错误的瞬间抛下其他 goroutine 直接返回,而是等所有已注册函数结束后再返回第一个错误。这一点保证调用方返回时,任务组中没有仍在写结果或占用资源的遗留任务。
func waitDependency(ctx context.Context, delay time.Duration) error {
// Timer 必须停止,避免提前取消时留下不必要的计时器资源。
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case
对于 HTTP 请求,应使用 http.NewRequestWithContext 或 req.WithContext;对于数据库,应使用 QueryContext、ExecContext 等方法。只在函数入口检查一次 ctx.Err(),之后执行一个不支持取消的长阻塞调用,仍然不能及时收敛。
给整个聚合请求加超时
errgroup 负责“同组首错取消”,调用方仍应给整体操作设置期限。超时 Context 放在 WithContext 外层,派生 Context 会同时响应父级超时和组内错误。完成后调用 cancel 可以及时释放计时器资源。
func (s *Service) LoadWithTimeout(
parent context.Context,
userID string,
) (Page, error) {
// 整个聚合接口最多等待 300ms,所有下游共享这份预算。
ctx, cancel := context.WithTimeout(parent, 300*time.Millisecond)
defer cancel()
page, err := s.Load(ctx, userID)
if err != nil {
// 保留包装后的依赖名称和底层取消原因,便于日志归因。
return Page{}, fmt.Errorf("加载首页: %w", err)
}
return page, nil
}
共享总预算和“每个依赖各有 300ms”不是一回事。前者能保证整个接口不超出预算;后者可能让某些依赖在父请求已经没有价值后仍继续工作。若某个依赖还需要更短的单独上限,可以从组内 ctx 再派生更短的超时,但不能延长父级截止时间。
用可重复基准比较串行和并发
不要只凭一次请求判断优化效果。可以先用可控 mock 固定三个依赖延迟,确认结构确实把等待时间从“求和”变成“取最大值”;随后再在集成环境观测真实连接池、网络和后端限流。
func BenchmarkLoadSequential(b *testing.B) {
service := newMockService(80*time.Millisecond, 120*time.Millisecond, 200*time.Millisecond)
b.ResetTimer()
for i := 0; i
# 固定单次基准时间并重复 5 轮,减少偶然调度抖动的影响。 go test -run '^$' -bench 'BenchmarkLoad' -benchtime=1x -count=5 ./... # 需要比较两个版本时保存输出,再用 benchstat 观察差异分布。 go test -run '^$' -bench 'BenchmarkLoad' -count=10 ./... > before.txt
在上述纯延迟 mock 中,串行预算约为 400ms,并发关键路径约为 200ms,理论上可以减少约 200ms 等待。真实结果若没有接近这个方向,应检查:连接池是否把并发请求重新串行化、三个调用是否实际存在数据依赖、客户端是否共用锁、下游是否限流,或序列化和本地 CPU 是否成为新瓶颈。
生产指标至少应同时看接口 P50/P95/P99、每个依赖的耗时和错误率、在途请求数、连接池等待、取消次数与 goroutine 数。只看平均耗时可能掩盖长尾;只看接口变快也可能忽略下游负载被放大。
批量扇出时用 SetLimit 控制并发

三个固定依赖通常不需要限流,但“为 1000 个商品同时查库存”就不同。SetLimit(n) 把组内活跃 goroutine 限制为最多 n 个;达到上限后,新的 Go 调用会阻塞,直到有任务退出。官方文档要求:有任务活跃时不能修改这个 limit。
func LoadStocks(
parent context.Context,
client InventoryClient,
productIDs []string,
limit int,
) ([]Inventory, error) {
if limit
limit 应由下游容量决定。例如数据库连接池最多允许 20 个活跃连接,库存查询不应仅因为输入有 1000 项就启动 1000 个并发调用。还要为其他请求预留连接,不要把整个共享池都交给一个批处理。
如果调用方不能阻塞等待并发槽,可以用 TryGo。它只在当前活跃 goroutine 数低于上限时启动任务,并返回是否成功。失败后可以选择排队、降级或返回“系统繁忙”,但不要无声丢弃任务。
started := g.TryGo(func() error {
// 任务只有拿到并发槽后才会启动。
return refreshCache(ctx, key)
})
if !started {
// 调用方明确选择降级,不把未启动误判为成功。
return ErrBusy
}
错误、panic 和部分结果的边界
Wait 返回的是第一个非空错误,不是所有错误的集合。首错发生后,其他任务可能因为派生 Context 被取消而返回 context.Canceled,这些后续错误不会替换首错。给错误加上依赖名称并用 %w 包装,可以同时保留业务位置和底层原因。
errgroup 的任务函数约定返回 error。不要把 panic 当作普通错误传播机制;依赖代码出现未恢复 panic 仍可能终止进程。真正可预期的失败应返回错误,必要的恢复应位于明确定义的进程边界,并记录堆栈,而不是在每个小任务里随意吞掉 panic。
示例选择“任一依赖失败,整个首页失败”。如果推荐是可选能力,可以让推荐任务在可识别错误时写入空推荐并返回 nil,但应同时记录降级指标。不要对所有错误都返回 nil,否则超时、鉴权失败和数据错误会被伪装成成功。
| 依赖类型 | 错误策略 | 返回结果 |
|---|---|---|
| 用户身份、权限 | 首错取消 | 整体失败 |
| 库存、价格 | 通常首错取消 | 避免展示错误交易信息 |
| 推荐、猜你喜欢 | 可控降级 | 返回空模块并记录指标 |
| 审计写入 | 按合规要求决定 | 不能默认忽略 |
常见问题
errgroup 和 sync.WaitGroup 有什么区别?
sync.WaitGroup 只等待任务完成;errgroup 让任务函数返回错误,并可通过 WithContext 在首错时取消同组工作。只需要等待、不需要错误传播时,WaitGroup 更简单。
为什么已经取消了,其他依赖还在运行?
Context 取消是协作信号,不会强制终止 goroutine。检查是否把派生 ctx 传给了依赖,以及底层调用是否支持 Context。纯计算循环还需要周期性检查 ctx.Done()。
Wait 返回后还能使用派生 Context 吗?
不应该。官方文档说明,派生 Context 在第一个错误或 Wait 首次返回时取消。它的生命周期属于这一次任务组,不应保存到结构体或跨请求复用。
SetLimit 设为 0 会怎样?
上限为 0 会阻止任何新 goroutine 被加入,调用 Go 会等待可用槽,但槽永远不会出现。业务配置应在创建任务组前验证为正数。
并发一定能把耗时降到最慢依赖吗?
那只是独立等待型任务的理想下界。连接池、共享锁、CPU、序列化、服务端排队和限流都会增加实际耗时。必须用基准和生产指标验证,不能只看代码已经并发就宣布优化完成。
落地检查清单
- 并发任务之间没有必须串行的数据依赖。
- 所有任务都使用
WithContext返回的派生 Context。 - 所有任务最终都会返回,阻塞点能响应取消或超时。
- 不同 goroutine 不会无同步地写同一个 map、slice 结构或字段。
- 错误使用
%w保留底层原因,并带有明确依赖名称。 - 批量扇出设置了与下游容量匹配的
SetLimit。 - 同时比较接口长尾、依赖耗时、错误率、取消数和下游负载。
errgroup 的价值不只是少写一个 WaitGroup。它把“这些 goroutine 属于同一次操作”变成代码边界:首错发出取消,所有任务共同收尾,调用方只在 Wait 后读取结果。先确认依赖独立,再传对 Context,最后用并发上限和指标约束优化,才能让延迟下降而不是把压力转移给下游。
-
248 收藏
-
350 收藏
-
189 收藏
-
297 收藏
-
Golang · Go教程 | 2小时前 | JSON · go · 泛型 · api设计 · encoding/json UnmarshalJSON 可选字段 零值 Go JSON处理 PATCH接口306 收藏
-
Golang · Go教程 | 2小时前 | JSON · 流式处理 · Go教程 · 内存优化 · 内存优化 encoding/json 流式解析 json.Decoder Go JSON处理 超大JSON数组449 收藏
-
242 收藏
-
331 收藏
-
378 收藏
-
Golang · Go教程 | 4小时前 | Go教程 · 可观测性 · net/http · HTTP客户端 · 请求头注入 http.Client Go RoundTripper HTTP耗时 Transport中间件500 收藏
-
345 收藏
-
148 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习