1. 问题背景:一个接口拖垮了整个服务
上个月我们有个用户画像服务,核心接口GET /api/profile需要聚合三个下游服务的数据:用户基础信息(MySQL)、行为标签(Redis)、风控评分(HTTP)。最初实现很朴素——同步依次调用,一个请求串行等三个IO。
压测结果惨不忍睹:P99延迟1.2s,QPS只有210,CPU闲置但线程池被打满。更头疼的是,下游风控服务偶尔慢查询,同步模型下这个接口直接拖垮整个Web进程——因为Flask默认是同步阻塞的,一个线程卡住,其他请求只能排队。
我当时的第一个想法是换FastAPI或者上celery,但业务代码已经写了一大堆Flask路由,迁移成本太高。后来意识到:瓶颈在IO等待,不在计算,用asyncio做异步HTTP和Redis调用,完全可以在不改框架的前提下解决问题。
2. 环境与版本
- Python 3.10.8(asyncio在3.10稳定,
asyncio.run()和TaskGroup都可用) - Flask 2.2.3(同步框架,靠
asgiref.sync.async_to_sync桥接) - httpx 0.24.1(支持异步HTTP/2,比aiohttp更易用)
- redis-py 4.5.4(原生支持asyncio)
- 压测工具:wrk 4.2.0,单机8核8G,跑在K8s里(资源限制:2核2G)
3. 方案设计:把同步IO换成异步协程
核心思路:保留Flask路由不变,将视图函数改为async,然后用asyncio.gather()并发发起三个IO请求。这里有个坑:Flask是WSGI同步模型,不能直接跑async函数,需要借助asgiref的async_to_sync包装器,或者用flask[async]扩展(但2.2版本对async支持还不稳定,我直接上async_to_sync最稳)。
架构图简单描述就是:
同步版本:请求 → 线程池 → MySQL(100ms) → Redis(50ms) → HTTP(300ms) → 返回(总耗时450ms)
异步版本:请求 → 协程 → gather(MySQL(100ms), Redis(50ms), HTTP(300ms)) → 返回(总耗时300ms)
理论上总耗时接近最慢的那个IO(300ms),但实际因为协程切换开销,大概320ms左右。
4. 核心实现:Before & After代码
Before:同步阻塞版(伪代码,但结构真实)
# profile_api_sync.py
import time
import requests
import redis
r = redis.Redis(host='redis-cache', port=6379, decode_responses=True)
def get_profile(user_id: str) -> dict:
# 1. 查MySQL(用了ORM,这里简化为耗时模拟)
time.sleep(0.1) # 模拟DB查询 100ms
user_info = {"user_id": user_id, "name": "张三"}
# 2. 查Redis缓存
tags = r.get(f"user:tags:{user_id}") # 实际是hgetall,模拟耗时50ms
# 3. 调风控HTTP接口
risk_resp = requests.post(
"http://risk-service/api/risk",
json={"user_id": user_id},
timeout=1.0
).json() # 实际耗时300ms
return {"user_info": user_info, "tags": tags, "risk_score": risk_resp["score"]}
# Flask路由
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/api/profile/")
def profile(user_id):
return jsonify(get_profile(user_id))
# 压测:wrk -t4 -c100 -d30s http://service/api/profile/123
# 结果:QPS 210, P99 1.2s
After:asyncio异步并发版
# profile_api_async.py
import asyncio
import httpx
import redis.asyncio as aioredis
from asgiref.sync import async_to_sync
# 初始化异步Redis连接池
r = aioredis.from_url("redis://redis-cache:6379", decode_responses=True, max_connections=20)
# 全局httpx客户端,复用连接池(关键!)
client = httpx.AsyncClient(base_url="http://risk-service", timeout=httpx.Timeout(1.0))
# 信号量限制并发数,避免打爆下游服务
semaphore = asyncio.Semaphore(50)
async def fetch_mysql(user_id: int) -> dict:
# 这里模拟DB查询,实际用aiomysql或asyncpg
await asyncio.sleep(0.1) # 实际场景:asyncpg执行SQL
return {"user_id": user_id, "name": "张三"}
async def fetch_redis(user_id: int) -> str:
# 异步Redis操作
tags = await r.get(f"user:tags:{user_id}")
return tags or "[]"
async def fetch_risk(user_id: int) -> float:
# 异步HTTP调用,受信号量限制
async with semaphore:
resp = await client.post("/api/risk", json={"user_id": user_id})
return resp.json()["score"]
async def get_profile_async(user_id: int) -> dict:
# 并发执行三个IO
user_info, tags, risk_score = await asyncio.gather(
fetch_mysql(user_id),
fetch_redis(user_id),
fetch_risk(user_id)
)
return {"user_info": user_info, "tags": tags, "risk_score": risk_score}
# Flask路由(通过async_to_sync桥接)
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/api/profile/")
def profile(user_id):
# 同步调用异步函数
result = async_to_sync(get_profile_async)(user_id)
return jsonify(result)
# 压测:wrk -t4 -c100 -d30s http://service/api/profile/123
# 结果:QPS 680, P99 320ms
5. 踩坑与优化:三个血泪教训
坑1:httpx连接池默认没有复用
第一次跑异步版,QPS只到300,一查全是TCP握手开销——因为每次请求都httpx.post()新建连接。改用全局httpx.AsyncClient后,连接复用率提升,QPS直接翻倍。注意:必须在应用启动时创建client,关闭时await client.aclose()。
坑2:Redis连接池爆掉
异步Redis默认连接数8个,压测100并发直接Connection pool exhausted。调大max_connections=20,并且把Redis操作抽成独立函数,避免gather里同时创建太多连接。
坑3:下游服务被我们打死了
并发一上来,风控服务开始返回Connection Reset——因为我们把它的QPS打到了2000。解决方案:加asyncio.Semaphore(50),限制全局并发数。实测50是安全阈值,再大会丢请求,再小吞吐下降明显。
6. 效果数据:对比一目了然
| 指标 | 同步版本 | 异步版本 | 提升 |
|---|---|---|---|
| 平均耗时 | 480ms | 160ms | 66.7%↓ |
| P99延迟 | 1.2s | 320ms | 73.3%↓ |
| QPS | 210 | 680 | 223%↑ |
| CPU使用率 | 35% | 68% | 接近饱和(正常) |
| 线程池占用 | 全满 | 几乎空闲 | — |
为什么QPS只翻3倍而不是更多? 因为下游风控服务成了新瓶颈——信号量限制在50,且它的响应时间抖动大。如果把信号量提到100,QPS能到800+,但P99会恶化到600ms,因为协程排队等待。任何优化都要考虑整条链路,别把瓶颈转移给别人。
7. 总结:什么时候值得用asyncio
这次改造用了大概半天时间,收益非常明显。但我想说,asyncio不是银弹:
- 适合场景:IO密集型(HTTP、Redis、数据库),且IO是独立的、可并行的
- 不适合场景:CPU密集型(图像处理、加密),协程切换毫无帮助
- 框架选择:如果新项目直接上FastAPI(原生async),但老Flask项目用
async_to_sync桥接也完全可行
最后留个思考:为什么我不直接用asyncio.run()在视图里?因为async_to_sync会维护一个事件循环用于跨线程传递,而asyncio.run()每次新建循环,上下文切换开销更大,实测QPS少10%左右。
如果你也在改造同步接口,建议先跑wrk压测拿到基线数据,改完对比P99和QPS,别凭感觉说“变快了”。
以上,有问题欢迎评论区交流。代码我放在GitHub仓库[链接],直接docker-compose up就能复现压测场景。