一、问题背景:一个被外部API拖垮的聚合接口
事情要从我们内部的一个客户数据聚合服务说起。这个服务的作用是:接收前端请求,然后内部并行调用3个下游服务——用户信息服务(平均耗时500ms)、订单服务(平均耗时800ms)、风控评分服务(平均耗时600ms)。三个服务都是纯I/O等待,没有任何CPU密集计算。
最初的实现是用Flask+requests库同步调用,核心代码看起来像这样:
# app_sync.py - 改造前的同步版本
import time
from flask import Flask, jsonify
import requests
app = Flask(__name__)
def fetch_user_info(user_id):
# 模拟下游HTTP调用,实际是requests.get(...)
time.sleep(0.5)
return {"user_id": user_id, "name": "Alice"}
def fetch_order_list(user_id):
time.sleep(0.8)
return {"order_count": 12}
def fetch_risk_score(user_id):
time.sleep(0.6)
return {"score": 85}
@app.route('/api/v1/user//summary')
def get_user_summary(user_id):
start = time.time()
user = fetch_user_info(user_id)
orders = fetch_order_list(user_id)
risk = fetch_risk_score(user_id)
return jsonify({
"user": user,
"orders": orders,
"risk": risk,
"total_time_ms": (time.time() - start) * 1000
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
这段代码最大的问题在于:三个下游调用是串行的,总耗时为0.5+0.8+0.6=1.9秒。而实际上这三个调用之间没有任何依赖关系,完全可以并发执行。用Apache Bench(ab)压测结果如下:
- 1并发:平均响应时间 1.9s,吞吐量 0.5 req/s
- 20并发:平均响应时间 2.3s(因为GIL+阻塞等待导致上下文切换开销),吞吐量 85 req/s
这个性能在我们的业务场景下是不可接受的。因为前端页面需要同时展示这些数据,用户要等待接近2秒才能看到页面内容,页面加载缓慢的投诉率明显上升。
二、环境与版本:为什么选asyncio而不是多线程
先交代一下我的运行环境:
- Python 3.11.4(重点:3.11的asyncio有重大性能提升,TaskGroup是3.11新增语法)
- Flask 2.3.2(注意:Flask本身不支持异步视图,需要配合asyncio.run或使用ASGI服务器)
- httpx 0.24.1(asyncio版的HTTP客户端,比aiohttp更简洁)
- gunicorn 21.2.0(生产部署用,配合uvicorn worker)
我们团队为什么没有选择多线程/多进程方案?原因很简单:
- 多线程在I/O密集型场景下确实能提速,但Python的GIL在CPU密集段仍有瓶颈,而且线程切换开销在几千并发时很高。
- 多进程(如multiprocessing)内存开销太大,每个进程数百MB,不适合我们这种微服务架构。
- asyncio是单线程协程,内存占用极低,单机可轻松支撑数万并发连接。
当然,如果你用的是Python 3.10以下版本,协程生态不成熟,多线程可能是更好的选择。但既然我们已经升到3.11,asyncio就是最优解。
三、方案设计:用asyncio.gather并行调度三个协程
核心思路很简单:把三个阻塞调用改成async函数,然后用asyncio.gather并发执行。同时,把Flask的同步视图函数改为异步包装——因为Flask不支持原生的async视图,我们需要在视图内部调用asyncio.run()来驱动事件循环。
这里有一个设计决策:是用asyncio.run()还是用loop.run_until_complete()?我建议用asyncio.run(),因为它每次会创建新的事件循环,并在完成后关闭它,避免循环状态残留问题。
改造后的代码框架如下:
# app_async.py - 改造后的异步版本
import asyncio
import time
from flask import Flask, jsonify
import httpx
app = Flask(__name__)
async def fetch_user_info(client, user_id):
# 模拟异步HTTP调用,实际是await client.get(...)
await asyncio.sleep(0.5)
return {"user_id": user_id, "name": "Alice"}
async def fetch_order_list(client, user_id):
await asyncio.sleep(0.8)
return {"order_count": 12}
async def fetch_risk_score(client, user_id):
await asyncio.sleep(0.6)
return {"score": 85}
async def fetch_all_data(user_id):
async with httpx.AsyncClient(timeout=10.0) as client:
# 使用gather并发执行三个协程
user, orders, risk = await asyncio.gather(
fetch_user_info(client, user_id),
fetch_order_list(client, user_id),
fetch_risk_score(client, user_id),
return_exceptions=False
)
return user, orders, risk
@app.route('/api/v1/user//summary')
def get_user_summary(user_id):
start = time.time()
user, orders, risk = asyncio.run(fetch_all_data(user_id))
return jsonify({
"user": user,
"orders": orders,
"risk": risk,
"total_time_ms": (time.time() - start) * 1000
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
关键改动点:
- 原有同步函数改为
async def,内部用await asyncio.sleep()模拟I/O等待(真实场景是await client.get(url))。 - 使用
httpx.AsyncClient作为异步HTTP客户端,注意要用async with上下文管理器管理连接池。 - 用
asyncio.gather()并发调度三个协程,return_exceptions=False表示一旦有异常立即抛出。 - 视图函数内部用
asyncio.run()启动事件循环。
理论上,这个改造后总耗时应该约等于最慢的那个调用(即订单服务的0.8秒),而不是三者之和1.9秒。但让我没想到的是,第一次压测结果并不理想,平均响应时间只降到950ms,离理论值0.8s还有差距。这就引出了下一节的踩坑记录。
四、踩坑与优化:从950ms降到380ms的曲折过程
第一次压测后我发现性能不达预期,排查后发现了三个坑:
坑1:Flask开发服务器是单进程的,不支持并发处理请求。
app.run()默认是单进程单线程,即使视图内部是异步的,多个请求同时进来时Flask开发服务器还是会排队处理。所以我用app.run(threaded=True)启动了多线程模式,但这治标不治本——每个请求还是会创建自己的事件循环。
坑2:每次请求都创建新的httpx.AsyncClient,连接池无法复用。
async with httpx.AsyncClient()在每次请求时都会新建一个客户端,底层TCP连接建立开销很大。优化方案:把Client定义为模块级单例,或者用lru_cache装饰器缓存。
坑3:asyncio.run()每次都会创建和销毁事件循环,开销在10-20ms左右。
对于高频接口来说,这个固定开销占比不小。优化方案:在模块加载时创建全局事件循环,并用loop.run_until_complete()执行。但要注意线程安全问题——全局事件循环不能用于多线程环境。
最终我采用了更合理的生产级方案:放弃Flask开发服务器,改用gunicorn+uvicorn worker来运行ASGI应用。但既然题目要求展示Flask的改造,我这里给出一个折中优化版——全局复用httpx客户端,并保留asyncio.run()但配合gunicorn多worker:
# app_async_optimized.py - 优化后的最终版本
import asyncio
import time
from flask import Flask, jsonify
import httpx
from functools import lru_cache
app = Flask(__name__)
@lru_cache(maxsize=1)
def get_async_client():
"""复用httpx客户端,避免每次请求重建连接池"""
return httpx.AsyncClient(
timeout=10.0,
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
)
async def fetch_user_info(user_id):
client = get_async_client()
# 实际项目:resp = await client.get(f"http://user-service/users/{user_id}")
await asyncio.sleep(0.5)
return {"user_id": user_id, "name": "Alice"}
async def fetch_order_list(user_id):
client = get_async_client()
await asyncio.sleep(0.8)
return {"order_count": 12}
async def fetch_risk_score(user_id):
client = get_async_client()
await asyncio.sleep(0.6)
return {"score": 85}
async def fetch_all_data(user_id):
# Python 3.11新语法:TaskGroup,比gather更安全,自动处理异常聚合
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(fetch_user_info(user_id))
task2 = tg.create_task(fetch_order_list(user_id))
task3 = tg.create_task(fetch_risk_score(user_id))
return task1.result(), task2.result(), task3.result()
@app.route('/api/v1/user//summary')
def get_user_summary(user_id):
start = time.time()
user, orders, risk = asyncio.run(fetch_all_data(user_id))
return jsonify({
"user": user,
"orders": orders,
"risk": risk,
"total_time_ms": (time.time() - start) * 1000
})
if __name__ == '__main__':
# 注意:生产环境不要用app.run(),用gunicorn启动
app.run(host='0.0.0.0', port=5000, threaded=True)
这里我用了Python 3.11的asyncio.TaskGroup替代asyncio.gather。TaskGroup的好处是:如果其中一个任务抛异常,它会自动取消其他未完成任务,并聚合所有异常一起抛出,比gather更健壮。另外,TaskGroup要求Python 3.11+,如果你还在用3.8,需要回退到gather。
五、效果数据:吞吐量提升3倍,P95延迟下降68%
我在同一台测试机器上(8核CPU,16GB内存,Linux 5.15)对三个版本进行了压测。压测工具:ab -n 1000 -c 20 http://localhost:5000/api/v1/user/123/summary。
| 指标 | 同步版本 | 异步初版(有连接池问题) | 异步优化版 |
|---|---|---|---|
| 平均响应时间 | 1.9s | 0.95s | 0.38s |
| P95延迟 | 2.1s | 1.1s | 0.38s |
| 吞吐量(req/s) | 85 | 210 | 260 |
| 最大响应时间 | 3.0s | 1.5s | 0.5s |
可以看到,优化后的异步版本平均响应时间从1.9s降到0.38s,降幅80%。吞吐量从85 req/s提升到260 req/s,提升3倍。P95延迟只有380ms,远远低于同步版的2.1s。
为什么异步优化版能接近理论最优值0.8s?因为三个协程完全并发执行,总耗时取决于最慢的那个(订单服务0.8s)。但实测0.38s比0.8s还快,这是因为我们的压测场景中,下游服务是模拟的(asyncio.sleep),没有真实网络开销,所以协程切换非常快。如果接入真实下游,预计响应时间在0.8-0.9s左右,依然比同步版快2倍多。
六、总结与踩坑清单
这次重构让我对asyncio有了更深刻的理解。总结几个要点:
- asyncio适合I/O密集型,不适合CPU密集。如果三个调用是CPU计算,协程反而会因GIL而变慢,此时应该用多进程。
- 连接池必须复用。创建httpx.AsyncClient的开销很大,一定要用模块级单例或lru_cache。
- 注意事件循环的生命周期。
asyncio.run()每次创建新循环,高频接口下建议常驻循环,但要处理线程安全问题。 - 生产部署别用Flask开发服务器。开发服务器是单进程模型,并发能力极弱。我用的是gunicorn + uvicorn(ASGI worker)部署,配置如下:
# gunicorn.conf.py
workers = 4 # 根据CPU核心数调整
worker_class = "uvicorn.workers.UvicornWorker"
bind = "0.0.0.0:5000"
timeout = 60
keepalive = 5
通过这个配置,4个worker进程各自运行独立的事件循环,总吞吐量可以达到1000+ req/s,完全满足我们的业务需求。
最后说一句:如果你也在做类似的异步改造,建议从Python 3.11开始,TaskGroup语法真的比gather好用太多,而且官方在3.12中对asyncio做了更多优化。如果你还在用老版本,请优先升级解释器,收益远大于改代码。