同步代码为什么慢?— 一次真实的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并发查询,用连接池复用连接
核心设计思路就两点:
- 把同步查询函数改成异步协程:用
async def+await关键字,配合aiomysql的异步连接和游标。 - 用
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资源。