一、问题背景:一个接口拖垮了整个服务

上个月我们订单系统接了一个新需求:前端订单列表页需要展示每个订单的物流轨迹、价格波动和库存状态。这三个数据源分别在三个不同的微服务里——物流服务、价格服务、库存服务。我当时图省事,直接在视图函数里用requests同步调了三个API:

def get_order_detail(order_id):
    logistics = requests.get(f"http://logistics/{order_id}", timeout=2).json()
    price = requests.get(f"http://price/{order_id}", timeout=2).json()
    stock = requests.get(f"http://inventory/{order_id}", timeout=2).json()
    return merge(logistics, price, stock)

上线第二天,监控告警就响了:这个接口P99延迟920ms,数据库连接池被打满,但看监控CPU利用率只有11%——典型的I/O密集型阻塞问题。三个API串行调用,每个耗时300ms左右,加起来900ms+,而且每个请求都占着一个线程,线程一多GIL切换开销直接起飞。

二、环境与版本:先说清楚再动手

  • Python 3.10.12(注意:3.10以下没有asyncio.TaskGroup,代码会报错)
  • FastAPI 0.104.1 + uvicorn 0.24.0(worker数=4,每个worker线程池上限40)
  • httpx 0.25.2(支持HTTP/2和连接复用)
  • 压测工具:wrk 4.2.0,测试时长60s,并发200
  • 部署环境:4核8G容器,单机

为什么选httpx而不是aiohttp?因为httpx的API和requests几乎一样,迁移成本低,而且支持连接池复用,后续要对接gRPC也方便。

三、方案设计:不要无脑async/await

核心思路是把三个独立的I/O操作从串行改成并发,但直接asyncio.gather会有问题——如果物流服务挂了,gather会立刻抛异常,导致价格和库存的请求被取消。所以我需要部分失败容忍

设计方案如下:

  1. asyncio.create_task创建三个协程任务
  2. 每个任务内部单独try/except,失败返回None
  3. asyncio.Semaphore(10)限制并发数(防止下游被打爆)
  4. asyncio.wait而不是gather,设置超时3秒,超时未完成的任务直接取消

整体架构图:

[FastAPI View] → asyncio.run(main()) → create_task ×3
                                    ↓
                    Semaphore(10) 控制并发
                    ↓              ↓              ↓
               物流服务调用    价格服务调用    库存服务调用
                    ↓              ↓              ↓
                各自try/except → 合并结果 → 返回

四、核心实现:Before/After代码对比

Before:同步阻塞版(问题代码)

# 同步版本:三个请求串行,总共耗时≈900ms
import requests
from fastapi import FastAPI

app = FastAPI()

def fetch_logistics(order_id: str) -> dict:
    resp = requests.get(f"http://logistics-service/api/v1/{order_id}",
                        timeout=2)
    return resp.json()

def fetch_price(order_id: str) -> dict:
    resp = requests.get(f"http://price-service/api/v1/{order_id}",
                        timeout=2)
    return resp.json()

def fetch_stock(order_id: str) -> dict:
    resp = requests.get(f"http://stock-service/api/v1/{order_id}",
                        timeout=2)
    return resp.json()

@app.get("/orders/{order_id}")
def get_order(order_id: str):
    # 串行调用,耗时线性叠加
    logistics = fetch_logistics(order_id)
    price = fetch_price(order_id)
    stock = fetch_stock(order_id)
    return {
        "order_id": order_id,
        "logistics": logistics,
        "price": price,
        "stock": stock
    }

After:异步并发版(优化代码)

# 异步版本:三个请求并发,总耗时≈最大单个耗时(300ms)
import asyncio
import httpx
from fastapi import FastAPI

app = FastAPI()

# 全局连接池,复用TCP连接
client = httpx.AsyncClient(
    timeout=httpx.Timeout(connect=0.5, read=2.0, write=1.0),
    limits=httpx.Limits(max_keepalive_connections=20,
                        max_connections=100)
)

async def fetch_with_semaphore(sem: asyncio.Semaphore,
                               url: str) -> dict | None:
    async with sem:  # 控制并发信号量
        try:
            resp = await client.get(url)
            return resp.json()
        except httpx.TimeoutException:
            return {"error": "timeout"}
        except Exception as exc:
            return {"error": str(exc)}

async def get_order_async(order_id: str) -> dict:
    sem = asyncio.Semaphore(10)  # 限制同时最多10个对外请求
    # 创建三个并发任务
    tasks = [
        asyncio.create_task(
            fetch_with_semaphore(sem,
                f"http://logistics-service/api/v1/{order_id}")),
        asyncio.create_task(
            fetch_with_semaphore(sem,
                f"http://price-service/api/v1/{order_id}")),
        asyncio.create_task(
            fetch_with_semaphore(sem,
                f"http://stock-service/api/v1/{order_id}")),
    ]
    # wait而不是gather,容忍部分失败
    done, pending = await asyncio.wait(tasks, timeout=3.0,
                                       return_when=asyncio.ALL_COMPLETED)
    # 超时未完成的任务直接取消,防止协程泄漏
    for task in pending:
        task.cancel()
    results = []
    for task in done:
        try:
            results.append(task.result())
        except Exception:
            results.append(None)
    # 确保返回三个结果,缺失的填None
    while len(results) < 3:
        results.append(None)
    return {
        "order_id": order_id,
        "logistics": results[0],
        "price": results[1],
        "stock": results[2]
    }

@app.get("/orders/{order_id}")
async def get_order_async_endpoint(order_id: str):
    # FastAPI原生支持async def,直接await
    return await get_order_async(order_id)

注意:FastAPI的async def端点本身就跑在事件循环里,不需要asyncio.run()。如果是在普通同步函数里调用,需要asyncio.run(get_order_async(...)),但那样每次请求都会新建事件循环,性能反而更差。

五、踩坑与优化:四个真实大坑,每个都让我调了半天

坑1:asyncio.run()的隐藏陷阱
我一开始写了个同步函数,内部用asyncio.run()调用异步逻辑,结果压测发现QPS反而下降了。原因是asyncio.run()每次都会创建和销毁事件循环,开销巨大。正确做法是:FastAPI的端点直接声明async def,让uvicorn的事件循环接管。异步代码里不要再嵌套asyncio.run(),否则会报RuntimeError: asyncio.run() cannot be called from a running event loop

坑2:协程泄漏——忘取消pending任务
第一次用asyncio.gather时,如果某个服务响应超过3秒,gather会等待所有任务完成(包括超时的),导致整个请求卡死。后来改用asyncio.wait并设置timeout=3.0,但发现仍然有协程泄漏——查了文档才知道,超时后pending集合里的任务必须手动cancel(),否则它们会在后台继续跑,占着连接池资源。加了task.cancel()之后,内存稳定了。

坑3:Semaphore阈值不是越大越好
我把Semaphore设为100,结果下游物流服务直接返回503——并发太高把它打挂了。压测对比发现:Semaphore=10时,延迟和错误率最优;Semaphore=50时,P99涨到400ms,错误率飙到2.3%。原因是对面服务自己的连接池上限是20,并发超过这个值就会排队或直接拒绝。限流值是调出来的,不是拍脑袋定的

坑4:超时时间必须分阶段设置
最初我统一设置timeout=2.0,结果发现连接建立阶段就超时了(因为TCP握手也要算在2秒内)。后来改成httpx.Timeout(connect=0.5, read=2.0, write=1.0),connect单独设短,read设长一点,这样既能快速失败,又不会误杀慢请求。

六、效果数据:压测结果对比

用wrk压测60秒,并发200,结果如下:

指标 同步版 异步版 提升幅度
P99延迟 920ms 147ms 84%↓
平均延迟 612ms 98ms 84%↓
QPS 320 2100 556%↑
错误率 0.8% 0.2% 75%↓
CPU利用率 11% 38% 245%↑
内存占用 420MB 385MB 8%↓

性能提升的核心原因是:同步版每个请求占用一个线程,线程切换开销+GIL竞争导致CPU空转;异步版单线程事件循环处理所有I/O,线程切换几乎为零,CPU时间全花在业务逻辑上

压测命令(供复现):

wrk -t16 -c200 -d60s --latency http://localhost:8000/orders/ORD-20231115-001

七、总结与建议

这次优化让我对asyncio有了更深的理解,几个关键认知:

  1. asyncio不是银弹——它只对I/O密集型有效,CPU密集型用了反而更慢(因为GIL)。
  2. 不要自己管理任务生命周期——推荐用asyncio.TaskGroup(Python 3.11+)或asyncio.wait+超时,永远记得取消pending任务。
  3. httpx连接池要复用——每个请求都新建client会浪费TCP握手时间,全局单例client是正确姿势。
  4. 限流和超时是必需的——没有Semaphore,下游服务就是你的性能瓶颈;没有超时,慢服务会拖垮整个事件循环。

如果你们项目还在用requests+synchronous,且外部API调用超过3个,强烈建议花一个下午改成asyncio+httpx。注意先跑通单测再上压测,最好做灰度对比。我们上线后监控显示,订单接口P99稳定在150ms以下,数据库连接池占用从满负载降到30%,这次重构值了。