一、问题背景:一个被IO拖死的查询接口

去年接手了一个订单中心的核心接口 /api/v1/orders/{user_id},逻辑很简单:根据用户ID查订单列表,再补充每个订单的物流状态。

上线初期数据量小,跑得还行。但随着日调用量涨到800万,监控开始报警:

  • P99 延迟 1830ms,P50 也有 420ms
  • 单实例 QPS 只有 120 左右,扩容到8个实例才勉强扛住
  • CPU 使用率长期低于 15%,但线程数飙到 200+

典型的 IO 密集 + 同步阻塞 症状。原来的代码长这样(简化版):

# before_sync.py  —— Python 3.11, Flask 2.3
import requests
import psycopg2
from flask import Flask, jsonify

app = Flask(__name__)
conn = psycopg2.connect("postgresql://user:pwd@db:5432/orderdb")
LOGISTICS_URL = "http://logistics-svc/status"

@app.route("/api/v1/orders/")
def get_orders(user_id):
    cur = conn.cursor()
    cur.execute("SELECT id, amount, created_at FROM orders WHERE user_id=%s", (user_id,))
    rows = cur.fetchall()
    cur.close()

    result = []
    for r in rows:                       # 串行调用物流服务
        resp = requests.get(f"{LOGISTICS_URL}/{r[0]}", timeout=2)
        result.append({
            "id": r[0],
            "amount": float(r[1]),
            "status": resp.json().get("status", "unknown"),
        })
    return jsonify(result)

问题一目了然:每个订单都要同步发一次 HTTP 请求,一个用户有20个订单,光物流查询就串行20次。再叠加GIL下线程切换的开销,吞吐量被彻底锁死。

二、环境与版本

  • Python 3.11.7(3.11 的 asyncio 性能比 3.9 提升约 20%,TaskGroup 也稳定了)
  • FastAPI 0.110.0 + Uvicorn 0.29.0uvloop 0.19.0)
  • asyncpg 0.29.0(PostgreSQL 15.6)
  • httpx 0.27.0(异步 HTTP 客户端)
  • 压测工具:wrk 4.2.0,-t4 -c200 -d60s

三、方案设计

改造分三层:

  1. Web 层:Flask → FastAPI,天然支持 async def 路由。
  2. DB 层:psycopg2 → asyncpg,配连接池(min_size=10, max_size=50)。
  3. 外部调用层:requests → httpx.AsyncClient,用 asyncio.gather 并发查询物流状态。

关键点:不要在 async 函数里写同步阻塞调用,否则事件循环会被卡住,比多线程还慢。

四、核心实现

改造后的代码:

# after_async.py  —— Python 3.11, FastAPI 0.110, asyncpg 0.29
import asyncio
import asyncpg
import httpx
from fastapi import FastAPI

app = FastAPI()
LOGISTICS_URL = "http://logistics-svc/status"
pool: asyncpg.Pool | None = None

@app.on_event("startup")
async def startup():
    global pool
    pool = await asyncpg.create_pool(
        dsn="postgresql://user:pwd@db:5432/orderdb",
        min_size=10,
        max_size=50,
        command_timeout=3,
    )

@app.on_event("shutdown")
async def shutdown():
    await pool.close()

async def fetch_status(client: httpx.AsyncClient, order_id: int) -> str:
    try:
        r = await client.get(f"{LOGISTICS_URL}/{order_id}", timeout=2.0)
        return r.json().get("status", "unknown")
    except (httpx.TimeoutException, httpx.HTTPError):
        return "unknown"

@app.get("/api/v1/orders/{user_id}")
async def get_orders(user_id: int):
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT id, amount, created_at FROM orders WHERE user_id=$1",
            user_id,
        )

    async with httpx.AsyncClient(
        limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
    ) as client:
        statuses = await asyncio.gather(
            *(fetch_status(client, r["id"]) for r in rows)
        )

    return [
        {"id": r["id"], "amount": float(r["amount"]), "status": s}
        for r, s in zip(rows, statuses)
    ]

如果订单量很大(比如 > 500),gather 会一次性创建过多任务,建议用 Semaphore 限流:

sem = asyncio.Semaphore(50)

async def fetch_status_limited(client, order_id):
    async with sem:
        return await fetch_status(client, order_id)

五、踩坑与优化

改造过程中踩了几个坑,记录一下:

坑1:连接池大小不是越大越好。 一开始把 max_size 设成 200,结果 DB 端 too many clients。PostgreSQL 默认 max_connections=100,多实例共享时要算总账。最终每实例 50,8 实例共 400,DB 侧调到 max_connections=500

坑2:httpx.AsyncClient 要复用。 最初每个请求都 async with AsyncClient(),导致 TCP 连接反复建立,延迟反而升高。改成应用级单例 client,配合 max_keepalive_connections=20 后,物流查询平均耗时从 45ms 降到 18ms。

坑3:uvloop 必须显式启用。 Uvicorn 默认会用 uvloop,但用 gunicorn -k uvicorn.workers.UvicornWorker 部署时要确认。启用后单核 QPS 大约再提升 15%。

坑4:超时要分层。 DB command_timeout=3,HTTP timeout=2.0,整体接口用中间件加 5s 兜底,避免慢查询拖垮整个事件循环。

六、效果数据

同一台 4C8G 机器,wrk 压测 60s,200 并发:

指标 Before (Flask+同步) After (FastAPI+asyncio) 提升
QPS 120 2300 19.2x
P50 延迟 420ms 32ms 13x
P99 延迟 1830ms 76ms 24x
CPU 使用率 12% 68%
单实例支撑日调用 ~100万 ~1900万 19x

扩容策略也跟着变了:原来要8个实例,现在2个实例就够,机器成本直接砍掉 75%。

七、总结

asyncio 不是什么银弹,它只解决一类问题:IO 密集、且 IO 可以并发。像这个订单接口,本质是「1次DB查询 + N次HTTP调用」,天然适合协程。但如果你的瓶颈是 CPU 计算(比如大量 JSON 解析、图像处理),上 asyncio 基本没用,该上多进程还是多进程。

三条经验送给准备改造的朋友:

  1. 先定位瓶颈,用 py-spy 抓火焰图,别拍脑袋上异步。
  2. async 函数里绝不能有同步阻塞调用requestspsycopg2time.sleep 都是雷。
  3. 连接池和客户端要复用,协程的优势是并发,不是「每次新建」。

改造完这个接口后,我又陆续把另外三个接口也异步化了,整体服务 P99 从 2s 降到 100ms 以内。如果你也在做类似的优化,欢迎评论区交流。