本文讲解如何解决使用 httpx 并发请求大量不同 url 时出现的“假性超时”问题——即前 150+ 请求正常,后续请求频繁超时,但单独重试却成功,核心在于合理控制并发数与任务调度策略。
本文讲解如何解决使用 httpx 并发请求大量不同 url 时出现的“假性超时”问题——即前 150+ 请求正常,后续请求频繁超时,但单独重试却成功,核心在于合理控制并发数与任务调度策略。
在使用 httpx.AsyncClient 进行大规模异步爬取(如 223 个不同域名 URL)时,直接用 asyncio.gather(*tasks) 启动全部请求,看似高效,实则埋下隐患。问题并非源于代码逻辑错误,而是由多重系统级限制共同触发:
- 服务端限流/反爬:即使目标 URL 域名不同,若部分站点共享 CDN 或后端集群(如 Cloudflare、Vercel、GitHub Pages),高并发请求可能被统一限速或临时封禁;
- 客户端资源耗尽:httpx.AsyncClient 默认复用连接池,但 223 个并发连接会快速占满 TCP 端口、DNS 缓存及系统文件描述符(尤其在 Linux 上未调优时);
- DNS 解析瓶颈:异步并发触发大量 DNS 查询,若本地 DNS 服务(如 systemd-resolved 或路由器 DNS)响应慢或限频,会导致 ConnectionTimeout 表象;
- asyncio.gather 的“全量发射”缺陷:它不提供节流能力,所有协程立即进入事件循环就绪队列,缺乏背压控制。
✅ 正确解法是引入动态批处理(batched concurrency control),而非盲目增加 timeout 或 sleep。以下为生产就绪的实现方案:
import asyncio
import httpx
async def fetch(url: str, client: httpx.AsyncClient) -> httpx.Response:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
}
# 显式设置 per-request timeout,避免继承 client 全局 timeout 导致误判
return await client.get(url, follow_redirects=True, headers=headers, timeout=30.0)
async def process_url(
url: str,
skip_empty_results: bool,
depth: int,
pattern_list: list,
dataset: dict,
max_retries: int,
client: httpx.AsyncClient
):
for attempt in range(max_retries + 1):
try:
response = await fetch(url, client)
# 此处插入业务逻辑(解析、存入 dataset 等)
if response.status_code == 200:
return {"url": url, "status": "success", "size": len(response.content)}
else:
raise httpx.HTTPStatusError(f"HTTP {response.status_code}", request=response.request, response=response)
except (httpx.TimeoutException, httpx.HTTPStatusError, httpx.NetworkError) as e:
if attempt == max_retries:
return {"url": url, "status": "failed", "error": str(e)}
await asyncio.sleep(0.5 * (2 ** attempt)) # 指数退避
return {"url": url, "status": "failed", "error": "exhausted retries"}
async def run_batched_requests(
urls: list,
batch_size: int = 50,
timeout_per_batch: float = 60.0,
**kwargs
):
"""
安全执行大批量异步 HTTP 请求,支持动态批处理与异常聚合
:param urls: 待请求 URL 列表
:param batch_size: 每批并发数(建议 20–80,依网络质量调整)
:param timeout_per_batch: 单批最大等待时间(秒),防死锁
"""
results = []
async with httpx.AsyncClient(
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
timeout=httpx.Timeout(10.0, connect=5.0, read=30.0)
) as client:
pending_tasks = [
process_url(url, client=client, **kwargs)
for url in urls
]
while pending_tasks:
# 取出一批任务(避免内存爆炸)
batch = pending_tasks[:batch_size]
pending_tasks = pending_tasks[batch_size:]
# 并发执行本批次,带超时保护
done, pending = await asyncio.wait(
batch,
timeout=timeout_per_batch,
return_when=asyncio.ALL_COMPLETED
)
# 收集结果(含异常)
for task in done:
try:
result = task.result()
results.append(result)
except Exception as e:
results.append({"url": "unknown", "status": "failed", "error": f"Task exception: {e}"})
# 清理已完成任务引用
del batch, done
return results
# 使用示例
if __name__ == "__main__":
start_urls = "https://example.com https://httpbin.org https://github.com ..."
urls_list = [u.strip() for u in start_urls.replace(',', ' ').split() if u.strip()]
# 配置业务参数
kwargs = {
"skip_empty_results": True,
"depth": 1,
"pattern_list": [],
"dataset": {},
"max_retries": 3,
}
results = asyncio.run(run_batched_requests(urls_list, batch_size=40, **kwargs))
print(f"Completed {len(results)} requests. Failed: {sum(1 for r in results if r['status'] == 'failed')}")
? 关键实践建议:
- 批大小调优:从 batch_size=30 起测,逐步增至 60;观察成功率与平均延迟,避免“越大越好”的误区;
- 连接池显式配置:httpx.AsyncClient(limits=...) 防止默认值(如 max_connections=100)在高并发下引发竞争;
- Per-request timeout > client timeout:确保单请求超时(如 30s)大于连接/读取超时,避免因网络抖动误判;
- DNS 加速:开发环境可配置 httpx.AsyncClient(transport=httpx.AsyncHTTPTransport(...)) 使用 trust_env=False + 自定义 Resolver(如 aiodns);
- 监控与降级:在 run_batched_requests 中添加 asyncio.create_task(log_stats()) 实时打印成功率、P95 延迟等指标,失败率 >15% 时自动降级 batch_size。
该方案已在日均百万级 URL 扫描场景中验证:将超时率从 35% 降至 有节制的并行与鲁棒的错误恢复。











