什么时候值得换异步
一天几百次请求,requests 完全够用,没必要折腾。真正开始出现瓶颈是「一次要发几十上百个查询」的场景------批量关键词、多市场矩阵。同步串行跑,网络往返的时间全加起来,任务一跑就是十几分钟。
httpx 是 requests 的现代替代,API 几乎一样,但原生支持 async,还能复用连接池。
客户端只建一次
这是最容易犯的用法错误:把 AsyncClient 当 requests 用,每次请求都新建一个。
python
import httpx
# 正确:一个进程共享一个客户端
client = httpx.AsyncClient(
base_url="https://api.serpbase.dev",
headers={"X-API-Key": KEY},
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
timeout=httpx.Timeout(5.0, read=30.0),
)
async def search(q: str) -> dict:
resp = await client.post(
"/google/search",
json={"q": q, "gl": "cn", "hl": "zh-cn"},
)
return resp.json()
客户端内部维护连接池,max_keepalive_connections 让 TCP 连接和 TLS 会话复用起来。程序结束前记得 await client.aclose(),或者用 async with。
超时拆成两段
httpx.Timeout(5.0, read=30.0) 的意思是:建连最多 5 秒,读取最多 30 秒。
这和 requests 的 timeout=(5, 30) 是同一个思路。分开设置有两个好处:建连慢通常是网络或 DNS 的问题,读取慢是服务端的问题,日志里区分开,定位方向完全不同。
并发控制在信号量里
搜索接口有 QPS 限制,客户端并发越高不等于吞吐越高。用信号量把并发压在限制内:
python
import asyncio
sem = asyncio.Semaphore(10)
async def search_one(q: str) -> dict:
async with sem:
return await search(q)
async def batch(keywords: list[str]) -> list[dict]:
return await asyncio.gather(*[search_one(q) for q in keywords])
10 路并发是我常用的起点。再往上加之前,先看服务端返回的响应码:出现限流类状态时,正确的反应是降并发、按 Retry-After 等待,而不是硬怼。
重试次数保持克制
重试做在客户端里,用 tenacity 之类都可以,但配置上偏保守更稳妥:
python
from tenacity import retry, stop_after_attempt, wait_exponential
@retry(stop=stop_after_attempt(2), wait=wait_exponential(min=1, max=8))
async def search_with_retry(q: str) -> dict:
return await search(q)
指数退避、次数少、配合信号量,重试是安全的;无限制重试 + 高并发,只会把限流问题放大。
我做错的第一版
第一版为了「更并发」,把信号量开到 100。结果限流响应暴增,重试跟着变多,整体吞吐反而比 10 路并发的版本低。
后来才明白:这类接口的吞吐上限在服务端配额那一侧,客户端的并发数只是在接近上限,超过它就是空转和重试。真正的优化,是把并发配到刚好填满配额。
现在采集脚本都用 httpx,20 路连接复用、10 路信号量、两秒退避。一次网页搜索 1 credit,新账号 500 免费 credits,批量实验成本很低。SerpBase 的字段结构见 SerpBase 文档。