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
intermediate
backend.go.errors-concurrency
2
backend.go.interview
backend.redis.lock-ratelimit
批量调用第三方接口:并发控制、超时和部分失败
业务背景与问题
电商后台的“批量查询物流状态”功能:运营一次选中几百个订单,系统要调用物流公司的查询接口,汇总后展示。第一版代码在一个循环里逐个调用,500 个订单要等一分多钟,接口经常超时;改成“每个订单开一个 goroutine”后又被物流公司限流(返回 429),偶尔还把自己服务的连接数打满。
约束
第三方接口限流:每个账号最多 10 个并发、每秒 50 次(以对方文档为准)。
单次调用一般 100~300 毫秒,偶尔卡住几十秒。
页面请求整体最多等 5 秒;部分订单查询失败不能让整批失败。
结果要和输入订单一一对应,方便前端展示。
方案设计
并发上限 :用带缓冲的 channel 做信号量,缓冲大小 = 对方允许的并发数。
两层超时 :整体 context 控制页面等待时间;单次请求再套一层短超时,避免一个慢请求占着名额。
部分失败 :每个订单的结果独立记录错误,最后返回“成功 + 失败(带原因)”,前端可以对失败的单独重试。
结果顺序 :预先分配结果切片,每个 goroutine 写自己的下标,不需要加锁。
进一步 :每秒次数限制用令牌桶(golang.org/x/time/rate);多实例部署时用 Redis 做全局限流;常用订单的物流状态缓存 1~5 分钟;数量很大时改成异步任务(提交后后台处理、完成后通知)。
关键代码
完整代码和测试见 examples/batch-fetch ,核心部分:
取舍
方案
优点
缺点
串行调用
简单,不会触发限流
太慢
每个订单一个 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 比例和耗时分布,逐步调整。