一、问题背景
去年接手一个内部推荐服务,逻辑很简单:客户端请求进来,服务端要调三个下游接口拿数据,然后聚合返回。
- 用户画像接口:平均耗时 80ms
- 商品特征接口:平均耗时 120ms
- 实时热度接口:平均耗时 60ms
三个接口互不依赖,理论上可以并发。但原代码是Flask同步写的,串行调用,一个请求光等下游就要 260ms 起步。加上框架开销和GC,P99直接飙到 480ms。
压测数据(4核8G,8个worker):
| 指标 | 数值 |
|---|---|
| QPS | 120 |
| P50 | 310ms |
| P99 | 480ms |
| 机器数 | 8台 |
业务方天天抱怨超时,运维说机器不够。我看了一眼代码,问题很明确:IO等待时间占了整个请求的 90% 以上,CPU 基本在摸鱼。
二、环境与版本
Python 3.11.6
Flask 3.0.0
aiohttp 3.9.1
uvicorn 0.25.0
gunicorn 21.2.0
httpx 0.26.0(用于压测对比)
选 Python 3.11 是因为它的 asyncio 在异常处理和 TaskGroup 上比 3.10 快不少,官方 benchmark 显示 async 场景有 10%~20% 的提升。
三、方案设计
核心思路:把 IO 密集的聚合逻辑改成异步,用 asyncio.gather 并发调三个下游。
技术选型上有几个考虑:
- 为什么不全换成 FastAPI? 改造成本太大,接口契约、中间件、鉴权都要动。折中方案是保留 Flask 做路由和参数校验,把耗时的聚合逻辑抽出来用 asyncio 跑。
- 怎么在 Flask 里跑 asyncio? Flask 是 WSGI 同步框架,直接在视图里
asyncio.run()每次都会新建事件循环,开销大。正确做法是在应用启动时创建一个全局事件循环,用run_until_complete或asyncio.run_coroutine_threadsafe提交任务。 - 下游调用用什么? aiohttp 的
ClientSession是连接池复用的关键,绝对不能每次请求新建 session。
四、核心实现
Before:同步串行版本
# before.py
import requests
from flask import Flask, jsonify
app = Flask(__name__)
def fetch_profile(uid):
r = requests.get(f"http://profile-svc/user/{uid}", timeout=1.0)
return r.json()
def fetch_features(uid):
r = requests.get(f"http://feature-svc/item/{uid}", timeout=1.0)
return r.json()
def fetch_hotness(uid):
r = requests.get(f"http://hot-svc/score/{uid}", timeout=1.0)
return r.json()
@app.route("/recommend/")
def recommend(uid):
# 串行调用,累加耗时
profile = fetch_profile(uid)
features = fetch_features(uid)
hotness = fetch_hotness(uid)
return jsonify({
"uid": uid,
"profile": profile,
"features": features,
"hotness": hotness,
})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)
这段代码的问题不用多说:三个请求串行,任何一个卡住,后面的都得等。requests 每次新建连接,TCP 握手 + TLS 又是一笔开销。
After:asyncio 并发版本
# after.py
import asyncio
import aiohttp
from flask import Flask, jsonify
app = Flask(__name__)
LOOP = asyncio.new_event_loop()
SESSION: aiohttp.ClientSession = None
TIMEOUT = aiohttp.ClientTimeout(total=0.8, connect=0.2)
CONNECTOR = aiohttp.TCPConnector(
limit=200, # 总连接数上限
limit_per_host=50, # 单host连接上限
ttl_dns_cache=300, # DNS缓存5分钟
keepalive_timeout=30,
)
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.json()
async def aggregate(uid):
# 三个请求并发发出
results = await asyncio.gather(
fetch(SESSION, f"http://profile-svc/user/{uid}"),
fetch(SESSION, f"http://feature-svc/item/{uid}"),
fetch(SESSION, f"http://hot-svc/score/{uid}"),
return_exceptions=True,
)
profile, features, hotness = results
# 单个下游失败不影响整体,降级为空
return {
"uid": uid,
"profile": profile if not isinstance(profile, Exception) else {},
"features": features if not isinstance(features, Exception) else {},
"hotness": hotness if not isinstance(hotness, Exception) else 0,
}
@app.route("/recommend/")
def recommend(uid):
future = asyncio.run_coroutine_threadsafe(aggregate(uid), LOOP)
return jsonify(future.result(timeout=1.0))
def start_background_loop(loop):
asyncio.set_event_loop(loop)
loop.run_forever()
if __name__ == "__main__":
import threading
# 在后台线程跑事件循环,主线程继续跑Flask
threading.Thread(target=start_background_loop, args=(LOOP,), daemon=True).start()
# 在事件循环里初始化session,避免跨loop问题
async def init_session():
global SESSION
SESSION = aiohttp.ClientSession(
connector=CONNECTOR,
timeout=TIMEOUT,
headers={"User-Agent": "rec-svc/1.0"},
)
asyncio.run_coroutine_threadsafe(init_session(), LOOP).result()
app.run(host="0.0.0.0", port=8000, threaded=True)
关键点:
asyncio.gather让三个请求真正并发,总耗时取决于最慢的那个(120ms)而不是累加(260ms)TCPConnector配置连接池,limit_per_host=50防止单下游被打爆return_exceptions=True让单个下游失败时整体不挂,配合降级逻辑- 全局
SESSION复用连接,省掉每次 TCP 握手 - Flask 视图里用
run_coroutine_threadsafe把协程提交到后台 loop,用future.result(timeout=1.0)同步等待结果
五、踩坑与优化
坑1:在Flask视图里直接 asyncio.run()
一开始偷懒,每个请求 asyncio.run(aggregate(uid)),结果压测时 QPS 不升反降。原因是每次调用都新建事件循环、重建 session,连接池完全没复用。改成全局 loop + 全局 session 后 QPS 直接翻倍。
坑2:跨事件循环使用 ClientSession
aiohttp 的 session 绑定创建它的事件循环。我在主线程 asyncio.run 里建 session,在后台 loop 里用,直接报 RuntimeError: Event loop is closed。解决方法是让 session 的创建和使用都在同一个 loop 里。
坑3:连接池打爆下游
limit=200 一开始设得太激进,压测时下游三个服务被我们打挂两次。后来改成 limit_per_host=50,并且和下游团队对齐了限流阈值,稳定了。
坑4:超时设置不合理
total=0.8 是拍脑袋定的。实际观察发现实时热度接口 P99 是 200ms,画像接口 P99 是 180ms,商品特征接口偶尔会飙到 600ms。后来把 total 设成 1.0,connect 设成 0.2,并且给商品特征接口单独配了 1.5s 的超时和熔断降级。
优化:任务组 + 取消传播
Python 3.11 的 TaskGroup 比 gather 更优雅,任何一个子任务抛异常会自动取消其他任务,避免资源泄漏。生产上我最终用的就是 TaskGroup 版本:
async def aggregate_v2(uid):
result = {"uid": uid, "profile": {}, "features": {}, "hotness": 0}
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch(SESSION, f"http://profile-svc/user/{uid}"))
t2 = tg.create_task(fetch(SESSION, f"http://feature-svc/item/{uid}"))
t3 = tg.create_task(fetch(SESSION, f"http://hot-svc/score/{uid}"))
# 走到这里说明全部成功,任何一个失败会抛ExceptionGroup
result["profile"] = t1.result()
result["features"] = t2.result()
result["hotness"] = t3.result()
return result
不过 TaskGroup 的异常处理是 ExceptionGroup,需要额外包一层 try/except 做降级,这点不如 gather + return_exceptions 直接。两种都用过,看团队习惯。
六、效果数据
压测工具用 wrk -t4 -c200 -d60s,4核8G机器,gunicorn 4 worker:
| 指标 | Before | After | 提升 |
|---|---|---|---|
| QPS | 120 | 1100 | 9.2x |
| P50 | 310ms | 42ms | 7.4x |
| P99 | 480ms | 95ms | 5.1x |
| CPU使用率 | 25% | 68% | - |
| 机器数 | 8台 | 2台 | 减少75% |
成本核算:8台4核8G换成2台,一年省了小十万服务器费用,运维也轻松了。
七、总结
这次优化核心就一句话:IO 密集场景,别让 CPU 陪着 IO 一起等。
几点经验:
- asyncio 不是银弹,CPU 密集场景反而会更慢(GIL + 事件循环开销)。判断标准:IO 等待时间占比 > 70% 才值得改。
- 全局事件循环 + 全局 ClientSession 是 Flask 里用 asyncio 的关键,别在视图里
asyncio.run。 - 连接池、超时、限流三个参数必须和下游对齐,不然异步化就是把串行压力变成并发压力,更容易打挂下游。
- Python 3.11 的 TaskGroup 值得用,但异常处理要额外注意。
- 如果项目从零开始,直接上 FastAPI + uvicorn 更省事,不用折腾 WSGI 里嵌 asyncio 这套。
最后提醒一句:异步化改造前先做压测基线,改造后再压一次,数据不会骗人。我见过太多人改完觉得"应该快了",结果因为连接池没配好反而更慢的案例。