同事小牛三下五除二就把“批量获取商品详情”这个功能干上线了,当时那版代码确实写得干净又漂亮,没有一行多余的代码,函数职责清晰,命名直白,连代码解耦都顺手做了。结果真数据一跑起来就露馅了:每个商品平均要0.8秒,一次同步几百个商品,页面能直接卡到超时,运营那边等得直拍桌子。今天就把这次“代码漂亮但性能翻车”的定位和提速过程完整复盘一遍,涉及连接复用、线程池、批量接口、缓存和数据库查询优化,适合所有写过批量拉取功能的后端开发,也适合正准备做类似接口优化的朋友参考。
1. 需求不复杂,第一天就上线了
1.1 先还原一下业务场景
需求本身不复杂:运营后台需要定期同步一批商品详情,输入是一串商品ID,输出是每个商品的标题、价格、库存、主图、描述等字段。用途包括商品快照、价格对比、周期性报表。当时预估的量级是单次任务最多一千个商品,数据源是上游商品中心暴露的详情接口,按单商品维度提供。
这类需求最直观的写法就是循环调用:你给我一个ID,我调一次接口,拿到数据放进列表,再进入下一个。没有分页、没有状态机、没有复杂事务,看起来十分钟就能搞定。
但“看起来简单”和“跑起来可靠”是两回事。批量任务的特征是数据量大、依赖外部服务、耗时集中在IO等待上。如果不提前想清楚请求次数和并发模型,代码再漂亮也只是表面光鲜。
1.2 第一版代码:干净,但是有个隐患
第一版实现大概长这样:
python复制def fetch_product_details(product_ids):
result = []
for pid in product_ids:
resp = requests.get(detail_url(pid), timeout=5)
result.append(resp.json())
return result
这个函数逻辑非常直白,也没有什么多余代码:一个循环、一个请求、一个JSON解析,没有任何花哨的抽象。当时我还特意把它从业务代码里拆出来,保持了函数级别的代码解耦,想着以后复用也方便。
问题恰恰藏在这个干净结构里:这是一个纯串行的请求模型。每次循环都要等上一次请求完全结束才开始下一次。看起来每个商品只花0.8秒,但一千个商品就是八百秒,等于十三分钟。这类问题不压测根本发现不了,因为单个请求确实很快,代码也确实没问题。
1.3 线上数据一跑,问题直接现形
我用两百个商品做了一轮本地验证,从发起任务到拿到全部结果,耗时大约160秒,平均每个商品0.8秒。按这个比例推算,一千个商品要跑十三分钟左右,运营点击一次同步按钮,中间够喝两杯咖啡。
更要命的是超时问题。前端调用这个同步接口时设了30秒超时,任务跑不完直接断连。断连后服务端线程还在继续跑,但用户端已经拿不到结果,后续流程全卡住。日志里全是超时告警,任务列表里出现一堆“执行中”但实际没人知道什么时候结束的脏数据。
我意识到问题不是出在代码风格上,而是出在“批量即串行,串行即灾难”这个结构上。从那时开始,我决定把800毫秒拆开看看,钱到底花在哪了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 慢的原因,我拆成了三笔账
2.1 一笔请求的800毫秒是怎么花掉的
我把单次商品详情请求用打点日志重新跑了一遍,把时间拆成三段:连接建立、服务端处理、响应传输与解析。本地实验数据大概是这样的:
| 阶段 | 耗时 | 说明 |
|---|---|---|
| 连接建立 | 40~80ms | 包含TCP握手,HTTPS还会多出TLS握手 |
| 服务端处理 | 550~620ms | 上游查库、组装详情、返回JSON |
| 传输与解析 | 50~100ms | 响应体传输、客户端JSON序列化 |
三块加在一起就是800毫秒左右。这个分解很关键:最贵的部分不是网络也不是解析,而是服务端处理时间,占了将近四分之三。这意味着客户端能优化的部分只有连接和调度层面的浪费,服务端那600毫秒是躲不掉的。
但换个角度想,正因为服务端处理是固定的,我们才更需要用并发把“等待时间”重叠起来。一次请求等600毫秒是正常的,一千次请求串行等六百个600毫秒就是灾难。
2.2 账一:每次请求都在重新握手
第一版代码用的是requests.get,每次调用都会新建一个TCP连接。如果目标是HTTPS接口,还要额外完成TLS握手。三次握手加TLS协商,一次连接建立成本在几十毫秒级别,看起来不多,但一千个商品就是几十秒的无谓开销。
连接复用就是让同一个TCP连接持续保持,后续请求直接在这个连接上发,省去握手过程。Python的requests.Session底层连接池默认支持这个能力,Java里用HttpClient或RestTemplate配合连接池也是一样的道理。
这一笔账是“最便宜”的优化,改动只有一行,把requests.get换成session.get。但它也只是治标,因为真正的大头还是服务端处理时间。
2.3 账二:串行等待,延迟线性累加
假设上游接口稳定在600毫秒处理时间,客户端网络加解析大约200毫秒。串行模式下,一千个商品的总耗时近似为:
1000 × 800ms = 800s
这就是最直观的线性累加。哪怕单个请求只要0.8秒,量一多就完全失控。
如果改成并发,情况就不同了。十六路并发时,理想总耗时接近:
800s / 16 + 调度开销 ≈ 50s
实际跑下来大约55到60秒,因为还有会话管理、响应体累积、线程切换这些小开销。但已经比十三分钟快了一个数量级。
这里的关键认知是:并发不会让单个请求变快,它只是把“多个请求的等待时间”重叠起来。真正省下的是队列里的排队时间。
2.4 账三:循环里藏着N+1查询
更隐蔽的问题是,第一版虽然只在客户端做一次循环,但上游服务端内部很可能是按单商品做多表查询的。比如一个详情接口内部要查基础商品表、SKU表、价格表、图片表,客户端每请求一次,服务端就循环执行好几次SQL。
这种模式就是典型的N+1查询。外部N个商品,内部每个商品再多查M张表,实际数据库压力变成N×M次。对数据库来说,收到一千次请求和收到五千次请求,性能表现完全是两个级别。看到这种表现,基本可以断定不是硬件问题,而是请求次数被放大导致的IO性能下滑。
所以批量任务的优化不能只盯着客户端代码,还要看接口形态。客户端能做的只是减少请求次数、合并等待时间,如果能在服务端把单商品接口改成批量接口,才能从根上把N+1消灭掉。
3. 优化落地:改得不多,效果拉满
3.1 第一步:连接复用,改一行就见效
我先做的第一件事是引入requests.Session:
python复制session = requests.Session()
def fetch_product_details(product_ids):
result = []
for pid in product_ids:
resp = session.get(detail_url(pid), timeout=(2, 5))
result.append(resp.json())
return result
这里把timeout也从单个数字改成了(connect_timeout, read_timeout)二元组,分别控制连接超时和读取超时,避免某个慢接口把线程长时间挂住。
实测下来,单次请求从800毫秒降到大约600毫秒,一千个商品从十三分钟降到十分钟左右。连接复用省掉的是每次握手的40到80毫秒,收益稳定可预期,而且代码几乎没有变复杂。
需要注意一个坑:requests.Session不是完全线程安全的,后续做并发时不能让多个线程共用一个Session直接发请求,否则会出现连接状态串扰。规范做法是每个线程持有自己的Session,或者用线程局部变量保存,每线程一个实例。
3.2 第二步:线程池并发,总耗时直接除以路数
连接复用只是开胃菜,真正质变的是并行化:
python复制from concurrent.futures import ThreadPoolExecutor
def fetch_product_details(product_ids, max_workers=16):
def fetch(pid):
session = requests.Session()
resp = session.get(detail_url(pid), timeout=(2, 5))
return resp.json()
with ThreadPoolExecutor(max_workers=max_workers) as pool:
return list(pool.map(fetch, product_ids))
每个线程内部自己创建Session,天然规避了线程安全问题。pool.map还能保证返回结果的顺序和输入商品ID顺序一致,对后续批量写入很友好。
实测一千个商品,十六路并发跑下来大约55到60秒,相比最初的十三分钟提升了十倍以上。
线程数怎么定?我一般会先用压测找拐点,从8路开始翻倍,观察两个指标:总耗时是否还在下降、错误率是否开始抬头。粗算公式也可以参考:如果上游QPS上限是100,单请求耗时0.6秒,理论并发上限大约就是100 × 0.6 = 60路,实际为了留余量,我会取一半左右,也就是30路以内。你永远不知道上游网关什么时候会收紧限制,留余量比极限压榨更务实。
3.3 第三步:推动批量接口,从根上减少请求
并发方案已经能把一千个商品压到一分钟内,但每次任务还是发起一千次HTTP请求。从运维角度来说,请求次数多意味着对上游压力大、日志量大、出错概率也高。更好的方式是推动上游提供一个批量详情接口:
python复制def fetch_product_details(product_ids):
session = requests.Session()
resp = session.post(
"https://api.example.com/products/batch",
json={"ids": product_ids},
timeout=(2, 30)
)
return resp.json()["items"]
一次POST把一千个ID全带上,上游服务端用一条WHERE id IN (...)查询把详情取出来,再批量返回。实测单次批量请求总耗时大约2到3秒,算下来单件等效耗时只有几毫秒,和最初的0.8秒完全是两个维度。
这里要特别提醒:不是所有上游都愿意改接口。在推动过程中,我遇到的最大阻力是“批量接口的返回体太大怎么办”。一千个商品每个两KB,响应体就是2MB,对网关和客户端传输都有压力。我的解决思路是控制单批数量,比如每500个ID一批,拆成两个子批次再并发调用,既能控制响应体大小,又能保持足够的吞吐。
如果上游实在不支持批量接口,还有一个折中方案:自己在网关层做聚合。对客户端暴露批量语义,内部仍然调用单商品接口,但使用并发和连接复用,把复杂细节隐藏在聚合服务里。这样下游业务拿到的还是简化接口,内部再慢慢推动上游改造。
3.4 第四步:超时、重试、缓存,一个都不能少
并发上去之后,最怕的就是某个慢请求把线程池占满。所以超时设置必须严格:连接阶段2秒,读取阶段5秒,批量接口给到30秒。我用的是(connect_timeout, read_timeout)这种二元组写法,比单一超时值更精准。
重试策略也要配套。对网络错误和5xx错误做有限重试,指数退避,比如第一次等1秒、第二次等2秒、第三次放弃。4xx错误不要重试,重试也是白搭,反而加重上游压力。
缓存是另一个立竿见影的手段。同一个商品在一轮任务周期内可能被重复请求好几次,比如运营先按类目同步一次,又按活动维度同步一次。我加了一个简单的Redis缓存,TTL设置成5分钟,命中时直接返回结果,连HTTP请求都省了。热点商品的重复请求多的时候,缓存命中率能到30%以上,整体任务时间进一步缩短。
3.5 优化前后对比
整轮优化做完,我留了一份对比表:
| 方案 | 1000个商品实测总耗时 | 说明 |
|---|---|---|
| 串行+每次新建连接 | 约13分钟 | 原始版本 |
| 串行+连接复用 | 约10分钟 | 最小改动 |
| 16路并发+连接复用 | 约1分钟 | 质变来自并行 |
| 批量接口+连接复用 | 约3秒 | 上游配合后的最优解 |
从十三分钟到三秒,整体提升大约260倍。这轮优化让我印象最深的一点是:每一步改动都不复杂,复杂的是判断“当前瓶颈到底在哪”。
4. 实战中踩过的坑与排查方法
4.1 并发一开,上游限流就来了
第一次把并发从8调到16时,我以为稳了,结果跑到一半开始连续报429和503。翻日志发现上游网关对单个调用方有QPS限制,我们这把并发一开,直接把人家限流阈值打穿了。
处理方式不是调低线程池那么简单。我最后用了一个信号量做整体限宽:
python复制from threading import BoundedSemaphore
semaphore = BoundedSemaphore(10)
def fetch_with_limit(pid):
with semaphore:
session = requests.Session()
resp = session.get(detail_url(pid), timeout=(2, 5))
return resp.json()
线程池可以开到16,但真正同时进行的外部请求被信号量限制在10个以内。这样既不浪费线程资源,也给上游留了缓冲空间。限流和并发是一对孪生兄弟,优化的时候总要一起考虑。
4.2 线程池不是越大越好
线程数开太大,收益反而递减。线程调度、上下文切换、内存开销都会被放大,而且下游一旦扛不住,错误率会拖着整体耗时上涨。
我见过一个比较典型的失误:把线程池开到200,任务是跑完了,但上游扛不住,错误率超过10%,重试逻辑又被触发,结果线程池里塞了几百个重试任务,最终任务时间比并发16路还慢。
线程池要关注的参数不只是核心线程数和最大线程数,还有队列策略。无界队列会让任务无限堆积,最终全部超时;有界队列配合一个合理的拒绝策略,比如CallerRunsPolicy,才能保证生产环境不失控。
4.3 缓存带来的三座大山
加了缓存之后,速度是快了,但缓存本身藏了不少坑。
第一个是穿透。如果请求的商品ID根本不存在,缓存和上游数据库里都没有,每次请求都会打到上游。解决办法是缓存空值,TTL设置短一点,比如60秒。
第二个是击穿。某个热点商品缓存恰好过期的一瞬间,几十个线程同时去上游拿数据。解决办法是加互斥锁,只允许一个线程去重建缓存,其他线程等锁之后直接读缓存。
第三个是雪崩。大量商品缓存同时过期,上游瞬间收到一大波请求。解决办法是给TTL加随机抖动,比如统一5分钟的缓存,实际过期时间在4到6分钟之间随机。
库存、价格这类高频变化字段,缓存时间不能太长,否则用户看到的价格已经变了,本地缓存还在返回旧值。我的经验是详情类基础字段缓存5到10分钟,库存价格字段最多1分钟,或者干脆不缓存。
4.4 批量查询也要注意索引
推动上游做批量接口之后,数据库侧又冒出新问题。如果服务端用WHERE id IN (5000个ID)这种写法,MySQL会直接报参数限制错误。就算不报错,索引也可能失效,变成全表扫描。
后来把所有批量SQL都控制在500个ID一批,并且先在explain里确认执行计划确实走了索引。涉及排序和联表查询时,还要注意临时表会不会过大。这类问题已经属于mysql性能调优的范畴,但批量接口一旦上线,服务端SQL就是新的瓶颈,必须一并排查。
另外提醒一点:批量接口返回的字段别贪多,只返回业务真正会用到的字段。每次返回几百个字段,序列化时间长,响应体也大,一次两次看不出来,量一上来差距就明显了。
4.5 这一类问题我惯用的排查流程
这类“批量拉取外部接口慢”的问题,我一般按这套流程走:
第一步,先做单次请求基准测试。连续调用同一个详情接口十次,看平均耗时和最大耗时。如果单次本来就要几百毫秒,说明不是客户端网络问题。
第二步,打点记录各阶段耗时。用一个简单装饰器统计函数耗时,输出p50和p99。
python复制import time
import statistics
def timed_fetch(product_id):
start = time.perf_counter()
session = requests.Session()
resp = session.get(detail_url(product_id), timeout=(2, 5))
cost = time.perf_counter() - start
return resp.status_code, cost
第三步,看并发后的错误率分布。如果429和503比例突然升高,基本就是限流;如果是超时,要区分是连接超时还是读取超时。
第四步,查数据库慢查询日志。重点排查是否存在N+1查询、IN列表过长、索引失效这些问题。
第五步,用压测工具做对比验证。wrk、go-wrk或者自己写的并发脚本都可以,关键是每轮优化前后都要跑同一组数据,用数字判断改动是不是真的有效。
这套流程走完,90%的批量接口性能问题都能定位到具体层面。剩下10%通常在上游服务端,只能靠抓日志和协调联查解决。
5. 这轮优化做完,我想明白的三件事
5.1 代码整洁和性能是两码事
第一版代码确实没有一行多余的,但它把“串行加高频请求”的问题藏在了一个赏心悦目的循环里。代码整洁描述的是可读性与可维护性,性能描述的是时间复杂度和IO路径,两者完全独立。以后我再看到“这代码写得真舒服”的评价,第一反应已经不是夸了,而是会追问一句:压测数据在哪儿?
5.2 没有量化打点,优化就是瞎调
这次从800毫秒到3秒,每一步改动都有实测数字支撑。没有打点之前,我甚至不确定慢在网络上还是慢在上游。后来我在任务代码里加了一段简单的耗时统计,每批跑完自动输出总量、均值、TP99,后面再调参数就只看数据说话,不再凭感觉。
5.3 性能瓶颈往往在接口形态上
客户端能做的优化是有上限的,真正的质变来自把一千次HTTP请求合并成一次批量请求。遇到“批量拉取XX”这类需求,第一件事不是急着写循环,而是先问上游有没有批量接口。没有的话就并发加连接复用来过渡,同时推动接口改造,这才是这条链路最优的演进路径。
