--- id: backend.go.scenario.batch-third-party-calls title: 批量调用第三方接口:并发控制、超时和部分失败 type: scenario domain: backend stack: [go] tags: [并发, 超时, 第三方接口, 限流, 重试] level: intermediate prerequisites: [backend.go.errors-concurrency] est_hours: 2 related: [backend.go.interview, backend.redis.lock-ratelimit] week: [3] --- # 批量调用第三方接口:并发控制、超时和部分失败 ## 业务背景与问题 电商后台的“批量查询物流状态”功能:运营一次选中几百个订单,系统要调用物流公司的查询接口,汇总后展示。第一版代码在一个循环里逐个调用,500 个订单要等一分多钟,接口经常超时;改成“每个订单开一个 goroutine”后又被物流公司限流(返回 429),偶尔还把自己服务的连接数打满。 ## 约束 - 第三方接口限流:每个账号最多 10 个并发、每秒 50 次(以对方文档为准)。 - 单次调用一般 100~300 毫秒,偶尔卡住几十秒。 - 页面请求整体最多等 5 秒;部分订单查询失败不能让整批失败。 - 结果要和输入订单一一对应,方便前端展示。 ## 方案设计 ```mermaid flowchart LR A[接口收到订单列表] --> B{整体 context
超时 5 秒} B --> C[信号量:最多 10 个并发] C --> D[单次请求 context
超时 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](../examples/batch-fetch/),核心部分: ```go 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 比例和耗时分布,逐步调整。