一、问题背景:同步阻塞的连锁反应
我们的服务是一个B端数据看板,前端需要在一个接口里聚合来自5个内部微服务的数据(用户画像、订单统计、库存状态、物流轨迹、价格策略)。最初实现用的是Flask + requests库,每个子请求串行执行。
线上监控显示:
- 单次请求平均耗时:3.2s
- P95耗时:5.8s
- 依赖服务慢时(超过2s),连接池被占满,新的请求直接排队
- 4台e2-medium(4vCPU/8GB)机器,CPU利用率只有12%,但RT持续飙升
代码大致长这样:
# before.py - 同步串行版本
import requests
from flask import Flask, jsonify
app = Flask(__name__)
def fetch_user_profile(user_id):
resp = requests.get(f"http://user-service/api/profile/{user_id}", timeout=3)
return resp.json()
def fetch_order_stats(user_id):
resp = requests.get(f"http://order-service/api/stats/{user_id}", timeout=3)
return resp.json()
def fetch_inventory(product_ids):
resp = requests.post("http://inventory-service/api/batch", json={"ids": product_ids}, timeout=3)
return resp.json()
@app.route("/dashboard/")
def dashboard(user_id):
# 串行调用,总耗时 = 3个请求耗时之和
profile = fetch_user_profile(user_id)
order_stats = fetch_order_stats(user_id)
inventory = fetch_inventory(order_stats["recent_products"])
return jsonify({
"profile": profile,
"order_stats": order_stats,
"inventory": inventory
})
问题很明显:fetch_user_profile 等3个IO操作完全独立,却被串行执行。如果每个下游响应需要1秒,总耗时就是3秒。更糟的是,线程池模式下每个请求占用一个线程,而线程之间切换开销大,并发能力被锁死。
二、环境与版本:Python 3.10 + FastAPI
考虑到Flask本身是WSGI同步框架,即使内部用asyncio也无法完全发挥协程优势,我们决定做两层改造:
- 框架层:从Flask迁移到 FastAPI 0.95.1(基于 Starlette,原生支持async def 端点)
- HTTP客户端:从requests 2.28.1 换成 httpx 0.24.0(支持异步,且API与requests高度兼容)
- Python版本:3.10.6(使用原生
asyncio,无需安装第三方loop库)
服务器环境:Ubuntu 20.04,Docker容器,--cpus=2限制下压测。
三、方案设计:异步化 + 并发控制
核心思路:把串行IO变成并发IO。但并发不是无限制的,下游服务扛不住,所以必须用asyncio.Semaphore控制最大并发数。
设计细节:
- 用
asyncio.gather()并发发起3个独立请求 - 用
Semaphore(10)限制全局并发协程数,防止突发流量打爆下游 - 对每个子请求设置独立的超时(
httpx.AsyncClient(timeout=2.0)) - 保留一个兜底降级:如果某个子请求失败,不整体报错,而是返回
null字段
四、核心实现:改造后的异步版本
# after.py - asyncio + httpx 异步并发版本
import asyncio
import httpx
from fastapi import FastAPI
from contextlib import asynccontextmanager
app = FastAPI()
# 全局信号量,控制最大10个并发协程
semaphore = asyncio.Semaphore(10)
# 复用连接池,减少TCP握手开销
client = httpx.AsyncClient(timeout=2.0)
async def fetch_profile(user_id):
async with semaphore:
resp = await client.get(f"http://user-service/api/profile/{user_id}")
return resp.json()
async def fetch_orders(user_id):
async with semaphore:
resp = await client.get(f"http://order-service/api/stats/{user_id}")
return resp.json()
async def fetch_inventory(product_ids):
async with semaphore:
resp = await client.post(
"http://inventory-service/api/batch",
json={"ids": product_ids}
)
return resp.json()
@app.get("/dashboard/{user_id}")
async def dashboard(user_id: str):
# 关键点:并发执行,而不是串行 await
profile_task = asyncio.create_task(fetch_profile(user_id))
order_task = asyncio.create_task(fetch_orders(user_id))
# 先拿到订单数据,才能知道商品ID,所以这里有一个依赖顺序
order_stats = await order_task
inventory_task = asyncio.create_task(fetch_inventory(order_stats["recent_products"]))
# 并行等待所有任务完成
profile, inventory = await asyncio.gather(profile_task, inventory_task)
return {
"profile": profile,
"order_stats": order_stats,
"inventory": inventory
}
# 关闭连接池
@app.on_event("shutdown")
async def shutdown():
await client.aclose()
注意:fetch_inventory依赖order_stats的结果,所以不能完全并行。但profile和order_stats可以同时发起,等order_stats返回后,再并发发起inventory请求。整体耗时从「串行3T」优化为「max(T_profile, T_orders) + T_inventory」。
五、踩坑与优化:三个真实的坑
坑1:信号量误用导致死锁
第一次我直接在fetch_profile内部创建Semaphore,结果每个协程各持有自己的信号量,完全失去全局限制作用。正确做法是在模块级别创建单一信号量,所有协程共享。
坑2:连接池耗尽
httpx.AsyncClient默认连接池上限是10(limits=httpx.Limits(max_connections=10))。压测时发现高并发下出现ConnectError。后来显式调大:
limits = httpx.Limits(max_connections=50, max_keepalive_connections=20)
client = httpx.AsyncClient(timeout=2.0, limits=limits)
坑3:事件循环被CPU阻塞
如果某个依赖是同步库(如pymongo),不能直接用await,需要丢给线程池:
import asyncio
from concurrent.futures import ThreadPoolExecutor
executor = ThreadPoolExecutor(max_workers=4)
def sync_mongo_query(user_id):
return mongo_collection.find_one({"user_id": user_id})
# 在async函数中调用
result = await asyncio.get_event_loop().run_in_executor(executor, sync_mongo_query, user_id)
六、效果数据:对比压测
压测工具:wrk -t4 -c100 -d30s http://localhost:8000/dashboard/1001
| 指标 | 改造前(Flask+requests) | 改造后(FastAPI+asyncio) |
|---|---|---|
| 平均RT | 3.2s | 0.41s |
| P95 RT | 5.8s | 0.63s |
| QPS | 约25 | 约320 |
| 单机CPU利用率 | 12% | 45%(有效利用) |
| 下游服务超时率 | 3.5% | 0.2%(信号量保护) |
稳定性测试:用locust模拟200并发用户,持续10分钟。改造后没有出现连接池溢出,P99稳定在0.8s以内。
七、总结与建议
这次改造的核心收获:
- asyncio不是银弹——它只对IO密集型有效。如果你的瓶颈是CPU计算,应该用多进程。
- 并发必须有限制——
Semaphore不是可选项,是必须项。否则下游一挂,你的服务跟着全挂。 - 依赖顺序决定最优并发策略——画个依赖图,把能并发的都并发,有依赖的按层级等待。
- 超时和降级比性能更重要——我们给每个子请求加了2s超时,失败时返回
null,而不是让用户看到500。
如果你还在用Flask+requests做聚合接口,强烈建议试试FastAPI+httpx这套组合。改造成本大约半天,收益是数量级的性能提升。如果你有更极致的性能需求,可以进一步考虑uvloop和orjson,但对我们这个场景,当前方案已经足够。
有任何问题欢迎评论区交流,特别是信号量死锁和连接池调优的细节,我可以再展开写一篇。