1. 问题背景:一次全链路压测暴露的瓶颈
上周五下午,运维同学在压测环境跑了一轮全链路测试,结果让我有点坐不住:我们的核心订单查询接口 /api/v1/orders 在并发600时,P95延迟飙升到2.3秒,而Tomcat(Java)下游接口却稳稳地保持在180ms。代码是我们自己写的,Python 3.9 + Flask + Gunicorn(同步worker),逻辑也不复杂:查MySQL订单表 + 调一次下游用户服务。问题很清楚——Flask的同步worker在IO等待时白白占着worker进程,一个请求阻塞,整个worker就被占满了。
机器配置:4核8G,Gunicorn配置为 --workers 4 --threads 8,MySQL连接池大小10,下游服务是Java的Spring Boot,通过HTTP调用。压测工具用wrk,脚本模拟真实请求分布:80%查询30天内的订单,20%查询历史归档表。
当时我第一反应是加机器,但仔细想想,这是典型的IO密集型场景,CPU利用率才12%,瓶颈完全在IO等待上。加机器不如动代码,决定引入 asyncio 重构。
2. 环境与版本:先交代清楚技术栈
- Python:3.9.18(升级到3.10+更好,但公司环境锁定3.9)
- asyncio:标准库,版本无独立概念,随Python发布
- aiohttp:3.8.6(替代requests做异步HTTP调用)
- asyncmy:0.2.9(异步MySQL驱动,基于PyMySQL重写,支持连接池)
- uvloop:0.17.0(事件循环替换,Linux下提升20%-30%性能)
- Sanic:22.12(替代Flask,原生支持异步,注意:别用Flask+异步,Flask的同步API无法配合async)
- 部署:Docker + Gunicorn(最终用Sanic自带的worker)
为什么不用FastAPI?因为现有代码是Flask风格的路由装饰器,Sanic的 @app.route 几乎零成本迁移,FastAPI的Pydantic模型和依赖注入改动量太大。如果你从零开始,FastAPI是更好的选择。
3. 方案设计:异步化改造的四个关键点
改造不是简单把 def 换成 async def,需要通盘考虑:
-
Web框架层:Flask的同步WSGI协议无法充分利用asyncio。Sanic使用
asyncio的Server协议,每个请求在事件循环中调度,IO等待时自动切换协程。这是最大的性能来源。 -
数据库访问层:
pymysql是同步阻塞的,调用它会让事件循环卡死。必须用asyncmy,它实现了真正的异步MySQL协议,支持连接池。改造后数据库查询从cursor.execute()变成await cursor.execute()。 -
下游HTTP调用:
requests库同样是同步阻塞,换成aiohttp.ClientSession,注意Session要复用(全局实例),避免每次请求都建立TCP连接。 -
CPU密集型任务:异步不解决CPU密集问题。我们的订单金额计算有少量正则和JSON解析,这部分用
asyncio.to_thread丢到线程池,避免阻塞事件循环。
架构图简化版:
请求 → Sanic (asyncio event loop)
├─ asyncmy连接池 → MySQL
├─ aiohttp Session → 下游Java服务
└─ asyncio.to_thread → 线程池处理CPU密集段
4. 核心实现:Before与After代码对比
4.1 Before:Flask同步版本(压测基线)
# app_sync.py - Flask 同步版本
from flask import Flask, jsonify, request
import pymysql
import requests
import time
app = Flask(__name__)
# 全局MySQL连接(实际上Gunicorn每个worker一个独立连接)
db = pymysql.connect(host='10.0.0.1', user='app', password='xxx',
database='orders', charset='utf8mb4')
@app.route('/api/v1/orders', methods=['GET'])
def get_orders():
user_id = request.args.get('user_id')
start = time.time()
try:
with db.cursor() as cur:
# 同步阻塞查询,耗时约80ms
cur.execute("SELECT order_id, amount, status FROM orders WHERE user_id=%s ORDER BY create_time DESC LIMIT 20", (user_id))
orders = cur.fetchall()
# 同步调用下游服务,耗时平均150ms
resp = requests.get(f"http://user-service:8080/api/users/{user_id}", timeout=1.5)
user_info = resp.json()
return jsonify({"code": 0, "data": {"orders": orders, "user": user_info}})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
# 运行:gunicorn -w 4 --threads 8 -b 0.0.0.0:5000 app_sync:app
4.2 After:Sanic + asyncio 异步版本
# app_async.py - Sanic 异步版本
from sanic import Sanic, json
from sanic.request import Request
import asyncmy
import aiohttp
import asyncio
import uvloop
import re
app = Sanic("order_api")
# 全局连接池 - asyncmy 支持真正的异步连接池
db_pool = None
# 全局复用 aiohttp Session,避免TCP三次握手开销
http_session = None
@app.listener('before_server_start')
async def init_db(app, loop):
global db_pool, http_session
# uvloop 替换默认事件循环,Linux 下性能提升 20%-30%
uvloop.install()
# 连接池:最小5,最大20,注意 maxsize 要评估并发量
db_pool = await asyncmy.create_pool(
host='10.0.0.1', user='app', password='xxx',
database='orders', charset='utf8mb4',
minsize=5, maxsize=20, autocommit=True,
pool_recycle=3600 # 连接存活1小时后自动回收
)
# aiohttp 连接池:每主机最大100连接
conn = aiohttp.TCPConnector(limit=100, ttl_dns_cache=300)
http_session = aiohttp.ClientSession(connector=conn, timeout=aiohttp.ClientTimeout(total=1.5))
@app.post('/api/v1/orders')
async def get_orders(request: Request):
user_id = request.args.get('user_id')
# 1. 异步MySQL查询 - 事件循环不会阻塞
async with db_pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"SELECT order_id, amount, status FROM orders WHERE user_id=%s ORDER BY create_time DESC LIMIT 20",
(user_id)
)
orders = await cur.fetchall()
# 2. 异步HTTP调用下游服务
async with http_session.get(f"http://user-service:8080/api/users/{user_id}") as resp:
user_info = await resp.json()
# 3. CPU密集部分(订单金额格式化+JSON序列化)丢线程池
def process_data(orders, user_info):
# 模拟正则清洗和金额格式化,约5ms
for o in orders:
o['amount'] = f"¥{o['amount']:.2f}"
return {"orders": orders, "user": user_info}
result = await asyncio.to_thread(process_data, orders, user_info)
return json({"code": 0, "data": result})
# 运行:sanic app_async:app --host=0.0.0.0 --port=8000 --workers=4 --debug=False
注意几点:
- async with db_pool.acquire() 管理连接获取与归还,异常时自动回池。
- aiohttp.ClientSession 必须全局复用,否则每个请求新建Session会打爆文件描述符。
- asyncio.to_thread 在 Python 3.9 中可用,3.8 用 loop.run_in_executor。
5. 踩坑与优化:三个真实的坑
坑1:数据库连接池泄漏
刚开始 maxsize=50,压测30分钟后MySQL连接数飙到200+,导致 Too many connections。排查发现是 asyncmy 在异常时不会自动释放连接,必须用 try/finally 包裹。最终改成 async with 语法,并设置 pool_recycle=3600,问题解决。
坑2:aiohttp Session 超时设置
默认没有总超时,下游Java服务一旦挂起,我们的协程会无限等待,占满事件循环。显式设置 aiohttp.ClientTimeout(total=1.5),并增加重试逻辑(这里省略)。注意:total 是整体超时,connect 是建连超时,建议分别设置。
坑3:事件循环被CPU密集型操作卡死
第一次改造没注意 json.dumps 和 re.sub 是CPU操作,在压测时发现事件循环延迟突然飙到500ms。用 asyncio.to_thread 之后,协程调度恢复正常。记住一个原则:凡是可能超过1ms的CPU操作,都要丢线程池。
优化:uvloop 是否值得用?
实测对比:同样代码,用 uvloop.install() 后,QPS从4600提升到5600,提升约21%。代价是调试时无法用标准库的 asyncio 调试工具(PYTHONASYNCIODEBUG 会减慢)。生产环境强烈建议开启。
6. 效果数据:压测对比
压测工具:wrk 2线程,1000连接,持续60秒。请求负载与基线一致。
| 指标 | 基线(Flask同步) | 改造后(Sanic异步) | 提升幅度 |
|---|---|---|---|
| QPS(每秒请求数) | 1,180 | 5,600 | 4.75倍 |
| P50延迟 | 210ms | 58ms | 3.6倍 |
| P95延迟 | 480ms | 95ms | 5.1倍 |
| P99延迟 | 1,200ms | 180ms | 6.7倍 |
| CPU利用率 | 12% | 38% | - |
| MySQL连接数峰值 | 10 | 20(池上限) | - |
关键点:P99从1.2秒降到180ms,意味着尾部延迟大幅改善,用户体验不再受偶发阻塞影响。同时CPU利用率从12%升到38%,说明资源被有效利用。
内存方面:Gunicorn同步worker每个占150MB(共4个),Sanic worker每个占220MB(共4个),总内存略增,但在可接受范围。
7. 总结:异步改造的适用边界
这次重构耗时2天,换来了近5倍的QPS提升和近5倍的P95延迟下降,但需要明确边界:
- IO密集型(数据库查询、HTTP调用、文件IO)是异步的主场,收益巨大。
- CPU密集型(图像处理、加密计算、复杂数值运算)异步几乎无效,反而增加调度开销,建议用多进程或Celery。
- 混合负载:用
asyncio.to_thread划分边界,但线程池大小要监控(默认32线程)。
最后给一个判断标准:如果压测时CPU利用率低于30%且延迟随并发线性上升,大概率是IO瓶颈,值得尝试异步化。如果CPU已经打满,就别折腾异步了,从算法和缓存入手。
下一步我计划把 asyncmy 换成 asyncpg(PostgreSQL),预计还能再快8%左右。等公司升级到Python 3.11后,asyncio 的调度效率会再提升10%,到时同步这篇文章更新数据。