1. 问题背景:一个聚合接口,下游三个服务

我们有个商品详情页接口 /api/v1/product/{pid},逻辑不复杂:先查本地Redis缓存(约10ms),缓存miss后并行调用三个下游服务——库存服务(平均80ms)、价格服务(平均120ms)、评价服务(平均200ms),最后聚合返回。

最初用Flask同步写,每个请求串行等待三个服务,最坏情况耗时 = 10 + 80 + 120 + 200 = 410ms。压测时单机4核8G,QPS稳定在120左右,P99却飙到2.3s。原因很简单:线程切换开销 + GIL限制,Flask的线程池模式在大量I/O等待时就是浪费CPU。

2. 环境与版本

  • Python 3.10.12(注意:3.10+的asyncio稳定多了,3.7的坑太多)
  • Flask 2.3.2(同步框架,但我们可以用asgirefsync_to_async桥接)
  • aiohttp 3.8.5(代替requests做并发HTTP调用)
  • redis-py 4.5.1(自带asyncio支持,不用额外包)
  • 压测工具:locust 2.15.1,200并发用户,持续5分钟
  • 部署:Gunicorn + gevent worker(同步版本),改造后换uvicorn(ASGI服务器)运行

3. 方案设计:把阻塞I/O全部换成异步

核心思路:
1. 保留Flask路由层,但把视图函数改成async def(需要ASGI容器支持,所以我们最终切到了FastAPI,但原理一样,代码我两种都贴)。
2. Redis读取用redis.asyncio,三个下游HTTP调用统一用asyncio.gather并发。
3. 关键点:绝对不能在协程里用同步requests,否则事件循环被阻塞,整个进程卡死。

架构图(文字版):

客户端 → ASGI Server (uvicorn) → FastAPI路由 → asyncio事件循环
                                                ├── redis.asyncio.get()
                                                ├── aiohttp.get(库存)
                                                ├── aiohttp.get(价格)
                                                └── aiohttp.get(评价)
                                    聚合结果 → 返回JSON

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

4.1 Before:同步Flask版本(可运行)

# before_sync.py  —  Flask同步版
import time
import requests
import redis
from flask import Flask, jsonify

app = Flask(__name__)
cache = redis.Redis(host='localhost', port=6379, db=0)

def fetch_inventory(pid):
    time.sleep(0.08)  # 模拟80ms I/O
    return {"pid": pid, "stock": 100}

def fetch_price(pid):
    time.sleep(0.12)  # 模拟120ms
    return {"pid": pid, "price": 199.0}

def fetch_reviews(pid):
    time.sleep(0.20)  # 模拟200ms
    return {"pid": pid, "rating": 4.5}

@app.route('/api/v1/product/')
def get_product(pid):
    cache_key = f"product:{pid}"
    cached = cache.get(cache_key)
    if cached:
        return jsonify(eval(cached))  # 简单演示,实际用json

    inv = fetch_inventory(pid)
    price = fetch_price(pid)
    rev = fetch_reviews(pid)

    result = {"pid": pid, "inventory": inv, "price": price, "reviews": rev}
    cache.set(cache_key, str(result), ex=60)
    return jsonify(result)

if __name__ == '__main__':
    app.run(threaded=True)  # 默认线程池

4.2 After:asyncio重构版(FastAPI + aiohttp)

# after_async.py  —  FastAPI + asyncio
import asyncio
import aiohttp
from fastapi import FastAPI
from redis.asyncio import Redis
import json

app = FastAPI()
redis_client = Redis(host='localhost', port=6379, db=0, decode_responses=True)
# 注意:aiohttp连接池,默认100,我们改成500,否则高并发会TimeoutError
session = None

@app.on_event("startup")
async def startup():
    global session
    conn = aiohttp.TCPConnector(limit=500, ttl_dns_cache=300)
    session = aiohttp.ClientSession(connector=conn)

@app.on_event("shutdown")
async def shutdown():
    await session.close()
    await redis_client.close()

async def fetch_inventory(pid):
    await asyncio.sleep(0.08)  # 模拟I/O
    return {"pid": pid, "stock": 100}

async def fetch_price(pid):
    await asyncio.sleep(0.12)
    return {"pid": pid, "price": 199.0}

async def fetch_reviews(pid):
    await asyncio.sleep(0.20)
    return {"pid": pid, "rating": 4.5}

@app.get("/api/v1/product/{pid}")
async def get_product(pid: int):
    cache_key = f"product:{pid}"
    cached = await redis_client.get(cache_key)
    if cached:
        return json.loads(cached)

    # 关键:并发跑三个协程
    inv, price, rev = await asyncio.gather(
        fetch_inventory(pid),
        fetch_price(pid),
        fetch_reviews(pid)
    )

    result = {"pid": pid, "inventory": inv, "price": price, "reviews": rev}
    await redis_client.set(cache_key, json.dumps(result), ex=60)
    return result

# 启动:uvicorn after_async:app --workers 4 --loop asyncio

注意区别:Before版本串行耗时410ms,After版本并发耗时max(80,120,200)=200ms,理论提升2倍。但实际压测提升远不止2倍,因为同步版本线程切换和GIL损耗在200并发下极大。

5. 踩坑记录:三个大坑

坑1:asyncio里混用requests导致死锁
第一次改造,我图省事在fetch_inventory里直接requests.get(),结果压测时所有请求全部卡死。因为requests阻塞了事件循环,后续协程永远得不到调度。用py-spy dump --pid看到所有线程卡在select上。修复:全部换aiohttp。

坑2:Redis连接池默认太小
redis.asyncio默认连接池10个,200并发直接ConnectionPoolError。解决:初始化时指定max_connections=200(我们实际用了Redis(connection_pool=... ),但最简单的就是redis_client = Redis(..., max_connections=200))。

坑3:uvicorn默认worker数太少
一开始用uvicorn --workers 1,单进程只能吃一个核,QPS上不去。后来改成--workers 4(和CPU核数一致),但要注意每个worker都有自己的事件循环,Redis连接池和aiohttp连接池是各worker独立的,所以压测时总连接数要乘以worker数,别把Redis打爆。

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

压测环境:同机4核8G,200并发,5分钟,Redis和模拟服务都在本机(排除网络干扰)。

指标 同步Flask (threaded) asyncio (uvicorn 4 workers) 提升倍数
QPS 122 847 6.9x
P50 310ms 210ms 1.5x
P99 2.3s 380ms 6.1x
超时率 8% 0.2% 40x

有意思的是P99提升远超理论值。分析下来同步版本在200并发时,线程上下文切换频繁,CPU大部分时间浪费在调度上,导致长尾特别严重。而asyncio单线程事件循环没有切换开销,P99基本等于模拟服务的最大延迟200ms + 一点点调度损耗。

额外发现:aiohttp连接池limit=500时,压测中看到socket.gaierror报错,排查发现是DNS解析并发过高。加了ttl_dns_cache=300(DNS缓存5分钟)后解决,这个参数平时没人注意,但高并发下特别关键。

7. 总结与建议

这次改造把QPS提升了7倍,P99从2.3s降到380ms,代价是代码复杂度上升——协程、连接池、事件循环这些概念需要团队理解。我的建议:

  1. 如果接口I/O等待占比超30%,果断上asyncio,收益远大于成本。
  2. 不要用Flask跑协程,切FastAPI或Sanic,ASGI是必须的。
  3. 所有网络库必须用异步版aiohttp替代requestsredis.asyncio替代redis,数据库用asyncpgaiomysql
  4. 连接池是隐形杀手,aiohttp的limit、Redis的max_connections都要根据压测峰值调整,并预留20%余量。
  5. 监控用py-spy,它能dump出协程栈,比看日志直观多了。

最后说一句:异步不是银弹,如果你的接口里有CPU密集计算(比如图片处理),该用多进程还是得用多进程。但纯I/O密集型服务,asyncio就是目前Python生态的最优解。


如果你也在做类似改造,欢迎评论区聊聊你踩过的坑——尤其是aiohttp连接池那个,我怀疑不止我一个人被坑过。