1. 问题背景:一个慢到被投诉的报表接口
上个月,我们数据分析平台有个/api/v1/report/summary接口被业务方投诉了。这个接口的逻辑很简单:前端需要展示一个汇总卡片,包含用户数、订单量、营收三个指标。但这三个数据分别存储在三个不同的微服务里——用户服务、订单服务、财务服务。
最初的实现是同步串行调用,伪代码如下:
def get_summary():
users = requests.get("http://user-service/api/count", timeout=2).json()
orders = requests.get("http://order-service/api/count", timeout=2).json()
revenue = requests.get("http://finance-service/api/revenue", timeout=2).json()
return {"users": users, "orders": orders, "revenue": revenue}
三个服务平均响应时间都是700-800ms,串行下来就是2.3秒左右。更糟的是,我们的API网关超时阈值是3秒,高峰期经常直接504。客户反馈“页面转圈转半天”,领导让我必须解决。
2. 环境与版本:先交代清楚再动手
- Python: 3.10.8(生产环境),本地测试也用了3.11.4做对比
- Web框架: Flask 2.2.3(同步框架,后面会说怎么兼容asyncio)
- HTTP客户端: httpx 0.24.1(支持async/await)
- 部署: Gunicorn 20.1.0,4个worker,每个worker单线程
- 压测工具: wrk 4.2.0,测试时长30秒,并发50连接
- 三个下游服务:均部署在同一内网,延迟模拟为正常+抖动(±150ms)
3. 方案设计:asyncio不是银弹,但这里是完美匹配
接口是纯IO密集型——CPU占用几乎为0,所有时间都在等网络响应。这正是asyncio的经典使用场景。
核心思路:
1. 用httpx.AsyncClient替代requests
2. 用asyncio.gather并发发起三个请求
3. 由于Flask是同步WSGI框架,不能直接跑async函数——需要asyncio.run()包一层,或者用asgiref.sync.async_to_sync桥接
我选择了asgiref,因为它在Django社区久经考验,而且能在同一个event loop里复用连接池,避免反复创建开销。
设计要点:
- 每个请求创建一个AsyncClient实例,设置limits=httpx.Limits(max_connections=50)
- 使用asyncio.Semaphore(20)限制并发度,防止下游服务被打爆
- 设置总超时5秒,单个请求超时2秒,避免极端情况拖垮整体
4. 核心实现:改造代码和对比
4.1 同步版本(改造前)
# sync_before.py
import requests
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/api/v1/report/summary")
def get_summary():
try:
users = requests.get("http://user-service/api/count", timeout=2).json()
orders = requests.get("http://order-service/api/count", timeout=2).json()
revenue = requests.get("http://finance-service/api/revenue", timeout=2).json()
return jsonify({"users": users["count"], "orders": orders["count"], "revenue": revenue["amount"]})
except requests.Timeout:
return jsonify({"error": "downstream timeout"}), 504
4.2 异步版本(改造后)
# async_after.py
import asyncio
import httpx
from flask import Flask, jsonify
from asgiref.sync import async_to_sync
app = Flask(__name__)
async def fetch_json(client, url):
resp = await client.get(url, timeout=2.0)
resp.raise_for_status()
return resp.json()
async def fetch_all():
# 设置连接池和并发信号量
limits = httpx.Limits(max_connections=50, max_keepalive_connections=20)
semaphore = asyncio.Semaphore(20)
async with httpx.AsyncClient(limits=limits) as client:
async def bounded_fetch(url):
async with semaphore:
return await fetch_json(client, url)
# 并发发起三个请求,asyncio.gather等待全部完成
results = await asyncio.gather(
bounded_fetch("http://user-service/api/count"),
bounded_fetch("http://order-service/api/count"),
bounded_fetch("http://finance-service/api/revenue"),
return_exceptions=True # 防止单个失败导致全部失败
)
# 检查是否有异常
for r in results:
if isinstance(r, Exception):
raise r
return results
@app.route("/api/v1/report/summary")
def get_summary():
try:
users, orders, revenue = async_to_sync(fetch_all)()
return jsonify({
"users": users["count"],
"orders": orders["count"],
"revenue": revenue["amount"]
})
except Exception as e:
return jsonify({"error": str(e)}), 504
注意return_exceptions=True这个参数——如果某个下游挂了,我们不能让整个请求失败,而是要继续等待其他两个成功返回,最后再统一报错。
5. 踩坑与优化:三个真实教训
5.1 坑1:asyncio.run()在Flask里会创建新的event loop,导致连接池失效
我第一版用的是asyncio.run(fetch_all()),结果每个请求都新建event loop,AsyncClient的连接池每次都要重新建立TCP连接。内网环境下TCP握手20ms左右,看似不严重,但压测时发现吞吐量上不去,因为每次请求都有额外的3次TCP建立开销。
解决:改用asgiref.sync.async_to_sync,它在Flask进程启动时创建一个持久化的event loop,所有请求共享同一个loop和连接池。
5.2 坑2:信号量忘了加,压测时把下游打挂了
第一版没有Semaphore,直接gather三个请求。wrk压测50并发,瞬间产生150个并发请求打到下游。下游服务直接CPU飙到95%,P99延迟从800ms变成2秒+。
解决:加asyncio.Semaphore(20),全局限制最多20个并发请求。信号量要定义在fetch_all外层,这样所有请求共享同一个计数。我最初放在async with里面,导致每个请求创建新的信号量,等于没限制。
5.3 优化:Python 3.11的性能提升
生产环境是3.10,本地测试了3.11。发现3.11的asyncio调度性能提升了约15%,特别是gather在任务数量少的情况下,开销更小。用3.11跑同样的压测,QPS从220提升到252。如果你的业务对延迟敏感,值得升级。
6. 效果数据:用数字说话
压测环境:wrk -t4 -c50 -d30s,请求/api/v1/report/summary
| 指标 | 同步版本 | 异步版本 | 提升幅度 |
|---|---|---|---|
| 平均延迟 | 2.31s | 0.42s | 82%↓ |
| P99延迟 | 2.87s | 0.63s | 78%↓ |
| QPS | 45 | 220 | 389%↑ |
| 超时请求(>3s) | 12.4% | 0% | 彻底解决 |
| CPU占用(worker) | 18% | 22% | 可接受 |
额外观察:
- 异步版本在50并发下,下游服务的请求峰值从150降到20,下游CPU占用从95%降至55%
- 异步版本的连接复用率非常高,max_keepalive_connections=20足够支撑220 QPS
7. 总结:什么时候该用asyncio
这次改造带来的收益远超预期,但我想说asyncio不是万能的。
适合asyncio的场景:
- 纯IO密集型,CPU消耗极低
- 需要并发调用多个外部服务
- 下游服务本身响应时间不短(至少50ms以上),否则异步开销反而变大
不适合的场景:
- CPU密集型计算(应该用多进程)
- 下游服务延迟极低(比如本地Redis <1ms),异步收益不明显
- 代码里混有阻塞调用(比如同步的requests),会完全卡住event loop
最后说一句:如果你还在用Flask,async_to_sync桥接方案是成本最低的改造方式。你不需要换FastAPI或Sanic,只需要把阻塞的IO调用改成async版本,包一层桥接即可。我们的生产环境已经稳定运行3周,无内存泄漏,无异常重启。
如果你也在做类似的改造,建议先把压测脚本准备好,用数据验证每一步优化。祝改造顺利。