99 lines
4.9 KiB
Markdown
99 lines
4.9 KiB
Markdown
---
|
|
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<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](../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 比例和耗时分布,逐步调整。
|