Files
zhuangang/topics/backend/go/scenarios/batch-third-party-calls.md

4.9 KiB

id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week
id title type domain stack tags level prerequisites est_hours related week
backend.go.scenario.batch-third-party-calls 批量调用第三方接口:并发控制、超时和部分失败 scenario backend
go
并发
超时
第三方接口
限流
重试
intermediate
backend.go.errors-concurrency
2
backend.go.interview
backend.redis.lock-ratelimit
3

批量调用第三方接口:并发控制、超时和部分失败

业务背景与问题

电商后台的“批量查询物流状态”功能:运营一次选中几百个订单,系统要调用物流公司的查询接口,汇总后展示。第一版代码在一个循环里逐个调用,500 个订单要等一分多钟,接口经常超时;改成“每个订单开一个 goroutine”后又被物流公司限流(返回 429),偶尔还把自己服务的连接数打满。

约束

  • 第三方接口限流:每个账号最多 10 个并发、每秒 50 次(以对方文档为准)。
  • 单次调用一般 100~300 毫秒,偶尔卡住几十秒。
  • 页面请求整体最多等 5 秒;部分订单查询失败不能让整批失败。
  • 结果要和输入订单一一对应,方便前端展示。

方案设计

flowchart LR
  A[接口收到订单列表] --> B{整体 context<br/>超时 5 秒}
  B --> C[信号量:最多 10 个并发]
  C --> D[单次请求 context<br/>超时 1 秒]
  D -->|成功| E[写入结果下标 i]
  D -->|失败 / 超时| F[记录该订单的错误]
  E --> G[等待全部完成]
  F --> G
  G --> H[返回:成功列表 + 失败列表]
  1. 并发上限:用带缓冲的 channel 做信号量,缓冲大小 = 对方允许的并发数。
  2. 两层超时:整体 context 控制页面等待时间;单次请求再套一层短超时,避免一个慢请求占着名额。
  3. 部分失败:每个订单的结果独立记录错误,最后返回“成功 + 失败(带原因)”,前端可以对失败的单独重试。
  4. 结果顺序:预先分配结果切片,每个 goroutine 写自己的下标,不需要加锁。
  5. 进一步:每秒次数限制用令牌桶(golang.org/x/time/rate);多实例部署时用 Redis 做全局限流;常用订单的物流状态缓存 1~5 分钟;数量很大时改成异步任务(提交后后台处理、完成后通知)。

关键代码

完整代码和测试见 examples/batch-fetch,核心部分:

sem := make(chan struct{}, limit) // 并发上限
for i, id := range ids {
	wg.Add(1)
	go func() {
		defer wg.Done()
		select {
		case sem <- struct{}{}:
		case <-ctx.Done(): // 整体超时后,没开始的请求不再发
			results[i] = Result{ID: id, Err: ctx.Err()}
			return
		}
		defer func() { <-sem }()
		results[i] = fetchOne(ctx, client, baseURL, id, perReq) // 内部再套单次超时
	}()
}
wg.Wait()

取舍

方案 优点 缺点
串行调用 简单,不会触发限流 太慢
每个订单一个 goroutine,不限并发 写法简单 被限流、连接数暴涨,下游故障时拖垮自己
信号量 + 两层超时(本方案) 速度和稳定性平衡,代码量小 单机限流,多实例需要 Redis 全局限流
异步任务 + 消息队列 能处理上万条、可重试 交互变成“提交—等待通知”,复杂度高

选择:几百条以内用本方案;超过几千条或需要可靠重试时改成异步任务。

易错点

  • 只设整体超时、不设单次超时:一个卡住的请求会一直占着并发名额。
  • 用 http.DefaultClient:它没有超时,生产代码要自己创建 http.Client 并设置 Timeout,或者每个请求都带 context。
  • 忘了 resp.Body.Close():连接无法复用,最终耗尽连接。
  • 对 429 / 5xx 立即重试:会让限流更严重;重试要有退避和次数上限,最好只重试幂等的查询。
  • 多个 goroutine 同时 append 同一个切片:数据竞争,用下标写入或加锁。

面试怎么讲

“我们有个批量查物流的功能,最早串行调用太慢,改成全并发又被第三方限流。我用带缓冲的 channel 做信号量把并发控制在对方允许的范围内,整体请求和单次调用分别设超时,每个订单独立记录成功或失败,最后返回部分成功的结果,前端可以单独重试失败的。测试里用 httptest 模拟慢请求和 500,验证并发上限、超时和结果顺序。如果数据量再大,我会改成异步任务加消息队列。”

可能的追问:

  • 多实例部署时怎么保证总并发不超过 10?→ Redis 分布式限流或者集中到一个调用服务。
  • 为什么不用 errgroup?→ errgroup 默认“一个失败全部取消”,这里需要部分失败;新版 errgroup 的 SetLimit 也能限并发,可以按需选择。
  • 怎么知道限流参数合不合适?→ 监控第三方的 429 比例和耗时分布,逐步调整。