小Cの已经记不起来的博客

Go 项目中用 errgroup 并发控制处理批量请求

起因

前阵子接了个小需求:要把数据库里几千个用户的信息,挨个调一遍第三方的接口同步过去。接口单次响应大概 200ms,我第一版图省事直接 for 循环串行调,跑完一遍得七八分钟,测试的同学在旁边等着,脸都绿了。

那还不简单,起 goroutine 呗。然后我就写出了这种东西:

for _, u := range users {
    go syncUser(u)
}

跑起来确实快,快到第三方直接给我限流了,返回一堆 429,中间还夹着几个连接被重置的。而且这样写错误也没法收集,哪个用户同步失败了完全不知道,反正就是跑完了,成没成功另说。为什么当初写代码的时候没人提醒我呢,可能是我瞎没搜到吧...

去翻了翻别人是怎么处理的,才发现 golang.org/x/sync 里早就给好了现成的工具:errgroup。这玩意儿用起来是真顺手,下面记录一下实测可用的写法和几个踩过的坑。

errgroup 是个啥

errgroup 你可以理解成一个带错误传递的 WaitGroup。WaitGroup 只管等所有 goroutine 跑完,出错的事它一概不管;errgroup 会把其中第一个非 nil 的错误记下来,Wait 的时候还给你。

先安装:

go get golang.org/x/sync

最简版本长这样:

g := new(errgroup.Group)

for _, u := range users {
    u := u
    g.Go(func() error {
        return syncUser(u)
    })
}

if err := g.Wait(); err != nil {
    return err
}

这样就能并发跑了,还能拿到错误。注意 u := u 这行,Go 1.22 之前循环变量是共享的,不拷贝一份的话闭包里拿到的可能全是最后一个值。虽然 1.22 之后不用这么写了,但很多老项目还没升上去,写上总没坏处。

控制并发数

光并发还不够,上面那种写法有多少任务就起多少 goroutine,几十万个任务再叠上第三方的限流,服务器当场就给你表演一个雪崩。errgroup 的 SetLimit 就是干这个的:

g, ctx := errgroup.WithContext(context.Background())
g.SetLimit(10)

for _, u := range users {
    u := u
    g.Go(func() error {
        return syncUser(ctx, u)
    })
}

err := g.Wait()

SetLimit(10) 表示同一时间最多 10 个 goroutine 在跑,满了之后 g.Go 会阻塞住,等有空位了再放新的进去。效果类似拿带缓冲的 channel 当信号量,只是不用自己收发那么麻烦。这个方法是比较新的版本才加进去的,如果你的 go.mod 里 errgroup 太老,升级一下就行。

用 WithContext 及时止损

上面例子里我用了 errgroup.WithContext,这个值得单独说。它的行为是:任何一个 goroutine 返回了非 nil 的错误,ctx 就会被取消。也就是说只要你把 ctx 传进每个请求里,一旦有一个任务挂了,剩下的请求会立刻收到取消信号,不用等全部跑完过后才知道失败了。

func syncUser(ctx context.Context, u User) error {
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
    if err != nil {
        return err
    }
    // ...
}

要是你的场景是“失败了也没关系,能同步几个是几个”,那就别用 WithContext,直接 new(errgroup.Group) 就行,一个任务失败不影响其他人。

还有一个点:g.Wait() 返回的是第一个非 nil 的错误,不是所有错误。想把每个失败的都记下来的话,得自己在 goroutine 里收集。另外 WithContext 只是发取消信号,Wait 还是会等所有 goroutine 退出来,所以函数里面得老老实实响应 ctx,别把信号忽略了。

整理成完整例子

最后贴一段我在项目里实际用的结构,按索引写回结果,连锁都不用加:

results := make([]Result, len(users))

g, ctx := errgroup.WithContext(context.Background())
g.SetLimit(10)

for i, u := range users {
    i, u := i, u
    g.Go(func() error {
        res, err := fetchUser(ctx, u)
        if err != nil {
            log.Printf("sync user %d failed: %v", u.ID, err)
            return err
        }
        results[i] = res
        return nil
    })
}

if err := g.Wait(); err != nil {
    // 有失败的处理,比如把失败的那部分捞出来重试
}

每个 goroutine 只写 results 里属于自己的那个下标,互不干扰,所以不用 mutex。要是任务数量在跑之前不确定,那就老实用锁,或者用 channel 收结果。

小结

总结一下,批量请求这个事,用 errgroup 基本就三步:SetLimit 控并发,WithContext 让失败能及时取消,Wait 收错误。比自己手搓 WaitGroup 加 channel 信号量省事多了,错误也不会漏。

唯一要注意的就是别一激动又写出裸起 goroutine 的代码,虽然跑起来是真快,但快过头了总归是要还的。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放