一、问题背景:一个"看起来很普通"的接口

我们有个订单详情接口 GET /api/v1/orders/{order_id},业务逻辑不复杂:拿到order_id后,需要聚合4个下游服务的数据——

  • 用户服务:查下单人信息(昵称、等级)
  • 商品服务:查订单里每个SKU的名称、图片
  • 库存服务:查当前可售库存
  • 物流服务:查最新物流轨迹

每个下游都是独立的HTTP服务,走内网,单个平均耗时80~200ms不等。最早的实现是同步的,用requests一个一个调,写完能跑,谁也没在意。

直到大促前压测,问题暴露了:单实例QPS只有120,P99延迟840ms。运维说扩容到20个实例能扛住,但成本摆在那。我看了下代码,发现这接口99%的时间都在等IO,典型的"同步代码跑异步场景"。于是有了这次改造。

二、环境与版本

先把环境交代清楚,避免复现时踩版本坑:

  • Python 3.11.7(3.11的asyncio性能比3.8好不少,尤其Task调度)
  • FastAPI 0.110.0
  • uvicorn 0.29.0,启动参数 --workers 4 --loop uvloop
  • httpx 0.27.0(异步HTTP客户端)
  • uvloop 0.19.0
  • 压测工具:wrk 4.2.0,wrk -t8 -c200 -d60s

机器配置:4核8G,下游服务都是内网mock,固定延迟模拟真实情况(用户80ms、商品120ms、库存60ms、物流200ms)。

三、方案设计:把串行变并行

核心思路很朴素:4个下游互不依赖,没必要串行等。同步版本总耗时约等于四个之和(80+120+60+200=460ms,加上框架开销到800ms左右),改成asyncio.gather并行后,总耗时约等于最慢的那个(200ms)。

但有几个点必须提前想清楚:

  1. 连接复用:不能每个请求新建AsyncClient,要用全局单例+连接池,否则TCP握手和TLS开销会吃掉并行收益。
  2. 超时控制:并行后任一慢请求都会拖垮整体,必须给每个下游设独立超时(我设的read timeout 300ms)。
  3. 异常隔离:某个下游挂了不能让整个接口500,用return_exceptions=True,失败的部分降级返回默认值。
  4. 并发限流:下游QPS有限,用Semaphore控制并发数,避免打爆对方。
  5. uvicorn worker数:异步不是万能的,CPU密集和事件循环阻塞仍会拖累,worker数按核数配。

四、核心实现:before/after代码

Before:同步串行版

# sync_version.py
import requests
from fastapi import FastAPI

app = FastAPI()
TIMEOUT = 0.5

@app.get("/api/v1/orders/{order_id}")
def get_order(order_id: str):
    # 串行调用,每个都阻塞
    user = requests.get(f"http://user-svc/users/{order_id}", timeout=TIMEOUT).json()
    items = requests.get(f"http://item-svc/items/{order_id}", timeout=TIMEOUT).json()
    stock = requests.get(f"http://stock-svc/stock/{order_id}", timeout=TIMEOUT).json()
    logistics = requests.get(f"http://logi-svc/trace/{order_id}", timeout=TIMEOUT).json()

    return {
        "order_id": order_id,
        "user": user,
        "items": items,
        "stock": stock,
        "logistics": logistics,
    }

注意这里用的是def不是async def,FastAPI会丢到线程池跑,但线程池默认40个线程,高并发下线程切换和GIL竞争同样严重。

After:asyncio并行版

# async_version.py
import asyncio
import httpx
from fastapi import FastAPI, HTTPException

app = FastAPI()

# 全局单例客户端,连接池复用
client = httpx.AsyncClient(
    timeout=httpx.Timeout(connect=0.1, read=0.3, write=0.3, pool=0.1),
    limits=httpx.Limits(max_connections=200, max_keepalive_connections=100),
)

# 每个下游独立限流,避免打爆对方
SEM_USER = asyncio.Semaphore(50)
SEM_ITEM = asyncio.Semaphore(50)
SEM_STOCK = asyncio.Semaphore(50)
SEM_LOGI = asyncio.Semaphore(30)   # 物流服务比较弱,限得狠一点


async def fetch(url: str, sem: asyncio.Semaphore, default=None):
    async with sem:
        try:
            resp = await client.get(url)
            resp.raise_for_status()
            return resp.json()
        except (httpx.TimeoutException, httpx.HTTPError) as e:
            # 降级:失败返回默认值,不阻断整体
            print(f"fetch {url} failed: {e}")
            return default


@app.get("/api/v1/orders/{order_id}")
async def get_order(order_id: str):
    user_task = fetch(f"http://user-svc/users/{order_id}", SEM_USER, {})
    item_task = fetch(f"http://item-svc/items/{order_id}", SEM_ITEM, [])
    stock_task = fetch(f"http://stock-svc/stock/{order_id}", SEM_STOCK, {"stock": -1})
    logi_task = fetch(f"http://logi-svc/trace/{order_id}", SEM_LOGI, [])

    # 关键:gather并发,return_exceptions保证单个失败不影响整体
    user, items, stock, logistics = await asyncio.gather(
        user_task, item_task, stock_task, logi_task,
        return_exceptions=True,
    )

    return {
        "order_id": order_id,
        "user": user,
        "items": items,
        "stock": stock,
        "logistics": logistics,
    }


@app.on_event("shutdown")
async def shutdown():
    await client.aclose()

几个细节值得说:

  • httpx.Timeout分开设connect/read/write/pool,pool超时容易被忽略,连接池满了也会等,0.1s快速失败更稳。
  • 限流信号量放在模块级,是全进程共享的,配合uvicorn多worker要注意是每worker独立的,总量要按worker数分摊。
  • return_exceptions=True后,失败项拿到的是Exception对象不是默认值,严格来说要在fetch里catch掉,我上面代码已经catch了,所以gather那层其实拿不到异常,但保留这个参数是防御性的。

启动命令:

uvicorn async_version:app --host 0.0.0.0 --port 8000 \
  --workers 4 --loop uvloop --http httptools

五、踩坑与优化:几个真实的坑

坑1:忘了用async def,白改一场。 第一版写的时候有个fetch函数漏了async,结果整个gather变成了"等待一个同步函数",完全没并行。用asyncio.iscoroutinefunction检查一遍,或者跑起来看日志时间戳。

坑2:AsyncClient在模块级初始化,但事件循环还没起来。 httpx 0.27在模块级直接实例化AsyncClient是OK的,但如果你在里面绑了什么loop相关的东西,会报"attached to a different loop"。稳妥做法是用lifespan管理:

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app):
    app.state.client = httpx.AsyncClient(...)
    yield
    await app.state.client.aclose()

坑3:uvicorn worker数和Semaphore的关系。 我一开始Semaphore设200,4个worker就是800并发打向下游,直接把库存服务打挂了。后来按总限流/worker数来配,每个worker 50,总200,下游稳了。

坑4:CPU密集操作阻塞事件循环。 订单里有段JSON解析+字段拼装,量大了会阻塞loop。用asyncio.to_thread丢到线程池,或者干脆用orjson加速解析,我选的后者,解析耗时从12ms降到2ms。

坑5:日志用同步logging会拖慢。 高QPS下每条日志都刷盘,改成logging.handlers.QueueHandler异步写,或者降日志级别,压测时直接关掉debug日志。

六、效果数据

同一台4核8G机器,wrk压测60秒,200并发,对比结果:

指标 同步版 asyncio版 提升
QPS 120 2100 17.5x
P50延迟 420ms 42ms 10x
P99延迟 840ms 96ms 8.75x
平均CPU 65% 78% -
内存 210MB 260MB 略增

几个观察:

  • QPS提升主要来自并行,理论上限是1/最慢下游,实测2100已经接近uvloop在这个配置下的天花板。
  • P99从840ms降到96ms,主要因为并行后不再累加,且超时快速失败。
  • CPU上去了,因为并发高了事件循环调度更频繁,但没到瓶颈。
  • 内存增加50MB,主要是连接池和协程对象,可接受。

再补一组稳定性数据:连续压测30分钟,asyncio版错误率0.02%(都是下游超时降级),同步版0.3%(线程池排队+超时)。

七、总结

这次改造没有用什么黑魔法,核心就三件事:把串行IO改成asyncio.gather并行、用全局AsyncClient复用连接、用Semaphore给下游限流。代码改动不到100行,QPS翻了17倍。

几点经验给同样在做类似优化的同学:

  1. 先定位瓶颈再动手。用py-spy dump一下,确认是IO等待而不是CPU,改异步才有意义。
  2. 异步不是银弹。如果下游本身就是瓶颈,或者你的逻辑CPU密集,异步收益有限,该加缓存加缓存,该上消息队列上消息队列。
  3. 超时和降级是异步的命门。并行之后一个慢请求就能拖垮整体,超时一定要设,且要分connect/read/pool分别设。
  4. 版本别乱升。Python 3.11的asyncio比3.8快30%以上,uvloop必开,httpx别用0.20以下的老版本,Timeout语义变过。

最后,代码能跑和能扛是两回事。压测数据不会骗人,上线前一定要用真实流量模型压一遍。