登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  python教程

Python asyncio 批量请求变慢:用连接池和并发上限稳住接口耗时

来源:17golang原创

时间:2026-07-20 10:03:19 196浏览 收藏

订单补数任务原本只拉200条记录,后来调整为单次补8000条。代码把每个请求都塞进 asyncio.gather(),本地测试跑起来速度很快,一到预发环境就频繁出现连接等待、429报错和P99耗时抖动。问题根源不在asyncio本身性能不足,而是并发任务数、HTTP连接数和上游服务能承接的请求量没有匹配对齐。

实践要点
  • 先记录P50、P95、错误率和连接等待指标,再判断是否真的需要调高并发。
  • 全局复用同一个 httpx.AsyncClient,靠连接池实现连接复用,不要每个请求都单独新建客户端。
  • asyncio.Semaphore 的上限要结合上游服务容量和超时预算实测调试出来,不能直接照着CPU核数随便填。
  • 超时、任务取消和重试逻辑必须共用同一份请求配额,不然少量失败就会被层层放大演变为全链路请求积压。

先看基线:为什么80个任务反而把尾延迟拉高

遇到这类问题别急着直接把并发数改到500。订单补数服务的 /v1/settlement/detail 平均响应耗时约180ms,上游服务约定单实例能稳定承接30个并发请求。压测过程中把并发任务数从12提升到80之后,吞吐量没有跟着线性上涨:可用连接很快被占满,处于等待状态的协程在超时阈值边缘不断堆积,后续触发的重试又把下一轮请求压力推得更高。

并发上限P95 耗时成功率观察
12310ms99.9%连接复用状态稳定
24420ms99.8%吞吐量接近上游峰值
481.8s98.7%请求等待和限流开始出现
804.6s94.1%超时和重试互相放大影响

这里给出的数值不是通用配置,只是用来演示判断的先后顺序:先找到稳定运行的区间,再决定要不要继续调高并发。如果上游返回的429占比已经明显上升,继续扩充并发任务数只会更快地生成大量失败请求。

Python asyncio 批量请求从提交、获取连接、上游响应到超时重试的时间线,展示并发过高时等待增加

把客户端初始化放在循环外:连接复用才有实际意义

最常见的错误用法是在循环里或者单个协程内部单独创建 AsyncClient。这种写法下每次请求都可能重新建立TCP连接,连接池也就完全失去了复用的价值。下面的示例把客户端和连接限制放在整批任务的外部初始化,同时给请求分别设置连接阶段、读取阶段的耗时阈值和总时长配额。

import asyncio
import httpx

limits = httpx.Limits(max_connections=32, max_keepalive_connections=16)
timeout = httpx.Timeout(timeout=3.0, connect=0.5, read=2.0)
gate = asyncio.Semaphore(24)

async def fetch_detail(client: httpx.AsyncClient, order_id: str) -> dict:
    async with gate:
        response = await client.get(
            "https://partner.example.com/v1/settlement/detail",
            params={"order_id": order_id},
        )
        response.raise_for_status()
        return response.json()

async def fetch_batch(order_ids: list[str]) -> list[dict]:
    async with httpx.AsyncClient(limits=limits, timeout=timeout) as client:
        jobs = [fetch_detail(client, order_id) for order_id in order_ids]
        return await asyncio.gather(*jobs)

max_connections 是客户端最多能同时占用的连接数量,Semaphore(24) 是业务侧允许同时发往上流的请求数量。两个值不需要完全相等:如果单个请求后续还会访问其他域名,总连接数可以设置得稍大一点;如果当前这个上游的限流规则更严格,业务侧的并发闸门要设置得更小。

并发闸门的放置位置,决定系统是排队等待还是直接失控

并发闸门只要包住真正消耗上游资源的那小段请求逻辑就行,不需要把整批任务的所有流程都锁住。上面的示例在发起HTTP调用前才进入 gate,请求完成后立刻释放配额,解析响应结果、写入本地日志这类轻量操作不需要一直占着并发名额。

如果后续处理还要把结果写回数据库,还要单独核对数据库的连接池配置。不要用同一个并发数同时管控HTTP上游和数据库操作:两类资源的服务耗时和承载能力通常差异很大。必要的时候可以给两个不同的资源分别设置独立的并发闸门,同时在监控指标里区分开 upstream_wait_msdb_wait_ms

Python asyncio 的并发闸门控制订单请求进入 HTTP 连接池并返回成功或限流结果的二维技术插画

压测时只调整单个变量,才能准确定位瓶颈位置

对比不同配置的效果时,保持请求数据量、上游运行环境、超时和重试策略完全一致,只按档位逐步调整并发上限。每一档配置至少要观察三类核心信号:

  • 业务结果:请求成功率、429占比、可重试错误的占比。
  • 耗时分布:P50/P95/P99耗时、连接阶段耗时和总超时请求数。
  • 资源状态:活跃连接数、等待协程数量、上游实例的CPU使用率或者限流计数。

一份清晰的实测结果记录,远比“感觉速度变快了”的主观判断靠谱。如果并发设24和32的吞吐量差不多,但32的P99耗时明显变差,更建议选择24这个档位,给突发流量和上游服务的正常抖动留出足够的冗余空间。

超时和重试要有明确边界,别让失败批次无限拉长

连接超时可以设置得短一些,读取超时按照接口的历史运行耗时配置,总耗时配额要覆盖一次完整调用的全流程。针对429、连接被重置这类临时故障,可以做少量带退避逻辑的重试;遇到4xx类的参数错误直接记录失败即可,不要继续发起无效请求。重试逻辑也必须走同一个并发闸门,不然系统拥塞的时候重试会直接形成一波额外的请求洪峰。

批量任务还要区分两种运行模式:允许部分失败、任意失败就终止全流程。前者可以用 asyncio.gather(..., return_exceptions=True) 收集每一条任务的运行结果;后者在检测到关键失败后直接取消剩余未执行的任务,同时把已经执行完成的记录落盘,避免下一次重复执行相同的补数操作。

延伸问答

连接池上限和Semaphore应该设成同一个数值吗?

不需要。连接池限制的是客户端的总连接数量,并发闸门限制的是业务逻辑侧对上流的同时访问数量。按照不同资源边界分开设置,后续排查问题的时候逻辑会更清晰。

为什么asyncio.gather会让接口突然变慢?

它会尽可能快地调度所有传入的协程。当总任务数远大于下游服务的承载能力时,请求等待、限流和重试的数量会同步上涨,尾延迟往往先出现失控。

只调大max_connections参数能解决429报错吗?

不能。429报错说明上游服务正在做限流或者你的配额已经用完,增加连接数只会更快触发限流规则。正确的处理方式是降低并发、缩小单批任务的数量或者和上游服务的对接方确认可承载的容量上限。

批量任务执行失败后怎么避免重复处理相同数据?

给每一条业务记录单独保存幂等键和执行状态;下一次启动任务的时候只拉取未完成的条目,同时把每条记录的重试次数和最后一次报错信息一并存储下来。

把并发当成配额使用,不要当成一键提速开关

asyncio可以很高效地把多个I/O等待的时间段重叠起来提升效率,但它没办法凭空增加上游服务的承载能力。全局复用同一个HTTP客户端、明确连接数和业务侧的并发闸门、用同一份超时配额约束所有重试逻辑,再通过实际压测拿到的指标选出最稳定的配置档位,批量请求的流程在流量上涨之后也能保持可控。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>