一、问题背景:同步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 架构决策

不重写整个服务,而是做局部改造:

  1. HTTP客户端:requests → aiohttp,用于调用商品服务
  2. 数据库驱动:PyMySQL → asyncpg,利用其二进制协议和预编译语句
  3. 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_lockepoll_wait耗在锁竞争上;改造后火焰图主要耗在asyncpg.readaiohttp的IO等待上,真正的CPU计算时间仅占18%。

六、总结与适用边界

这次改造让我对asyncio的认知从“语法糖”升级为“生产力工具”。三个关键收获:

  1. 连接池复用是性能核心:如果每个请求都新建连接,性能反而更差。一定要用全局连接池,并预热。
  2. 事件循环生命周期管理:在Flask这种同步框架中,用asyncio.new_event_loop()在每个请求中创建独立事件循环是安全的,但必须loop.close()。不要试图跨请求复用事件循环。
  3. 超时和重试是必备:异步IO挂起比同步更隐蔽,必须设置超时,否则出错时由于协程不会主动终止,连接池会被僵尸协程占满。

适用边界:如果你接口80%以上是IO操作(数据库、缓存、外部API),且并发量超过50,asyncio能带来明显收益。但如果是CPU密集型计算,建议用多进程而不是协程。

最后提醒:生产环境部署时,记得用Gunicorn配合gthread或者直接用uvicorn作为WSGI服务器,单纯Flask内置服务器性能仍然有限。我们的最终方案是Nginx → uvicorn(ASGI) → Flask应用,这才是完全体。