一、问题背景:同步调用链路的“三连击”
先交代一下业务场景:这是一个电商后台的订单导出接口,逻辑很简单——根据用户ID查出订单,然后需要从三个独立微服务获取附加信息:
- 用户服务:获取用户昵称、等级(平均耗时 220ms)
- 库存服务:获取订单中每个SKU的当前库存状态(平均耗时 260ms)
- 价格服务:获取下单时的快照价格与当前价格的对比(平均耗时 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%↓ |
解释几个关键点:
- 响应时间:从850ms降到120ms,是因为三个下游从串行变并行。120ms = 280ms(最慢的) + 15ms(gather调度开销) + 25ms(Flask处理逻辑),符合预期。
- 数据库连接数下降:因为同步代码里,
requests.get会占用线程,每个线程都持有数据库连接(因为Flask的g对象中缓存了连接)。异步后线程数大幅减少,连接池压力骤降。 - CPU下降:同步版大量线程在等待IO时CPU空转,异步版线程数少,上下文切换减少。
七、总结与思考
这次改造的收益远超预期,但有几个前提必须强调:
- 下游服务必须是无状态的、可并发调用的。如果三个调用之间有数据依赖,就不能用
gather,得用asyncio.wait加条件判断。 - 信号量的值要压测后定。我最初设为100,结果把下游打出了429,调成50才稳定。
- 不要迷信异步。如果下游服务本身很慢(比如超过5秒),异步也救不了,考虑加缓存或改消息队列。
最后留个问题:如果业务里需要调用20个下游服务,asyncio.gather会导致连接数爆炸,你会怎么优化?评论区聊聊。
(全文完,代码已脱敏,实际生产环境比这个复杂,但核心思路一致。欢迎交流。)