一、问题背景:一次线上告警引发的重构
先说结论:如果你的Flask接口里串行调用了多个外部服务,且这些服务平均响应时间都在150ms以上,那么用asyncio做并发改造是性价比最高的优化手段,没有之一。
事情起因是某个周三下午,监控告警群里突然刷屏:/api/v1/orders/detail 接口P95延迟从400ms飙到1.8s。查了日志,发现该接口的逻辑是——先查MySQL订单主记录,然后依次调用用户服务、商品服务、库存服务三个HTTP接口,最后再查一次MySQL的订单扩展表。四个步骤串行执行,光网络往返就要1.2s左右。
当时的生产环境是Flask 2.2 + Gunicorn(gevent worker),数据库用MySQL 8.0,三个依赖服务都是Java写的,部署在同一内网。串行代码逻辑清晰,但扛不住流量。下面先看改造前的核心代码。
二、环境与版本
- Python 3.10.12(必须3.8+,我用到了asyncio.TaskGroup,3.11才正式可用)
- Flask 2.2.5 + Gunicorn 20.1.0(worker_class设为
gthread,线程数8) - httpx 0.24.1(原生支持async/await)
- asyncpg 0.28.0(替代psycopg2,异步驱动)
- uvloop 0.19.0(可选,事件循环性能提升约15%)
- 压测工具:Locust 2.15.1,200并发,持续5分钟
注意:Flask本身不支持异步视图函数,所以我的方案是——保持Flask作为Web层,但把业务逻辑抽出来,用asyncio.run()在独立事件循环中执行。不要试图在Flask里直接await,那是Python 3.12+的玩法,而且Flask 3.0都不原生支持。
三、方案设计:把串行改成并发
先画出改造前后的依赖调用时序:
改造前:MySQL查订单 → HTTP用户服务 → HTTP商品服务 → HTTP库存服务 → MySQL查扩展表
改造后:MySQL查订单 → 并发发起(HTTP用户服务 | HTTP商品服务 | HTTP库存服务 | MySQL查扩展表)
核心思路:四个独立操作没有数据依赖,完全可以并发。用asyncio.gather()把它们打包成一个协程组,等待最慢的那个返回即可。
另外,每个HTTP调用都要设置超时(我用5秒),并且用asyncio.Semaphore限制并发数(设为50),防止下游服务被我们的突发流量打爆。数据库查询用asyncpg连接池,pool_size设为20。
四、核心实现:before/after代码对比
4.1 改造前(串行阻塞)
# 改造前:串行调用,总耗时 = sum(单个耗时)
import psycopg2
import requests
def get_order_detail(order_id):
# 1. 查MySQL订单主记录
conn = psycopg2.connect("dbname=orders user=postgres")
cur = conn.cursor()
cur.execute("SELECT user_id, product_id, stock_id FROM orders WHERE id=%s", (order_id,))
order = cur.fetchone()
# 2. 调用用户服务
user_resp = requests.get(f"http://user-service/api/users/{order[0]}", timeout=5)
user_data = user_resp.json()
# 3. 调用商品服务
product_resp = requests.get(f"http://product-service/api/products/{order[1]}", timeout=5)
product_data = product_resp.json()
# 4. 调用库存服务
stock_resp = requests.get(f"http://stock-service/api/stocks/{order[2]}", timeout=5)
stock_data = stock_resp.json()
# 5. 再查一次MySQL扩展表
cur.execute("SELECT remark FROM order_ext WHERE order_id=%s", (order_id,))
remark = cur.fetchone()
conn.close()
return {"user": user_data, "product": product_data,
"stock": stock_data, "remark": remark}
这段代码的问题一眼可见:四次网络IO全部串行,假设每次耗时200ms,总耗时至少800ms,加上数据库连接建立(每次约50ms),轻松过1秒。
4.2 改造后(asyncio并发)
# 改造后:并发发起所有独立请求
import asyncio
import asyncpg
import httpx
# 事件循环中创建全局连接池(避免每次请求重建连接)
async def init_pool():
return await asyncpg.create_pool(
"postgresql://postgres@localhost/orders",
min_size=5, max_size=20,
command_timeout=10 # 秒
)
# 信号量限制并发数,防止打爆下游
_semaphore = asyncio.Semaphore(50)
async def fetch_with_limit(client, url):
async with _semaphore:
resp = await client.get(url, timeout=5.0)
return resp.json()
async def get_order_detail_async(pool, order_id, client):
# 1. 先查MySQL订单主记录(必须串行,因为后面需要它的值)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT user_id, product_id, stock_id FROM orders WHERE id=$1", order_id
)
# 2. 并发发起三个HTTP调用和一个DB查询
user_url = f"http://user-service/api/users/{row['user_id']}"
product_url = f"http://product-service/api/products/{row['product_id']}"
stock_url = f"http://stock-service/api/stocks/{row['stock_id']}"
# 用TaskGroup管理协程,3.11+特性,异常处理更优雅
async with asyncio.TaskGroup() as tg:
task_user = tg.create_task(fetch_with_limit(client, user_url))
task_product = tg.create_task(fetch_with_limit(client, product_url))
task_stock = tg.create_task(fetch_with_limit(client, stock_url))
task_remark = tg.create_task(
pool.fetchval("SELECT remark FROM order_ext WHERE order_id=$1", order_id)
)
return {
"user": task_user.result(),
"product": task_product.result(),
"stock": task_stock.result(),
"remark": task_remark.result()
}
# Flask视图函数入口
def get_order_detail_view(order_id):
# 每次请求创建新的事件循环(Flask线程模型下最简单的方式)
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
return loop.run_until_complete(
get_order_detail_async(global_pool, order_id, global_client)
)
finally:
loop.close()
这里有个关键点:global_pool和global_client必须在应用启动时初始化。我用Flask的before_first_request钩子创建,但注意Gunicorn多worker下每个worker独立初始化。
五、踩坑与优化:那些文档里不会写的事
5.1 坑1:在async def里用了requests库
一开始我天真地以为只要把函数改成async def,里面用requests.get就行。结果发现requests是同步阻塞的,会直接卡死整个事件循环——其他所有协程都得等它。必须换成httpx.AsyncClient或aiohttp。这个坑我花了大半天才排查出来,现象是并发量一高,所有请求都排队。
5.2 坑2:数据库连接池必须全局复用
改造前每次请求都新建psycopg2连接,耗时约50ms。用asyncpg后,我把pool设为全局变量,连接复用后,单次DB查询耗时降到2ms左右。但注意pool的min_size不要设太大,我调过几次,从10到20,最后发现5-10就够,因为并发查询能复用连接。
5.3 优化:uvloop替换默认事件循环
Python 3.10自带的事件循环在纯Python层面实现,uvloop用Cython重写了底层。实测在高并发下,CPU占用降低约12%,请求吞吐量提升15%。用法很简单:
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
但注意:uvloop只支持Unix系统,Windows下会报错。生产环境是Linux,没问题。
5.4 超时与重试策略
下游服务偶尔会超时(比如用户服务高峰期响应超过5秒)。我加了httpx.AsyncClient的timeout=5.0,并且对失败请求做了一次重试(指数退避,初始0.5s,翻倍)。但重试只对幂等请求做——GET请求没问题。
六、效果数据:不是玄学,是实打实的数字
在同样200并发、5分钟压测下(Locust),对比数据如下:
| 指标 | 改造前 | 改造后 | 提升幅度 |
|---|---|---|---|
| 平均响应时间 | 1.2s | 420ms | 65% |
| P50 | 1.1s | 380ms | 65.5% |
| P95 | 1.8s | 560ms | 68.9% |
| P99 | 2.4s | 1.1s | 54.2% |
| 错误率 | 2.3% | 0.4% | 82.6% |
| 单worker吞吐量 | 85 req/s | 238 req/s | 180% |
最直观的感受:P95从1.8s降到560ms,直接跨过用户体验的“1秒红线”。而且错误率大幅下降,因为超时请求不再串行阻塞,某个服务慢不会拖垮整个接口。
注意:性能提升不是线性的——如果你的串行调用本来就很快(比如每个都小于50ms),那收益不大,反而引入协程开销。我们的场景是三个HTTP服务+两个DB查询,每个都在100-300ms,所以效果显著。
七、总结与适用场景
这次改造的核心收益不是“用了asyncio”,而是让IO等待重叠。asyncio适合IO密集型任务——HTTP调用、数据库查询、文件读写。如果你的代码里有大量CPU计算(比如加密、JSON解析大对象),asyncio帮不上忙,反而因为GIL还更慢。
几个实践建议:
- 别在Flask视图里直接await。Python 3.12前Flask不支持异步视图,用
asyncio.run()或run_until_complete包裹即可。 - 连接池和Client必须全局复用。每次重建等于自杀。
- 信号量必须加。否则下游服务会被你的高并发打挂——我们第一次上线时把用户服务搞出了5xx告警。
- 用TaskGroup替代gather。3.11+的TaskGroup在异常处理上更优雅,某个协程挂了不会影响其他已完成的结果。
- 压测一定要看P95。平均响应时间在长尾分布下没有参考价值。
最后说一句:如果你的代码已经用了gevent或threading,其实也能达到类似效果,但asyncio的优势在于更可控的调度和更低的资源占用。我们后来把Gunicorn的worker_class从gevent改成gthread(因为gevent和asyncio的事件循环冲突),配合asyncio,整体稳定性提升了一个档次。
有问题评论区聊,我看到会回。