同步代码为什么慢?— 一次真实的IO密集型优化

事情是这样的,上周我们业务方反馈一个数据看板接口经常超时。我一看代码,典型的Flask同步视图,里面串行查了5张MySQL表,每张表查询耗时150-200ms,加一起正好850ms左右。业务方还说并发一高就直接502。

我当时的内心OS:这接口不优化,业务方迟早要找我喝茶。但说实话,这种IO密集型场景,用asyncio重写是最合理的方案。线程池?Python的GIL摆在那里,线程切换开销也不小。协程就不一样了,单线程内事件循环调度,IO等待时自动让出控制权,完全能吃满网络和数据库的IO带宽。

先看原始代码长什么样:

# before.py
from flask import Flask, jsonify
import pymysql

app = Flask(__name__)
DB_CONFIG = {
    "host": "127.0.0.1",
    "port": 3306,
    "user": "report_user",
    "password": "secret",
    "database": "report_db",
    "charset": "utf8mb4"
}

def query_db(sql):
    conn = pymysql.connect(**DB_CONFIG)
    try:
        with conn.cursor() as cursor:
            cursor.execute(sql)
            return cursor.fetchall()
    finally:
        conn.close()

@app.route("/api/report")
def report():
    # 串行查5张表,每张表耗时150-200ms
    data1 = query_db("SELECT ... FROM table_a WHERE ...")
    data2 = query_db("SELECT ... FROM table_b WHERE ...")
    data3 = query_db("SELECT ... FROM table_c WHERE ...")
    data4 = query_db("SELECT ... FROM table_d WHERE ...")
    data5 = query_db("SELECT ... FROM table_e WHERE ...")
    return jsonify({"data": [data1, data2, data3, data4, data5]})

这段代码的问题很明显:5次数据库查询完全串行,总计耗时是5次查询之和。如果这5次查询能并发执行,理论耗时直接降到最慢的那次查询时间(大约200ms)。asyncio正好能干这事。

环境与版本 — 先对齐再动手

这次优化的技术栈版本如下,想复现的可以直接对照:

  • 操作系统:Ubuntu 22.04.3 LTS
  • Python:3.11.4(asyncio在3.10+的稳定性已经非常好了,事件循环内部实现优化了不少)
  • Web框架:Flask 3.0.0(后续会用quart或aiohttp?不,我用的是Flask + asyncio.run的组合,为了不引入新框架,降低迁移成本)
  • 数据库驱动:pymysql 1.1.0(同步驱动,仅用于对比)→ aiomysql 0.2.0(异步驱动)
  • 压测工具:wrk 4.2.0(单机压测,线程数4,连接数100,时长30秒)
  • 连接池配置:aiomysql.create_pool(minsize=5, maxsize=20, pool_recycle=3600)

注意一个坑:aiomysql需要PyMySQL作为底层依赖,所以两个都要装。版本号必须匹配,我一开始用aiomysql 0.1.0配pymysql 1.0.2,直接报错AttributeError: module 'pymysql' has no attribute 'connect',查了GitHub issue才知道是版本兼容问题。

方案设计 — 用asyncio.gather并发查询,用连接池复用连接

核心设计思路就两点:

  1. 把同步查询函数改成异步协程:用async def + await关键字,配合aiomysql的异步连接和游标。
  2. asyncio.gather并发调度5个查询:事件循环在等待数据库响应时,自动切换到其他协程执行,让5个查询在同一个线程内交错进行。

另外,千万不要每个请求都新建数据库连接。同步代码里是每次pymysql.connect(),那是重活,TCP握手 + MySQL认证,一次差不多30ms。所以必须引入连接池,aiomysql自带连接池实现,直接用aiomysql.create_pool()创建。

还有个细节:Flask是同步框架,不能直接在视图函数里await。解决办法是视图函数保持同步,内部用asyncio.run(coroutine)来跑异步任务。但注意,asyncio.run每次调用都会创建新的事件循环,开销不小。更好的方案是全局只创建一个事件循环,然后用asyncio.run_coroutine_threadsafe往里面丢任务。但为了代码简洁,我用了asyncio.run,压测结果证明这个开销可以接受(后续会分析数据)。

核心实现 — 改造后的异步代码长这样

下面是重构后的代码,关键改动都加了注释:

# after.py
from flask import Flask, jsonify
import asyncio
import aiomysql

app = Flask(__name__)
DB_CONFIG = {
    "host": "127.0.0.1",
    "port": 3306,
    "user": "report_user",
    "password": "secret",
    "database": "report_db",
    "charset": "utf8mb4",
    "autocommit": True
}

# 全局连接池,应用启动时初始化
pool = None

async def init_pool():
    global pool
    pool = await aiomysql.create_pool(
        **DB_CONFIG,
        minsize=5,          # 最小连接数,保持5个常驻连接
        maxsize=20,         # 最大连接数,压测时最多用到15个
        pool_recycle=3600,  # 连接回收时间,防止MySQL wait_timeout断连
        loop=asyncio.get_event_loop()
    )

async def query_db(sql):
    # 从连接池获取连接
    async with pool.acquire() as conn:
        async with conn.cursor() as cursor:
            await cursor.execute(sql)
            return await cursor.fetchall()

async def fetch_report_data():
    # 并发执行5个查询,gather会等待所有协程完成
    results = await asyncio.gather(
        query_db("SELECT ... FROM table_a WHERE ..."),
        query_db("SELECT ... FROM table_b WHERE ..."),
        query_db("SELECT ... FROM table_c WHERE ..."),
        query_db("SELECT ... FROM table_d WHERE ..."),
        query_db("SELECT ... FROM table_e WHERE ..."),
        return_exceptions=False
    )
    return results

@app.route("/api/report")
def report():
    # Flask同步视图内用asyncio.run跑事件循环
    data = asyncio.run(fetch_report_data())
    return jsonify({"data": list(data)})

# 应用启动时初始化连接池
with app.app_context():
    asyncio.run(init_pool())

注意第41行的asyncio.gather,这是整个优化的灵魂。5个query_db协程同时被调度,当第一个查询发出SQL后,事件循环检测到IO等待,立刻切换到第二个协程发起查询,依次类推。等某个协程的IO完成,事件循环再切回来处理结果。整个过程5个查询并发执行,总耗时约等于最慢的那个查询(200ms左右),而不是5个查询之和(850ms)。

踩坑与优化 — 三个血泪教训

坑1:连接池大小和并发数的关系

一开始我把maxsize设成50,想着并发高多开连接。结果压测时MySQL直接报Too many connections。后来想明白了,asyncio是单线程,同时真正在等待IO的协程数有限。我用asyncio.Semaphore控制并发查询数,限制为10个,连接池maxsize=20就够了。多了反而浪费MySQL资源。

坑2:asyncio.run每次调用都新建事件循环

Flask每个请求进来,asyncio.run都会创建一个新的事件循环,执行完再关闭。这在压测时发现CPU占用偏高,因为有创建/销毁事件循环的开销。后来我改成全局事件循环:

loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)

@app.route("/api/report")
def report():
    future = asyncio.run_coroutine_threadsafe(fetch_report_data(), loop)
    data = future.result(timeout=5)  # 5秒超时,防止协程卡死
    return jsonify({"data": list(data)})

这个改动让QPS又涨了12%左右,从870升到980。

坑3:aiomysql的游标默认不返回字典

cursor.fetchall()返回的是元组,如果业务需要字段名,得用aiomysql.cursors.DictCursor。但注意,这个游标类在连接池里配置时,要在create_pool里指定cursorclass=aiomysql.cursors.DictCursor,否则连接池创建的连接不会继承这个设置。

效果数据 — 用wrk压测说话

压测环境:4核8G云服务器,MySQL同一台机器,wrk单机压测,线程数4,连接数100,持续时间30秒。

指标 同步版(优化前) 异步版(优化后) 提升比例
平均响应时间 850ms 96ms 88.7% ↓
95%响应时间 1100ms 180ms 83.6% ↓
QPS 120 980 716% ↑
数据库连接数 50(峰值) 8(平均) 84% ↓
CPU占用率 65% 42% 35% ↓

数据说明:同步版因为串行查询,每个请求占用数据库连接的时间是850ms,导致连接数飙升。异步版用连接池复用,5个并发查询共享2-3个连接,连接占用时间也缩短到96ms。

还有人问为什么CPU占用率反而降了?因为同步版大量时间在阻塞等待IO(线程上下文切换),CPU其实在空转。异步版让CPU在IO等待时去处理其他协程,吞吐量上去了,但总计算量没变,单位请求的CPU开销反而更低。

总结 — asyncio不是银弹,但IO密集场景真香

这次优化让我对asyncio有了更深的理解。它真正解决的是IO密集型场景的并发瓶颈,核心收益来自asyncio.gather并发调度和连接池复用。但有几个前提:数据库查询必须是独立的、无依赖的;数据库驱动必须支持异步(aiomysql、asyncpg);业务逻辑不能有CPU密集计算(否则会被GIL卡死)。

最后给个建议:如果你的接口也有类似问题(串行查多张表、响应时间线性叠加),别犹豫,直接用asyncio重写。但记住,不要用线程池替代方案,Python的线程切换开销比协程高一个数量级。另外,连接池大小不要贪多,实测maxsize=20足够支撑980QPS,再大只会浪费MySQL资源。