一、问题背景:一个被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.0(
uvloop0.19.0) - asyncpg 0.29.0(PostgreSQL 15.6)
- httpx 0.27.0(异步 HTTP 客户端)
- 压测工具:wrk 4.2.0,
-t4 -c200 -d60s
三、方案设计
改造分三层:
- Web 层:Flask → FastAPI,天然支持
async def路由。 - DB 层:psycopg2 → asyncpg,配连接池(
min_size=10, max_size=50)。 - 外部调用层: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 基本没用,该上多进程还是多进程。
三条经验送给准备改造的朋友:
- 先定位瓶颈,用 py-spy 抓火焰图,别拍脑袋上异步。
- async 函数里绝不能有同步阻塞调用,
requests、psycopg2、time.sleep都是雷。 - 连接池和客户端要复用,协程的优势是并发,不是「每次新建」。
改造完这个接口后,我又陆续把另外三个接口也异步化了,整体服务 P99 从 2s 降到 100ms 以内。如果你也在做类似的优化,欢迎评论区交流。