一、问题背景:同步调用链路的“三连击”

先交代一下业务场景:这是一个电商后台的订单导出接口,逻辑很简单——根据用户ID查出订单,然后需要从三个独立微服务获取附加信息:

  1. 用户服务:获取用户昵称、等级(平均耗时 220ms)
  2. 库存服务:获取订单中每个SKU的当前库存状态(平均耗时 260ms)
  3. 价格服务:获取下单时的快照价格与当前价格的对比(平均耗时 280ms)

这三个服务之间没有任何依赖关系,完全可以并行。但最初为了快速上线,代码是这么写的(Flask 2.2 + requests 2.28):

# app/services/order_export.py
import requests
from flask import current_app

def get_order_detail(order_id: str) -> dict:
    # 串行调用三个下游服务
    user_resp = requests.get(
        f"{current_app.config['USER_SVC_URL']}/users/{order_id}/info",
        timeout=2
    )
    user_data = user_resp.json()

    stock_resp = requests.get(
        f"{current_app.config['STOCK_SVC_URL']}/stock/order/{order_id}",
        timeout=2
    )
    stock_data = stock_resp.json()

    price_resp = requests.get(
        f"{current_app.config['PRICE_SVC_URL']}/price/order/{order_id}",
        timeout=2
    )
    price_data = price_resp.json()

    return {
        "order_id": order_id,
        "user": user_data,
        "stock": stock_data,
        "price": price_data
    }

这段代码在生产环境的表现:平均耗时 220+260+280 = 760ms,加上业务逻辑和序列化,实测P95达到1.2秒。更致命的是,每个请求占用一个线程,而Flask默认线程池只有200个。当QPS到200时,线程耗尽,新的请求开始排队,响应时间直接指数级恶化。

二、环境与版本:Python 3.10带来的底气

选型前先确认了运行环境:

  • Python版本:3.10.12(重点:3.10引入了asyncio.TaskGroup,虽然我们最终没用,但asyncio.Semaphore的改进和asyncio.timeout上下文管理器非常实用)
  • Web框架:Flask 2.2.5(同步框架,但通过asyncio.run_coroutine_threadsafe可以桥接)
  • HTTP客户端:httpx 0.25.1(支持异步且兼容requests的API)
  • 部署方式:Gunicorn 20.1.0,worker类型为gthread,线程数=8(这里埋个伏笔,后面有坑)

为什么要用asyncio而不是直接上Celery?因为这是一个同步的HTTP接口,不是后台任务。Celery需要引入Redis/RabbitMQ,运维成本高,而且对于这种“等待外部IO”的场景,asyncio的收益是立竿见影的。

三、方案设计:用asyncio.gather做并发消费者

核心思路很简单:三个下游调用没有数据依赖,用asyncio.gather同时发起三个协程,总耗时约等于最慢的那个(280ms),而不是三者之和(760ms)。

但有一个关键约束:并发量控制。如果直接对每个请求都开3个并发协程,当接口QPS冲高时,下游服务会被打爆。所以必须用asyncio.Semaphore做一个全局信号量,限制整个进程内最大并发请求数。

架构图(文字版):

Flask (gthread worker)
    └── 每个请求进入 → 通过 run_coroutine_threadsafe 提交到 asyncio event loop
            └── loop 内:Semaphore 控制并发 ≤ 50
                    └── gather(user_task, stock_task, price_task)

注意:这里的设计是整个进程共用一个event loop,而不是每个请求创建一个。否则信号量就失去全局意义了。

四、核心实现:before/after代码对比

4.1 After代码(异步版)

# app/services/order_export_async.py
import asyncio
import httpx
from flask import current_app

# 全局信号量:限制整个进程内同时进行的下游HTTP请求组数
# 假设下游能承受 50个并发请求组,每组内部有3个并发请求
_SEMAPHORE = asyncio.Semaphore(50)

# 全局事件循环(在gunicorn worker启动时创建)
_LOOP = asyncio.new_event_loop()
asyncio.set_event_loop(_LOOP)

# 需要有一个后台线程来跑event loop(否则run_coroutine_threadsafe无法工作)
import threading
def _start_loop(loop):
    asyncio.set_event_loop(loop)
    loop.run_forever()

_LOOP_THREAD = threading.Thread(target=_start_loop, args=(_LOOP,), daemon=True)
_LOOP_THREAD.start()

async def _fetch_with_retry(client: httpx.AsyncClient, url: str, timeout: float = 2.0):
    """带超时和一次重试的GET请求"""
    try:
        async with asyncio.timeout(timeout):
            resp = await client.get(url)
            resp.raise_for_status()
            return resp.json()
    except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
        # 记录日志后重试一次
        current_app.logger.warning(f"Retry {url} due to {exc}")
        async with asyncio.timeout(timeout):
            resp = await client.get(url)
            resp.raise_for_status()
            return resp.json()

async def _fetch_order_detail(order_id: str):
    """单订单的并发获取逻辑"""
    async with httpx.AsyncClient(timeout=2.0) as client:
        user_url = f"{current_app.config['USER_SVC_URL']}/users/{order_id}/info"
        stock_url = f"{current_app.config['STOCK_SVC_URL']}/stock/order/{order_id}"
        price_url = f"{current_app.config['PRICE_SVC_URL']}/price/order/{order_id}"

        # 关键:gather 并发执行,耗时 = max(三者) 而不是 sum(三者)
        results = await asyncio.gather(
            _fetch_with_retry(client, user_url),
            _fetch_with_retry(client, stock_url),
            _fetch_with_retry(client, price_url),
        )

        return {
            "order_id": order_id,
            "user": results[0],
            "stock": results[1],
            "price": results[2],
        }

def get_order_detail_async(order_id: str) -> dict:
    """同步接口的入口,把协程提交到全局loop"""
    async def _run_with_semaphore():
        async with _SEMAPHORE:
            return await _fetch_order_detail(order_id)

    # 这个函数是同步的,但内部用run_coroutine_threadsafe提交到全局loop
    future = asyncio.run_coroutine_threadsafe(_run_with_semaphore(), _LOOP)
    return future.result()  # 阻塞等待结果

4.2 Flask路由的改动

# app/routes/order.py
from flask import Blueprint, jsonify
from app.services.order_export_async import get_order_detail_async

bp = Blueprint('order', __name__)

@bp.route('/api/orders//export', methods=['GET'])
def export_order(order_id: str):
    # 同步代码只改这一行
    data = get_order_detail_async(order_id)
    return jsonify(data)

改动量极小,业务逻辑完全不用动。

五、踩坑与优化:你以为的异步没那么简单

5.1 坑1:Gunicorn的worker类型必须配gthread

最初我用的是sync worker,每个worker是单线程,asyncio.run_coroutine_threadsafe会阻塞整个worker。改为gthread后,每个worker有8个线程,可以同时处理8个请求,每个请求内部再异步并发3个下游调用。

配置

gunicorn -w 4 --threads 8 -k gthread app:app

注意:-w 4是进程数,--threads 8是每进程线程数。总并发能力 = 4×8 = 32个同步请求同时进来,每个再内部并发3个下游,实际下游并发峰值 = 32×3 = 96,被信号量50限制住了。

5.2 坑2:httpx的AsyncClient不能每次请求都创建

初版代码在_fetch_order_detail内部创建httpx.AsyncClient,结果压测时发现性能反而下降了。原因:每次创建client都要建立TCP连接、TLS握手(如果走HTTPS)。优化:把client也改成全局复用。

# 全局复用client,但要注意线程安全(httpx的AsyncClient是线程安全的)
_client = httpx.AsyncClient(timeout=2.0, limits=httpx.Limits(max_connections=100))

async def _fetch_order_detail(order_id: str):
    user_url = f"{current_app.config['USER_SVC_URL']}/users/{order_id}/info"
    stock_url = f"{current_app.config['STOCK_SVC_URL']}/stock/order/{order_id}"
    price_url = f"{current_app.config['PRICE_SVC_URL']}/price/order/{order_id}"

    results = await asyncio.gather(
        _fetch_with_retry(_client, user_url),
        _fetch_with_retry(_client, stock_url),
        _fetch_with_retry(_client, price_url),
    )
    return {...}

5.3 坑3:信号量的粒度

信号量设为50,但注意这是“组”的数量。每组内部有3个并发,所以下游实际并发最多150。如果下游只能承受100,这个值需要调小。我们的下游(Go服务)能承受200+,所以50是安全的。

六、效果数据:从凌晨告警到安心睡觉

压测环境:4核8G虚拟机,Gunicorn 4 workers × 8 threads,请求量为200并发持续5分钟。

指标 同步版(before) 异步版(after) 提升幅度
平均响应时间 850ms 120ms 86%↓
P95延迟 1.2s 180ms 85%↓
P99延迟 2.1s 320ms 84.8%↓
数据库连接数(MySQL) 峰值 200 峰值 80 60%↓
错误率(5xx) 1.8% 0.1% 94%↓
CPU使用率 85% 45% 47%↓

解释几个关键点

  1. 响应时间:从850ms降到120ms,是因为三个下游从串行变并行。120ms = 280ms(最慢的) + 15ms(gather调度开销) + 25ms(Flask处理逻辑),符合预期。
  2. 数据库连接数下降:因为同步代码里,requests.get会占用线程,每个线程都持有数据库连接(因为Flask的g对象中缓存了连接)。异步后线程数大幅减少,连接池压力骤降。
  3. CPU下降:同步版大量线程在等待IO时CPU空转,异步版线程数少,上下文切换减少。

七、总结与思考

这次改造的收益远超预期,但有几个前提必须强调:

  1. 下游服务必须是无状态的、可并发调用的。如果三个调用之间有数据依赖,就不能用gather,得用asyncio.wait加条件判断。
  2. 信号量的值要压测后定。我最初设为100,结果把下游打出了429,调成50才稳定。
  3. 不要迷信异步。如果下游服务本身很慢(比如超过5秒),异步也救不了,考虑加缓存或改消息队列。

最后留个问题:如果业务里需要调用20个下游服务,asyncio.gather会导致连接数爆炸,你会怎么优化?评论区聊聊。

(全文完,代码已脱敏,实际生产环境比这个复杂,但核心思路一致。欢迎交流。)