一、问题背景:一个被同步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

三、方案设计:并发、批量化、超时控制

改造思路分三层:

  1. 请求级并发:把串行的下游调用改成asyncio.gather并发,10个用户的30次调用理论上可以同时发起。
  2. 连接池复用:httpx的AsyncClient全局复用,避免每次请求建TCP连接。连接池max_connections设成200,max_keepalive_connections设成100。
  3. 超时与降级:每个下游调用设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卡在三位数,值得花两天时间做一次异步改造。收益比加机器划算得多。