一、问题背景:一个慢得离谱的聚合接口

接手的一个Flask项目里有个/api/order/summary接口,逻辑很简单:根据订单ID,依次调用用户服务、商品服务、优惠券服务获取数据,然后聚合返回。三个服务都是内网HTTP接口,平均响应时间分别是:用户服务800ms、商品服务1200ms、优惠券服务600ms。串行调用,总耗时稳定在2.6-2.9秒。

线上用Gunicorn部署,4个worker进程,每个worker用默认的sync worker。压测结果惨不忍睹:

  • 并发50,QPS 320,P99延迟 4.2s
  • 并发100,QPS 350,P99延迟 7.8s,大量timeout

这接口被业务方天天催。最气人的是,三个服务之间完全无依赖,串行调用纯粹是代码偷懒。当时第一反应是用concurrent.futures.ThreadPoolExecutor改并行,但后来测试发现线程切换开销大,而且Gunicorn sync worker下线程池容易把CPU打满。最终选择了asyncio方案——毕竟IO密集型的活儿,协程才是正统解法。

二、环境与版本:Python 3.10 + Flask 2.2 + httpx 0.24

先交代一下改造环境:

  • Python 3.10.9(重点:3.10才有的asyncio.TaskGroup,3.9及以下得用asyncio.gather
  • Flask 2.2.3(保持原框架不动,只改视图函数内部实现)
  • httpx 0.24.1(比requests更适合asyncio,原生支持async/await)
  • Gunicorn 20.1.0 + gevent worker(注意:这里有个坑,后面细说)
  • 压测工具:wrk 4.2.0,单机跑

原本的串行代码长这样,简单粗暴:

# before.py - 串行调用三个服务
import requests
from flask import Flask, jsonify

app = Flask(__name__)

USER_SERVICE_URL = "http://user-service.internal/api/user"
PRODUCT_SERVICE_URL = "http://product-service.internal/api/product"
COUPON_SERVICE_URL = "http://coupon-service.internal/api/coupon"

@app.route("/api/order/summary", methods=["GET"])
def order_summary():
    order_id = request.args.get("order_id")
    # 串行调用,总耗时 ≈ 800ms + 1200ms + 600ms = 2.6s
    user_resp = requests.get(f"{USER_SERVICE_URL}?order_id={order_id}", timeout=3)
    user_data = user_resp.json()

    product_resp = requests.get(f"{PRODUCT_SERVICE_URL}?order_id={order_id}", timeout=3)
    product_data = product_resp.json()

    coupon_resp = requests.get(f"{COUPON_SERVICE_URL}?order_id={order_id}", timeout=3)
    coupon_data = coupon_resp.json()

    return jsonify({
        "user": user_data,
        "product": product_data,
        "coupon": coupon_data
    })

三、方案设计:asyncio + httpx 并发请求

核心思路很简单:把三个requests.get换成httpx.AsyncClient.get,用asyncio.gather(或3.10的TaskGroup)并发执行。

但有几个设计决策要提前定:

  1. 连接池复用httpx.AsyncClient必须复用,不能每次请求都新建。因为每次创建client会重新建立TCP连接,握手开销在HTTP/1.1下约50-100ms,在HTTP/2下更高。我们设计为模块级单例,配合async with语法。

  2. 信号量限流:虽然这里是3个并发,但如果接口被频繁调用,每个请求都开3个协程,瞬间并发量会翻3倍。用asyncio.Semaphore(10)限制单个请求内最大并发数,防止把下游服务打挂。

  3. 超时控制httpx的timeout参数要设置为httpx.Timeout(connect=1.0, read=2.0, write=1.0, pool=1.0),不能简单设一个float。之前踩过坑,设置timeout=3只代表总体超时,如果某个服务卡住,其他两个协程会跟着等。

  4. 错误隔离:三个服务任何一个挂了,不能拖垮整个接口。用return_exceptions=TrueTaskGroup的异常捕获,单独处理每个协程的错误。

改造后的核心代码:

# after.py - asyncio + httpx 并发调用
import asyncio
import httpx
from flask import Flask, jsonify, request

app = Flask(__name__)

# 模块级单例,复用连接池
_http_client = None

def get_client():
    global _http_client
    if _http_client is None:
        _http_client = httpx.AsyncClient(
            timeout=httpx.Timeout(connect=1.0, read=2.0, write=1.0, pool=1.0),
            limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
            http2=True  # 如果下游支持HTTP/2,可以开启
        )
    return _http_client

async def fetch_service(client, url, order_id):
    """获取单个服务数据,带错误兜底"""
    try:
        resp = await client.get(f"{url}?order_id={order_id}")
        resp.raise_for_status()
        return resp.json()
    except Exception as e:
        # 记录日志,返回空字典,不让单点故障拖垮整个接口
        app.logger.error(f"Service {url} failed: {e}")
        return {"error": str(e), "service": url}

@app.route("/api/order/summary", methods=["GET"])
async def order_summary():
    order_id = request.args.get("order_id")
    client = get_client()

    # 信号量限制最大并发为10
    semaphore = asyncio.Semaphore(10)

    async def bounded_fetch(url):
        async with semaphore:
            return await fetch_service(client, url, order_id)

    # Python 3.10+ 推荐 TaskGroup,自动管理协程生命周期
    async with asyncio.TaskGroup() as tg:
        task_user = tg.create_task(bounded_fetch(USER_SERVICE_URL))
        task_product = tg.create_task(bounded_fetch(PRODUCT_SERVICE_URL))
        task_coupon = tg.create_task(bounded_fetch(COUPON_SERVICE_URL))

    return jsonify({
        "user": task_user.result(),
        "product": task_product.result(),
        "coupon": task_coupon.result()
    })

注意:Flask 2.2默认不支持async视图函数,需要pip install flask[async],或者用asgirefsync_to_async包装。如果不想改Flask版本,可以用asyncio.run()包裹——但那样每次请求都会创建新事件循环,性能会打折扣。这里我直接用了Flask 2.2的async支持,配合ASGI服务器(Uvicorn)运行。

四、Gunicorn部署坑:sync worker与asyncio不兼容

这个坑差点让我放弃asyncio方案。原部署是gunicorn -w 4 app:app,sync worker。改造后接口变成async,但Gunicorn sync worker本身是同步的,它会把async视图函数当成普通函数调用,导致RuntimeError: no running event loop

解决方案有两种:

  1. 换用Uvicorn workergunicorn -w 4 -k uvicorn.workers.UvicornWorker app:app。这是最直接的方案,Uvicorn worker专门处理asyncio应用。

  2. 保留Gunicorn但用gevent workergunicorn -w 4 -k gevent app:app,然后视图函数内部用asyncio.run()。但这样每次请求都创建新事件循环,连接池复用失效,性能打折。实测QPS只有800多,不推荐。

最终我选了Uvicorn worker,配置如下:

gunicorn app:app -w 4 -k uvicorn.workers.UvicornWorker \
  --bind 0.0.0.0:8000 \
  --timeout 30 \
  --worker-connections 1000 \
  --max-requests 2000 \
  --max-requests-jitter 100

max-requestsmax-requests-jitter是为了防止worker内存泄漏,Uvicorn worker跑久了会有小概率内存增长。

五、踩坑与优化:从600ms到0.6s的三个关键点

1. Event Loop关闭报错

第一次压测时,大量请求报RuntimeError: Event loop is closed。排查发现是httpx.AsyncClient初始化在模块级别,但Gunicorn worker fork进程时,事件循环是父进程创建的。子进程复用了一个已经关闭的loop。

解决:把client初始化延迟到第一次请求时(lazy init),并放在worker进程内。上面代码里的get_client()就是干这个的。另外,Uvicorn worker会在worker退出时自动关闭loop,所以不需要手动关闭client。

2. 超时参数精细化

最初设timeout=3.0,结果发现如果用户服务耗时1.5秒、商品服务耗时1.5秒、优惠券服务耗时0.5秒,总耗时不是2秒而是2.0秒——因为httpx的timeout是总体超时,不是每次请求的超时。三个协程共享同一个超时预算。

解决:改用httpx.Timeout(connect=1.0, read=2.0, write=1.0, pool=1.0),read超时2秒是单个请求的读取超时,不会影响其他协程。这样即使优惠券服务卡住,用户和商品的请求也能正常返回。

3. 连接池限制

默认httpx.Limits(max_connections=100, max_keepalive_connections=20)。压测QPS高时,并发请求数可能超过100,导致连接池排队。调大连接池后,内存占用上升约30MB,但QPS提升明显。最终定为max_connections=50,因为下游服务有单机连接数限制,超过50会被拒绝。

六、效果数据:QPS提升5.5倍,P99下降85%

用wrk压测,命令:

wrk -t8 -c100 -d30s http://localhost:8000/api/order/summary?order_id=12345
指标 改造前(串行+sync worker) 改造后(asyncio+Uvicorn worker) 提升幅度
QPS 320 1750 5.5倍
平均延迟 2.8s 0.58s 79%下降
P99延迟 4.2s 0.9s 78.6%下降
错误率 2.3% 0.1% 96%下降
CPU使用率 85% 65% 23%下降

内存方面,改造前每个worker约120MB(因为三个requests连接各自独立),改造后约150MB(httpx连接池复用),但4个worker总共才多出120MB,可接受。

七、总结:asyncio不是银弹,但IO密集型是真香

这次改造的核心收益不在于“用asyncio”这个动作,而在于将串行IO改为并发IO。如果你还在用concurrent.futures.ThreadPoolExecutor,其实也能达到类似效果,但线程切换和GIL会让CPU更忙。asyncio的优势在于:

  1. 单线程内并发,无锁竞争,无线程切换开销
  2. 连接池复用,TCP握手次数从N次降到1次
  3. 超时控制更精细httpx.Timeout可以拆分connect/read/write

但asyncio也有代价:调试更复杂(协程堆栈不直观),需要理解事件循环模型。如果你的项目是纯CPU密集型,asyncio没意义。如果是IO密集且下游服务可并发,asyncio是当前Python生态里性价比最高的方案。

最后给个建议:如果你的Flask应用要上asyncio,别用Gunicorn sync worker,直接上Uvicorn或者Hypercorn。别在兼容性上浪费人生。