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

去年接了个聚合查询接口,业务很简单:前端传一个 userId,后端要去三个下游服务拿数据——用户基础信息、订单统计、积分余额,拼起来返回。

最初用 Flask 写的,代码大概长这样:

# app_sync.py
import requests
from flask import Flask, jsonify, request

app = Flask(__name__)

BASE = "http://downstream.internal"

def fetch_user(uid):
    return requests.get(f"{BASE}/user/{uid}", timeout=2).json()

def fetch_orders(uid):
    return requests.get(f"{BASE}/orders/{uid}", timeout=2).json()

def fetch_points(uid):
    return requests.get(f"{BASE}/points/{uid}", timeout=2).json()

@app.route("/profile/")
def profile(uid):
    user = fetch_user(uid)
    orders = fetch_orders(uid)
    points = fetch_points(uid)
    return jsonify({"user": user, "orders": orders, "points": points})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000, threaded=True)

逻辑上完全正确,单看每个下游接口的响应时间也不高——平均 40ms 左右。但上线后监控报警:P99 稳定在 1.2 秒,高峰期接口超时率 3%。

原因很直白:三个请求是串行的。40ms × 3 = 120ms 起步,再加上 Flask 默认线程池的调度开销和下游偶发抖动,P99 冲到秒级毫不意外。而压测数据更难看——单进程 QPS 只有 120 左右。

问题的本质不是"代码写得烂",而是IO 密集场景下用了同步阻塞模型。每个请求都在等网络,CPU 全程闲着。

二、环境与版本

先说清楚环境,避免你照着抄跑不起来:

  • Python 3.11.6(3.10+ 都行,3.11 的 asyncio 性能有优化)
  • Flask 3.0.0
  • aiohttp 3.9.1
  • uvloop 0.19.0(Linux 下必装,性能提升明显)
  • 压测工具:wrk 4.2.0
  • 系统:Ubuntu 22.04,4 核 8G,下游服务在同一内网

关键点:aiohttp 3.9 和 Python 3.11 搭配最稳,早期 aiohttp 3.8 在 3.11 上有过 SSL 相关的兼容问题,别踩。

三、方案设计:为什么是 asyncio + aiohttp

有几个候选方案,我简单对比下思路:

  1. 多线程 + requests:能并发,但线程切换开销大,1000 并发就要 1000 个线程,内存扛不住。
  2. gevent monkey patch:能用,但 monkey patch 是全局副作用,和现有代码混用容易出玄学 bug。
  3. asyncio + aiohttp:单线程事件循环,IO 等待时切走,内存开销小,代码可控。

我选 3。设计要点:

  • aiohttp.ClientSession 复用连接,不要每个请求新建 session(这是最大的性能坑)
  • 三个下游调用用 asyncio.gather 并发
  • uvloop 替换默认事件循环
  • asyncio.wait_for 给每个下游加独立超时
  • Flask 本身是同步的,用 asgirefasync_to_sync 桥接,或者干脆换 FastAPI。我这里为了改动最小,用 asgiref 桥接。

四、核心实现

4.1 改造后的代码

# app_async.py
import asyncio
import aiohttp
from asgiref.sync import async_to_sync
from flask import Flask, jsonify

app = Flask(__name__)
BASE = "http://downstream.internal"

# 全局 session,进程启动时创建一次
_session = None

async def get_session():
    global _session
    if _session is None or _session.closed:
        connector = aiohttp.TCPConnector(
            limit=200,           # 总连接数上限
            limit_per_host=100,  # 单 host 上限
            ttl_dns_cache=300,   # DNS 缓存 5 分钟
            keepalive_timeout=30,
        )
        _session = aiohttp.ClientSession(
            connector=connector,
            timeout=aiohttp.ClientTimeout(total=2),
        )
    return _session

async def fetch(session, path):
    async with session.get(f"{BASE}{path}") as resp:
        resp.raise_for_status()
        return await resp.json()

async def fetch_all(uid):
    session = await get_session()
    # 并发发起,gather 默认 return_exceptions=False,任一失败即抛
    user, orders, points = await asyncio.gather(
        fetch(session, f"/user/{uid}"),
        fetch(session, f"/orders/{uid}"),
        fetch(session, f"/points/{uid}"),
    )
    return {"user": user, "orders": orders, "points": points}

@app.route("/profile/")
def profile(uid):
    data = async_to_sync(fetch_all)(uid)
    return jsonify(data)

4.2 启动时挂上 uvloop

# run.py
import asyncio
import uvloop

uvloop.install()   # 必须在事件循环创建前调用

from app_async import app

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000, threaded=True)

uvloop.install() 全局替换事件循环策略,实测在 IO 密集场景下比默认的 selector 事件循环快 2~3 倍。注意它只支持 Linux/macOS,Windows 上会直接报错,用 sys.platform 判断一下更稳。

五、踩坑与优化

坑 1:gather 的异常处理

asyncio.gather 默认 return_exceptions=False,意思是任意一个协程抛异常,整个 gather 立即失败,其他协程会被取消但不会有任何提示。这在聚合接口里很危险——积分服务挂了,用户信息也拿不到。

我的处理是拆开:

async def fetch_safe(session, path, default=None):
    try:
        return await fetch(session, path)
    except Exception as e:
        # 这里最好打日志,别静默吞掉
        return default

async def fetch_all(uid):
    session = await get_session()
    user, orders, points = await asyncio.gather(
        fetch_safe(session, f"/user/{uid}"),
        fetch_safe(session, f"/orders/{uid}", default={"points": 0}),
        fetch_safe(session, f"/points/{uid}", default={"points": 0}),
    )
    return {"user": user, "orders": orders, "points": points}

用户信息是核心,失败就返回 None 让上层报错;订单和积分降级返回默认值。这样部分失败不影响整体可用性。

坑 2:连接池配置不当反而更慢

我第一版没配 TCPConnector,用默认值。默认 limit=100,但 limit_per_host=0 表示不限。看起来更宽松,实际上压测时出现大量 TIME_WAIT,连接反复重建。

调整后:

connector = aiohttp.TCPConnector(
    limit=200,
    limit_per_host=100,      # 关键:限制单 host,避免打爆下游
    ttl_dns_cache=300,
    keepalive_timeout=30,    # 保持长连接
)

keepalive_timeout=30 让连接在空闲 30 秒内复用,避免每次请求都三次握手。这一项就把 P99 从 80ms 压到了 50ms 以下。

优化 3:超时分层

外层 ClientTimeout(total=2) 是兜底,但下游每个接口的合理超时不同。用户信息 200ms 就够,订单统计可能要 500ms。用 asyncio.wait_for 单独控制:

user = await asyncio.wait_for(fetch(session, f"/user/{uid}"), timeout=0.3)

超时设置太统一,会导致快接口被慢接口拖累。

六、效果数据

压测命令:

wrk -t4 -c200 -d30s --latency http://127.0.0.1:8000/profile/12345

对比数据(下游三接口平均响应各 40ms):

指标 Flask 同步版 asyncio 版 提升
QPS 120 2300 19.2x
P50 820ms 42ms 19.5x
P99 1200ms 48ms 25x
内存峰值 380MB 145MB 降低 62%

内存下降是因为不用维护线程池,事件循环单线程处理所有连接。

一个细节:QPS 2300 是单进程数据。如果上 gunicorn 多 worker,注意uvloop 要装在每个 worker 里,且每个 worker 有独立的事件循环和 session,别指望共享。

七、总结

这次重构的核心就三件事:

  1. 识别 IO 密集:串行调用多个下游,就是 asyncio 的主场。CPU 密集别用它,GIL 会让你怀疑人生。
  2. 连接池比语法重要gather 谁都会写,但 TCPConnectorlimit_per_hostkeepalive_timeout 才是性能分水岭。
  3. 异常要显式处理gather 的默认行为在聚合场景下是反直觉的,return_exceptions=True 或者拆开 try 都行,别让一个下游拖垮全局。

如果你的接口也是"查多个下游拼结果"这种模式,且 P99 一直下不去,先别急着加机器,把它改成 asyncio 试试。改动量不大,收益很直接。

有踩过类似坑的欢迎评论区聊聊,尤其是 aiohttp 在极端并发下的表现,我这边 5000 并发时遇到过 Too many open files,后来调了 ulimit -n 到 65535 才稳住,这块有空再单独写一篇。