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,需要通盘考虑:

  1. Web框架层:Flask的同步WSGI协议无法充分利用asyncio。Sanic使用 asyncioServer 协议,每个请求在事件循环中调度,IO等待时自动切换协程。这是最大的性能来源。

  2. 数据库访问层pymysql 是同步阻塞的,调用它会让事件循环卡死。必须用 asyncmy,它实现了真正的异步MySQL协议,支持连接池。改造后数据库查询从 cursor.execute() 变成 await cursor.execute()

  3. 下游HTTP调用requests 库同样是同步阻塞,换成 aiohttp.ClientSession,注意Session要复用(全局实例),避免每次请求都建立TCP连接。

  4. 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.dumpsre.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%,到时同步这篇文章更新数据。