一、问题背景:一个"看起来很闲"的接口

事情是这样的。我们有个内部服务,提供一个 /api/aggregate 接口,作用很简单:接收一个用户ID,去下游三个服务(用户画像、订单历史、推荐列表)拉数据,拼装后返回。下游都是HTTP接口,平均响应时间 80~150ms。

代码是典型的Flask同步写法,用requests逐个请求:

# before: app.py (Flask 2.2.5 + requests 2.28.2)
import requests
from flask import Flask, jsonify, request

app = Flask(__name__)

PROFILE_URL = "http://profile-svc:8001/user/{}"
ORDER_URL = "http://order-svc:8002/orders?uid={}"
RECO_URL = "http://reco-svc:8003/list/{}"

@app.route("/api/aggregate")
def aggregate():
    uid = request.args.get("uid")
    profile = requests.get(PROFILE_URL.format(uid), timeout=2).json()
    orders = requests.get(ORDER_URL.format(uid), timeout=2).json()
    reco = requests.get(RECO_URL.format(uid), timeout=2).json()
    return jsonify({"profile": profile, "orders": orders, "reco": reco})

三个请求串行,每个100ms左右,接口总耗时300ms+。用wrk压测(4线程、100连接、跑30秒):

Running 30s test @ http://127.0.0.1:5000/api/aggregate?uid=123
  4 threads and 100 connections
  Requests/sec:    121.83
  Latency  P50: 780ms   P99: 1.82s

QPS 120,P99 1.8秒。但看监控,CPU使用率只有8%,进程数1,内存200MB。CPU闲着、请求排队——这就是同步IO的经典症状:每个请求都在等网络,Gunicorn 默认同步worker只能同时处理固定数量请求。当时配的是 gunicorn -w 4,4个worker,每个worker一次处理一个请求,等于并发度只有4。

最直觉的方案是加worker数量,但这是治标:worker是进程,每个占内存,加到32个worker内存直接起飞,而且下游连接数也会爆炸。真正的问题是:这三个下游请求彼此独立,完全可以并发发出。

二、环境与版本

  • Python 3.11.6(选3.11是因为asyncio在3.11有显著的任务调度优化)
  • aiohttp 3.9.1
  • Flask 2.2.5 → 保留Flask做路由层,用 asgiref 桥接(后面解释为什么不全换FastAPI)
  • gunicorn 21.2.0,worker class 换成 gunicorn.workers.gthread.GThreadWorker 或直接用 uvicorn
  • 压测工具:wrk 4.2.0

先说明一个决策:为什么不直接把Flask换成FastAPI?因为这个服务还有十几个同步接口,全量迁移风险大。我的做法是只把这个热点接口改成异步,用 asgiref.sync.async_to_sync 在Flask里跑协程——后来发现这条路在gunicorn下有问题,最终这个接口单独拆了一个uvicorn进程,监听不同端口,网关层做路由。下面会讲这个坑。

三、方案设计

核心思路三条:

  1. 并发发起下游请求:三个请求用 asyncio.gather 并发,总耗时从 3×100ms 降到 max(100ms)。
  2. 连接复用:aiohttp 的 ClientSession 持有连接池,避免每次请求重新TCP握手。默认 limit=100,我按压测结果调到 limit=200,limit_per_host=50。
  3. 并发上限保护:不能让无限请求同时打下游。用 asyncio.Semaphore 限制同时进行的下游调用总数,超出的排队等待,配合 asyncio.wait_for 做超时兜底。

架构上,接口拆成独立进程:

网关(Nginx) → /api/aggregate → uvicorn:8100 (asyncio版)
            → 其它路由      → gunicorn:5000 (原Flask)

四、核心实现

4.1 after 代码

# after: async_app.py  (aiohttp 3.9.1, Python 3.11.6)
import asyncio
import aiohttp
from aiohttp import web, ClientTimeout

PROFILE_URL = "http://profile-svc:8001/user/{}"
ORDER_URL = "http://order-svc:8002/orders?uid={}"
RECO_URL = "http://reco-svc:8003/list/{}"

# 全局信号量:限制同时在途的下游请求总数
DOWNSTREAM_SEM = asyncio.Semaphore(64)

async def fetch(session: aiohttp.ClientSession, url: str, timeout: float = 2.0):
    async with DOWNSTREAM_SEM:
        try:
            async with session.get(url, timeout=ClientTimeout(total=timeout)) as resp:
                resp.raise_for_status()
                return await resp.json()
        except (aiohttp.ClientError, asyncio.TimeoutError) as e:
            # 单个下游失败不拖垮整个接口,返回降级值
            return {"error": type(e).__name__, "url": url}

async def aggregate(request: web.Request) -> web.Response:
    uid = request.query.get("uid")
    if not uid:
        raise web.HTTPBadRequest(text="uid required")

    session: aiohttp.ClientSession = request.app["session"]
    profile, orders, reco = await asyncio.gather(
        fetch(session, PROFILE_URL.format(uid)),
        fetch(session, ORDER_URL.format(uid)),
        fetch(session, RECO_URL.format(uid)),
    )
    return web.json_response({"profile": profile, "orders": orders, "reco": reco})

async def make_app() -> web.Application:
    app = web.Application()
    # 连接池参数:limit 总连接数,limit_per_host 单host上限
    connector = aiohttp.TCPConnector(
        limit=200,
        limit_per_host=50,
        ttl_dns_cache=300,
        enable_cleanup_closed=True,
    )
    app["session"] = aiohttp.ClientSession(
        connector=connector,
        timeout=ClientTimeout(total=3.0, connect=0.5),
    )
    app.router.add_get("/api/aggregate", aggregate)
    return app

async def on_cleanup(app):
    await app["session"].close()

if __name__ == "__main__":
    app = asyncio.run(make_app())
    app.on_cleanup.append(on_cleanup)
    web.run_app(app, host="0.0.0.0", port=8100, access_log=None)

启动:python async_app.py,或者生产用 uvicorn async_app:make_app --factory --workers 1 --loop uvloop --http httptools --port 8100。注意 workers 必须为 1——asyncio 单进程单事件循环,多worker会各自建连接池,反而稀释了连接复用收益,而且 Semaphore 是进程内的,多进程下限流失效。要扩并发靠单进程内的协程数,要扩CPU靠多机,不要用多worker。

4.2 连接池与超时参数怎么定的

几个关键参数和我的取值依据:

  • limit=200:压测时从100加到200,QPS从1600涨到2050;到300时下游开始报连接拒绝,所以停在200。
  • limit_per_host=50:三个下游各50,留余量给健康检查。
  • connect=0.5:建连超时0.5s,下游都在内网,正常建连<10ms,0.5s足够,避免慢建连拖住事件循环。
  • total=2.0(单请求)/ total=3.0(session级):下游P99约300ms,2s是给异常留的兜底。
  • Semaphore(64):这是下游总并发的闸门,不是接口并发。64 = 200连接池里同时活跃的上限,实测超过64后下游P99开始劣化,就定在这。

五、踩坑与优化

坑1:Flask里用async_to_sync,gunicorn sync worker下直接死锁。 我一开始想省事,在Flask路由里 async_to_sync(aggregate)(),本地跑通了,上gunicorn(sync worker)后压测直接hang住。原因是sync worker里没有运行中的事件循环,asgiref每次新建loop,而aiohttp的session是在另一个loop里创建的,跨loop使用连接池报错/挂起。教训:aiohttp的ClientSession必须和事件循环同生命周期,别想着在同步框架里偷懒复用。

坑2:ClientSession在模块顶层创建。 很多人写成 session = aiohttp.ClientSession() 放模块级,Python 3.10+ 下import时没有running loop,直接 RuntimeError: no running event loop。必须放进 make_app() 里、在loop内创建,并用 on_cleanup 关闭。我第一次就是这么写的,服务起不来。

坑3:超时用 asyncio.wait_for 包 session.get,连接没释放。 早期版本我用 wait_for(session.get(url), 2),超时后协程被cancel,但底层连接有时没归还池子,压测久了报 Connection pool is full。正确做法是给 session.get 传 ClientTimeout,让aiohttp自己管理超时和连接释放,而不是在外面套 wait_for。

优化1:uvloop。 换 uvloop 后同压力下QPS +18%,P99降了约15ms。启动加 --loop uvloop。

优化2:gather 加 return_exceptions=True。 我最初的 fetch 内部try/except已经兜住了,但如果哪天有人改代码漏了,一个下游异常会让整个 gather 抛错。保险起见改成 asyncio.gather(..., return_exceptions=True),再统一处理。

优化3:DNS缓存。 ttl_dns_cache=300,内网服务名解析虽然快,但高QPS下DNS查询也是开销,缓存5分钟。

六、效果数据

同一台机器(4C8G),wrk 4线程100连接30秒,下游用mock服务固定延迟100ms:

指标 before (Flask+requests, 4 worker) after (aiohttp+uvloop, 1 worker)
QPS 121.83 2104.6
P50 延迟 780ms 142ms
P99 延迟 1.82s 213ms
CPU 使用率 8% 62%
内存 RSS 201MB 138MB
下游连接数峰值 ~4 ~180(复用,实际TCP连接<60)

QPS 提升约17倍,P99 从1.8s降到213ms。注意CPU从8%涨到62%——这不是变差了,是之前CPU在空转等IO,现在真的在干活。内存降了是因为单进程替代了4个Flask worker,且连接复用减少了socket缓冲。

真实下游(非mock)环境复测:QPS 1850左右,P99 240ms,比mock略低,因为下游本身P99有波动,但量级一致。

七、总结

这次改造的核心其实就一句话:IO密集的接口,串行改并发,同步改异步,收益是数量级的。但有几个点值得记住:

  1. 别在同步框架里硬塞asyncio,ClientSession和事件循环必须同生命周期,跨loop必炸。
  2. 连接池参数(limit、limit_per_host)和 Semaphore 要一起调,前者管TCP复用,后者管下游压力,缺一个都会在高压下出问题。
  3. 超时交给aiohttp的 ClientTimeout,别在外面套 wait_for,否则连接泄漏。
  4. asyncio服务用单worker+uvloop,扩并发靠协程,扩CPU靠多机,别用 --workers N。

如果你的服务里也有这种"多个独立下游串行调用"的接口,可以按这个路子先改一个试试,压测数据会说服你的。代码里那个 fetch 的降级返回也建议保留——异步并发下,一个下游抖动很容易放大成雪崩,单点失败降级比整体超时划算得多。