一、问题背景

去年接手了一个内部聚合服务,职责很简单:接收前端一个查询请求,并行调用用户中心、订单中心、商品中心三个下游HTTP接口,把结果拼装后返回。听起来就是个典型的IO密集场景,但当时的实现是这样的:

# before: Flask 同步实现
@app.route('/api/v1/aggregate')
def aggregate():
    uid = request.args.get('uid')
    user = requests.get(f'{USER_SVC}/user/{uid}', timeout=3).json()
    orders = requests.get(f'{ORDER_SVC}/orders?uid={uid}', timeout=3).json()
    products = requests.get(f'{PRODUCT_SVC}/products?uid={uid}', timeout=3).json()
    return jsonify({'user': user, 'orders': orders, 'products': products})

三个接口串行调用,每个平均120ms左右,加起来360ms起步。上线后压测数据很难看:

  • 单请求平均耗时:380ms
  • 4 worker(gunicorn sync模式)下 QPS:约120
  • P99:1.2s
  • 高峰期CPU利用率不到15%,但请求就是排不上队

问题很明显:进程都在等网络IO,CPU闲着。当时的临时方案是加机器,从4台扩到8台,勉强扛住了。但这显然不是长久之计,于是决定用asyncio重构。

二、环境与版本

先把环境交代清楚,避免读者踩版本坑:

  • Python 3.11.6(3.8以下不建议,asyncio.to_thread、TaskGroup之类用不了)
  • aiohttp 3.9.1
  • FastAPI 0.109.0 + uvicorn 0.25.0(替代Flask,因为原生支持async def路由)
  • gunicorn 21.2.0(如果继续用Flask也可以用,但这里直接换FastAPI)
  • 压测工具:wrk 4.2.0,wrk -t4 -c200 -d60s
  • 部署:4C8G 容器,下游服务为内网HTTP,RTT约1ms

选FastAPI不是因为跟风,主要是它的路由天然支持async def,而Flask的async支持一直比较尴尬(需要额外装flask[async]且生态兼容性一般)。如果团队强依赖Flask,其实也可以用asgiref包一层,但没必要给自己找麻烦。

三、方案设计

核心思路就一句话:把串行IO改成并发IO。

具体设计:

  1. 三个下游调用用asyncio.gather并发发起,总耗时从 t1+t2+t3 变成 max(t1,t2,t3)
  2. 用aiohttp.ClientSession复用连接,避免每次请求重建TCP
  3. 连接池限制为limit=100,limit_per_host=50,配合下游承载能力
  4. 每个下游调用单独设超时(ClientTimeout(total=2)),并用return_exceptions=True做降级——某个下游挂了不能拖垮整个接口
  5. uvicorn worker数设为2(4核机器),每个worker单线程跑事件循环

这里有个关键决策:worker数量。同步模式下gunicorn通常开2*CPU+1个worker,但异步模式下单个worker就能扛住大量并发连接,worker开多了反而增加上下文切换和内存开销。4C机器开2个uvicorn worker是比较稳的配置,实测开到4个QPS反而略降。

四、核心实现

4.1 重构后的代码

# after: FastAPI + asyncio + aiohttp
import asyncio
import aiohttp
from fastapi import FastAPI, Query
from contextlib import asynccontextmanager

USER_SVC = "http://user-svc.internal"
ORDER_SVC = "http://order-svc.internal"
PRODUCT_SVC = "http://product-svc.internal"

# 全局 session,随应用生命周期创建/关闭
session: aiohttp.ClientSession | None = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    global session
    connector = aiohttp.TCPConnector(
        limit=100,            # 总连接池上限
        limit_per_host=50,    # 单host上限
        ttl_dns_cache=300,    # DNS缓存5分钟
        enable_cleanup_closed=True,
    )
    timeout = aiohttp.ClientTimeout(total=2, connect=0.5, sock_read=1.5)
    session = aiohttp.ClientSession(connector=connector, timeout=timeout)
    yield
    await session.close()

app = FastAPI(lifespan=lifespan)


async def fetch_json(url: str, name: str):
    try:
        async with session.get(url) as resp:
            resp.raise_for_status()
            return name, await resp.json()
    except Exception as e:
        # 单下游失败降级,不阻断整体
        return name, {"error": str(e)}


@app.get("/api/v1/aggregate")
async def aggregate(uid: str = Query(...)):
    tasks = [
        fetch_json(f"{USER_SVC}/user/{uid}", "user"),
        fetch_json(f"{ORDER_SVC}/orders?uid={uid}", "orders"),
        fetch_json(f"{PRODUCT_SVC}/products?uid={uid}", "products"),
    ]
    results = await asyncio.gather(*tasks, return_exceptions=False)
    data = dict(results)
    return {"code": 0, "data": data}

几个细节值得说:

  • ClientSession必须是全局复用,每次请求新建session是最常见的性能杀手,握手开销直接吃掉异步收益
  • sock_read=1.5比total=2更细,能区分"连不上"和"连上了但下游慢"
  • return_exceptions=False是因为fetch_json内部已经try/except兜底了,这里不需要再让gather吞异常
  • 降级返回{"error": ...}而不是抛500,前端可以做局部渲染

4.2 启动命令

uvicorn app:app --host 0.0.0.0 --port 8000 \
  --workers 2 \
  --loop uvloop \
  --http httptools \
  --backlog 2048

uvloop和httptools这两个可选依赖装上,实测QPS能再提升15%左右,白给的性能没理由不要。

五、踩坑与优化

重构过程不是一帆风顺,记录几个真实踩过的坑:

坑1:aiohttp的DNS解析阻塞。 一开始没配ttl_dns_cache,压测时发现每隔一段时间有毛刺,P99飙到800ms。原因是aiohttp默认每次新建连接都要走getaddrinfo,这个调用在Linux上是阻塞的,会卡住整个事件循环。加上ttl_dns_cache=300后毛刺消失。

坑2:连接池太小反而更慢。 最初把limit设成20,想着保守点,结果压测QPS卡在800上不去。原因是并发200时连接不够用,请求在池里排队。调到100后QPS直接翻倍。经验值:limit至少设为预期并发的50%,但要小于下游服务的连接承载上限。

坑3:同步库混用。 中间有段代码需要读Redis,用的是redis-py同步客户端,结果一压测事件循环就被阻塞。换成redis.asyncio后解决。异步函数里绝对不能出现同步IO调用,这一条要写进团队规范。

坑4:uvicorn worker数。 一开始按同步思维开了8个worker,结果内存暴涨(每个worker一份session池),QPS反而不如2个worker。异步模式下worker数建议CPU核数/2到CPU核数之间。

坑5:超时配置不统一。 下游某个接口偶尔慢到5s,但我们的total=2会先超时,触发降级。这里要跟下游对齐SLA,别自己拍脑袋定超时值。

六、效果数据

压测条件一致:wrk -t4 -c200 -d60s,下游为mock服务固定120ms延迟。

指标 重构前(Flask+gunicorn 4w) 重构后(FastAPI+uvicorn 2w) 变化
QPS 120 2100 17.5x
平均延迟 380ms 135ms -64%
P99延迟 1.2s 180ms -85%
CPU利用率 12% 68% -
单机内存 320MB 410MB +28%
线上机器数 8台 2台 -75%

线上灰度一周后,高峰期P99稳定在200ms以内,没有再出现排队超时。机器从8台缩到2台,省下的成本够团队喝一年咖啡了。

需要说明的是,QPS提升17倍看着夸张,主要是因为原实现的串行IO浪费太严重。如果原本就是合理并发(比如用线程池),提升幅度大概在3-5倍。asyncio的收益和IO占比强相关,CPU密集场景用它没意义。

七、总结

这次重构最大的体会是:asyncio不是银弹,但对IO密集的聚合类接口是降维打击。几个可复用的经验:

  1. ClientSession必须全局复用,连接池参数按并发量调,别用默认值
  2. 异步代码里严禁同步IO,混用等于白改
  3. worker数在异步模式下要往下调,不是越多越好
  4. 超时和降级要设计好,异步并发会把下游的抖动放大
  5. uvloop+httptools是低成本高收益的优化项

如果你们的服务也是这种"一个请求打多个下游"的模式,且CPU长期低于30%,那基本可以确定是IO在拖后腿,值得动手改一改。改完之后记得用真实流量灰度,mock压测和线上的差距有时候不小。