一、问题背景:不是所有慢都是代码的错
先交代下业务场景。我们有个内部订单聚合服务,前端需要一次拿到用户信息、近30天订单统计、以及商品推荐,三个数据分别来自三个独立微服务A/B/C。老代码是Flask同步视图里用requests顺序调三个接口:
# before.py - Flask同步版本
import requests
from flask import Flask, jsonify
app = Flask(__name__)
def fetch_user(uid):
r = requests.get(f"http://svc-a/user/{uid}", timeout=2)
return r.json()
def fetch_orders(uid):
r = requests.get(f"http://svc-b/orders/{uid}", timeout=2)
return r.json()
def fetch_recommend(uid):
r = requests.get(f"http://svc-c/recommend/{uid}", timeout=2)
return r.json()
@app.route("/api/agg/")
def aggregate(uid):
# 三个请求串行,每个平均300ms,总耗时约900ms
user = fetch_user(uid)
orders = fetch_orders(uid)
reco = fetch_recommend(uid)
return jsonify({"user": user, "orders": orders, "reco": reco})
压测数据(wrk -t4 -c100 -d30s):
- QPS:120左右
- P99延迟:2.8s(因为线程池满了开始排队)
- CPU:80%(GIL + requests阻塞,线程切换开销巨大)
问题很明显:三个HTTP请求之间没有依赖关系,却串行等待。每个请求空等IO时,线程被白白占住。Flask默认threaded=True,但线程创建和切换成本高,IO密集场景下效率极低。
二、方案设计:asyncio + httpx,把等待时间叠加起来
核心思路:用asyncio事件循环代替线程池,用httpx.AsyncClient代替requests,三个请求并发发出,总耗时从“三个之和”变成“最慢的一个”。
技术选型版本:
- Python 3.10.12(注意:3.10是asyncio性能分水岭,3.11+更好,但生产环境还是3.10)
- Flask 2.3.3(保持同步框架,只把IO部分换协程)
- httpx 0.25.1(requests作者出的异步兼容库)
- uvicorn 0.23.2(用[standard]扩展,内部用uvloop)
架构调整:
1. Flask视图函数保持同步,内部用asyncio.run()跑协程(注意:Flask不支持原生async视图,除非换quart或fastapi,但改造成本太大,我们选择包装)
2. 用asyncio.gather()并发三个请求
3. 用asyncio.Semaphore(20)做全局并发限流,防止打爆下游服务
4. 每个请求设置超时,用asyncio.timeout(Python 3.11+,但3.10可以用asyncio.wait_for)
三、核心实现:Before/After代码对比
改造后代码:
# after.py - asyncio + httpx 并发版本
import asyncio
import httpx
from flask import Flask, jsonify
app = Flask(__name__)
# 全局信号量,限制同时最多20个协程在跑IO
_semaphore = asyncio.Semaphore(20)
_client = None
def get_client():
global _client
if _client is None or _client.is_closed:
# httpx.AsyncClient内部有连接池,必须全局复用
_client = httpx.AsyncClient(
limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
timeout=httpx.Timeout(2.0, connect=0.5),
http2=True # 内部服务支持HTTP2,省握手时间
)
return _client
async def fetch_with_limit(client, url):
async with _semaphore:
try:
# wait_for替代3.10的timeout,超时抛asyncio.TimeoutError
resp = await asyncio.wait_for(client.get(url), timeout=2.0)
return resp.json()
except (httpx.TimeoutException, asyncio.TimeoutError, httpx.HTTPError) as e:
# 失败快速返回降级数据,不让聚合接口整体失败
return {"error": str(e), "data": None}
async def fetch_all(uid):
client = get_client()
urls = [
f"http://svc-a/user/{uid}",
f"http://svc-b/orders/{uid}",
f"http://svc-c/recommend/{uid}",
]
# gather并发,return_exceptions=True防止一个挂了全挂
results = await asyncio.gather(
*(fetch_with_limit(client, url) for url in urls),
return_exceptions=True
)
return results
@app.route("/api/agg/")
def aggregate(uid):
# 每个请求独立事件循环,注意:这里不能复用全局loop
try:
user, orders, reco = asyncio.run(fetch_all(uid))
except Exception as e:
return jsonify({"error": "aggregate failed"}), 500
return jsonify({"user": user, "orders": orders, "reco": reco})
if __name__ == "__main__":
# 生产用uvicorn跑,不用app.run
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000, workers=4)
改动要点:
1. asyncio.run()每次请求创建新事件循环——这是一个坑,后面细说
2. httpx.AsyncClient必须全局单例,否则每次请求重建连接池,性能反而更差
3. Semaphore(20)信号量控制并发,防止下游小服务被瞬间打挂
4. wait_for包超时,比httpx自带timeout更可靠(httpx的超时是IO层面的,wait_for是协程层面的,两者配合双保险)
四、踩坑与优化:你以为写完就完了?太天真
坑1:asyncio.run()每次新建事件循环的开销
实测发现,asyncio.run()创建/销毁事件循环大约耗时0.5ms,对低延迟接口影响不大,但高并发下会累积。优化方案:在模块级创建全局loop,但Flask多线程模式下不能安全共享。最终妥协:asyncio.run() + uvicorn多worker(4个),实测损耗可接受。
坑2:uvicorn的workers参数和asyncio.Semaphore的冲突
每个worker进程有独立的信号量,意味着实际并发上限是 workers x 20 = 80。如果下游服务只能承受50并发,需要把Semaphore改成Redis分布式信号量——但为了简单,我们直接调低了Semaphore到10,压测后确认下游无压力。
坑3:pymysql是同步阻塞的!
如果聚合接口里还有DB查询,千万不要在协程里用pymysql,会阻塞整个事件循环。我们当时有个隐藏的MySQL查询,导致并发一高就卡死。解决:用aiomysql或者把DB查询放到asyncio.to_thread()里跑(Python 3.9+)。
# 错误示范:同步DB查询在协程里
async def bad_sql():
cursor = pymysql.connect(...) # 阻塞事件循环!
cursor.execute("SELECT ...")
# 正确做法:丢到线程池
async def good_sql():
result = await asyncio.to_thread(sync_db_query, sql)
坑4:HTTP2和keepalive的收益
开启http2=True后,P99从620ms降到420ms,因为内部服务握手次数减少了。但注意:如果下游是老旧Nginx不支持HTTP2,必须改回http1.1,否则会报错。
五、效果数据:用数据说话
压测环境:4核8G云主机,wrk -t8 -c200 -d60s,模拟真实流量。
| 指标 | 同步版(requests) | 异步版(asyncio+httpx) | 提升 |
|---|---|---|---|
| QPS | 120 | 1800 | 15倍 |
| P50 | 780ms | 210ms | 3.7倍 |
| P99 | 2.8s | 420ms | 6.7倍 |
| CPU占用 | 80% | 45% | 下降 |
| 内存占用 | 2.3GB | 1.1GB | 下降52% |
关键数据点:
- 在200并发下,异步版P95稳定在350ms以内,无超时错误
- 下游服务A/B/C的QPS从各自120涨到1800,它们没做任何改动,只是我们这边变快了
- 4个uvicorn worker,每个worker内事件循环跑满单核,整体CPU控制在45%,还有余量
六、总结与建议:什么时候该用asyncio?
适合用asyncio的场景:
- IO密集型:大量外部HTTP/RPC/DB查询,且无依赖关系
- 需要高并发连接:比如WebSocket服务、爬虫
- 延迟敏感:每个请求都在等IO,协程切换成本远低于线程
不适合的场景:
- CPU密集型:计算、加密、图片处理,asyncio无优势,反而更慢
- 已有成熟的线程池架构:比如用gunicorn+gevent,不一定非要重写
最后说点实话:
asyncio不是银弹。我们踩了三个月的坑才稳定下来,主要问题集中在事件循环阻塞、信号量语义、以及和同步库的混用。如果项目是绿色field(新项目),直接上FastAPI + async SQLAlchemy是更干净的选择。但如果是老Flask项目,像我们这样用asyncio包装IO段,改造风险可控,收益巨大。
如果你的接口也卡在“多次串行IO”上,别急着上微服务,先试试asyncio。成本低,见效快,代码改动量大概200行以内。这波不亏。