1. 问题背景:一个“看起来不慢”的查询接口

上个月接手了一个订单服务,核心逻辑是:接收前端请求 -> 调用内部用户服务(HTTP) -> 调用商品服务(HTTP) -> 聚合数据返回。单次调用内部服务耗时约80ms,两个串行就是160ms。当时线上QPS峰值150,单机4核8G,CPU空闲但响应时间飘到800ms+。

ab压测结果如下:

Concurrency Level: 50
Time taken: 10.01s
Requests per second: 121.37
TP99: 812ms

显然瓶颈不在计算,而在同步等待I/O。每个请求占用一个线程,而线程在等待HTTP响应时完全阻塞。方案很明确:用asyncio把阻塞I/O变成非阻塞协程

2. 环境与版本:别用Flask跑asyncio

先说结论:Flask不支持asyncio,它的WSGI模型是同步的。所以我用aiohttp写了一个纯异步API服务,前端Nginx做反向代理,保留原Flask服务做内部接口兼容。

组件 版本
Python 3.10.11 (CPython)
aiohttp 3.8.5
Flask (旧服务) 2.2.5
gunicorn (旧部署) 20.1.0
uvloop 0.17.0
压测工具 wrk / ab

关键决策:不混用asyncio.run()Flask,而是整个服务用aiohttp.web重写。因为Flask的线程池和事件循环混在一起会有非常隐蔽的bug(后面踩坑部分详述)。

3. 方案设计:三个核心点

改造不是简单地把requests.get换成aiohttp.get,需要系统性设计:

  1. 连接池复用:每次请求新建连接是灾难,必须用aiohttp.TCPConnector维护连接池,设置limit=200(超过会排队),ttl_dns_cache=300减少DNS解析。
  2. 并发限流:内部服务有负载上限,全量并发会把下游打挂。用asyncio.Semaphore(100)控制最大并发HTTP调用数。
  3. 串行改并行:原来用户服务和商品服务是串行调用,改成asyncio.gather()并行发起,总耗时从160ms降到80ms。

4. 核心实现:Before & After代码

Before (Flask + requests,同步阻塞)

# app_sync.py - 改造前
from flask import Flask, jsonify
import requests
import time

app = Flask(__name__)

def fetch_user(user_id):
    # 模拟下游服务,实际为HTTP调用
    resp = requests.get(f"http://user-service/users/{user_id}", timeout=1)
    return resp.json()

def fetch_order(order_id):
    resp = requests.get(f"http://order-service/orders/{order_id}", timeout=1)
    return resp.json()

@app.route("/api/order/")
def get_order(order_id):
    # 串行调用两个下游服务
    user_data = fetch_user(order_id)
    order_data = fetch_order(order_id)
    # 聚合逻辑
    result = {"user": user_data, "order": order_data, "ts": time.time()}
    return jsonify(result)

if __name__ == "__main__":
    app.run(threaded=True, processes=1)

After (aiohttp + asyncio,协程并发)

# app_async.py - 改造后
from aiohttp import web
import aiohttp
import asyncio
import uvloop
import time

asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())

# 全局连接池,limit=200表示最多200个并发连接
_connector = aiohttp.TCPConnector(limit=200, ttl_dns_cache=300, enable_cleanup_closed=True)
_semaphore = asyncio.Semaphore(100)  # 限制下游并发

async def fetch(session, url):
    # 使用信号量控制并发
    async with _semaphore:
        async with session.get(url, timeout=aiohttp.ClientTimeout(total=2)) as resp:
            return await resp.json()

async def fetch_user(session, user_id):
    return await fetch(session, f"http://user-service/users/{user_id}")

async def fetch_order(session, order_id):
    return await fetch(session, f"http://order-service/orders/{order_id}")

async def handler(request):
    order_id = request.match_info["order_id"]
    async with aiohttp.ClientSession(connector=_connector) as session:
        # 并行调用两个下游服务,总耗时约80ms而非160ms
        user_data, order_data = await asyncio.gather(
            fetch_user(session, order_id),
            fetch_order(session, order_id)
        )
        result = {"user": user_data, "order": order_data, "ts": time.time()}
        return web.json_response(result)

if __name__ == "__main__":
    app = web.Application()
    app.router.add_get("/api/order/{order_id}", handler)
    web.run_app(app, host="0.0.0.0", port=8080)

关键点解释
- uvloop 替换默认事件循环,提升约15%的吞吐(官方基准测试数据)。
- TCPConnector(limit=200) 而不是默认的 limit=100,在4核8G机器上测出来200是最优值,过高会触发TIME_WAIT堆积。
- asyncio.gather() 是性能提升的核心:两个80ms的串行调用变成并行后,单请求耗时直接减半。

5. 踩坑与优化:三个真实生产事故

坑1:把连接池放在请求内创建

第一个版本我在handler里写async with aiohttp.ClientSession() as session,导致每个请求都新建连接池。压测时QPS反而降到80,因为连接建立和销毁的开销远大于节省的时间。修复:把ClientSession提升到模块级别,配合connector复用。

坑2:Semaphore 和 Connection Pool 的相互作用

设置_semaphore = Semaphore(200),但TCPConnector(limit=100)。结果因为信号量放行200个协程,但连接池只有100个连接,导致一半协程排队等待连接池,反而增加了延迟。经验公式semaphore_limit <= connector_limit,通常取connector_limit * 0.8

坑3:asyncio.TimeoutError 和 aiohttp.ClientTimeout 的坑

asyncio.wait_for(session.get(url), timeout=2) 这种写法会取消协程,但底层socket未必立即关闭,导致连接泄漏。修复:使用aiohttp.ClientTimeout(total=2),它会在超时后正确清理连接。

6. 效果数据:wrk压测对比

在同一台4核8G CentOS 7.9机器上,用wrk -t4 -c50 -d30s压测:

指标 Flask+requests aiohttp+asyncio 提升倍数
QPS 121 2147 17.7x
TP99 812ms 45ms 18x
TP999 2.1s 89ms 23.6x
CPU利用率 28% 85% -
内存占用 420MB 310MB -

解释:QPS提升主要来自两个因素——(1) 并行调用下游服务让单请求耗时减半;(2) 协程切换比线程切换轻量得多,4核可以轻松跑2000+并发协程。内存下降是因为协程栈比线程栈小得多(默认线程栈8MB vs 协程栈~几KB)。

7. 总结与建议

适用场景:如果你的API是I/O密集型(HTTP调用、数据库查询、文件读写),并且QPS卡在几百上不去,asyncio是首选方案。但如果是CPU密集型(图像处理、加密解密),asyncio帮助不大,应该用多进程。

三条经验
1. 别在Flask里混用asyncio,要么全异步(aiohttp/FastAPI),要么全同步(gunicorn多worker)。混用会触发RuntimeError: no running event loop
2. 连接池和信号量必须一起调优,只调一个参数没有意义。建议用locustwrk做全参数矩阵压测。
3. 生产环境务必加uvloop,收益虽小但零成本。另外建议开启aiohttpdebug=True(仅测试环境)排查连接泄漏。

最后留个问题:如果你的下游服务是数据库操作,asyncio方案中应该用asyncpg还是aiomysql?两者性能差异巨大,我下一篇文章会专门对比。欢迎评论区讨论。