一、问题背景:一个被同步IO拖死的接口
去年接手了一个内部服务,功能很简单:接收用户ID列表,调用下游3个微服务(用户画像、订单历史、风控标签)聚合数据后返回。业务逻辑不复杂,但问题很明显——每天下午高峰期,接口P99延迟飙到1.8秒,监控上Gunicorn worker的CPU利用率不到15%,但请求队列堆到几百。
典型的IO密集型场景被同步模型拖死了。原来的代码大概长这样:
# before: Flask同步版本
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
@app.route("/api/user/profile", methods=["POST"])
def get_user_profile():
user_ids = request.json["user_ids"]
results = []
for uid in user_ids:
# 三次串行HTTP调用,每次平均120ms
profile = requests.get(f"http://profile-svc/user/{uid}", timeout=2).json()
orders = requests.get(f"http://order-svc/orders/{uid}", timeout=2).json()
risk = requests.get(f"http://risk-svc/tags/{uid}", timeout=2).json()
results.append({**profile, "orders": orders, "risk": risk})
return jsonify(results)
假设一次请求带10个用户,每个用户3次调用串行,光下游IO就要 10 × 3 × 120ms = 3.6秒。Gunicorn配了8个worker,每个worker同一时刻只能处理一个请求,QPS自然上不去。
二、环境与版本
- Python: 3.11.6(3.11的asyncio性能比3.8提升约25%,任务调度器重写过)
- Web框架: FastAPI 0.109.0 + Uvicorn 0.27.0
- HTTP客户端: httpx 0.26.0(支持async,比aiohttp的API更友好)
- 部署: Gunicorn 21.2.0 + UvicornWorker,4个worker
- 压测工具: wrk 4.2.0,locust 2.20.0
- 机器: 4C8G,内网下游服务RTT稳定在100-130ms
三、方案设计:并发、批量化、超时控制
改造思路分三层:
- 请求级并发:把串行的下游调用改成
asyncio.gather并发,10个用户的30次调用理论上可以同时发起。 - 连接池复用:httpx的
AsyncClient全局复用,避免每次请求建TCP连接。连接池max_connections设成200,max_keepalive_connections设成100。 - 超时与降级:每个下游调用设2秒超时,用
asyncio.wait_for包一层,失败返回空对象而不是让整个请求挂掉。
有人会问为什么不用线程池+requests?试过,10个用户×3调用=30个线程,上下文切换开销大,而且GIL下CPU调度反而更差。asyncio单线程事件循环处理这种纯IO场景是最优解。
四、核心实现
先看改造后的完整代码:
# after: FastAPI + asyncio 异步版本
import asyncio
import httpx
from fastapi import FastAPI
from pydantic import BaseModel
from typing import List
app = FastAPI()
# 全局复用客户端,连接池参数是关键
client = httpx.AsyncClient(
timeout=httpx.Timeout(2.0, connect=0.5),
limits=httpx.Limits(
max_connections=200,
max_keepalive_connections=100,
keepalive_expiry=30.0,
),
)
class ProfileRequest(BaseModel):
user_ids: List[str]
async def fetch_one(uid: str) -> dict:
"""单个用户的三个下游调用并发执行"""
profile_task = client.get(f"http://profile-svc/user/{uid}")
orders_task = client.get(f"http://order-svc/orders/{uid}")
risk_task = client.get(f"http://risk-svc/tags/{uid}")
try:
profile, orders, risk = await asyncio.gather(
profile_task, orders_task, risk_task,
return_exceptions=True,
)
return {
"user_id": uid,
"profile": profile.json() if not isinstance(profile, Exception) else {},
"orders": orders.json() if not isinstance(orders, Exception) else [],
"risk": risk.json() if not isinstance(risk, Exception) else {},
}
except Exception as e:
return {"user_id": uid, "error": str(e)}
@app.post("/api/user/profile")
async def get_user_profile(req: ProfileRequest):
# 所有用户并发处理
tasks = [fetch_one(uid) for uid in req.user_ids]
results = await asyncio.gather(*tasks)
return {"data": results}
启动命令:
gunicorn main:app \
-w 4 \
-k uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--timeout 30 \
--access-logfile - \
--log-level info
注意-k uvicorn.workers.UvicornWorker,不用这个的话Gunicorn默认是sync worker,asyncio根本跑不起来。
五、踩坑与优化
坑1:asyncio.gather默认不取消兄弟任务。 早期版本我用gather(return_exceptions=False),一个下游超时抛异常,整个请求500,但其他任务还在后台跑,白白占用连接。后来改成return_exceptions=True,单个失败降级为空数据,可用性从99.2%提到99.95%。
坑2:httpx连接池打满。 压测到1500 QPS时开始出现PoolTimeout。原因是max_connections默认只有100。调到200后,配合Uvicorn 4个worker,稳定跑到2300 QPS。这个数值要按 worker数 × 单worker并发数 来估,别拍脑袋。
坑3:不要在async函数里写同步阻塞代码。 团队有人顺手加了段json.loads读本地缓存文件,结果事件循环被卡住,P99直接炸到800ms。同步IO一律用 await asyncio.to_thread(...) 包起来。
坑4:Python 3.11之前的版本,asyncio.gather在大量任务时性能下降明显。 我们做了一次对照压测,同样的代码在3.9上QPS只有1600,3.11能到2300。3.11对Task对象的C实现优化效果显著,有条件一定要升级。
六、效果数据
压测条件:wrk -t4 -c200 -d60s,请求体固定10个user_id。
| 指标 | Before (Flask+Gunicorn sync) | After (FastAPI+asyncio) | 提升 |
|---|---|---|---|
| QPS | 120 | 2300 | 19.2x |
| P50延迟 | 820ms | 42ms | 19.5x |
| P99延迟 | 1800ms | 86ms | 20.9x |
| CPU利用率 | 12% | 68% | - |
| 单机内存 | 380MB | 520MB | - |
| 生产机器数 | 12台 | 3台 | 75%↓ |
生产环境灰度一周后的真实数据:日均800万请求,P99稳定在90ms以内,错误率0.03%(主要是下游超时降级),服务器成本每月省了约2.4万。
七、总结
这次改造最大的收获不是QPS数字,而是理解了asyncio的适用边界:纯IO密集型场景收益巨大,CPU密集场景反而会因单线程事件循环变成瓶颈。如果下游有大量计算逻辑,应该用ProcessPoolExecutor配合,而不是硬塞进协程。
另外几点经验:
- 迁移前先用asyncio.to_thread做小范围验证,别一次性全改。
- 连接池、超时、并发数三个参数必须一起调,缺一不可。
- Python版本对asyncio性能影响很大,3.11是分水岭。
如果你的服务也是「多下游聚合」这种形态,QPS卡在三位数,值得花两天时间做一次异步改造。收益比加机器划算得多。