1. 问题背景:一个聚合接口为何拖垮了整个详情页

公司有一个BFF(Backend For Frontend)服务,其中一个/api/v1/product/detail接口承担了首页和详情页的全部流量。这个接口的逻辑很简单:拿到商品ID后,依次调用三个内部HTTP服务获取基础信息、库存和促销活动,最后组装返回。

压测结果惨不忍睹:

指标 优化前
P50 820ms
P95 1.5s
P99 2.1s
吞吐量 180 req/s

监控面板显示Tomcat线程池被打满(这虽然是个Python服务,但Gunicorn的同步Worker同样致命)。核心原因一目了然:三个下游HTTP请求是串行执行的。每个下游服务本身只要150-250ms,但加在一起就不可接受了。

2. 环境与版本:Python 3.10带来的原生协程红利

这次改造前,团队内部其实对是否引入asyncio有分歧。有人觉得用concurrent.futures.ThreadPoolExecutor把三个请求丢到线程池里就完事了。但考虑到线程切换开销和GIL限制,以及后续要接入WebSocket长连接,我们决定一步到位。

我的验证环境:

Python: 3.10.11 (原生asyncio,无需uvloop)
aiohttp: 3.8.4
asyncpg: 0.27.0 (注意:项目中的Redis读取也用redis.asyncio替换了)
Gunicorn: 20.1.0 (worker_class改为'uvicorn.workers.UvicornWorker')

关键点:Python 3.10的asyncio已经非常成熟,TaskGroup(3.11才有的特性)用不上,所以用asyncio.gather

3. 方案设计:用协程将串行IO改为并发IO

我们先看一下优化前的同步代码(核心逻辑):

# 优化前:同步串行调用 (Flask 2.2.5, Gunicorn同步Worker)
import requests
import time

def get_product_detail(product_id: str) -> dict:
    start = time.perf_counter()
    # 1. 调用商品基础服务
    base_info = requests.get(
        f"http://product-service/api/v1/products/{product_id}",
        timeout=0.5
    ).json()
    # 2. 调用库存服务 (依赖商品ID)
    stock_info = requests.get(
        f"http://stock-service/api/v1/stocks/{product_id}",
        timeout=0.5
    ).json()
    # 3. 调用促销服务
    promo_info = requests.get(
        f"http://promo-service/api/v1/promos/{product_id}",
        timeout=0.5
    ).json()
    # 组装返回
    result = {
        "product_id": product_id,
        "name": base_info["name"],
        "stock": stock_info["available"],
        "promo": promo_info.get("discount", 1.0)
    }
    return result

这三个请求之间其实没有数据依赖。它们唯一的共同点是都依赖product_id。所以完全可以用asyncio.gather并发发起。

4. 核心实现:asyncio + aiohttp 重构

改造后,我把整个接口从Flask迁移到了FastAPI(FastAPI 0.100.0,天然支持异步视图函数)。如果你坚持用Flask,可以用asyncio.run()包一层,但那样性能会打折扣,因为Flask的同步WSGI处理会阻塞事件循环。

# 优化后:asyncio并发调用 (FastAPI 0.100.0, uvicorn 0.23.2)
import asyncio
import aiohttp
from fastapi import FastAPI
import asyncpg

app = FastAPI()
# 全局复用连接池
pg_pool = None
semaphore = asyncio.Semaphore(50)  # 限制并发,防止压垮下游

async def fetch_json(session: aiohttp.ClientSession, url: str, timeout: float = 0.8):
    """带超时和信号量的GET请求"""
    async with semaphore:  # 控制并发数
        try:
            async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as resp:
                if resp.status == 200:
                    return await resp.json()
                else:
                    return None
        except asyncio.TimeoutError:
            return None  # 降级处理

@app.get("/api/v1/product/detail")
async def get_product_detail(product_id: str):
    # 并发请求三个服务
    async with aiohttp.ClientSession() as session:
        tasks = [
            fetch_json(session, f"http://product-service/api/v1/products/{product_id}"),
            fetch_json(session, f"http://stock-service/api/v1/stocks/{product_id}"),
            fetch_json(session, f"http://promo-service/api/v1/promos/{product_id}")
        ]
        base_info, stock_info, promo_info = await asyncio.gather(*tasks)

    # 后续组装,如果有DB操作则用asyncpg
    # 注意:这里故意省略了DB调用,实际项目中用asyncpg.fetchrow
    return {
        "product_id": product_id,
        "name": base_info.get("name") if base_info else "unknown",
        "stock": stock_info.get("available") if stock_info else 0,
        "promo": promo_info.get("discount") if promo_info else 1.0
    }

5. 踩坑与优化:三个必须注意的细节

坑1:Gunicorn的Worker必须换掉。如果你还用gunicorn -w 4 -b :8000 app:app跑Flask,那asyncio一点用都没有。因为同步Worker会阻塞事件循环。我最终用的是:

gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --timeout 60

坑2:aiohttp.ClientSession不能每次请求都创建。我在第一版代码里每个请求都async with aiohttp.ClientSession(),结果压测发现性能只提升了30%。原因是创建Session的开销很大(SSL握手、连接池初始化)。正确做法是全局单例:

# app启动时创建
session = aiohttp.ClientSession()
# 关闭时确保释放
@app.on_event("shutdown")
async def close_session():
    await session.close()

坑3:最容易忽略的——asyncio.gather默认不取消其他任务。如果其中一个请求超时(比如促销服务卡了3秒),fetch_json里的asyncio.TimeoutError会让这个任务返回None,但另外两个任务会继续执行。这没问题。但如果你用了asyncio.wait_for包裹整个gather,那超时后所有任务都会被取消,可能丢失已有响应。我这里选择的是每个子任务自己控制超时,而不是整体超时。

6. 性能对比数据:压测结果

wrk -t8 -c100 -d30s压测,环境是4核8G的Docker容器(跟生产一致):

指标 优化前 (同步Flask) 优化后 (asyncio+FastAPI) 提升
P50 820ms 96ms 8.5x
P95 1.5s 180ms 8.3x
P99 2.1s 280ms 7.5x
吞吐量 180 req/s 1450 req/s 8.1x
错误率 2.3% 0.1% -

还有一个意外收获:因为asyncio.Semaphore(50)限制了并发,下游服务的负载从之前的每秒3000次请求降到了每秒1500次,它们的CPU使用率反而下降了,GC暂停也变少了。

7. 总结与反思

这次改造最大的收益不是代码层面的,而是思维层面的——Python的异步模型不是银弹,但它非常适合IO密集型任务。如果你的接口里有超过2个无依赖的IO操作(HTTP、DB、Redis),无脑用asyncio就对了。

但也要泼一盆冷水:如果你的服务里CPU密集计算占大头(比如图像处理、加密),那asyncio帮不了你,需要上multiprocessing或C扩展。另外,异步代码的调试成本确实更高,建议把DEBUG级别的日志打开,配合asyncio.get_event_loop().set_debug(True)

最后留个彩蛋:如果你升级到Python 3.11,把asyncio.gather换成asyncio.TaskGroup,代码会更清晰。但我们在生产环境选择保守,3.10 + 手动gather更稳定。