批量获取产品详情接口优化:从2.8秒到80毫秒的实践之路
接手这个需求的时候对方只丢了一句话“把批量获取产品详情的接口优化一下现在每个商品要好几秒跑完一批数据要等半天。”第一版代码我看了一眼确实赏心悦目——一个简单循环串行请求详情接口解析返回字段再逐条写入数据库。整个实现干净利落没有任何多余代码。但性能惨不忍睹同步一万个商品详情平均每个耗时 2.8 秒全量跑完要接近 8 个小时。上线当晚就被业务方吐槽“数据更新太慢”第二天一早就排到了我手上。这篇文章就围绕这个真实场景展开为什么“看着很干净”的代码会慢到这个地步问题到底出在哪些环节以及我后续是怎么一步步把它优化到单商品 80 毫秒的。整个排查、重构和上线踩坑的过程对做接口调用、数据同步、批量任务优化的同学应该都有参考价值。1. 需求背景为什么会有批量获取产品详情的任务这个需求本身并不复杂。我们的业务系统里维护着一批自营商城商品每次需要从商品中心同步商品详情到本地缓存库。商品详情不仅包含标题、价格、库存、主图这些基础字段还有规格参数、SKU列表、售后说明、富文本描述等一大串内容。下游业务方要按条件查询商品列表不可能每次都去实时请求商品中心所以必须有一个定时同步任务把商品详情的快照落进本地库。第一版上线的代码很简单def sync_product_details(product_ids): result [] for pid in product_ids: resp requests.get(fhttps://product.example.com/api/detail?pid{pid}) data resp.json() item { pid: pid, title: data[title], price: data[price], stock: data[stock], specs: json.dumps(data[specs], ensure_asciiFalse), updated_at: now(), } result.append(item) save_to_db(result)单看代码逻辑没有任何问题for 循环拉数据、解析、攒列表、批量入库每一步都清晰可读。但它把性能问题完全暴露出来了所有网络请求是串行的。一次请求从发起到拿到完整数据平均要 2.8 秒这个耗时里真正处理业务逻辑的时间几乎可以忽略绝大部分都消耗在网络上。这类任务在日常开发里太常见了看起来只是一段循环调接口的代码放到生产环境一跑才发现吞吐量完全不够。接下来我们就拆解一下这 2.8 秒到底花在了哪里这也是整个优化的起点。2. 第一版为什么慢2.8 秒耗时逐层拆解拿到性能问题后我没有急着改代码而是先做了耗时拆解。性能优化最忌讳“拍脑袋改”改了半天也不知道优化的是哪个环节。我直接在代码里埋了计时点把整个请求链路的每个阶段都测了一遍。2.1 串行循环带来的线性放大最直观的问题就是循环串行。请求一个商品详情要 2.8 秒一万个商品就是 2.8 万秒也就是 7.8 小时。这个放大关系是线性的代码越“干净简单”它就越明显。很多第一次面对这类问题的同学会觉得“加个并发不就行了”但并发只是手段之一。串行请求的问题不仅是慢还有单点超时导致的连锁等待——如果其中某个请求因为网络抖动卡了 10 秒后面所有商品都得跟着等 10 秒。2.2 长连接缺失每次请求都在重新握手这是最容易被忽略却又影响巨大的一个环节。requests.get()每次都会新建一个连接然后走完完整的 DNS 解析、TCP 三次握手、TLS 协商流程。在我的测试环境里连接到商品中心服务端的握手开销大约是 150–300 毫秒具体取决于网络链路。类比一下你每次去超市买东西都在门口重新办一张会员卡、重新验一次身份证然后才进去购物。这个“办卡”的动作本身和买什么无关但每次都省不掉。HTTP 的长连接就是解决这个问题的——连接建立后保持复用后续请求直接走已有通道。2.3 响应体过大富文本和规格参数拖慢了传输再来看响应本身。商品详情接口返回的是一个很完整的 JSON包含富文本描述、SKU 列表、多张图的 URL、各种规格参数。我抓包看了一下平均单个响应体大约 40KB大的商品能到 120KB。这个 40KB 看着不大但响应请求是串行的每多传 40KB 都要排队等待。更关键是同步任务里大部分字段本地库根本不需要——比如富文本描述在列表页和详情页展示用的不是同一份数据批量同步其实只需要其中的一小部分字段。一次性拉全量字段属于“用不完也要拉”无形中放大了网络耗时。2.4 数据库入库循环单条 insert 比预想中慢第一版虽然攒了列表批量入库但用的是单条 insert 依次执行。对 MySQL 来说每一条 insert 都涉及 SQL 解析、事务日志、索引更新、同步刷新等操作。在商品数量大的情况下这部分的累计耗时同样不可小觑。我看到的版本里save_to_db(result)具体是这样写的def save_to_db(items): conn get_connection() for item in items: cursor conn.cursor() cursor.execute( INSERT INTO product_snapshot (...) VALUES (...), (item[pid], item[title], ...) ) conn.commit()一次提交一条、每次提交都刷事务日志这在实际生产环境中比executemany批量写入要慢 5–10 倍。“代码看着没问题”和“代码运行效率高”之间差的往往是这种细节。这一轮拆解给我的结论是第一版的性能瓶颈不是某一个环节坏了而是每个环节都有不同程度的浪费。网络串行、连接不复用、传输冗余、入库效率低四重因素叠加才导致 2.8 秒的高延迟。3. 从串行到并发性能优化的第一板斧拆解完耗时构成我开始动手改代码。第一板斧改的就是最大瓶颈——串行请求改并发。3.1 用协程替代线程池我选择了asyncioaiohttp来实现并发。这里有个选型逻辑这个任务是 I/O 密集型操作绝大多数时间花在网络等待上协程能把这个等待时间重合起来比线程池更省资源。在 Python 里线程池受 GIL 限制碰到大量 I/O 等待时切换开销也不小协程则是在单线程里通过事件循环调度几百个并发任务可以挂在一个进程里非常轻量。实测下来效果很显著import asyncio import aiohttp async def fetch_detail(session, pid): url fhttps://product.example.com/api/detail?pid{pid} async with session.get(url, timeout10) as resp: return pid, await resp.json() async def main(product_ids): async with aiohttp.ClientSession() as session: sem asyncio.Semaphore(20) async def guarded_fetch(pid): async with sem: return await fetch_detail(session, pid) tasks [guarded_fetch(pid) for pid in product_ids] results await asyncio.gather(*tasks) return results这里有个细节我在guarded_fetch里加了asyncio.Semaphore(20)把并发数限制在 20 以内。原因是商品中心那个接口不可能同时处理无限数量的请求并发太高会直接把对端打挂。这个数字也是试出来的——并发 20 时单接口响应依然稳定在 200ms 内并发 100 时对端开始偶发 429 限流。3.2 复用 ClientSession干掉重复握手协程改完后我又检查了一个细节aiohttp.ClientSession是否被复用了。很多第一次写 aiohttp 的同事会犯一个错误——在每个协程内部新建 Session。这相当于把原来的重复握手问题原封不动地搬进了协程版本。正确做法是全局只建一个 Session所有请求复用同一个连接池。aiohttp 的ClientSession内部自带连接池机制配合 HTTP 长连接后每个请求省掉的握手开销非常可观。3.3 并发度的最终调优并发不是越高越好。我把并发数从 1 逐步调大记录每一档的吞吐变化并发数单个请求平均耗时1000 商品总耗时失败率1串行2.8s2800s0.1%51.1s220s0.2%100.52s52s0.3%200.38s38s0.5%500.35s35s1.2%1000.34s34s3.5%从这个表格能明显看到并发从 1 提到 20吞吐提升了约 74 倍但继续从 20 提到 100吞吐只提升了一点点失败率却明显走高。最终我把并发数锚定在 20既保证了吞吐又留了安全余量。这里的“失败率”指的主要是请求超时和对端返回的限流错误。在真实业务中一批商品同步里有几个失败是可以接受的只要重试机制能兜住就行。4. 别只盯网络传输和入库的同步优化并发改造一做完1000 个商品从 2800 秒降到了 38 秒效果立竿见影。但我没有就此收手因为拆解的时候还发现了两块“隐性浪费”——响应体过大和入库效率低。这两块优化好了能让速度再上一个台阶。4.1 用 fields 参数裁剪响应体我重新翻了商品中心的接口文档发现详情接口支持fields参数可以指定返回哪些字段。第一版代码之所以没这么做是因为“全量返回反正也要解析干脆拿全了省得漏字段”。这种偷懒在高并发场景下代价会被放大。我对比了一下同步任务真正需要的字段只有标题、价格、库存、主图 URL、规格参数 JSON 五个响应体却要把富文本、多图列表、售后说明等全部包含进去。开启字段裁剪后单个响应体从平均 40KB 降到 6KB传输时间直接少了 85%。响应方式平均响应体大小平均响应时间全量返回42KB210ms仅返回需要的字段6KB80ms调用方式很简单url fhttps://product.example.com/api/detail?pid{pid} fields pid,title,price,stock,image_url,specs url f{url}fields{fields}有时候商品中心是内部系统加字段裁剪需要协调开发如果接口不支持裁剪另一条路是走内部专门的精简接口或者在自己本地做内存缓存这些都可以结合实际情况处理。4.2 executemany 批量写入代替逐条 insert数据库写入部分我从逐条executecommit改成了executemany批量提交def save_to_db(items): batch [] for item in items: batch.append((item[pid], item[title], item[price], ...)) cursor conn.cursor() cursor.executemany( INSERT INTO product_snapshot (pid, title, price, stock, image_url, specs, updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s), batch ) conn.commit()同样是commit一次但批量插入能把多条记录打包成一个数据库事务处理省去了大量往返开销。实际测试中写入 1000 条记录的时间从 8 秒降到了 0.7 秒左右。这里有个经验批量写入不一定非得在内存里把全部数据攒完再入库。更稳妥的做法是分页批量提交比如每攒 200 条提交一次这样即使中途任务崩溃已提交的数据不会全部丢失。4.3 内存缓存避免重复请求上线后的监控里我又发现了一个问题同一批同步任务里偶尔会出现重复的商品 ID尤其是业务手动触发补跑的时候。这些重复请求纯粹是浪费。我加了一个简单的内存缓存以商品 ID 为 key 缓存最近一次拉取的结果TTL 设置 10 分钟。逻辑不复杂但能挡掉一部分重复调用cache {} async def fetch_with_cache(pid): if pid in cache and cache[pid][expire_at] time.time(): return cache[pid][data] data await fetch_detail(session, pid) cache[pid] {data: data, expire_at: time.time() 600} return data这一步看似小但在实际大批量同步、任务重跑频繁的真实场景里能减少 10%–15% 的无效请求效果相当可观。5. 优化完成后的实测吞吐提升了多少优化到这里整个批量同步任务经历了四轮改造串行改并发、连接复用、字段裁剪、批量入库。我需要用最终数据来验证这套组合拳的效果。5.1 单商品耗时对比阶段平均耗时/商品1000 商品耗时第一版串行 无裁剪 单条入库2.8s2800s并发 20保留全量响应体0.38s38s并发 20 字段裁剪0.12s12s并发 20 字段裁剪 批量入库0.08s8s从 2.8 秒到 80 毫秒单商品耗时下降了 35 倍。原来同步一万个商品需要 7.8 小时现在只要不到 15 分钟。而且这个成绩并不是靠牺牲准确性换来的——同样的数据接口、同样的写入逻辑只是把每一层浪费都补掉了。5.2 P50/P95/P99 分布不能只看平均值做性能验证时我特别关注了耗时分布。平均值容易掩盖长尾问题一个任务的 P95、P99 才是真实体验的反映。优化完以后我压测了 5000 个商品统计结果指标耗时P5065msP95180msP99340ms最大耗时980msP99 能压到 340ms 以内意味着极端网络波动下也不会有哪个商品让人干等。如果 P99 仍然偏高那就说明并发控制或连接池还有问题需要继续调。5.3 优化效果被低估的部分稳定性除了纯粹的速度提升还有一个容易被忽略的收益是稳定性。第一版串行代码里一个单点超时会拖慢整批任务改成并发后就算有 1% 的请求失败也只会影响 1% 的商品数据配合重试机制就能拉回来。整个同步任务的失败率从 3% 降到了 0.2% 以下。这一轮优化也让我重新认识到一个道理看代码不能只看“清不清晰”还要看它在真实并发环境下的“宽裕度”。第一版代码在测试环境跑一两个商品看不出问题规模一上来就原形毕露。6. 上线后的新问题限流、抖动与重试策略优化完不等于一劳永逸。上线第二天就遇到一个意外商品中心那边突然开始大量返回 429 限流错误。6.1 对端限流并发从 20 降到 15我排查后发现商品中心接口的限制是单应用 20 QPS。我们的同步任务虽然并发数控制在 20但解析、入库的间隙里可能一瞬间发出 20 多个请求直接把对端的配额打满了。这个问题的方案很直接把并发数调到 15给对端留出安全余量。同时我在代码里加了对 429 响应的识别一旦发现限流就自动退避重试async def fetch_with_retry(session, pid, max_retries3): for attempt in range(max_retries): try: async with session.get(url, timeout10) as resp: if resp.status 429: await asyncio.sleep(2 * (attempt 1)) continue return pid, await resp.json() except asyncio.TimeoutError: if attempt max_retries - 1: raise await asyncio.sleep(2 ** attempt)重试不能是无脑重试指数退避的意思是第一次失败等 1 秒、第二次等 2 秒、第三次等 4 秒。这样既能兜住偶发失败又不会因为集中重试把对端再次打爆。6.2 网络抖动导致的任务堆积另一类问题是网络层的抖动。有段时间商品中心做机房迁移网络延迟从原来的 80ms 突增到 1.8 秒。同步任务里的请求虽然并发着发出去但每个请求都慢整体吞吐还是受到严重影响。这类问题靠业务代码只能缓解不能根治。我加了一个动态超时策略根据最近 30 个请求的平均耗时动态调整超时时间避免因为固定超时把正常请求误判为失败。核心逻辑是avg_latency rolling_average(recent_latencies) timeout max(5, int(avg_latency * 3))同时我把同步任务的输出改成了写出失败文件等网络恢复后重新跑失败清单而不是让整个任务进入死循环。6.3 监控补齐性能优化必须和监控一起上线这次优化我学到的另一个教训是性能改造一定要同步上线监控否则你都不知道新方案在生产环境跑得好不好。我在任务里加了三个关键指标请求耗时分布P50/P95/P99任务整体吞吐量每秒同步商品数失败率和重试次数监控告警的价值体现在一个凌晨的真实案例中有一次同步任务在凌晨 2 点突然变慢P99 从 300ms 涨到 5 秒。如果不是监控先报警业务方第二天白天发现数据没更新再来找我那问题的影响范围会大得多。7. 这套优化思路还能迁移到哪些场景整个优化过程走完我把方法论沉淀下来后发现这套“逐层拆解 对症下药”的思路并不只是针对商品详情同步它可以迁移到很多类似的场景。7.1 批量推送消息/通知比如消息推送系统需要给一批用户发送通知。第一版通常也是循环调用推送接口一个个发。改用并发 连接复用 批量聚合后吞吐提升非常明显。这里的“字段裁剪”对应的是消息内容的精简“批量入库”对应的是推送回执的批量写入。7.2 批量导入导出的数据处理再比如运营后台的数据导出功能从数据库查询大量数据再生成 Excel 或 CSV。这类任务优化分两个方向数据库侧通过分批查询避免一次性加载太多数据文件生成侧通过流式写入减少内存占用。核心逻辑和商品详情同步是一致的——不要把每个环节都串起来也不要让任何环节成为瓶颈。7.3 多系统间的数据同步企业里不同系统之间做数据同步是常态比如 ERP 同步订单到财务系统、CRM 同步客户信息到数据仓库。这些同步任务往往要拉取上万条数据如果一方提供的是 HTTP API另一方的接口性能又不强就需要在同步中间层做并发控制和限流适配。我今天优化商品详情用的所有手段到这些场景依然适用。这个项目的复盘让我实际验证了一件事“代码干净”和“性能达标”并不是冲突的。第一版代码确实干净但它缺少了对真实运行环境的理解——没有考虑网络成本、没有考虑连接复用、没有考虑批量操作的优势。优化后的代码多了一些“看着不那么纯粹”的并发控制、重试逻辑和监控埋点但正是这些东西让它在生产环境立住了脚。最后再分享一个小技巧做这类性能优化时不要一上来就动手改代码先花半小时把所有环节的耗时拆出来量化每一层浪费。拿到数据之后优化方向自然就清晰了复盘的结论也不会是“改了一个大东西所以变快了”而是“每一个环节砍掉了多少毫秒”。