一、问题背景:同步Flask在IO密集场景下的瓶颈
去年年底我负责的订单服务遭遇了一次生产事故——双11大促前的全链路压测中,/api/v1/order/detail接口在80并发下直接雪崩。这个接口逻辑并不复杂:先查Redis缓存,未命中则查MySQL订单表,再查商品服务获取快照信息。但压测结果触目惊心:
| 指标 | 50并发 | 80并发 |
|---|---|---|
| P99延迟 | 820ms | 3.2s |
| 错误率 | 0.4% | 8.7% |
| MySQL连接池活跃连接 | 45/50 | 78/80 |
原因很直白——Flask自带的Werkzeug是同步WSGI服务器,每个请求占用一个线程,而线程中的IO操作(Redis查询、MySQL查询、HTTP调用)都是阻塞的。80个并发就有80个线程在等待IO,造成严重的上下文切换开销和连接池争用。
当时我第一反应是换Tornado或Sanic,但考虑到团队技术栈和中间件兼容性,决定用asyncio在原架构上做改造。
二、环境与版本:Python 3.8带来的关键特性
改造前先明确环境:
Python 3.8.10
Flask 2.0.3
aiohttp 3.8.1
asyncpg 0.25.0
redis-py 4.3.1 (使用其asyncio模式)
为什么强调Python 3.8?因为asyncio.run()在3.7才正式稳定,而3.8的asyncio.create_task()和asyncio.gather()才是真正好用的并发原语。如果你的项目还在3.6,建议先升级再说。
另外注意:Flask 2.x本身不支持异步视图函数,我们采用同步Flask + asyncio.run()的混合模式——即Flask处理HTTP协议,视图函数内部通过asyncio.run()驱动异步IO。这个方法有个坑,后面第五节细说。
三、方案设计:三层异步化改造
3.1 架构决策
不重写整个服务,而是做局部改造:
- HTTP客户端:requests → aiohttp,用于调用商品服务
- 数据库驱动:PyMySQL → asyncpg,利用其二进制协议和预编译语句
- Redis客户端:redis-py的同步模式改为
redis.asyncio模块
核心思想:所有IO操作都挂在同一个事件循环上,用await让出CPU。
3.2 关键代码改造
先看改造前的同步代码(简化版):
# 改造前:同步阻塞版本
from flask import Flask, jsonify
import requests
import pymysql
import redis
app = Flask(__name__)
r = redis.Redis(host='localhost', port=6379)
def get_order(order_id):
# 1. 查缓存
cached = r.get(f'order:{order_id}')
if cached:
return jsonify(eval(cached))
# 2. 查数据库
conn = pymysql.connect(host='localhost', user='root', password='123456', database='orders')
cursor = conn.cursor()
cursor.execute("SELECT * FROM orders WHERE id=%s", (order_id,))
order = cursor.fetchone()
# 3. 调外部服务
resp = requests.get(f'http://product-service/api/v1/product/{order[2]}', timeout=2)
product = resp.json()
return jsonify({'order': order, 'product': product})
@app.route('/api/v1/order/detail/')
def order_detail(order_id):
return get_order(order_id)
改造后核心逻辑:
# 改造后:asyncio异步版本
import asyncio
import aiohttp
import asyncpg
from redis.asyncio import Redis
from flask import Flask, jsonify
app = Flask(__name__)
redis_pool: Redis = None
pg_pool: asyncpg.Pool = None
async def init_pools():
"""初始化连接池,在应用启动时调用一次"""
global redis_pool, pg_pool
redis_pool = Redis(host='localhost', port=6379, decode_responses=True)
pg_pool = await asyncpg.create_pool(
host='localhost',
user='root',
password='123456',
database='orders',
min_size=10, # 重要:最小连接数
max_size=30, # 重要:最大连接数,比原来80个线程的消耗低得多
command_timeout=5 # 防止慢SQL拖死整个协程
)
async def fetch_order_detail(order_id: int):
# 1. 缓存查询
cached = await redis_pool.get(f'order:{order_id}')
if cached:
return eval(cached) # 生产环境建议用JSON模块
# 2. 数据库查询 + 外部API调用,并发执行
async with pg_pool.acquire() as conn:
order_row = await conn.fetchrow(
"SELECT * FROM orders WHERE id = $1", order_id
)
if not order_row:
return None
# 3. 并发调用商品服务,设置超时
timeout = aiohttp.ClientTimeout(total=2)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(f'http://product-service/api/v1/product/{order_row["product_id"]}') as resp:
product = await resp.json()
result = {'order': dict(order_row), 'product': product}
# 回填缓存,设置过期时间
await redis_pool.set(f'order:{order_id}', repr(result), ex=60)
return result
@app.route('/api/v1/order/detail/')
def order_detail(order_id):
# Flask同步视图内调用事件循环
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(fetch_order_detail(order_id))
finally:
loop.close()
return jsonify(result)
# 应用启动时初始化连接池
if __name__ == '__main__':
asyncio.run(init_pools()) # 启动时创建连接池
app.run(host='0.0.0.0', port=5000, threaded=False) # 关键:关闭多线程模式
3.3 为什么用asyncpg而不是aiomysql?
对比过两个驱动,asyncpg在相同场景下查询速度快20%左右(纯Python实现vs C扩展的区别)。它使用PostgreSQL的二进制协议,支持预编译语句和类型映射,减少了解析开销。如果你还在用MySQL,可以改用aiomysql,但记得设置maxsize参数。
四、踩坑与优化:三个让人抓狂的问题
4.1 Flask的threaded=True必须关闭
这是第一个坑。Flask默认threaded=True,每个请求开一个新线程。如果视图函数内的asyncio.run()在新线程中执行,每次都会创建新的事件循环,连接池无法复用。更严重的是,多个线程同时操作asyncio的事件循环会导致RuntimeError: There is no current event loop in thread。
解决方案:启动时设置app.run(threaded=False),让Flask单线程处理请求。这样所有请求共享同一个线程,事件循环可以复用。但注意,这要求视图函数内部不能有阻塞IO,否则会卡住整个服务。我们的IO全部变成了异步调用,所以没问题。
4.2 连接池预热与连接泄漏
改造初期发现启动后前10个请求延迟特别高,达到500ms+。排查发现是连接池懒加载——直到第一个请求才建立数据库连接。asyncpg默认不预热连接。
解决:在init_pools()中显式创建10个连接:
async def init_pools():
global pg_pool, redis_pool
pg_pool = await asyncpg.create_pool(...)
# 预热:获取并归还连接,触发实际TCP连接建立
for _ in range(10):
async with pg_pool.acquire() as conn:
await conn.execute('SELECT 1') # 简单心跳
另外,每个视图函数中必须用async with pg_pool.acquire():获取连接,如果忘了release(),连接池会耗尽。asyncpg的acquire()是上下文管理器,用async with就能保证释放。
4.3 外部API超时没有默认值
商品服务接口偶尔响应慢,如果不设置超时,会导致协程挂死,占用连接池资源。用aiohttp.ClientTimeout(total=2)设置全局超时,再单独为每个请求加retry逻辑:
async def fetch_with_retry(session, url, retries=2):
for i in range(retries):
try:
async with session.get(url) as resp:
return await resp.json()
except asyncio.TimeoutError:
if i == retries - 1:
raise
await asyncio.sleep(0.1 * (i + 1)) # 退避重试
五、效果数据:压测对比与资源消耗
使用JMeter做压测,环境:4核8G云主机,模拟100个虚拟用户,持续5分钟。
5.1 核心指标对比
| 指标 | 改造前(Flask同步) | 改造后(Flask+asyncio) | 提升 |
|---|---|---|---|
| P50延迟 | 240ms | 45ms | 5.3x |
| P99延迟 | 820ms | 152ms | 5.4x |
| 吞吐量(QPS) | 312 | 1478 | 4.7x |
| 错误率 | 0.4% | 0.02% | 20x |
| MySQL连接数峰值 | 78 | 23 | 3.4x |
5.2 资源占用对比
- 线程数:改造前80并发时线程数达到95个;改造后线程数恒定为1(Flask主线程)+ 事件循环内部协程数
- 内存:每个线程栈默认8MB,95个线程约760MB;改造后协程栈默认4KB,1000协程仅4MB,内存占用下降95%
- CPU:上下文切换从每秒数千次降到几乎为0,CPU整体利用率下降30%
5.3 火焰图对比
改造前的火焰图能看到大量pthread_mutex_lock和epoll_wait耗在锁竞争上;改造后火焰图主要耗在asyncpg.read和aiohttp的IO等待上,真正的CPU计算时间仅占18%。
六、总结与适用边界
这次改造让我对asyncio的认知从“语法糖”升级为“生产力工具”。三个关键收获:
- 连接池复用是性能核心:如果每个请求都新建连接,性能反而更差。一定要用全局连接池,并预热。
- 事件循环生命周期管理:在Flask这种同步框架中,用
asyncio.new_event_loop()在每个请求中创建独立事件循环是安全的,但必须loop.close()。不要试图跨请求复用事件循环。 - 超时和重试是必备:异步IO挂起比同步更隐蔽,必须设置超时,否则出错时由于协程不会主动终止,连接池会被僵尸协程占满。
适用边界:如果你接口80%以上是IO操作(数据库、缓存、外部API),且并发量超过50,asyncio能带来明显收益。但如果是CPU密集型计算,建议用多进程而不是协程。
最后提醒:生产环境部署时,记得用Gunicorn配合gthread或者直接用uvicorn作为WSGI服务器,单纯Flask内置服务器性能仍然有限。我们的最终方案是Nginx → uvicorn(ASGI) → Flask应用,这才是完全体。