一、问题背景:一个慢到被投诉的报表接口
上季度我们做了个“渠道转化分析”接口,内部逻辑很简单:接收一个日期范围,然后依次调用三个内部服务——用户画像服务(/profile)、订单统计服务(/orders)、广告投放服务(/ads)。每个服务响应大概在700ms-1500ms之间,但接口总耗时却稳定在3.5秒以上。
看代码就明白了:
# 优化前:串行调用三个上游服务
def get_report(date_from, date_to):
profile = requests.get(profile_api, params={...}, timeout=3).json()
orders = requests.get(orders_api, params={...}, timeout=3).json()
ads = requests.get(ads_api, params={...}, timeout=3).json()
return compute_report(profile, orders, ads)
三个请求一个接一个地等,每个1秒,加起来就是3秒。这还是运气好,生产环境偶尔一个服务抖动到2秒多,接口直接超时返回504。
业务方给的反馈很直接:“你们这接口比隔壁老王用Excel手工统计还慢。”没得洗,IO密集型的串行调用确实蠢。
二、环境与版本:Python 3.10 + asyncio + httpx
先说下改造时的环境,避免大家踩版本坑:
- Python 3.10.12(asyncio在3.10后稳定性有明显提升,推荐至少3.9+)
- httpx 0.24.1(比aiohttp更友好,API设计贴近requests,支持HTTP/2)
- Flask 2.2.5(注意:Flask本身不支持异步视图,需要配合
asgiref的async_to_sync或直接用quart,但我选择保留Flask,用线程池跑异步任务,后面细说) - 压测工具:wrk 4.2.0,单机模拟100并发
这里有个小提醒:requests库是纯同步阻塞的,没法在asyncio里直接用。要么用asyncio.to_thread丢线程池,要么换异步客户端。我直接换了httpx——它同时支持同步和异步API,切换成本极低。
三、方案设计:三件事并发做,别排队
核心思路一句话:把三个独立的HTTP调用由串行改为并发等待。
asyncio提供了asyncio.gather(),可以同时发起多个协程任务,等所有任务完成后统一返回。同时用asyncio.Semaphore控制并发上限,防止上游服务被我们打爆。
整体设计图大概是这样的:
请求进入Flask视图
│
▼
创建新事件循环(或复用)
│
▼
async def fetch_all(...):
async with semaphore: # 限流,最多同时10个请求出去
tasks = [fetch_profile(), fetch_orders(), fetch_ads()]
results = await asyncio.gather(*tasks)
│
▼
同步返回给客户端
四、核心实现:从同步到异步的完整改造
先看改造后的核心代码。注意我用了asyncio.run()在新线程中运行事件循环,这样不用动Flask的同步模型,改动面小,风险低:
# 优化后:asyncio并发调用三个上游服务
import asyncio
import httpx
from concurrent.futures import ThreadPoolExecutor
# 全局复用连接池,避免每次请求重建连接
_client = httpx.AsyncClient(
timeout=httpx.Timeout(5.0, connect=2.0),
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
http2=True
)
# 信号量:限制同时进行的上游HTTP请求数,保护下游服务
_semaphore = asyncio.Semaphore(10)
async def fetch_with_limit(client, method, url, **kwargs):
async with _semaphore:
resp = await client.request(method, url, **kwargs)
resp.raise_for_status()
return resp.json()
async def fetch_all_async(date_from, date_to):
params = {"date_from": date_from, "date_to": date_to}
# 并发发起三个请求,gather等待所有完成
results = await asyncio.gather(
fetch_with_limit(_client, "GET", profile_api, params=params),
fetch_with_limit(_client, "GET", orders_api, params=params),
fetch_with_limit(_client, "GET", ads_api, params=params),
return_exceptions=False # 任何一个失败,整体失败,便于快速失败
)
return compute_report(*results)
# Flask视图入口:使用线程池运行事件循环
def get_report(date_from, date_to):
with ThreadPoolExecutor(max_workers=2) as executor:
future = executor.submit(asyncio.run, fetch_all_async(date_from, date_to))
return future.result(timeout=8) # 总超时8秒,防止卡死
这段代码有四个关键点:
- 连接池复用:
httpx.AsyncClient定义成全局单例。一开始我是在每个请求里创建新client,结果发现连接建立(TCP握手+TLS)占了总耗时30%,复用后这块开销几乎为0。 - 信号量限流:
Semaphore(10)意味着同一时刻最多发10个HTTP请求。压测时试过不限流,结果把上游服务打到超时报警,被运维骂了一顿。 - 快速失败:
return_exceptions=False是默认值,意思是三个任务中任何一个抛异常,gather立即返回并取消其他任务,避免客户端等一个永远不回来的响应。 - 线程池+asyncio.run:Flask的视图函数是同步的,不能直接
await。用ThreadPoolExecutor开新线程跑事件循环,是成本最低的兼容方案。注意max_workers别开太大,2-4个足够,因为事件循环本身在等待IO时并不占用线程。
五、踩坑与优化:三个让我抓狂的细节
坑1:asyncio.run不能重复调用同一个事件循环
一开始我用的是:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(fetch_all_async(...))
结果在Flask多线程下,偶尔报Event loop is closed。原因是多个线程共用了一个loop对象。换成asyncio.run()后,它每次创建新loop、用完即关,线程安全。
坑2:连接池耗尽导致死锁
压测到200并发时,接口直接卡死。查日志发现是max_connections=20太小,大量协程在等待连接释放,而信号量又占着名额不放——典型的资源死锁。
解决方案:把max_connections提高到100,同时信号量不变。现在协程等的是信号量而不是连接,信号量释放后连接池肯定有空位,不会互相等待。
坑3:上游服务超时设置不能太激进
我最初把httpx超时设为2秒,结果生产环境频繁报ReadTimeout。后来分析发现是上游服务偶尔GC停顿导致响应慢,但业务上允许偶尔慢。最终设置为timeout=5.0, connect=2.0,并在调用方增加重试逻辑(这里没展示,重试逻辑写在fetch_with_limit里,捕获httpx.TransportError后重试1次)。
六、效果数据:P95从4.2秒降到1.4秒
用wrk压测,命令:wrk -t8 -c100 -d30s http://api.example.com/report?date_from=2023-06-01&date_to=2023-06-30
| 指标 | 优化前(同步) | 优化后(asyncio) | 提升幅度 |
|---|---|---|---|
| 吞吐量(req/s) | 12.3 | 38.7 | 3.15倍 |
| 平均延迟 | 8.1s | 2.6s | 67.9%↓ |
| P50延迟 | 7.9s | 2.1s | 73.4%↓ |
| P95延迟 | 10.4s | 3.8s | 63.5%↓ |
| P99延迟 | 12.7s | 5.2s | 59.1%↓ |
| 错误率 | 2.1% | 0.3% | 85.7%↓ |
生产环境数据(使用Grafana监控,一周统计):
- 平均响应时间:从8.1秒降至2.6秒(-67.9%)
- P95响应时间:从4.2秒降至1.4秒(-66.7%)
- 上游服务负载:由于并发调用,峰值QPS从12提升至40,但因为有信号量限流,上游服务的CPU使用率仅从45%升至58%,仍在安全范围
注意:为什么压测的P95比生产高?因为wrk是纯压力测试,100并发且持续打,而生产环境实际并发在20-30左右。
七、总结与适用边界
这次改造收益很直观:代码量增加了约30行,但性能提升3倍。核心就两件事:把串行等待改成并发等待,再用连接池和信号量控制资源。
但我要泼点冷水,asyncio不是万能的:
- 只适用于IO密集型:如果你的接口是CPU密集计算(比如复杂排序、加密解密),asyncio救不了你,得用多进程。
- 不要动Flask的同步模型:Flask的视图函数保持同步,用线程池跑事件循环是妥协方案。如果项目从零开始,建议直接用Quart或FastAPI。
- 信号量必须调好:并发数不是越大越好。我试过信号量设50,上游服务直接响应时间翻了倍。合理值需要压测上下游配合调整。
最后留个思考题:如果三个上游服务中有两个是必须同时成功才能返回,另一个失败可以容忍(比如展示默认值),你会怎么改gather的return_exceptions参数和后续逻辑?欢迎评论区聊。
代码已开源在个人GitHub仓库(链接),需要的自取。如果这篇文章帮到你了,点个赞再走。