一、问题背景

去年接手一个订单中心的聚合接口,逻辑很"朴素":前端传一个order_id,后端要依次去三个服务拿数据——订单主表、物流状态、优惠券明细,最后拼成一个JSON返回。原实现是Flask + requests,代码大概长这样:

@app.route("/api/order/")
def get_order_detail(order_id):
    order = requests.get(f"{ORDER_SVC}/order/{order_id}", timeout=2).json()
    logistics = requests.get(f"{LOGISTICS_SVC}/logistics/{order_id}", timeout=2).json()
    coupon = requests.get(f"{COUPON_SVC}/coupon/{order_id}", timeout=2).json()
    return jsonify({"order": order, "logistics": logistics, "coupon": coupon})

问题很直观:三个下游请求是串行的,每个平均耗时200~300ms,加起来单请求就要700ms以上。线上4核8G的Pod,压测QPS只有120左右,P95达到820ms。大促期间接口超时率一度冲到3%。

这三个服务之间没有数据依赖,完全可以并发。于是决定用asyncio重构。

二、环境与版本

  • Python 3.11.6(3.11的TaskGroup和异常组很好用,但为了兼容性我最终用了gather)
  • FastAPI 0.110.0 + uvicorn 0.29.0(标准版,uvicorn[standard])
  • httpx 0.27.0(异步客户端,替代requests)
  • 原环境:Flask 2.3.3 + requests 2.31.0 + gunicorn 21.2.0(4 workers)
  • 压测工具:wrk 4.2.0,wrk -t4 -c200 -d30s

部署方式也做了调整:原来gunicorn用同步worker,现在换成uvicorn单进程多协程。因为asyncio的并发模型里,多进程反而会浪费内存,单进程靠事件循环就能吃满IO等待。

三、方案设计

核心思路只有一句话:把IO等待重叠起来。

  • 用httpx.AsyncClient复用一个全局连接池,避免每次请求重建TCP
  • 用asyncio.gather并发发起3个下游调用
  • 给每个下游单独设超时,用asyncio.wait_for包一层,慢的那个不拖垮整体
  • 关键服务做降级:物流和优惠券拿不到就返回空,订单主表失败才整体报错

一个容易忽略的点是超时预算。串行时总超时是2s×3=6s,并发后总预算应该压到1.5s以内,否则并发没意义。我给每个下游设800ms超时,整体兜底1.2s。

四、核心实现

先看改造后的完整代码:

import asyncio
import httpx
from fastapi import FastAPI, HTTPException

app = FastAPI()

# 全局复用连接池,限制最大连接数,避免打爆下游
client = httpx.AsyncClient(
    timeout=httpx.Timeout(0.8, connect=0.3),
    limits=httpx.Limits(max_connections=200, max_keepalive_connections=50),
    http2=True,
)

ORDER_SVC = "http://order-svc"
LOGISTICS_SVC = "http://logistics-svc"
COUPON_SVC = "http://coupon-svc"


async def fetch(url: str, timeout: float = 0.8):
    """带独立超时的GET,失败抛异常由上层处理"""
    resp = await asyncio.wait_for(client.get(url), timeout=timeout)
    resp.raise_for_status()
    return resp.json()


async def safe_fetch(url: str, default=None):
    """降级版本:失败返回默认值,不阻断整体"""
    try:
        return await fetch(url)
    except (httpx.HTTPError, asyncio.TimeoutError):
        return default


@app.get("/api/order/{order_id}")
async def get_order_detail(order_id: str):
    # 订单主表是强依赖,失败直接报错;另外两个可降级
    order_task = asyncio.create_task(fetch(f"{ORDER_SVC}/order/{order_id}"))
    logistics_task = asyncio.create_task(
        safe_fetch(f"{LOGISTICS_SVC}/logistics/{order_id}", default={})
    )
    coupon_task = asyncio.create_task(
        safe_fetch(f"{COUPON_SVC}/coupon/{order_id}", default=[])
    )

    try:
        order, logistics, coupon = await asyncio.wait_for(
            asyncio.gather(order_task, logistics_task, coupon_task),
            timeout=1.2,
        )
    except (httpx.HTTPError, asyncio.TimeoutError):
        raise HTTPException(status_code=504, detail="order service timeout")

    return {"order": order, "logistics": logistics, "coupon": coupon}

启动命令:

uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1 --loop uvloop --http httptools

这里--workers 1是刻意的。asyncio场景下,单进程的事件循环足以处理大量并发IO,多worker只会在进程间重复建连接池。如果CPU成为瓶颈(比如有大量JSON序列化),再考虑加worker。

再贴一个压测用的对比脚本,方便你复现:

# bench.py  —— 简单对比串行 vs 并发
import asyncio, time, httpx

URLS = [
    "http://order-svc/order/123",
    "http://logistics-svc/logistics/123",
    "http://coupon-svc/coupon/123",
]

async def serial(client):
    for u in URLS:
        await client.get(u)

async def concurrent(client):
    await asyncio.gather(*[client.get(u) for u in URLS])

async def main():
    async with httpx.AsyncClient(timeout=1.0) as client:
        for name, fn in [("serial", serial), ("concurrent", concurrent)]:
            t = time.perf_counter()
            for _ in range(100):
                await fn(client)
            cost = (time.perf_counter() - t) / 100 * 1000
            print(f"{name}: {cost:.1f} ms/req")

asyncio.run(main())

五、踩坑与优化

坑1:忘了复用client。 第一版我在每个请求里async with httpx.AsyncClient(),结果QPS反而比原来低——每次都在做TLS握手和连接建立。改成全局client后,P95直接掉了300ms。记住:AsyncClient是设计来长生命周期复用的。

坑2:连接池太小。 默认max_connections=100,压测到并发200时出现大量PoolTimeout。调到200后消失。经验值是max_connections ≥ 预期并发数,否则请求会在池里排队,并发形同虚设。

坑3:gather的异常传播。 asyncio.gather默认一个任务抛异常,其他任务不会被取消,但整体会立刻抛错。我一开始没设return_exceptions,导致物流服务抖动时整个接口挂掉。后来用safe_fetch包了一层降级才稳。

另外把日志里的time.time()换成time.perf_counter()做耗时统计,精度高很多。

六、效果数据

同一台4核8G机器,wrk压测-t4 -c200 -d30s:

指标 Before (Flask+requests) After (FastAPI+asyncio)
QPS 120 1800
P50 720ms 62ms
P95 820ms 95ms
P99 1100ms 140ms
错误率 0.3% 0%
内存占用 380MB 210MB

QPS提升15倍,P95降了88%。注意这个提升主要来自并发重叠IO和连接池复用,不是FastAPI本身比Flask快多少——如果下游是CPU密集,asyncio一点忙都帮不上。

七、总结

asyncio不是银弹,它只解决一件事:IO等待时的CPU空转。判断要不要用,就看你的接口有没有大量可并发的IO。像这个订单聚合接口,3个下游串行750ms,并发后取最慢的那个约250ms,理论收益就是3倍,实际因为连接复用和框架开销还更高。

几个可以直接抄的结论:
- AsyncClient全局复用,max_connections调到并发量级
- 每个下游独立超时,总预算压在1.5s内
- 强依赖用gather直接抛错,弱依赖用safe_fetch降级
- uvicorn单worker起步,CPU打满再加

如果你的接口是纯数据库查询,先把连接池和SQL优化做完再考虑asyncio,顺序别搞反了。