一、问题背景:一个被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换成可等待的协程,让单线程在等待期间去处理别的请求。

具体拆成三块:

  1. Web层:FastAPI + Uvicorn。Uvicorn是asyncio事件循环驱动,一个worker就能并发扛大量连接,不用再开8个进程抢CPU。
  2. DB层:asyncpg + 连接池。主表查询和三个附属表查询之间没有依赖(都只需要order_id),用 asyncio.gather 并发发起,把4次串行RTT压成1次。
  3. 外部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密集型接口上收益非常直接。

几条可以带走的经验:

  1. 先确认IO之间无依赖再并发,有依赖链的用asyncio也白搭,得从业务或缓存层解决。
  2. 连接池参数要算,不要拍脑袋,max_size 按 QPS×持连时间×2 估,再压测微调。
  3. 异步生态里,任何同步调用都是毒药。改造时我把代码里所有 requests、psycopg2、time.sleep 都搜了一遍,漏一个就会阻塞整个事件循环,表现为"偶发卡顿"。
  4. 超时要分层,内层短外层长,配合降级,别让第三方接口拖死主流程。
  5. 不是所有服务都值得改。如果接口是CPU密集型,或者QPS本来就低,改异步的收益覆盖不了维护成本。这次值得改,是因为它IO占比超过80%且调用量大。

代码已脱敏,连接串和第三方地址是示例。如果你的服务也在被同步IO拖慢,可以先从压测数据看CPU和IO等待的比例,再决定要不要上asyncio。