一、问题背景:一个接口拖垮整个服务
上个月我们接到前端反馈,说后台管理系统的“用户详情”页面打开要转圈2-3秒。查了监控,发现是/api/v1/users/{id}/profile这个接口的P95响应时间达到了860ms。
这个接口的逻辑很简单:根据用户ID,依次调用三个内部微服务——用户基础信息服务(user-svc)、订单统计服务(order-svc)、会员等级服务(vip-svc),然后聚合数据返回。三个服务都是独立无关的,但代码里用的是requests库串行调用:
# 改造前:串行调用三个独立服务
def get_user_profile(user_id: str):
# 第一次RPC:耗时约150ms
user_info = requests.get(
f"http://user-svc:8080/api/users/{user_id}",
timeout=2
).json()
# 第二次RPC:耗时约180ms
order_stats = requests.get(
f"http://order-svc:8080/api/orders/stats?user_id={user_id}",
timeout=2
).json()
# 第三次RPC:耗时约120ms
vip_level = requests.get(
f"http://vip-svc:8080/api/vip/level?user_id={user_id}",
timeout=2
).json()
return {
"user_info": user_info,
"order_stats": order_stats,
"vip_level": vip_level,
"request_id": uuid.uuid4().hex
}
三个服务调用加起来450ms左右(网络RTT + 服务处理时间),再算上序列化和路由开销,P95就飙到了860ms。其实问题很明确——串行等三个独立服务,纯属浪费。
二、环境与版本:选型说明
改造前先明确环境,避免版本不一致导致踩坑:
- Python版本:3.10.12(asyncio在3.10后API已很稳定,无需额外安装)
- Web框架:Flask 2.3.3(注意:Flask本身是同步WSGI,不支持异步视图,需要适配方案)
- HTTP客户端:httpx 0.25.2(支持
AsyncClient,比aiohttp更贴近requests的API风格) - WSGI服务器:gunicorn 21.2.0 + gevent worker(用于生产部署,配合异步代码)
- 压测工具:wrk 4.2.0 + 自定义脚本
关键点:Python 3.10的asyncio模块已经支持asyncio.timeout()(3.11才有),所以超时控制我用的是asyncio.wait_for。另外,Flask 2.x不支持异步视图函数(Django 4.0+才支持),所以我的方案是保留Flask同步视图,在视图内部用asyncio.run()驱动异步代码——这是最稳妥的过渡方案。
三、方案设计:asyncio + httpx并发请求
核心思路:用asyncio.gather把三个独立的HTTP请求变成并发。但有几个设计决策:
- 为什么用httpx而不是aiohttp? 因为httpx的API和
requests几乎一样,改造成本低。而且httpx.AsyncClient内置连接池复用,比每次新建连接快很多。 - 连接池大小:默认10,三个并发请求足够用。但要注意,如果单实例QPS大于10,需要调大
limits参数。 - 超时控制:统一设置为2秒(和原来一致),但用
asyncio.wait_for包一层,保证任何一个请求超时都不会拖累整体。 - 异常隔离:某个服务挂了不能影响另外两个。用
asyncio.gather(..., return_exceptions=True)捕获异常,返回默认值。
方案设计图(文字描述):
原来的串行流程(450ms):
[user-svc] --150ms--> [order-svc] --180ms--> [vip-svc] --120ms--> 响应
改造后并发流程(约180ms):
┌── user-svc (150ms)
[gather] ├── order-svc (180ms) ── 最慢决定总耗时
└── vip-svc (120ms)
总耗时从“三个时间之和”变成“最长时间+少量调度开销”。
四、核心实现:改造后的代码
4.1 异步HTTP客户端封装
# async_client.py
import asyncio
import httpx
from typing import Dict, Any
class AsyncHttpClient:
"""异步HTTP客户端,管理连接池和超时"""
def __init__(self, base_timeout: float = 2.0, max_connections: int = 100):
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(base_timeout, connect=1.0),
limits=httpx.Limits(
max_connections=max_connections,
max_keepalive_connections=20
),
headers={"User-Agent": "profile-service/1.0"}
)
async def get_json(self, url: str, params: Dict[str, Any] = None, timeout: float = 2.0):
"""带超时控制的GET请求,返回JSON或None"""
try:
resp = await asyncio.wait_for(
self._client.get(url, params=params),
timeout=timeout
)
resp.raise_for_status()
return resp.json()
except (httpx.HTTPError, asyncio.TimeoutError) as e:
# 记录日志,生产环境这里接入sentry
print(f"[AsyncHttpClient] Request failed: {url}, error: {e}")
return None
async def close(self):
await self._client.aclose()
# 全局单例,避免重复创建连接池
_http_client = None
def get_http_client() -> AsyncHttpClient:
global _http_client
if _http_client is None:
_http_client = AsyncHttpClient()
return _http_client
4.2 Flask视图改造
# views.py
import asyncio
from flask import Flask, jsonify, request
from async_client import AsyncHttpClient, get_http_client
app = Flask(__name__)
def get_user_profile_sync(user_id: str):
"""同步包装器:在Flask视图内部驱动异步代码"""
client = get_http_client()
base_url = "http://{service}/api"
async def fetch_all():
# 三个独立请求并发执行
user_info, order_stats, vip_level = await asyncio.gather(
client.get_json(
f"{base_url.format(service='user-svc:8080')}/users/{user_id}"
),
client.get_json(
f"{base_url.format(service='order-svc:8080')}/orders/stats",
params={"user_id": user_id}
),
client.get_json(
f"{base_url.format(service='vip-svc:8080')}/vip/level",
params={"user_id": user_id}
),
return_exceptions=True # 任一失败不影响其他
)
# 容错处理:某个服务失败时返回空dict
return {
"user_info": user_info or {},
"order_stats": order_stats or {"total_orders": 0},
"vip_level": vip_level or {"level": "NORMAL"},
}
# 关键:使用asyncio.run()在同步上下文中驱动异步代码
return asyncio.run(fetch_all())
@app.route("/api/v1/users//profile", methods=["GET"])
def user_profile(user_id: str):
data = get_user_profile_sync(user_id)
return jsonify({
"code": 0,
"data": data,
"request_id": request.headers.get("X-Request-ID", "")
})
if __name__ == "__main__":
# 开发环境调试用
app.run(host="0.0.0.0", port=5000, debug=False)
4.3 生产部署配置
gunicorn配置(gunicorn.conf.py):
# gunicorn.conf.py
workers = 4 # 多进程,每个进程内有独立的asyncio事件循环
worker_class = "gevent" # 使用gevent worker,可以容纳同步和异步混合
timeout = 10
keepalive = 5
max_requests = 10000
max_requests_jitter = 1000
注意:worker_class = "gevent"是因为Flask是同步框架,如果直接用uvicorn跑异步,需要重写整个Flask应用。gevent可以让我们在保持现有代码结构的前提下,通过monkey_patch提升并发能力。
五、踩坑与优化:三个意想不到的坑
坑1:asyncio.run()不能在已运行的事件循环中调用
第一次测试时,我在Flask的请求处理函数中直接调用了await fetch_all(),结果报错:
RuntimeError: asyncio.run() cannot be called from a running event loop
原因:Flask是同步的,但gunicorn的gevent worker内部用了greenlet + monkey_patch,导致主线程里可能已经有事件循环。解决方案:用asyncio.run()包一层,它会创建一个新的事件循环,运行完即关闭。如果遇到“event loop is closed”的报错,需要检查是否在全局创建了AsyncClient而没有正确关闭。
坑2:连接池耗尽导致大量ConnectTimeout
上线后第一波流量,监控发现ConnectTimeout报错暴增。排查后发现:httpx.AsyncClient默认max_connections=100,但gunicorn有4个worker进程,每个进程都有自己的连接池。如果某下游服务慢,会占满连接池。
解决方案:给AsyncClient设置合理的max_keepalive_connections,并加上asyncio.Semaphore控制并发上限:
# 在AsyncHttpClient中添加信号量
class AsyncHttpClient:
def __init__(self, max_concurrency: int = 50):
self._semaphore = asyncio.Semaphore(max_concurrency)
async def get_json(self, url, params=None, timeout=2.0):
async with self._semaphore:
# 原逻辑
坑3:asyncio.gather的异常返回值类型
return_exceptions=True时,如果请求失败,gather返回的是异常对象,而不是None。所以我的代码里需要判断:
result = await client.get_json(...)
if isinstance(result, Exception):
result = None # 或者默认值
我最初直接用user_info or {},但异常对象是Truthy,导致返回了异常对象给前端。修正后:
user_info, order_stats, vip_level = await asyncio.gather(...)
# 统一处理:过滤异常对象
def safe_extract(value, default):
return value if isinstance(value, dict) else default
六、效果数据:性能提升3.5倍
用wrk压测2000个请求,保持10个连接并发,结果如下:
| 指标 | 改造前 | 改造后 | 提升 |
|---|---|---|---|
| P50延迟 | 450ms | 120ms | -73% |
| P95延迟 | 860ms | 210ms | -75.6% |
| P99延迟 | 1.2s | 300ms | -75% |
| QPS(10并发) | 22 req/s | 71 req/s | 3.2倍 |
| 平均CPU占用 | 8.2% | 5.9% | -28% |
另外,监控显示:
- 下游服务平均调用耗时不变,但整体接口耗时从“和”变成“最大值”。
- 连接复用后,TCP连接建立次数减少了约60%(从每次请求新建3个连接,变成复用池中的连接)。
- 异常率从0.8%降低到0.2%(因为某个服务超时不再影响其他两个)。
压测命令参考:
# 安装wrk:brew install wrk / apt install wrk
wrk -t4 -c10 -d30s --latency \
-H "X-Request-ID: test-123" \
http://localhost:5000/api/v1/users/U10001/profile
七、总结与建议
这次改造的核心价值:用asyncio把串行I/O变成并发I/O,而不是提高单次请求的速度。三个独立服务,串行450ms,并发后理论极限是180ms(最慢的那个),实际能做到120ms是因为连接池复用减少了TCP握手开销。
几点建议:
- Flask 2.x用户:不要为了用async而强行切换到FastAPI,用
asyncio.run()包一层即可,成本最低。 - 连接池一定要复用:每次请求新建
AsyncClient是反模式,性能反而更差。 - 超时和并发控制是必须的:否则下游抖动会拖垮你的服务。
- 如果Python版本低于3.8,
asyncio.run不可用,用loop.run_until_complete()替代。
最后提醒一句:异步不是银弹。如果你的接口本身是CPU密集型(比如大量计算),异步反而会降低性能。只有I/O密集型(HTTP、DB、文件)场景,asyncio才能发挥作用。我们这次是典型的I/O等待场景,收益显著。
代码已提交到公司内网仓库,关键文件就两个:async_client.py和views.py,改造量不大但收益明显,值得一试。