一、问题背景:同步代码在大促下的崩溃

去年双11大促前,我们团队负责的订单服务突然报警。监控显示/api/orders接口在500并发下,P95延迟从平时的800ms飙升到4.2秒,数据库连接池(max_connections=50)直接被占满,大量请求排队等待连接。

先看当时的代码逻辑(简化版):

# orders_api.py (改造前)
from flask import Flask, jsonify
import psycopg2
from psycopg2.pool import ThreadedConnectionPool

app = Flask(__name__)
pool = ThreadedConnectionPool(5, 50, dsn="postgresql://user:pass@db/orders")

@app.route('/api/orders/')
def get_orders(user_id):
    conn = pool.getconn()  # 同步阻塞等待连接
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT * FROM orders WHERE user_id = %s", (user_id,))
            rows = cur.fetchall()
        return jsonify([dict(row) for row in rows])
    finally:
        pool.putconn(conn)

问题很明显:
1. 线程阻塞:每个请求占用一个线程,线程池默认200,500并发直接排队
2. 数据库连接浪费:同步等待IO时,连接被持有但不工作
3. 无超时控制:数据库慢查询会无限拖垮整个服务

二、环境与版本:选型说明

改造前先明确技术栈:

Python: 3.10.12
框架: Flask 2.3.3(保留Flask做HTTP层,接入asyncio用flask[async])
数据库驱动: asyncpg 0.28.0(比psycopg2异步版更成熟)
异步服务器: hypercorn 0.15.0(支持asyncio的WSGI服务器)
压测工具: wrk 4.2.0

为什么不用FastAPI?因为现有代码是Flask,不想大改路由层。Flask 2.x支持异步视图函数(async def),配合hypercorn可以跑在asyncio事件循环上。

三、方案设计:异步化改造的三个层次

我的改造分三步走:

1. 数据库层:psycopg2 → asyncpg + 连接池

asyncpg是纯asyncio驱动,支持连接池复用,避免每次请求新建连接。

2. 应用层:同步视图 → 异步视图

Flask 2.2+允许async def视图函数,但要注意不能阻塞事件循环。

3. 并发控制:Semaphore限流

防止数据库连接池被瞬时打爆,用asyncio.Semaphore(20)控制同时执行的数据库查询数。

四、核心实现:before/after代码对比

改造前(同步版,已在上文展示)

改造后(异步版)

# orders_api_async.py (改造后)
import asyncio
import asyncpg
from flask import Flask, jsonify

app = Flask(__name__)

# 全局连接池(启动时初始化)
pool = None

async def init_pool():
    global pool
    # 连接池大小=20,比原来的50小,但因为是异步复用,实际效率更高
    pool = await asyncpg.create_pool(
        user='user', password='pass', database='orders',
        host='db', min_size=5, max_size=20,
        command_timeout=5.0  # 关键:5秒超时,防止慢查询拖死服务
    )

# Flask异步视图
@app.route('/api/orders/')
async def get_orders_async(user_id):
    # 信号量限制并发查询数,保护数据库
    async with semaphore:
        try:
            # asyncpg直接返回dict列表,比psycopg2的cursor更方便
            rows = await pool.fetch(
                "SELECT * FROM orders WHERE user_id = $1", user_id
            )
            return jsonify(rows)
        except asyncio.TimeoutError:
            return jsonify({"error": "db timeout"}), 504
        except asyncpg.PostgresError as e:
            return jsonify({"error": str(e)}), 500

# 应用入口:用asyncio.run启动连接池
def create_app():
    asyncio.run(init_pool())  # 注意:Flask 2.3+在app上下文外需要手动跑
    return app

if __name__ == '__main__':
    app = create_app()
    # 用hypercorn启动,支持asyncio
    from hypercorn.asyncio import serve
    from hypercorn.config import Config
    config = Config()
    config.bind = ["0.0.0.0:5000"]
    config.workers = 4  # 多进程,每个进程有自己的事件循环
    asyncio.run(serve(app, config))

关键改动点:
- asyncpg.create_poolmin_size/max_size 控制连接数,比同步池少一半但够用
- command_timeout=5.0 强制数据库操作5秒超时,防止拖垮整个服务
- asyncio.Semaphore(20) 全局限流,避免瞬时并发打满连接池

五、踩坑与优化:三个让我抓狂的问题

坑1:Flask的async def视图阻塞事件循环

现象:改完异步后,性能反而下降,P95到了6秒。

原因:Flask 2.3的异步视图支持是“伪异步”——如果视图里调用了同步库(比如jsonify)。实际上jsonify是同步的,但它在异步函数里会阻塞事件循环。我最初在异步视图里用了jsonify(rows),其实应该用flask.json.dumps + Response

解决

from flask import Response, json
return Response(json.dumps(rows, default=str), mimetype='application/json')

坑2:连接池耗尽导致TimeoutError频繁

现象:压测到400并发时,大量asyncio.TimeoutError(连接池获取超时)。

原因asyncpg.create_pool 默认 max_size=10(我设了20),但Semaphore设的是20,导致等待连接的协程排队,而连接池已经满了。

解决:Semaphore大小设为max_size - 5(即15),留出缓冲。同时把max_inactive_connection_lifetime调短,防止连接被空占。

坑3:hypercorn的worker数和事件循环冲突

现象:多worker时,每个worker独立事件循环,但init_pool只跑了一次。

解决:把连接池初始化移到worker内部,用hypercorn.workerlifespan钩子:

async def startup():
    global pool
    if pool is None:
        pool = await asyncpg.create_pool(...)

# hypercorn通过ASGI的lifespan协议调用startup

六、效果数据:压测对比

用wrk压测(10线程,持续60秒):

指标 同步版(改造前) 异步版(改造后) 提升
P50延迟 820ms 210ms 3.9x
P95延迟 4.2s 1.1s 3.8x
P99延迟 6.8s 2.3s 2.9x
吞吐量 380 req/s 1210 req/s 3.2x
数据库连接数 50(打满) 15(稳定) 3.3x

测试环境:8核16G云主机,PostgreSQL 14单机。

结论:吞吐提升320%不是玄学,是IO复用的数学结果——同步线程在等待IO时CPU是空闲的,而异步协程在等待时能处理其他请求。

七、总结与建议

这次改造的核心收获:
1. 异步不是银弹:只适合IO密集型场景。如果你的接口是CPU密集(比如图像处理),异步反而增加调度开销。
2. 连接池和限流必须配套Semaphore大小要略小于连接池max_size,留出10%-20%缓冲。
3. 超时是必须品command_timeout和HTTP请求超时都要设置,否则一个慢查询能拖垮整个服务。
4. Flask异步视图有坑:别在异步函数里用任何同步IO库,包括jsonify

如果你也是Flask老项目想优化,建议先压测确认瓶颈在IO等待,再考虑异步改造。如果瓶颈在数据库本身(比如慢查询),先优化SQL和索引,别急着换框架。

最后提醒:生产环境建议用uvicornhypercorn替代flask run,它们能正确处理ASGI的异步协议。我的完整代码(含docker-compose)已放在GitHub:github.com/example/async-orders-api,有需要的可以参考。