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

上个月我们接到前端反馈,说后台管理系统的“用户详情”页面打开要转圈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请求变成并发。但有几个设计决策:

  1. 为什么用httpx而不是aiohttp? 因为httpx的API和requests几乎一样,改造成本低。而且httpx.AsyncClient内置连接池复用,比每次新建连接快很多。
  2. 连接池大小:默认10,三个并发请求足够用。但要注意,如果单实例QPS大于10,需要调大limits参数。
  3. 超时控制:统一设置为2秒(和原来一致),但用asyncio.wait_for包一层,保证任何一个请求超时都不会拖累整体。
  4. 异常隔离:某个服务挂了不能影响另外两个。用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握手开销。

几点建议:

  1. Flask 2.x用户:不要为了用async而强行切换到FastAPI,用asyncio.run()包一层即可,成本最低。
  2. 连接池一定要复用:每次请求新建AsyncClient是反模式,性能反而更差。
  3. 超时和并发控制是必须的:否则下游抖动会拖垮你的服务。
  4. 如果Python版本低于3.8asyncio.run不可用,用loop.run_until_complete()替代。

最后提醒一句:异步不是银弹。如果你的接口本身是CPU密集型(比如大量计算),异步反而会降低性能。只有I/O密集型(HTTP、DB、文件)场景,asyncio才能发挥作用。我们这次是典型的I/O等待场景,收益显著。

代码已提交到公司内网仓库,关键文件就两个:async_client.pyviews.py,改造量不大但收益明显,值得一试。