一、问题背景:一个被同步 IO 拖死的查询接口

先说场景。我们有个内部商品聚合查询接口 GET /api/v1/product/{id},逻辑很简单:拿商品 ID,去三个下游服务取数据,拼装后返回。

  • 商品基础信息:商品中心,HTTP 接口,平均耗时 80ms
  • 库存:库存服务,HTTP 接口,平均耗时 120ms
  • 价格:价格服务,HTTP 接口,平均耗时 60ms

原来的实现是 Flask + requests,三个调用串行写:

# before: app_sync.py
import requests
from flask import Flask, jsonify

app = Flask(__name__)

BASE = "http://internal-gateway/api"

@app.route("/api/v1/product/")
def get_product(pid):
    # 三次串行 HTTP 调用,累计耗时 = 80 + 120 + 60 = 260ms 起步
    r1 = requests.get(f"{BASE}/product/{pid}", timeout=1)
    r2 = requests.get(f"{BASE}/stock/{pid}", timeout=1)
    r3 = requests.get(f"{BASE}/price/{pid}", timeout=1)

    if not (r1.ok and r2.ok and r3.ok):
        return jsonify({"error": "upstream failed"}), 502

    return jsonify({
        "product": r1.json(),
        "stock": r2.json(),
        "price": r3.json(),
    })

if __name__ == "__main__":
    # 同步 WSGI 部署,gunicorn -w 4 -k sync 起步
    app.run(port=8000)

线上部署是 gunicorn -w 4 -k sync -b 0.0.0.0:8000 app_sync:app,4 核机器开 4 个 worker。

问题很典型:每个 worker 同一时刻只能处理一个请求,一个请求里 260ms 全在等网络 IO,CPU 基本闲着。4 个 worker 理论上限就是 4 / 0.26s ≈ 15 QPS,再加上下游抖动、GC、日志,压测出来的实际 QPS 只有 120 左右(下游有本地缓存,实际均值比 260ms 低)。

更难受的是 P99。下游偶尔抖一下,串行链路会被放大:任意一个下游 1s 超时,整个请求就 1s+。压测时 P99 跑到 1.8s,业务方天天来问。

这不是代码写得烂,是同步阻塞模型的天然上限。要动就得换模型。

二、环境与版本

改造时的环境,都是真实跑在生产的版本:

  • Python 3.11.6(3.11 的 asyncio 性能比 3.8 好不少,TaskGroup 也能用)
  • FastAPI 0.104.1
  • uvicorn 0.24.0,--workers 4
  • httpx 0.25.1(异步客户端,替代 requests)
  • gunicorn 21.2.0(用 uvicorn.workers.UvicornWorker)
  • 压测工具:wrk 4.2.0,wrk -t4 -c200 -d60s

机器:4 核 8G,下游服务在内网,RTT 约 1ms,所以耗时基本是下游自身处理时间。

选 FastAPI 不选 aiohttp 的原因很朴素:团队已有 FastAPI 项目,Pydantic 校验现成,迁移成本最低。核心不是框架,是 asyncio 的并发模型。

三、方案设计:把串行等待改成并发等待

核心思路一句话:三个下游互不依赖,没必要排队,应该并发发出去。

改造分三层:

  1. Web 层:Flask 同步 WSGI 换成 FastAPI + uvicorn,单 worker 内部用事件循环处理并发请求。
  2. HTTP 客户端:requests 换成 httpx.AsyncClient,全局复用一个 client,走连接池,避免每次建连。
  3. 聚合逻辑:asyncio.gather 并发调用三个下游,配合 asyncio.wait_for 做整体超时,asyncio.Semaphore 限制单实例对下游的并发数,防止打爆下游。

理论收益:串行 260ms 变成 max(80,120,60)=120ms,延迟直接砍一半。吞吐上,一个 worker 在等待 IO 时可以处理成百上千个协程,QPS 不再受 worker 数 × 单请求耗时限制。

设计上有几个刻意的取舍:

  • 不用 gather 的默认行为兜底:gather 默认一个任务抛异常就整体失败,但我们希望库存服务挂了也能返回商品信息(降级),所以每个下游包一层,异常转成 None。
  • 超时分层:单下游 800ms,整体聚合 1s。单下游超时不影响其他,整体超时兜底。
  • Semaphore 按下游分别设,不是全局一个。商品中心给 200,库存给 100(它最慢最脆),价格给 200。

四、核心实现

先看异步客户端的封装。关键是 client 全局复用 + 生命周期管理,不要在每个请求里 async with httpx.AsyncClient(),那样连接池白建了。

# clients.py
import httpx

BASE = "http://internal-gateway/api"

# 全局单例,在 FastAPI lifespan 里创建和关闭
_http_client: httpx.AsyncClient | None = None

def get_client() -> httpx.AsyncClient:
    global _http_client
    if _http_client is None:
        _http_client = httpx.AsyncClient(
            base_url=BASE,
            timeout=httpx.Timeout(0.8, connect=0.3),  # 单请求 800ms,建连 300ms
            limits=httpx.Limits(
                max_connections=300,          # 连接池总上限
                max_keepalive_connections=100, # 长连接保留数
                keepalive_expiry=30.0,
            ),
        )
    return _http_client

async def close_client() -> None:
    global _http_client
    if _http_client is not None:
        await _http_client.aclose()
        _http_client = None

然后是聚合服务。这里我用 asyncio.Semaphore 控制下游并发,用 wait_for 做单请求超时,异常统一转 None 实现降级:

# service.py
import asyncio
import logging
from clients import get_client

logger = logging.getLogger(__name__)

# 按下游分别限流,库存服务最脆,给最小配额
SEM_PRODUCT = asyncio.Semaphore(200)
SEM_STOCK = asyncio.Semaphore(100)
SEM_PRICE = asyncio.Semaphore(200)

PER_CALL_TIMEOUT = 0.8   # 单个下游超时
TOTAL_TIMEOUT = 1.0      # 整体聚合超时

async def _fetch(path: str, sem: asyncio.Semaphore, name: str):
    client = get_client()
    async with sem:
        try:
            resp = await asyncio.wait_for(
                client.get(path), timeout=PER_CALL_TIMEOUT
            )
            resp.raise_for_status()
            return resp.json()
        except (asyncio.TimeoutError, httpx.HTTPError) as e:
            # 降级:记录并返回 None,不拖垮整个请求
            logger.warning("upstream %s failed: %r", name, e)
            return None

async def get_product_detail(pid: int) -> dict:
    # 三个下游并发发出
    tasks = [
        _fetch(f"/product/{pid}", SEM_PRODUCT, "product"),
        _fetch(f"/stock/{pid}", SEM_STOCK, "stock"),
        _fetch(f"/price/{pid}", SEM_PRICE, "price"),
    ]
    try:
        product, stock, price = await asyncio.wait_for(
            asyncio.gather(*tasks), timeout=TOTAL_TIMEOUT
        )
    except asyncio.TimeoutError:
        logger.error("aggregate timeout for pid=%s", pid)
        return {"error": "aggregate timeout"}

    # 商品信息是必需的,缺了就算失败;库存/价格可降级
    if product is None:
        return {"error": "product unavailable"}

    return {
        "product": product,
        "stock": stock,
        "price": price,
    }

FastAPI 入口,用 lifespan 管理 client 生命周期:

# app_async.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from clients import close_client
from service import get_product_detail

@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await close_client()

app = FastAPI(lifespan=lifespan)

@app.get("/api/v1/product/{pid}")
async def product(pid: int):
    return await get_product_detail(pid)

部署命令换成:

gunicorn app_async:app \
  -w 4 \
  -k uvicorn.workers.UvicornWorker \
  -b 0.0.0.0:8000 \
  --timeout 30

注意 -k uvicorn.workers.UvicornWorker,这是让 gunicorn 用异步 worker 的关键,写错成默认 sync worker,所有异步代码等于白写(这个坑后面细说)。

五、踩坑与优化

坑一:gunicorn worker 类型写错,性能纹丝不动。
第一次上线我沿用了 -k sync,代码是 async def,uvicorn 能跑,但每个请求实际上还是在同步 worker 里被 asyncio.run 包着执行,并发能力等于零。压测 QPS 还是 130 左右,我还以为是 asyncio 没用。换成 UvicornWorker 后 QPS 直接翻了十几倍。教训:异步代码 + 同步 worker = 白改。

坑二:连接池耗尽导致大量请求排队。
初期我把 max_connections 设成 100,压测到 1500 QPS 时开始出现 httpx.PoolTimeout。原因是一个请求并发发 3 个下游调用,200 并发 × 3 = 600 个并发连接需求,池子只有 100,全在排队。把 max_connections 提到 300、max_keepalive_connections 提到 100 后解决。经验值:连接池上限 ≈ 预期峰值并发 × 单请求下游调用数 × 1.2。

坑三:在 async 函数里写了同步代码,事件循环被卡死。
有个日志里顺手写了 requests.get 做埋点上报,还有一处用了同步的 redis 客户端。单看每次几毫秒,但事件循环是单线程的,这些同步调用会把整个 worker 卡住,P99 直接飙到 2s。排查用 asyncio.debug 模式(PYTHONASYNCIODEBUG=1)能打出慢回调告警。全部换成 httpx 和 redis.asyncio 后恢复。async 函数里不允许出现任何阻塞调用,这是铁律。

另外两个小优化:

  • 给 httpx.Timeout 单独设了 connect=0.3,建连超时比读超时短,避免下游网络问题拖长尾。
  • 商品信息是必需项,我加了个 30s 的本地 TTL 缓存(cachetools.TTLCache),命中率约 35%,进一步压低了商品中心的压力。

六、效果数据

同一台 4 核 8G 机器,wrk -t4 -c200 -d60s,下游服务状态一致:

指标 改造前 (Flask+requests) 改造后 (FastAPI+asyncio) 变化
QPS 120 2100 17.5x
P50 延迟 240ms 118ms -51%
P95 延迟 920ms 190ms -79%
P99 延迟 1800ms 210ms -88%
单请求下游耗时 260ms(串行) 120ms(并发) -54%
4C8G 内存占用 约 600MB 约 750MB +25%

内存涨了 25%,主要是连接池和协程栈的开销,完全可接受。CPU 使用率从改造前的 8% 涨到 45% 左右——这才正常,说明机器终于干活了。

下游视角:库存服务的峰值 QPS 从 120 涨到约 700(因为聚合接口整体吞吐上去了),但因为有 Semaphore 限流 100 并发,没有出现被打爆的情况,反而比改造前更平稳。

七、总结

这次改造的核心不是"换了个框架",而是把阻塞等待换成了并发等待。三条经验:

  1. 先判断瓶颈是不是 IO 等待。如果是 CPU 密集,asyncio 帮不了你,还得靠多进程。我们这个场景 90% 时间在等下游,asyncio 是正解。
  2. 异步化的收益全在细节里:worker 类型、连接池大小、超时分层、限流配额,任何一个配错,性能就回退到同步水平。上线前一定用真实压测验证。
  3. 降级比并发更重要。并发之后单点故障的放大效应更明显,必须给每个下游设超时和降级策略,否则一个慢下游能把整个接口拖死。

代码量没增加多少,但延迟和吞吐是两个数量级的差别。如果你的接口也是"一个请求串行调好几个下游",值得花半天改一下。