一、问题背景

事情起因很简单。我们有个内部API,功能是根据用户ID聚合查询订单信息,需要同时调用三个下游:订单服务(HTTP)、用户服务(HTTP)、商品库存服务(PostgreSQL)。上线后监控一直报警,P99延迟1.2秒,压测QPS只有120。

我先看了下原来的代码,典型的问题写法:

# before_api.py
import requests
import psycopg2
from fastapi import FastAPI

app = FastAPI()

DB_CONN = psycopg2.connect(
    host="10.0.1.20", port=5432, dbname="shop",
    user="api", password="xxx"
)

@app.get("/order/{user_id}")
def get_order_detail(user_id: int):
    # 串行调用三个下游
    order_resp = requests.get(f"http://order-svc/orders?user_id={user_id}", timeout=3)
    orders = order_resp.json()

    user_resp = requests.get(f"http://user-svc/users/{user_id}", timeout=3)
    user = user_resp.json()

    # 同步DB查询
    cur = DB_CONN.cursor()
    cur.execute("SELECT sku_id, stock FROM inventory WHERE user_id = %s", (user_id,))
    stocks = cur.fetchall()
    cur.close()

    return {"user": user, "orders": orders, "stocks": stocks}

问题一眼就能看出来:

  1. 三个下游串行调用,总耗时 = 三者之和
  2. requests 是同步阻塞库,FastAPI虽然用def会丢到线程池,但线程池默认只有40个worker,很容易打满
  3. psycopg2 全局单个连接,多线程共享会出问题,而且没有连接池
  4. 没有超时重试,下游抖动直接拖垮整个接口

二、环境与版本

  • Python: 3.11.6(3.11的asyncio性能比3.8好一大截,尤其Task创建开销)
  • FastAPI: 0.109.0
  • uvicorn: 0.27.0,启动参数 --workers 4 --loop uvloop
  • httpx: 0.26.0(支持HTTP/2和连接池)
  • asyncpg: 0.29.0
  • 压测工具: wrk 4.2.0,wrk -t8 -c200 -d30s

机器配置:4C8G,下游服务在同一内网。

三、方案设计

核心思路就三点:

1. 把串行改并行。 三个下游之间没有依赖关系,用asyncio.gather并发。理论上耗时从 t1+t2+t3 变成 max(t1,t2,t3)

2. 全链路异步化。 requestshttpx.AsyncClientpsycopg2asyncpg。任何一个同步阻塞点都会卡住整个event loop,这是asyncio最容易翻车的地方。

3. 连接池复用。 httpx的AsyncClient要全局单例复用,asyncpg用create_pool,避免每次请求都建连接。

关于异常处理,我没用默认的gather,因为它一个任务抛异常会直接冒泡,其他任务的结果拿不到。我用return_exceptions=True,然后在结果里判断,保证部分下游挂掉时接口还能返回降级数据。

四、核心实现

改造后的代码:

# after_api.py
import asyncio
import asyncpg
import httpx
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException

# 全局资源
http_client: httpx.AsyncClient = None
pg_pool: asyncpg.Pool = None


@asynccontextmanager
async def lifespan(app: FastAPI):
    global http_client, pg_pool
    # 连接池参数:根据下游承载能力调,max_connections别超过下游限流
    limits = httpx.Limits(
        max_connections=200,
        max_keepalive_connections=50,
        keepalive_expiry=30.0,
    )
    timeout = httpx.Timeout(connect=0.5, read=2.0, write=1.0, pool=1.0)
    http_client = httpx.AsyncClient(limits=limits, timeout=timeout, http2=True)

    pg_pool = await asyncpg.create_pool(
        host="10.0.1.20", port=5432, database="shop",
        user="api", password="xxx",
        min_size=10, max_size=50,
        command_timeout=2.0,
    )
    yield
    await http_client.aclose()
    await pg_pool.close()


app = FastAPI(lifespan=lifespan)


async def fetch_orders(user_id: int):
    resp = await http_client.get(f"http://order-svc/orders", params={"user_id": user_id})
    resp.raise_for_status()
    return resp.json()


async def fetch_user(user_id: int):
    resp = await http_client.get(f"http://user-svc/users/{user_id}")
    resp.raise_for_status()
    return resp.json()


async def fetch_stocks(user_id: int):
    async with pg_pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT sku_id, stock FROM inventory WHERE user_id = $1", user_id
        )
    return [dict(r) for r in rows]


@app.get("/order/{user_id}")
async def get_order_detail(user_id: int):
    # 并发执行,return_exceptions=True 保证单个失败不影响整体
    results = await asyncio.gather(
        fetch_orders(user_id),
        fetch_user(user_id),
        fetch_stocks(user_id),
        return_exceptions=True,
    )

    orders, user, stocks = results
    degraded = []

    if isinstance(orders, Exception):
        degraded.append("orders")
        orders = []
    if isinstance(user, Exception):
        degraded.append("user")
        user = {"user_id": user_id, "name": "unknown"}
    if isinstance(stocks, Exception):
        degraded.append("stocks")
        stocks = []

    # 全挂才算失败;部分失败返回降级数据 + 标记
    if len(degraded) == 3:
        raise HTTPException(status_code=503, detail="all upstreams unavailable")

    return {
        "user": user,
        "orders": orders,
        "stocks": stocks,
        "degraded": degraded,
    }

关键点说明:

  • lifespan 里初始化连接池,全局复用,不要在每个请求里AsyncClient(),那样等于每次都新建TCP连接,性能还不如同步。
  • httpx.Timeout 分阶段设置,connect=0.5 防止TCP握手卡住,read=2.0 是业务容忍上限。
  • return_exceptions=True 配合类型判断,实现优雅降级。
  • asyncpg的$1占位符和psycopg2的%s不一样,改的时候容易漏。

五、踩坑与优化

坑1:AsyncClient在请求里创建。 我第一版图省事,在每个fetch_xxxasync with httpx.AsyncClient() as c,结果QPS只到400。原因是每条请求都要建TLS握手和连接,复用连接池后直接翻4倍多。

坑2:同步库偷偷阻塞。 我们代码里有个日志模块用了logging.FileHandler,高并发下写文件是同步IO,会短时间卡住event loop。后来改成QueueHandler+后台线程写,或者直接用aiologger

坑3:连接池配置反了。 一开始max_connections=500,结果下游订单服务被打挂,因为我们的并发请求瞬间涌过去。连接池的max不是越大越好,要跟下游的承载能力匹配。最终定在200。

坑4:gather的异常吞掉。 不加return_exceptions=True时,一个下游超时会导致整个接口500,其他两个已经拿到的数据也白费了。加了之后要做类型判断,isinstance(x, Exception),注意BaseExceptionException的区别,asyncio.CancelledError在3.8+是BaseException子类。

坑5:uvloop必须显式启用。 默认的asyncio事件循环在Linux上性能一般,uvicorn --loop uvloop 后QPS能再涨15%左右。

坑6:CPU密集任务别丢进asyncio。 我们有个JSON序列化用了自定义encoder比较重,在event loop里跑会拖慢所有请求。这种用loop.run_in_executor丢线程池,或者干脆用orjson

六、效果数据

压测条件:wrk -t8 -c200 -d30s,下游mock服务固定延迟50ms。

指标 Before (sync) After (asyncio) 提升
QPS 120 1800 15x
P50延迟 680ms 42ms -94%
P99延迟 1210ms 78ms -94%
CPU使用率 85% (线程切换多) 45% -
内存 320MB 210MB -34%

单请求耗时从串行的50+50+30=130ms变成并发的max(50,50,30)=50ms,加上连接池复用省掉的握手时间,理论值就是这样。QPS 1800是4个worker的总和,单worker约450。

七、总结

asyncio不是什么银弹,它解决的是IO等待问题。如果你的接口是CPU密集的,用asyncio反而更慢。判断标准很简单:看你的请求耗时里,有多少比例是在等网络、等磁盘、等DB。

这次改造的核心就三句话:能并行的别串行,能用异步库的别用同步库,连接池要全局复用并匹配下游容量

最后提醒一点,asyncio的调试比同步代码难,异常堆栈经常断在gather里看不出是哪条挂了。建议在fetch_xxx里加日志埋点,记录每个下游的耗时,出问题时能快速定位。我们后来接了OpenTelemetry,每个协程的span都能追踪,排查效率高很多。