一、问题背景:CPU 闲着,接口却慢

我们有个订单详情聚合接口 /api/order/,职责很简单:拿订单基础信息,再去 5 个下游服务补齐数据——用户中心、库存服务、优惠券服务、物流服务、风控服务。返回一个拼装好的 JSON 给前端。

上线初期没感觉,QPS 上了 40 之后监控开始报警。看数据:

  • P50:620ms
  • P99:1.2s
  • 单实例 QPS:45 左右就打满
  • CPU 使用率:稳定在 8%~12%

CPU 这么低,说明不是算力问题。上 py-spy 抓了一下,火焰图里大片时间都耗在 socket.recv 和 select 上——五个下游调用是串行写的,每个 150~250ms,加起来正好 1s 出头。这就是典型的 IO 密集型场景,同步模型下线程全在干等。

第一反应是加线程池,但下游调用有 5 个,每个请求要占 5 个线程的等待时间,GIL 下上下文切换开销也不小,而且线程池大小很难调。既然瓶颈是等待,那就该用 asyncio。

二、环境与版本

先说清楚环境,异步这块版本差异挺大,尤其是事件循环相关的 API:

  • Python 3.11.6(3.10 以下 asyncio.to_thread 的行为和 3.11 有细微差别,建议 3.11+)
  • Flask 3.0.2(同步 WSGI 框架,这点后面会重点讲)
  • httpx 0.27.0(AsyncClient 支持连接池和 HTTP/2)
  • gunicorn 21.2.0 + gevent 24.2.1(注意:这个组合和 asyncio 有冲突,后面踩坑章节详述)
  • 压测:wrk 4.2.0,wrk -t4 -c100 -d60s

下游 5 个服务的平均 RT 我先测了一遍(单次串行调用,100 次取均值):

下游服务 平均 RT
用户中心 180ms
库存服务 150ms
优惠券服务 220ms
物流服务 250ms
风控服务 200ms

串行总和 ≈ 1000ms,和线上 P50 对得上。

三、方案设计

思路很直接:5 个下游调用之间没有依赖关系,全部可以并发。用 asyncio.gather 一次性发出去,理论上总耗时取决于最慢的那个(250ms)加上调度开销。

几个设计要点:

  1. 连接池复用:httpx 的 AsyncClient 内部维护连接池,必须全局复用一个实例,不能每次请求 new 一个,否则 TLS 握手就吃掉几十毫秒。limits=httpx.Limits(max_connections=200, max_keepalive_connections=50)。
  2. 超时收敛:给每个下游单独设超时,用户中心和库存 300ms,物流 400ms,风控 350ms。用 httpx.Timeout 统一配 connect/read。
  3. 异常隔离:asyncio.gather(..., return_exceptions=True),某个下游挂了不能让整个接口 500,降级返回空数据。
  4. 事件循环管理:Flask 是同步 WSGI,没有原生事件循环。方案是在应用启动时创建一个后台线程跑一个常驻 loop,请求进来用 asyncio.run_coroutine_threadsafe 把协程丢进去。这个方案比每次 asyncio.run() 稳定得多,后者每次都要新建/销毁 loop,开销大且容易和连接池打架。

四、核心实现(含 before/after 代码)

改造前(同步串行版):

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

app = Flask(__name__)

SESSION = requests.Session()
SESSION.mount("http://", requests.adapters.HTTPAdapter(pool_connections=20, pool_maxsize=50))

USER_URL = "http://user-svc:8001/user/{}"
STOCK_URL = "http://stock-svc:8002/stock/{}"
COUPON_URL = "http://coupon-svc:8003/coupon/{}"
LOGISTICS_URL = "http://logistics-svc:8004/logistics/{}"
RISK_URL = "http://risk-svc:8005/risk/{}"

@app.route("/api/order/")
def get_order(order_id):
    user = SESSION.get(USER_URL.format(order_id), timeout=0.3).json()
    stock = SESSION.get(STOCK_URL.format(order_id), timeout=0.3).json()
    coupon = SESSION.get(COUPON_URL.format(order_id), timeout=0.3).json()
    logistics = SESSION.get(LOGISTICS_URL.format(order_id), timeout=0.4).json()
    risk = SESSION.get(RISK_URL.format(order_id), timeout=0.4).json()

    return jsonify({
        "order_id": order_id,
        "user": user,
        "stock": stock,
        "coupon": coupon,
        "logistics": logistics,
        "risk": risk,
    })

改造后(asyncio 并发版):

# after.py
import asyncio
import threading
import httpx
from flask import Flask, jsonify

app = Flask(__name__)

# ---- 常驻事件循环,应用启动时初始化 ----
_loop = asyncio.new_event_loop()
_loop_thread = threading.Thread(target=_loop.run_forever, daemon=True)
_loop_thread.start()

# ---- 全局复用的 AsyncClient,连接池只建一次 ----
_client: httpx.AsyncClient = None

def _init_client():
    global _client
    _client = httpx.AsyncClient(
        timeout=httpx.Timeout(connect=0.2, read=0.4, write=0.4, pool=0.5),
        limits=httpx.Limits(max_connections=200, max_keepalive_connections=50),
        http2=False,  # 下游都是 HTTP/1.1,关掉省握手
    )

_init_client()

URLS = {
    "user": "http://user-svc:8001/user/{}",
    "stock": "http://stock-svc:8002/stock/{}",
    "coupon": "http://coupon-svc:8003/coupon/{}",
    "logistics": "http://logistics-svc:8004/logistics/{}",
    "risk": "http://risk-svc:8005/risk/{}",
}

# 每个下游单独超时,避免慢服务拖垮整体
TIMEOUTS = {"user": 0.3, "stock": 0.3, "coupon": 0.3, "logistics": 0.4, "risk": 0.35}
KEYS = list(URLS.keys())

async def _fetch_one(key: str, order_id: str):
    try:
        r = await _client.get(
            URLS[key].format(order_id),
            timeout=TIMEOUTS[key],
        )
        r.raise_for_status()
        return key, r.json()
    except Exception as e:
        # 降级:单点失败返回 None,不抛给上层
        return key, {"__error__": str(e)}

async def _fetch_all(order_id: str):
    results = await asyncio.gather(
        *[_fetch_one(k, order_id) for k in KEYS],
        return_exceptions=False,
    )
    return dict(results)

@app.route("/api/order/")
def get_order(order_id):
    # 关键:把协程丢进常驻 loop,阻塞等结果
    fut = asyncio.run_coroutine_threadsafe(_fetch_all(order_id), _loop)
    try:
        data = fut.result(timeout=1.0)  # 兜底超时,防止 loop 卡死
    except Exception:
        return jsonify({"order_id": order_id, "error": "upstream_timeout"}), 504

    return jsonify({"order_id": order_id, **data})

# ---- 进程退出时清理 ----
@app.teardown_appcontext
def _noop(exc):
    pass

import atexit
@atexit.register
def _shutdown():
    if _client:
        asyncio.run_coroutine_threadsafe(_client.aclose(), _loop)
    _loop.call_soon_threadsafe(_loop.stop)

注意 fut.result(timeout=1.0) 这个兜底:下游即使全挂,协程内部已经用 return_exceptions 兜过一次,但万一 loop 本身出问题(比如某个协程死循环),这层能保证 Flask 线程不会永久阻塞。

五、踩坑与优化

坑 1:gunicorn + gevent 下事件循环起不来

第一版直接上 gunicorn -k gevent -w 4,结果启动就报 RuntimeError: Cannot add child handler to a running event loop。原因是 gevent 的 monkey patch 把 threading 和 socket 都替换了,和 asyncio 底层冲突。

解决:换成 gunicorn -k gthread -w 4 --threads 8,用原生线程模型。或者直接用 uvicorn + Starlette 重写,但我们暂时不想动框架。最终配置:

gunicorn -k gthread -w 4 --threads 8 --worker-connections 1000 \
  --timeout 5 --graceful-timeout 3 app:app

坑 2:AsyncClient 每次请求 new 一个,性能反而更差

中途我图省事,在 _fetch_all 里 async with httpx.AsyncClient() as c:,结果 P99 从 1.2s 变成 1.8s——每个请求都要重新建连接池、握手、TLS(下游虽然是内网 HTTP,但 TCP 三次握手也要 1ms 左右,5 个并发就是 5ms,加上池初始化,反而更慢)。

解决:就是上面代码里的全局单例 + max_keepalive_connections=50。这一项对 P99 影响最大,实测从 1.8s 直接回到 400ms 以下。

坑 3:超时设置太粗,慢服务拖垮整体

一开始 httpx.Timeout(0.5) 一刀切,结果物流服务偶尔 600ms 才返回(虽然均值 250ms),触发超时后整个接口降级。改成每个下游按 P99 单独设:物流 0.4,其余 0.3~0.35。另外 pool=0.5 是必须的,连接池排队超时如果没设,高并发下会无限等。

坑 4:事件循环线程异常静默退出

有一次压测中途 loop 线程挂了(下游返回非 JSON,r.json() 抛异常没被 catch 到),但 Flask 主线程还在接请求,run_coroutine_threadsafe 投进去的 fut 永远 pending,最后全走 504。解决:_fetch_one 里把 r.json() 也包进 try,并且 loop 线程加 _loop.set_exception_handler 打日志。

六、效果数据

压测条件:wrk -t4 -c100 -d60s,单实例 4 worker × 8 threads,下游服务真实部署(内网),取 3 轮中位数。

改造前(同步串行):

指标 数值
QPS 45
P50 620ms
P90 980ms
P99 1.2s
CPU 10%
错误率 0.02%

改造后(asyncio 并发):

指标 数值
QPS 260
P50 95ms
P90 140ms
P99 180ms
CPU 38%
错误率 0.01%

P99 从 1.2s 降到 180ms,约 6.6 倍;QPS 从 45 到 260,约 5.8 倍。CPU 从 10% 涨到 38% 是正常的——以前线程都在等 IO,现在真正在干活了。

顺带说下内存:常驻 loop + 连接池后,单 worker RSS 从 180MB 涨到 210MB,可接受。

七、总结

这次改造让我重新确认了一个老结论:IO 等待型的接口,加机器不如换模型。asyncio 在这个场景下的收益是实打实的,但前提是几件事做对:

  1. 事件循环必须常驻,别每次请求 asyncio.run();
  2. AsyncClient 必须全局复用,连接池参数要调;
  3. 每个下游单独设超时,pool 超时别漏;
  4. 异常要隔离,return_exceptions 或 try/except 至少有一个;
  5. 和 gevent 这类 monkey patch 框架别混用。

如果让我重来一次,可能会直接上 FastAPI + uvicorn,省掉 Flask 里套事件循环的别扭劲。但如果是存量 Flask 项目不想大动,上面这套方案能撑住 200+ QPS,够用一阵了。

代码都在仓库里,下游地址是假的,跑之前把 URL 换成自己的就行。