1. 问题背景:一个拖垮网关的“简单”API

上个月我们有一个内部监控面板接口 /api/v1/health/overview,它要做的事很简单:

  1. 调A服务(MySQL查询)拿节点状态
  2. 调B服务(Redis聚合)拿流量统计
  3. 调C服务(冷存储)拿历史告警

这三个服务都是独立的HTTP接口,平均响应分别是300ms、500ms、400ms。但因为是同步串行调用,总耗时稳稳落在1.2秒。

压测结果更难看:单机4核8G,QPS只有120,P95延迟2.1秒。网关那边已经报警了——因为上游超时设置是2秒,这意味着高峰期有大量请求会直接504。

我第一反应是:这接口怎么写成串行的?打开代码一看,果不其然:

# 改造前:同步串行调用
def get_overview():
    node_status = requests.get(NODE_API, timeout=0.8).json()
    traffic_data = requests.get(TRAFFIC_API, timeout=0.8).json()
    alert_data = requests.get(ALERT_API, timeout=0.8).json()
    return merge_data(node_status, traffic_data, alert_data)

三个请求,每个都在等IO,CPU完全空闲。经典的多IO阻塞场景,这就是asyncio的用武之地。

2. 环境与版本:Python 3.11 + FastAPI + httpx

先说环境,避免版本坑:

  • Python 3.11.4(用到asyncio.TaskGroup,3.11才稳定)
  • FastAPI 0.104.1(异步框架,但改造也适用于Flask+async)
  • httpx 0.25.1(支持异步的HTTP客户端,替代requests)
  • uvicorn 0.24.0(ASGI服务器)
  • 压测工具:wrk 4.2.0,单机4线程,连接数200

注意: 如果你还在用requests库,它是纯同步的,没法配合asyncio。必须换成httpx或者aiohttp。

部署环境是Docker容器,CPU 4核,内存8G,Python官方slim镜像。

3. 方案设计:并发IO + 信号量限流

核心思路很简单:三个独立的HTTP请求,改成并发发出,总耗时从“三者之和”变成“三者最大值”。

但有两个细节必须考虑:

  1. 并发度控制:如果接口被刷爆,每个请求都并发3个下游调用,下游会被打挂。需要asyncio.Semaphore限制最大并发数。
  2. 超时控制:原来每个请求0.8s超时,并发后总超时应该控制在1s内,否则失去了优化的意义。

我设计了一个轻量级异步调度器:

# 改造后:asyncio并发请求
import asyncio
import httpx
from asyncio import Semaphore, TaskGroup

# 全局信号量:限制单个进程中最多50个并发下游请求
_semaphore = Semaphore(50)

async def fetch_with_timeout(client: httpx.AsyncClient, url: str, timeout: float = 0.9):
    """带信号量和超时控制的异步请求"""
    async with _semaphore:
        try:
            response = await client.get(url, timeout=timeout)
            response.raise_for_status()
            return response.json()
        except (httpx.TimeoutException, httpx.HTTPStatusError) as e:
            # 下游挂了不能拖垮主链路,返回降级数据
            return {"error": str(e), "degraded": True}

async def get_overview_async():
    async with httpx.AsyncClient() as client:
        # TaskGroup 自动管理三个任务的并发和异常
        async with TaskGroup() as tg:
            task1 = tg.create_task(fetch_with_timeout(client, NODE_API))
            task2 = tg.create_task(fetch_with_timeout(client, TRAFFIC_API))
            task3 = tg.create_task(fetch_with_timeout(client, ALERT_API))

        # 注意:TaskGroup退出时会等待所有任务完成
        return merge_data(task1.result(), task2.result(), task3.result())

这里有个Python 3.11的细节:TaskGroup如果某个任务抛异常(除了我们捕获的),会直接取消其他兄弟任务。所以我们内部必须捕干净异常,否则一个下游抖动,三个任务全废。

4. 核心实现:FastAPI接入异步路由

FastAPI天生支持async路由,但要注意:如果你在async函数里用了同步库(比如requests),事件循环会被阻塞,性能反而更差。 必须保证整条调用链都是异步的。

改造路由层:

# main.py
from fastapi import FastAPI
import asyncio

app = FastAPI()

@app.get("/api/v1/health/overview")
async def health_overview():
    # 直接调用异步版本,不阻塞事件循环
    data = await get_overview_async()
    return data

# 如果非要兼容同步代码,用run_in_executor跑线程池,但这不是最佳方案
# 推荐:全部改成async def

同时,我把下游超时从0.8s调整到0.9s——因为并发后,总耗时 = max(0.3, 0.5, 0.4) ≈ 0.5s,给单次请求留出0.9s的余量足够,而不像原来串行时总耗时要累加。

5. 踩坑与优化:连接池耗尽和DNS解析阻塞

坑1:httpx.AsyncClient的复用问题

第一版代码我在每个请求里都async with httpx.AsyncClient(),相当于每次新建TCP连接。压测发现连接建立开销巨大,且TIME_WAIT连接堆积。

解决:把client设为模块级单例,并配置连接池:

# client_pool.py
import httpx

# 连接池最大100个连接,保持10个空闲连接
client = httpx.AsyncClient(
    limits=httpx.Limits(max_connections=100, max_keepalive_connections=10),
    timeout=httpx.Timeout(timeout=0.9, connect=0.3)
)

坑2:DNS解析阻塞事件循环

httpx默认用同步DNS解析,这在高并发下会阻塞事件循环。表现为:QPS到达500后,延迟突然飙升。

解决:显式开启异步DNS(需要额外安装httpx[socks]或者用anyio的DNS解析器)。我用了最简单的方式——把IP直连写死,跳过DNS(因为我们下游服务是内网静态IP)。

坑3:Semaphore的等待队列

Semaphore(50)意味着第51个请求会等待。但如果有1000个并发进来,等待队列会积压,导致超时。我加了信号量获取超时:

async def fetch_with_timeout(client, url, timeout=0.9):
    try:
        # 最多等待0.2秒获取信号量,拿不到就直接降级
        await asyncio.wait_for(_semaphore.acquire(), timeout=0.2)
    except asyncio.TimeoutError:
        return {"error": "semaphore_timeout", "degraded": True}
    try:
        response = await client.get(url, timeout=timeout)
        return response.json()
    finally:
        _semaphore.release()

6. 效果数据:QPS提升6倍,P95降低82%

直接上压测数据,wrk压测5分钟,参数:wrk -t4 -c200 -d300s http://localhost:8080/api/v1/health/overview

指标 改造前(同步串行) 改造后(asyncio并发) 提升
QPS 120 850 6.1倍
平均延迟 1.2s 350ms 70.8%↓
P95延迟 2.1s 380ms 81.9%↓
错误率 3.2%(超时504) 0.1%(仅降级数据) 96.9%↓

抖动测试:故意将C服务(冷存储)延迟拉到2秒,改造前整个接口直接超时;改造后,A和B的数据正常返回,C返回降级数据,接口仍然在400ms内响应。这就是异步+异常隔离带来的稳定性提升。

资源占用:CPU从改造前的85%降到40%(因为不再忙等IO),内存从1.2G降到800M(连接池复用)。4核8G的容器现在可以支撑原先6倍的流量。

7. 总结:什么时候该用asyncio?

这次改造收益巨大,但我要泼点冷水:asyncio不是银弹

  • 如果你的IO等待时间 > 1ms,且并发量 > 50,asyncio收益明显。
  • 如果是CPU密集型任务(如JSON大字段处理、图片压缩),asyncio没用,该用多进程。
  • 如果下游服务不稳定,一定要配合信号量限流和超时降级,否则异步会让你的下游雪崩。

最后说一句:Python 3.11的TaskGroup真的比asyncio.gather好用太多,异常处理逻辑清晰,推荐升级。

这次改造整体花了半天时间,换来了6倍的性能提升。下次遇到接口性能瓶颈,先看看是不是IO串行了——大概率是。