一、问题背景:一个慢到被投诉的报表接口

上季度我们做了个“渠道转化分析”接口,内部逻辑很简单:接收一个日期范围,然后依次调用三个内部服务——用户画像服务(/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本身不支持异步视图,需要配合asgirefasync_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秒,防止卡死

这段代码有四个关键点:

  1. 连接池复用httpx.AsyncClient定义成全局单例。一开始我是在每个请求里创建新client,结果发现连接建立(TCP握手+TLS)占了总耗时30%,复用后这块开销几乎为0。
  2. 信号量限流Semaphore(10)意味着同一时刻最多发10个HTTP请求。压测时试过不限流,结果把上游服务打到超时报警,被运维骂了一顿。
  3. 快速失败return_exceptions=False是默认值,意思是三个任务中任何一个抛异常,gather立即返回并取消其他任务,避免客户端等一个永远不回来的响应。
  4. 线程池+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,上游服务直接响应时间翻了倍。合理值需要压测上下游配合调整。

最后留个思考题:如果三个上游服务中有两个是必须同时成功才能返回,另一个失败可以容忍(比如展示默认值),你会怎么改gatherreturn_exceptions参数和后续逻辑?欢迎评论区聊。

代码已开源在个人GitHub仓库(链接),需要的自取。如果这篇文章帮到你了,点个赞再走。