一、问题背景:一个被串行调用拖死的聚合接口

我们有个商品详情聚合接口 /api/v1/product/detail,逻辑很朴素:拿到 product_id 后,依次调用三个下游服务——商品基础信息(HTTP)、库存服务(HTTP)、促销服务(HTTP),然后拼装返回。三个下游的平均响应时间分别是 40ms、35ms、45ms,加起来 120ms 左右,理论上单请求耗时也就 130ms 上下。

但线上监控显示 P99 是 1.2 秒,单机 QPS 卡在 120 左右,高峰期 CPU 利用率只有 15%,线程池却排满了。原因很简单:Flask 默认的多线程模型下,每个请求的大部分时间都在 requests.get() 上阻塞等 IO,线程全被占住,新请求只能排队。4核机器开了 32 个 worker 线程,理论上限就是 32 / 0.13s ≈ 246 QPS,实际因为下游抖动和 GIL 争抢,只跑到 120。

这不是加机器能优雅解决的问题——加一倍机器 QPS 翻倍,但成本也翻倍,而且线程上下文切换的开销会越来越难看。真正的解法是让等待 IO 的时间不再占用线程,也就是异步。

二、环境与版本

先把环境交代清楚,异步这块版本差异影响很大:

  • Python 3.10.12(3.10 的 asyncio 已经比较稳定,asyncio.run 和 TaskGroup 之外的东西基本够用)
  • Flask 2.2.5(老服务,暂不整体迁移到 FastAPI,采用渐进式改造)
  • aiohttp 3.9.5
  • uvloop 0.19.0(Linux 下的高性能事件循环)
  • gunicorn 21.2.0 + gevent 23.9.1(过渡期用)
  • 压测工具:wrk 4.2.0,wrk -t8 -c200 -d60s

机器规格:4 核 8G,CentOS 7.9,下游服务在同一内网,RTT 约 0.5ms。

三、方案设计:不整体重写,先隔离异步边界

一开始考虑过直接把整个服务迁到 FastAPI,但服务里有二十多个接口,迁移成本和回归测试量太大。最后定的方案是渐进式:只把最热的这个聚合接口改成异步,其余接口保持不变,通过一个"异步执行器"在 Flask 里跑事件循环。

核心思路:

  1. 在应用启动时创建一个全局事件循环,跑在独立线程里(避免和 Flask 的 WSGI 线程冲突);
  2. 聚合接口把协程提交到这个循环执行,用 asyncio.run_coroutine_threadsafe 拿结果;
  3. 三个下游调用改成 aiohttp 并发发起,用 asyncio.gather 聚合;
  4. 全局复用一个 aiohttp.ClientSession,配好连接池。

这样改动的代码面很小,其他接口零影响,出问题可以一键切回同步实现(加个开关)。

另一个关键决策是并发控制。下游服务扛不住无限并发,所以对每个下游用 asyncio.Semaphore 限流,商品服务 200 并发、库存 150、促销 100,这个数字是压测调出来的,后面细说。

四、核心实现

4.1 异步执行器(Flask 里跑事件循环)

# async_runtime.py
import asyncio
import threading
from concurrent.futures import Future

class AsyncRuntime:
    def __init__(self):
        self._loop = None
        self._thread = None
        self._ready = threading.Event()

    def start(self):
        def _run():
            # Linux 下用 uvloop 替换默认事件循环,实测吞吐提升约 15%
            import uvloop
            asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
            self._loop = asyncio.new_event_loop()
            asyncio.set_event_loop(self._loop)
            self._ready.set()
            self._loop.run_forever()

        self._thread = threading.Thread(target=_run, name="async-loop", daemon=True)
        self._thread.start()
        self._ready.wait(timeout=5)
        if self._loop is None:
            raise RuntimeError("async event loop failed to start")

    def submit(self, coro, timeout=3.0):
        if self._loop is None:
            raise RuntimeError("runtime not started")
        fut: Future = asyncio.run_coroutine_threadsafe(coro, self._loop)
        return fut.result(timeout=timeout)

    def stop(self):
        if self._loop:
            self._loop.call_soon_threadsafe(self._loop.stop)


runtime = AsyncRuntime()

这里有个细节:run_coroutine_threadsafe 返回的是 concurrent.futures.Future,.result(timeout) 是阻塞调用的。所以在 Flask 的 worker 线程里,这个线程依然会被占住——但没关系,因为它占的是一个轻量线程,而真正的 IO 等待发生在事件循环线程里,一个循环线程可以同时处理成百上千个协程。Flask 那侧只要把线程数调大一点(比如 64),瓶颈就不在它身上了。

4.2 聚合接口的异步实现

# product_service.py
import asyncio
import aiohttp
from aiohttp import ClientTimeout

# 全局连接池配置:这是调优的重头戏,见 4.3
CONNECTOR = aiohttp.TCPConnector(
    limit=300,            # 总连接数上限
    limit_per_host=100,   # 单 host 上限
    ttl_dns_cache=300,    # DNS 缓存 5 分钟
    keepalive_timeout=30,
)
TIMEOUT = ClientTimeout(total=2.0, connect=0.5, sock_read=1.5)

_session: aiohttp.ClientSession | None = None

# 每个下游的并发闸门,数字来自压测
SEM_PRODUCT = asyncio.Semaphore(200)
SEM_STOCK = asyncio.Semaphore(150)
SEM_PROMO = asyncio.Semaphore(100)


def get_session() -> aiohttp.ClientSession:
    global _session
    if _session is None or _session.closed:
        _session = aiohttp.ClientSession(connector=CONNECTOR, timeout=TIMEOUT)
    return _session


async def _fetch(session, sem, url, params):
    async with sem:  # 超过闸门就排队,保护下游
        async with session.get(url, params=params) as resp:
            resp.raise_for_status()
            return await resp.json()


async def fetch_product_detail(product_id: int) -> dict:
    session = get_session()
    # 三个下游并发发起,总耗时 ≈ max(40, 35, 45) 而非 120
    product_task = _fetch(session, SEM_PRODUCT,
                          "http://product-svc/internal/detail", {"id": product_id})
    stock_task = _fetch(session, SEM_STOCK,
                        "http://stock-svc/internal/stock", {"id": product_id})
    promo_task = _fetch(session, SEM_PROMO,
                        "http://promo-svc/internal/promo", {"id": product_id})

    # return_exceptions=True 让单个下游失败不拖垮整个接口
    product, stock, promo = await asyncio.gather(
        product_task, stock_task, promo_task, return_exceptions=True
    )

    if isinstance(product, Exception):
        raise RuntimeError(f"product service failed: {product!r}")

    return {
        "product": product,
        "stock": None if isinstance(stock, Exception) else stock,
        "promo": None if isinstance(promo, Exception) else promo,
    }

Flask 侧的视图函数几乎没动:

@app.route("/api/v1/product/detail")
def product_detail():
    product_id = int(request.args["id"])
    data = runtime.submit(fetch_product_detail(product_id), timeout=2.5)
    return jsonify({"code": 0, "data": data})

4.3 连接池参数:别小看这几个数字

limit=300 / limit_per_host=100 不是拍脑袋来的。压测时先用 limit=100,QPS 到 900 就上不去了,aiohttp 开始报 Connection pool is full, discarding connection 的 warning。原因是单请求要占 3 个连接(三个下游各一个),并发 200 时理论上需要 600 个连接,但每个 host 只有 100 个上限,实际把每个下游卡在 100 并发。改成 limit_per_host=100 配合下游闸门(200/150/100)后,连接数和闸门上限对齐,warning 消失,QPS 直接上到 1500+。

keepalive_timeout=30 也是调过的:默认是 15 秒,压测时下游连接复用率不够,每次重建连接多花 1~2ms,累计起来 P99 会高 20ms 左右。改成 30 秒后连接复用率从 78% 提到 96%。

五、踩坑与优化

坑一:全局 ClientSession 在 fork 后失效。 gunicorn 用 gevent worker 时,如果 ClientSession 在 master 进程里创建,fork 到 worker 后连接池句柄是坏的,表现为随机 RuntimeError: Event loop is closed。解法是延迟到第一次请求时再创建(上面的 get_session 就是懒加载),并注册 atexit 关闭。

坑二:asyncio.gather 不带 return_exceptions 会连坐。 最初没加这个参数,促销服务偶发 500 时,整个商品详情接口直接 500。改成 return_exceptions=True 后,促销挂了就返回 promo: null,前端降级展示,可用性从 99.2% 提到 99.95%。

坑三:超时设置要分层。 一开始只设了 ClientTimeout(total=2.0),结果下游"慢但不断"的时候,请求会一直挂着直到总超时,把信号量占满。后来拆成 connect=0.5, sock_read=1.5,连接慢和读慢分开处理,信号量占用时间明显下降。另外 Flask 侧的 submit(timeout=2.5) 要略大于 aiohttp 的总超时,否则事件循环里还在跑、外面已经抛超时了,日志会对不上。

坑四:日志和 trace_id 的传递。 异步后一个请求跨了多个协程,threading.local 失效。改用 contextvars.ContextVar 存 trace_id,在 fetch_product_detail 入口 set,_fetch 里 get,日志才能串起来。这块不复杂但容易漏,漏了之后排查线上问题会很痛苦。

优化:uvloop 和 DNS 缓存。 换 uvloop 后同样的压测脚本 QPS 从 1560 提到 1800 左右。ttl_dns_cache=300 把 DNS 解析从每次请求一次降到 5 分钟一次,内网 DNS 虽然快,但省下来的 0.3ms 在 P99 上还是能看出来。

六、效果数据

同一台 4 核 8G 机器,wrk 压测 60 秒,并发 200:

指标 改造前(Flask 同步) 改造后(asyncio+aiohttp)
QPS 120 1800
P50 延迟 210ms 68ms
P99 延迟 1200ms 180ms
CPU 利用率 15% 62%
内存占用 380MB 420MB
下游错误率 0.8% 0.05%

QPS 提升 15 倍,P99 从 1.2 秒降到 180ms。CPU 利用率上来了是好事——说明机器终于被用起来了,而不是在等 IO。内存涨了 40MB,主要是连接池和事件循环的开销,可接受。

下游错误率反而降了,因为信号量限流把突发流量挡在了下游前面,下游不再被瞬时打爆。

七、总结

这次改造最大的体会是:异步不是银弹,边界要划清楚。我们没有整体迁移框架,只在最热的接口上切开一个异步边界,用 run_coroutine_threadsafe 桥接同步和异步世界,改动量控制在 300 行以内,风险可控。

几个可以直接抄走的经验:

  1. Semaphore 的数值一定要压测调,宁小勿大,保护下游比榨干自己更重要;
  2. ClientSession 全局复用 + 懒加载,连接池 limit_per_host 要和并发闸门对齐;
  3. gather(return_exceptions=True) 是聚合接口的标配,单点失败要能降级;
  4. 超时分层(connect / sock_read / total),别只设一个 total;
  5. contextvars 替代 threading.local,不然日志没法看。

下一步打算把库存扣减这类写接口也迁过来,但那涉及事务和幂等,坑会更多,等踩完再写一篇。