一、问题背景: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)加上调度开销。
几个设计要点:
- 连接池复用:httpx 的
AsyncClient内部维护连接池,必须全局复用一个实例,不能每次请求 new 一个,否则 TLS 握手就吃掉几十毫秒。limits=httpx.Limits(max_connections=200, max_keepalive_connections=50)。 - 超时收敛:给每个下游单独设超时,用户中心和库存 300ms,物流 400ms,风控 350ms。用
httpx.Timeout统一配 connect/read。 - 异常隔离:
asyncio.gather(..., return_exceptions=True),某个下游挂了不能让整个接口 500,降级返回空数据。 - 事件循环管理: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 在这个场景下的收益是实打实的,但前提是几件事做对:
- 事件循环必须常驻,别每次请求
asyncio.run(); - AsyncClient 必须全局复用,连接池参数要调;
- 每个下游单独设超时,
pool超时别漏; - 异常要隔离,
return_exceptions或 try/except 至少有一个; - 和 gevent 这类 monkey patch 框架别混用。
如果让我重来一次,可能会直接上 FastAPI + uvicorn,省掉 Flask 里套事件循环的别扭劲。但如果是存量 Flask 项目不想大动,上面这套方案能撑住 200+ QPS,够用一阵了。
代码都在仓库里,下游地址是假的,跑之前把 URL 换成自己的就行。