一、问题背景:一个接口拖垮了整个服务
上个月我们订单系统接了一个新需求:前端订单列表页需要展示每个订单的物流轨迹、价格波动和库存状态。这三个数据源分别在三个不同的微服务里——物流服务、价格服务、库存服务。我当时图省事,直接在视图函数里用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会立刻抛异常,导致价格和库存的请求被取消。所以我需要部分失败容忍。
设计方案如下:
- 用
asyncio.create_task创建三个协程任务 - 每个任务内部单独try/except,失败返回None
- 用
asyncio.Semaphore(10)限制并发数(防止下游被打爆) - 用
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有了更深的理解,几个关键认知:
- asyncio不是银弹——它只对I/O密集型有效,CPU密集型用了反而更慢(因为GIL)。
- 不要自己管理任务生命周期——推荐用
asyncio.TaskGroup(Python 3.11+)或asyncio.wait+超时,永远记得取消pending任务。 - httpx连接池要复用——每个请求都新建client会浪费TCP握手时间,全局单例client是正确姿势。
- 限流和超时是必需的——没有Semaphore,下游服务就是你的性能瓶颈;没有超时,慢服务会拖垮整个事件循环。
如果你们项目还在用requests+synchronous,且外部API调用超过3个,强烈建议花一个下午改成asyncio+httpx。注意先跑通单测再上压测,最好做灰度对比。我们上线后监控显示,订单接口P99稳定在150ms以下,数据库连接池占用从满负载降到30%,这次重构值了。