一、问题背景:一个被IO拖死的查询接口
去年底接手了一个内部订单中台服务,其中一个 /api/order/detail 接口被前端和多个下游服务高频调用。逻辑本身不复杂:根据订单号查主表,再并发查三个维度的附属表(商品明细、支付流水、物流状态),最后拼装返回。
代码是典型的Flask同步写法,Gunicorn开了8个sync worker,每个请求串行执行4次数据库查询。上线初期量不大,没人在意。直到大促前压测,问题暴露了:
- 单机QPS峰值 120 左右就上不去了,CPU利用率只有25%,但worker全在等IO
- P99延迟 1.2s,P50也有 380ms
- 数据库连接数经常打满,Gunicorn队列堆请求,超时告警不断
典型症状:CPU闲着,线程/进程全在阻塞等待网络。这就是同步IO模型的吞吐天花板——每个worker同一时刻只能处理一个请求,4次串行DB查询,每次哪怕只要5ms,光等待就20ms,加上上下文切换,吞吐自然上不去。
我的判断很直接:这不是加机器能优雅解决的问题,得换IO模型。
二、环境与版本
先交代下改造前后的技术栈,避免大家踩版本坑:
| 组件 | 改造前 | 改造后 |
|---|---|---|
| Web框架 | Flask 2.2.5 + Gunicorn 20.1.0 (sync worker) | FastAPI 0.110.0 + Uvicorn 0.29.0 |
| 数据库驱动 | psycopg2-binary 2.9.9 (同步) | asyncpg 0.29.0 (异步) |
| HTTP客户端 | requests 2.31.0 (同步) | aiohttp 3.9.3 (异步) |
| Python | 3.10.12 | 3.10.12(不变) |
| 数据库 | PostgreSQL 14.9 | PostgreSQL 14.9(不变) |
| 压测机 | wrk 4.2.0 | wrk 4.2.0(不变) |
| 部署机 | 4核8G,Docker | 4核8G,Docker(不变) |
数据库和机器都不动,只改应用层IO模型,这样对比才有意义。Python选3.10是因为 asyncio.TaskGroup 要3.11才有,3.10用 asyncio.gather 足够,且线上镜像现成。
三、方案设计
核心思路就一句话:把所有阻塞IO换成可等待的协程,让单线程在等待期间去处理别的请求。
具体拆成三块:
- Web层:FastAPI + Uvicorn。Uvicorn是asyncio事件循环驱动,一个worker就能并发扛大量连接,不用再开8个进程抢CPU。
- DB层:asyncpg + 连接池。主表查询和三个附属表查询之间没有依赖(都只需要order_id),用
asyncio.gather并发发起,把4次串行RTT压成1次。 - 外部HTTP:物流状态要调第三方接口,用aiohttp + 全局复用的
ClientSession,避免每次请求新建连接。
这里有个关键决策:附属表查询是否真的能并发。我确认过,四张表都只依赖order_id,没有前后依赖,所以并发是安全的。如果存在依赖链,就得老老实实串行,asyncio也救不了。
连接池参数我设的是:asyncpg min_size=5, max_size=20,Uvicorn workers=2(4核机器,2个worker各跑一个事件循环,够用且留余量)。这两个数字后面压测时调过,先按下不表。
四、核心实现
改造前(同步版,Flask + psycopg2 + requests)
# app_sync.py —— 改造前
import psycopg2
import requests
from flask import Flask, jsonify, request
app = Flask(__name__)
def get_conn():
return psycopg2.connect(
host="127.0.0.1", port=5432, dbname="orderdb",
user="app", password="app", connect_timeout=3
)
@app.route("/api/order/detail")
def order_detail():
order_id = request.args.get("order_id")
conn = get_conn()
cur = conn.cursor()
try:
# 1. 主表
cur.execute("SELECT id, user_id, amount, status FROM orders WHERE id=%s", (order_id,))
row = cur.fetchone()
if not row:
return jsonify({"code": 404, "msg": "not found"}), 404
order = {"id": row[0], "user_id": row[1], "amount": float(row[2]), "status": row[3]}
# 2. 商品明细
cur.execute("SELECT sku, qty, price FROM order_items WHERE order_id=%s", (order_id,))
order["items"] = [{"sku": r[0], "qty": r[1], "price": float(r[2])} for r in cur.fetchall()]
# 3. 支付流水
cur.execute("SELECT pay_no, paid_at FROM payments WHERE order_id=%s", (order_id,))
order["payments"] = [{"pay_no": r[0], "paid_at": str(r[1])} for r in cur.fetchall()]
# 4. 物流(第三方HTTP,同步阻塞)
resp = requests.get(f"http://logistics.internal/api/status?order_id={order_id}", timeout=2)
order["logistics"] = resp.json() if resp.ok else {}
return jsonify({"code": 0, "data": order})
finally:
cur.close()
conn.close()
启动命令:gunicorn -w 8 -k sync -b 0.0.0.0:8000 app_sync:app
问题一眼可见:4次IO全串行,每次请求还新建DB连接(get_conn() 没复用),requests每次也新建TCP连接。压测时连接风暴直接把DB打满。
改造后(异步版,FastAPI + asyncpg + aiohttp)
# app_async.py —— 改造后
import asyncio
import asyncpg
import aiohttp
from fastapi import FastAPI, HTTPException
from contextlib import asynccontextmanager
app = FastAPI()
pool: asyncpg.Pool = None
http_session: aiohttp.ClientSession = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool, http_session
pool = await asyncpg.create_pool(
host="127.0.0.1", port=5432, database="orderdb",
user="app", password="app",
min_size=5, max_size=20, command_timeout=3
)
http_session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=2),
connector=aiohttp.TCPConnector(limit=100, ttl_dns_cache=300)
)
yield
await http_session.close()
await pool.close()
app.router.lifespan_context = lifespan
async def fetch_logistics(order_id: int) -> dict:
url = f"http://logistics.internal/api/status?order_id={order_id}"
try:
async with http_session.get(url) as resp:
return await resp.json() if resp.status == 200 else {}
except (asyncio.TimeoutError, aiohttp.ClientError):
return {} # 物流失败降级,不影响主流程
@app.get("/api/order/detail")
async def order_detail(order_id: int):
async with pool.acquire() as conn:
# 主表先查,不存在直接返回,避免无谓并发
row = await conn.fetchrow(
"SELECT id, user_id, amount, status FROM orders WHERE id=$1", order_id
)
if not row:
raise HTTPException(status_code=404, detail="not found")
# 三个附属查询 + 外部HTTP 并发执行
items_task = conn.fetch(
"SELECT sku, qty, price FROM order_items WHERE order_id=$1", order_id
)
pay_task = conn.fetch(
"SELECT pay_no, paid_at FROM payments WHERE order_id=$1", order_id
)
logistics_task = fetch_logistics(order_id)
items, payments, logistics = await asyncio.gather(
items_task, pay_task, logistics_task
)
return {
"code": 0,
"data": {
"id": row["id"], "user_id": row["user_id"],
"amount": float(row["amount"]), "status": row["status"],
"items": [{"sku": r["sku"], "qty": r["qty"], "price": float(r["price"])} for r in items],
"payments": [{"pay_no": r["pay_no"], "paid_at": str(r["paid_at"])} for r in payments],
"logistics": logistics,
},
}
启动命令:uvicorn app_async:app --host 0.0.0.0 --port 8000 --workers 2
两个代码块都能直接跑(改下DB连接串即可)。注意异步版的几个细节:
pool.acquire()用async with,连接自动归还,不用手动close- 三个附属查询在同一个连接上并发?不行。asyncpg单连接不支持并发查询,这里
items_task和pay_task都用了同一个conn,实际执行时asyncpg会串行化。要真正并发DB查询,得从池里拿两个连接。我压测时发现了这点,见下一节。 fetch_logistics做了降级:物流接口挂了返回空dict,不阻断订单详情。这个在同步版里是裸调用,第三方抖一下整个接口就500。
五、踩坑与优化
坑1:单连接复用导致DB查询并未真并发。 上面代码里 items_task 和 pay_task 共用一个 conn,asyncpg内部会排队。改成从池里各取一个连接:
async def query_items():
async with pool.acquire() as c:
return await c.fetch("SELECT sku, qty, price FROM order_items WHERE order_id=$1", order_id)
async def query_payments():
async with pool.acquire() as c:
return await c.fetch("SELECT pay_no, paid_at FROM payments WHERE order_id=$1", order_id)
然后 asyncio.gather(query_items(), query_payments(), fetch_logistics(order_id))。改完后DB部分耗时从 ~18ms 降到 ~7ms。
坑2:连接池max_size设太小。 一开始 max_size=10,压测到1200 QPS时开始出现 TimeoutError: pool is full。算一下:单请求占连接约7ms,1200 QPS并发需求约 1200×0.007≈8.4 个连接,但峰值抖动会翻倍。调到20后稳定。经验值:max_size ≈ 预期QPS × 单请求持连时间 × 2。
坑3:aiohttp的ClientSession不能每次请求新建。 新建会泄漏连接且丢失连接池复用,我第一版写在函数里,压测时文件描述符暴涨。必须全局单例,在lifespan里创建和关闭。
坑4:Uvicorn workers别开太多。 4核机器我试过 workers=4,QPS反而比 workers=2 低8%,因为事件循环切换和DB连接池(每个worker一个池)竞争加剧。最终定 workers=2,每worker max_size=20,总连接40,DB侧 max_connections=100 绰绰有余。
坑5:超时配置。 asyncpg command_timeout=3,aiohttp total=2,Uvicorn默认无超时,在网关层加了5s。三层超时要形成梯度(2 < 3 < 5),否则慢查询会拖垮整个池。
六、效果数据
压测条件:wrk,-t4 -c200 -d60s,同一台4核8G机器,同一PG实例,订单号随机命中10万条数据。每组跑3次取中位数。
| 指标 | 同步版(Flask+Gunicorn×8) | 异步版(FastAPI+Uvicorn×2) | 提升 |
|---|---|---|---|
| QPS | 120 | 1800 | 15× |
| P50延迟 | 380ms | 42ms | 9× |
| P99延迟 | 1200ms | 180ms | 6.7× |
| CPU利用率 | 25% | 72% | — |
| 内存占用 | 1.6GB | 1.1GB | -31% |
| DB连接峰值 | 打满(100) | 38 | — |
几个值得说的点:
- QPS 15倍提升里,约4倍来自并发化(4次IO压成1次),约3倍来自连接复用(DB池+HTTP池),剩下来自进程模型(8个sync worker的上下文切换开销被2个事件循环替代)。
- CPU从25%升到72%,说明之前不是算力不够,是IO模型浪费了算力。异步版CPU打得更满,但延迟反而更低。
- 内存降31%主要是进程数从8降到2,每个Python进程的基线内存省下来了。
- P99从1.2s到180ms,用户体验上是质变,超时告警基本消失。
七、总结
这次改造让我更确信一件事:同步Web服务的吞吐瓶颈,八成在IO等待,不在CPU。asyncio不是银弹,但它把"等待"这件事的成本从"占一个worker"降到了"占一点内存",在IO密集型接口上收益非常直接。
几条可以带走的经验:
- 先确认IO之间无依赖再并发,有依赖链的用asyncio也白搭,得从业务或缓存层解决。
- 连接池参数要算,不要拍脑袋,
max_size按 QPS×持连时间×2 估,再压测微调。 - 异步生态里,任何同步调用都是毒药。改造时我把代码里所有
requests、psycopg2、time.sleep都搜了一遍,漏一个就会阻塞整个事件循环,表现为"偶发卡顿"。 - 超时要分层,内层短外层长,配合降级,别让第三方接口拖死主流程。
- 不是所有服务都值得改。如果接口是CPU密集型,或者QPS本来就低,改异步的收益覆盖不了维护成本。这次值得改,是因为它IO占比超过80%且调用量大。
代码已脱敏,连接串和第三方地址是示例。如果你的服务也在被同步IO拖慢,可以先从压测数据看CPU和IO等待的比例,再决定要不要上asyncio。