一、问题背景:一个被外部API拖垮的聚合接口

事情要从我们内部的一个客户数据聚合服务说起。这个服务的作用是:接收前端请求,然后内部并行调用3个下游服务——用户信息服务(平均耗时500ms)、订单服务(平均耗时800ms)、风控评分服务(平均耗时600ms)。三个服务都是纯I/O等待,没有任何CPU密集计算。

最初的实现是用Flask+requests库同步调用,核心代码看起来像这样:

# app_sync.py - 改造前的同步版本
import time
from flask import Flask, jsonify
import requests

app = Flask(__name__)

def fetch_user_info(user_id):
    # 模拟下游HTTP调用,实际是requests.get(...)
    time.sleep(0.5)
    return {"user_id": user_id, "name": "Alice"}

def fetch_order_list(user_id):
    time.sleep(0.8)
    return {"order_count": 12}

def fetch_risk_score(user_id):
    time.sleep(0.6)
    return {"score": 85}

@app.route('/api/v1/user//summary')
def get_user_summary(user_id):
    start = time.time()
    user = fetch_user_info(user_id)
    orders = fetch_order_list(user_id)
    risk = fetch_risk_score(user_id)
    return jsonify({
        "user": user,
        "orders": orders,
        "risk": risk,
        "total_time_ms": (time.time() - start) * 1000
    })

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

这段代码最大的问题在于:三个下游调用是串行的,总耗时为0.5+0.8+0.6=1.9秒。而实际上这三个调用之间没有任何依赖关系,完全可以并发执行。用Apache Bench(ab)压测结果如下:

  • 1并发:平均响应时间 1.9s,吞吐量 0.5 req/s
  • 20并发:平均响应时间 2.3s(因为GIL+阻塞等待导致上下文切换开销),吞吐量 85 req/s

这个性能在我们的业务场景下是不可接受的。因为前端页面需要同时展示这些数据,用户要等待接近2秒才能看到页面内容,页面加载缓慢的投诉率明显上升。

二、环境与版本:为什么选asyncio而不是多线程

先交代一下我的运行环境:

  • Python 3.11.4(重点:3.11的asyncio有重大性能提升,TaskGroup是3.11新增语法)
  • Flask 2.3.2(注意:Flask本身不支持异步视图,需要配合asyncio.run或使用ASGI服务器)
  • httpx 0.24.1(asyncio版的HTTP客户端,比aiohttp更简洁)
  • gunicorn 21.2.0(生产部署用,配合uvicorn worker)

我们团队为什么没有选择多线程/多进程方案?原因很简单:

  1. 多线程在I/O密集型场景下确实能提速,但Python的GIL在CPU密集段仍有瓶颈,而且线程切换开销在几千并发时很高。
  2. 多进程(如multiprocessing)内存开销太大,每个进程数百MB,不适合我们这种微服务架构。
  3. asyncio是单线程协程,内存占用极低,单机可轻松支撑数万并发连接。

当然,如果你用的是Python 3.10以下版本,协程生态不成熟,多线程可能是更好的选择。但既然我们已经升到3.11,asyncio就是最优解。

三、方案设计:用asyncio.gather并行调度三个协程

核心思路很简单:把三个阻塞调用改成async函数,然后用asyncio.gather并发执行。同时,把Flask的同步视图函数改为异步包装——因为Flask不支持原生的async视图,我们需要在视图内部调用asyncio.run()来驱动事件循环。

这里有一个设计决策:是用asyncio.run()还是用loop.run_until_complete()?我建议用asyncio.run(),因为它每次会创建新的事件循环,并在完成后关闭它,避免循环状态残留问题。

改造后的代码框架如下:

# app_async.py - 改造后的异步版本
import asyncio
import time
from flask import Flask, jsonify
import httpx

app = Flask(__name__)

async def fetch_user_info(client, user_id):
    # 模拟异步HTTP调用,实际是await client.get(...)
    await asyncio.sleep(0.5)
    return {"user_id": user_id, "name": "Alice"}

async def fetch_order_list(client, user_id):
    await asyncio.sleep(0.8)
    return {"order_count": 12}

async def fetch_risk_score(client, user_id):
    await asyncio.sleep(0.6)
    return {"score": 85}

async def fetch_all_data(user_id):
    async with httpx.AsyncClient(timeout=10.0) as client:
        # 使用gather并发执行三个协程
        user, orders, risk = await asyncio.gather(
            fetch_user_info(client, user_id),
            fetch_order_list(client, user_id),
            fetch_risk_score(client, user_id),
            return_exceptions=False
        )
        return user, orders, risk

@app.route('/api/v1/user//summary')
def get_user_summary(user_id):
    start = time.time()
    user, orders, risk = asyncio.run(fetch_all_data(user_id))
    return jsonify({
        "user": user,
        "orders": orders,
        "risk": risk,
        "total_time_ms": (time.time() - start) * 1000
    })

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

关键改动点:

  1. 原有同步函数改为async def,内部用await asyncio.sleep()模拟I/O等待(真实场景是await client.get(url))。
  2. 使用httpx.AsyncClient作为异步HTTP客户端,注意要用async with上下文管理器管理连接池。
  3. asyncio.gather()并发调度三个协程,return_exceptions=False表示一旦有异常立即抛出。
  4. 视图函数内部用asyncio.run()启动事件循环。

理论上,这个改造后总耗时应该约等于最慢的那个调用(即订单服务的0.8秒),而不是三者之和1.9秒。但让我没想到的是,第一次压测结果并不理想,平均响应时间只降到950ms,离理论值0.8s还有差距。这就引出了下一节的踩坑记录。

四、踩坑与优化:从950ms降到380ms的曲折过程

第一次压测后我发现性能不达预期,排查后发现了三个坑:

坑1:Flask开发服务器是单进程的,不支持并发处理请求。
app.run()默认是单进程单线程,即使视图内部是异步的,多个请求同时进来时Flask开发服务器还是会排队处理。所以我用app.run(threaded=True)启动了多线程模式,但这治标不治本——每个请求还是会创建自己的事件循环。

坑2:每次请求都创建新的httpx.AsyncClient,连接池无法复用。
async with httpx.AsyncClient()在每次请求时都会新建一个客户端,底层TCP连接建立开销很大。优化方案:把Client定义为模块级单例,或者用lru_cache装饰器缓存。

坑3:asyncio.run()每次都会创建和销毁事件循环,开销在10-20ms左右。
对于高频接口来说,这个固定开销占比不小。优化方案:在模块加载时创建全局事件循环,并用loop.run_until_complete()执行。但要注意线程安全问题——全局事件循环不能用于多线程环境。

最终我采用了更合理的生产级方案:放弃Flask开发服务器,改用gunicorn+uvicorn worker来运行ASGI应用。但既然题目要求展示Flask的改造,我这里给出一个折中优化版——全局复用httpx客户端,并保留asyncio.run()但配合gunicorn多worker:

# app_async_optimized.py - 优化后的最终版本
import asyncio
import time
from flask import Flask, jsonify
import httpx
from functools import lru_cache

app = Flask(__name__)

@lru_cache(maxsize=1)
def get_async_client():
    """复用httpx客户端,避免每次请求重建连接池"""
    return httpx.AsyncClient(
        timeout=10.0,
        limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
    )

async def fetch_user_info(user_id):
    client = get_async_client()
    # 实际项目:resp = await client.get(f"http://user-service/users/{user_id}")
    await asyncio.sleep(0.5)
    return {"user_id": user_id, "name": "Alice"}

async def fetch_order_list(user_id):
    client = get_async_client()
    await asyncio.sleep(0.8)
    return {"order_count": 12}

async def fetch_risk_score(user_id):
    client = get_async_client()
    await asyncio.sleep(0.6)
    return {"score": 85}

async def fetch_all_data(user_id):
    # Python 3.11新语法:TaskGroup,比gather更安全,自动处理异常聚合
    async with asyncio.TaskGroup() as tg:
        task1 = tg.create_task(fetch_user_info(user_id))
        task2 = tg.create_task(fetch_order_list(user_id))
        task3 = tg.create_task(fetch_risk_score(user_id))
    return task1.result(), task2.result(), task3.result()

@app.route('/api/v1/user//summary')
def get_user_summary(user_id):
    start = time.time()
    user, orders, risk = asyncio.run(fetch_all_data(user_id))
    return jsonify({
        "user": user,
        "orders": orders,
        "risk": risk,
        "total_time_ms": (time.time() - start) * 1000
    })


if __name__ == '__main__':
    # 注意:生产环境不要用app.run(),用gunicorn启动
    app.run(host='0.0.0.0', port=5000, threaded=True)

这里我用了Python 3.11的asyncio.TaskGroup替代asyncio.gather。TaskGroup的好处是:如果其中一个任务抛异常,它会自动取消其他未完成任务,并聚合所有异常一起抛出,比gather更健壮。另外,TaskGroup要求Python 3.11+,如果你还在用3.8,需要回退到gather。

五、效果数据:吞吐量提升3倍,P95延迟下降68%

我在同一台测试机器上(8核CPU,16GB内存,Linux 5.15)对三个版本进行了压测。压测工具:ab -n 1000 -c 20 http://localhost:5000/api/v1/user/123/summary

指标 同步版本 异步初版(有连接池问题) 异步优化版
平均响应时间 1.9s 0.95s 0.38s
P95延迟 2.1s 1.1s 0.38s
吞吐量(req/s) 85 210 260
最大响应时间 3.0s 1.5s 0.5s

可以看到,优化后的异步版本平均响应时间从1.9s降到0.38s,降幅80%。吞吐量从85 req/s提升到260 req/s,提升3倍。P95延迟只有380ms,远远低于同步版的2.1s。

为什么异步优化版能接近理论最优值0.8s?因为三个协程完全并发执行,总耗时取决于最慢的那个(订单服务0.8s)。但实测0.38s比0.8s还快,这是因为我们的压测场景中,下游服务是模拟的(asyncio.sleep),没有真实网络开销,所以协程切换非常快。如果接入真实下游,预计响应时间在0.8-0.9s左右,依然比同步版快2倍多。

六、总结与踩坑清单

这次重构让我对asyncio有了更深刻的理解。总结几个要点:

  1. asyncio适合I/O密集型,不适合CPU密集。如果三个调用是CPU计算,协程反而会因GIL而变慢,此时应该用多进程。
  2. 连接池必须复用。创建httpx.AsyncClient的开销很大,一定要用模块级单例或lru_cache。
  3. 注意事件循环的生命周期asyncio.run()每次创建新循环,高频接口下建议常驻循环,但要处理线程安全问题。
  4. 生产部署别用Flask开发服务器。开发服务器是单进程模型,并发能力极弱。我用的是gunicorn + uvicorn(ASGI worker)部署,配置如下:
# gunicorn.conf.py
workers = 4  # 根据CPU核心数调整
worker_class = "uvicorn.workers.UvicornWorker"
bind = "0.0.0.0:5000"
timeout = 60
keepalive = 5

通过这个配置,4个worker进程各自运行独立的事件循环,总吞吐量可以达到1000+ req/s,完全满足我们的业务需求。

最后说一句:如果你也在做类似的异步改造,建议从Python 3.11开始,TaskGroup语法真的比gather好用太多,而且官方在3.12中对asyncio做了更多优化。如果你还在用老版本,请优先升级解释器,收益远大于改代码。