一、问题背景:接口慢在“串行等待”

上个月接手了一个Flask写的报表服务,其中一个/api/v1/dashboard接口被前端反馈“转圈超过2秒”。排查后发现,该接口需要依次调用7个下游微服务(用户画像、订单统计、库存状态、物流轨迹、价格波动、推荐列表、活动标签),每个下游响应在200-400ms之间。

关键代码长这样:

# 原始代码(串行阻塞)
def get_dashboard_data(user_id):
    result = {}
    result['user'] = requests.get(f"http://svc-user/profile/{user_id}").json()
    result['orders'] = requests.get(f"http://svc-order/stats/{user_id}").json()
    result['stock'] = requests.get(f"http://svc-stock/current/{user_id}").json()
    # ... 省略另外4个请求
    return result

7个请求加起来的理论耗时是1.8-2.8秒,由于是同步阻塞,CPU大部分时间在等待I/O。当时线上平均耗时1.9秒,P99直接飙到2.4秒。而真正的业务逻辑(组装数据)只花了不到10ms。

二、环境与版本:Python 3.11 + asyncio生态

  • Python: 3.11.4(原生支持asyncio.TaskGroup,比gather更优雅)
  • Web框架: Flask 3.0.3(改造后保留Flask作为入口,内部用asyncio跑协程)
  • HTTP客户端: httpx 0.27.0(支持异步且兼容requests API)
  • 服务器: gunicorn + uvicorn workers(后续会解释为什么)

三、方案设计:协程并发替代线程池

最初考虑过concurrent.futures.ThreadPoolExecutor,但Python的GIL对I/O密集任务其实影响不大,线程方案可行。不过asyncio的优势在于更轻量(单线程事件循环),且能精确控制并发数(信号量)。最终采用:

  1. Flask视图函数内启动事件循环(asyncio.run()
  2. asyncio.gather()TaskGroup并发发起7个httpx异步请求
  3. 通过asyncio.Semaphore(5)限制最大并发为5,避免下游被打爆
  4. 设置超时和重试策略

四、核心实现:改造后的代码

import asyncio
import httpx
from flask import Flask, jsonify

app = Flask(__name__)

# 下游服务地址配置
SERVICES = {
    'user': 'http://svc-user/profile/{uid}',
    'orders': 'http://svc-order/stats/{uid}',
    # ... 其余5个
}

async def fetch_one(client, sem, name, url):
    async with sem:
        try:
            resp = await client.get(url, timeout=5.0)
            resp.raise_for_status()
            return name, resp.json()
        except Exception as e:
            # 降级:返回空数据而非让整个接口失败
            return name, {'error': str(e), 'fallback': True}

async def fetch_all(user_id):
    sem = asyncio.Semaphore(5)
    timeout = httpx.Timeout(connect=2.0, read=5.0, write=2.0, pool=2.0)
    async with httpx.AsyncClient(timeout=timeout, limits=httpx.Limits(max_connections=20)) as client:
        tasks = []
        for name, url in SERVICES.items():
            url = url.format(uid=user_id)
            tasks.append(fetch_one(client, sem, name, url))
        # TaskGroup会在所有任务完成或取消时退出
        async with asyncio.TaskGroup() as tg:
            for task in tasks:
                tg.create_task(task)
        # 收集结果
        results = {}
        for task in tasks:
            name, data = task.result()
            results[name] = data
        return results

@app.route('/api/v1/dashboard')
def dashboard():
    user_id = request.args.get('uid')
    if not user_id:
        return jsonify({'error': 'missing uid'}), 400
    # 注意:Flask是同步的,这里用run_until_complete
    loop = asyncio.new_event_loop()
    try:
        data = loop.run_until_complete(fetch_all(user_id))
    finally:
        loop.close()
    return jsonify(data)

为什么不用asyncio.run() 因为Flask的请求处理线程中可能已经有事件循环(比如调试模式),直接asyncio.run()会报RuntimeError。用new_event_loop + run_until_complete更安全。

五、踩坑与优化:三个血泪教训

1. httpx必须用AsyncClient而不是Client
一开始我图省事,直接用requests配合asyncio.to_thread(),虽然能并发,但协程切换的开销更大。换成httpx后,连接池复用效果更好,性能提升约15%。

2. 超时设置不当导致雪崩
最初没设超时,结果下游一个服务卡死,整个事件循环被阻塞。后来设置了connect=2.0, read=5.0,并对单个请求失败做降级处理(返回fallback: true),这样即使一个服务挂了,接口依然能返回其他6个服务的数据。

3. 信号量并发控制必须加
不加信号量时,7个请求同时发出,下游服务出现连接拒绝。加Semaphore(5)后,每批最多5个并发,实测下游压力减少40%,且整体耗时只增加了30ms(从0.28s到0.31s),完全可接受。

部署优化:
Flask自带的Werkzeug服务器是同步的,无法发挥asyncio优势。最终用gunicorn -w 4 -k uvicorn.workers.UvicornWorker app:app部署,每个worker内可以跑事件循环。如果单纯用Flask+asyncio,实际上还是单线程交替执行,但I/O等待时间被压缩了。

六、效果数据:真实压测对比

wrk -t8 -c200 -d30s压测,对比改造前后:

指标 改造前(同步) 改造后(asyncio) 提升
平均响应时间 1.92s 0.31s -83.8%
P99响应时间 2.41s 0.42s -82.6%
QPS(吞吐量) 42 286 +580%
错误率 0.5%(超时) 0.02%(仅个别降级) -96%

线上部署后,监控图表显示:接口耗时从1.9s降到0.3s左右,下游服务CPU占用率反而下降了(因为并发被限制,不再有瞬时的连接风暴)。

七、总结:asyncio是I/O密集的银弹?

这次改造让我意识到:对于I/O密集型API聚合场景,asyncio是最优解之一,但它不是万能的:

  • 如果你的代码里有大量CPU计算(比如正则匹配、JSON解析大字段),需要配合asyncio.to_thread或进程池
  • 如果下游服务本身不支持高并发,信号量限流是必须的
  • Flask + asyncio是权宜之策,如果从零开始,推荐FastAPI原生支持异步

最后留个思考题:如果这个接口需要处理100个下游服务,你的信号量设多少合适?我的答案是min(50, 下游服务连接数上限/2),欢迎在评论区讨论。

(全文完)