一、问题背景:一个被同步 IO 拖死的查询接口
先说场景。我们有个内部商品聚合查询接口 GET /api/v1/product/{id},逻辑很简单:拿商品 ID,去三个下游服务取数据,拼装后返回。
- 商品基础信息:商品中心,HTTP 接口,平均耗时 80ms
- 库存:库存服务,HTTP 接口,平均耗时 120ms
- 价格:价格服务,HTTP 接口,平均耗时 60ms
原来的实现是 Flask + requests,三个调用串行写:
# before: app_sync.py
import requests
from flask import Flask, jsonify
app = Flask(__name__)
BASE = "http://internal-gateway/api"
@app.route("/api/v1/product/")
def get_product(pid):
# 三次串行 HTTP 调用,累计耗时 = 80 + 120 + 60 = 260ms 起步
r1 = requests.get(f"{BASE}/product/{pid}", timeout=1)
r2 = requests.get(f"{BASE}/stock/{pid}", timeout=1)
r3 = requests.get(f"{BASE}/price/{pid}", timeout=1)
if not (r1.ok and r2.ok and r3.ok):
return jsonify({"error": "upstream failed"}), 502
return jsonify({
"product": r1.json(),
"stock": r2.json(),
"price": r3.json(),
})
if __name__ == "__main__":
# 同步 WSGI 部署,gunicorn -w 4 -k sync 起步
app.run(port=8000)
线上部署是 gunicorn -w 4 -k sync -b 0.0.0.0:8000 app_sync:app,4 核机器开 4 个 worker。
问题很典型:每个 worker 同一时刻只能处理一个请求,一个请求里 260ms 全在等网络 IO,CPU 基本闲着。4 个 worker 理论上限就是 4 / 0.26s ≈ 15 QPS,再加上下游抖动、GC、日志,压测出来的实际 QPS 只有 120 左右(下游有本地缓存,实际均值比 260ms 低)。
更难受的是 P99。下游偶尔抖一下,串行链路会被放大:任意一个下游 1s 超时,整个请求就 1s+。压测时 P99 跑到 1.8s,业务方天天来问。
这不是代码写得烂,是同步阻塞模型的天然上限。要动就得换模型。
二、环境与版本
改造时的环境,都是真实跑在生产的版本:
- Python 3.11.6(3.11 的 asyncio 性能比 3.8 好不少,TaskGroup 也能用)
- FastAPI 0.104.1
- uvicorn 0.24.0,
--workers 4 - httpx 0.25.1(异步客户端,替代 requests)
- gunicorn 21.2.0(用
uvicorn.workers.UvicornWorker) - 压测工具:wrk 4.2.0,
wrk -t4 -c200 -d60s
机器:4 核 8G,下游服务在内网,RTT 约 1ms,所以耗时基本是下游自身处理时间。
选 FastAPI 不选 aiohttp 的原因很朴素:团队已有 FastAPI 项目,Pydantic 校验现成,迁移成本最低。核心不是框架,是 asyncio 的并发模型。
三、方案设计:把串行等待改成并发等待
核心思路一句话:三个下游互不依赖,没必要排队,应该并发发出去。
改造分三层:
- Web 层:Flask 同步 WSGI 换成 FastAPI + uvicorn,单 worker 内部用事件循环处理并发请求。
- HTTP 客户端:
requests换成httpx.AsyncClient,全局复用一个 client,走连接池,避免每次建连。 - 聚合逻辑:
asyncio.gather并发调用三个下游,配合asyncio.wait_for做整体超时,asyncio.Semaphore限制单实例对下游的并发数,防止打爆下游。
理论收益:串行 260ms 变成 max(80,120,60)=120ms,延迟直接砍一半。吞吐上,一个 worker 在等待 IO 时可以处理成百上千个协程,QPS 不再受 worker 数 × 单请求耗时限制。
设计上有几个刻意的取舍:
- 不用
gather的默认行为兜底:gather默认一个任务抛异常就整体失败,但我们希望库存服务挂了也能返回商品信息(降级),所以每个下游包一层,异常转成None。 - 超时分层:单下游 800ms,整体聚合 1s。单下游超时不影响其他,整体超时兜底。
- Semaphore 按下游分别设,不是全局一个。商品中心给 200,库存给 100(它最慢最脆),价格给 200。
四、核心实现
先看异步客户端的封装。关键是 client 全局复用 + 生命周期管理,不要在每个请求里 async with httpx.AsyncClient(),那样连接池白建了。
# clients.py
import httpx
BASE = "http://internal-gateway/api"
# 全局单例,在 FastAPI lifespan 里创建和关闭
_http_client: httpx.AsyncClient | None = None
def get_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient(
base_url=BASE,
timeout=httpx.Timeout(0.8, connect=0.3), # 单请求 800ms,建连 300ms
limits=httpx.Limits(
max_connections=300, # 连接池总上限
max_keepalive_connections=100, # 长连接保留数
keepalive_expiry=30.0,
),
)
return _http_client
async def close_client() -> None:
global _http_client
if _http_client is not None:
await _http_client.aclose()
_http_client = None
然后是聚合服务。这里我用 asyncio.Semaphore 控制下游并发,用 wait_for 做单请求超时,异常统一转 None 实现降级:
# service.py
import asyncio
import logging
from clients import get_client
logger = logging.getLogger(__name__)
# 按下游分别限流,库存服务最脆,给最小配额
SEM_PRODUCT = asyncio.Semaphore(200)
SEM_STOCK = asyncio.Semaphore(100)
SEM_PRICE = asyncio.Semaphore(200)
PER_CALL_TIMEOUT = 0.8 # 单个下游超时
TOTAL_TIMEOUT = 1.0 # 整体聚合超时
async def _fetch(path: str, sem: asyncio.Semaphore, name: str):
client = get_client()
async with sem:
try:
resp = await asyncio.wait_for(
client.get(path), timeout=PER_CALL_TIMEOUT
)
resp.raise_for_status()
return resp.json()
except (asyncio.TimeoutError, httpx.HTTPError) as e:
# 降级:记录并返回 None,不拖垮整个请求
logger.warning("upstream %s failed: %r", name, e)
return None
async def get_product_detail(pid: int) -> dict:
# 三个下游并发发出
tasks = [
_fetch(f"/product/{pid}", SEM_PRODUCT, "product"),
_fetch(f"/stock/{pid}", SEM_STOCK, "stock"),
_fetch(f"/price/{pid}", SEM_PRICE, "price"),
]
try:
product, stock, price = await asyncio.wait_for(
asyncio.gather(*tasks), timeout=TOTAL_TIMEOUT
)
except asyncio.TimeoutError:
logger.error("aggregate timeout for pid=%s", pid)
return {"error": "aggregate timeout"}
# 商品信息是必需的,缺了就算失败;库存/价格可降级
if product is None:
return {"error": "product unavailable"}
return {
"product": product,
"stock": stock,
"price": price,
}
FastAPI 入口,用 lifespan 管理 client 生命周期:
# app_async.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from clients import close_client
from service import get_product_detail
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
await close_client()
app = FastAPI(lifespan=lifespan)
@app.get("/api/v1/product/{pid}")
async def product(pid: int):
return await get_product_detail(pid)
部署命令换成:
gunicorn app_async:app \
-w 4 \
-k uvicorn.workers.UvicornWorker \
-b 0.0.0.0:8000 \
--timeout 30
注意 -k uvicorn.workers.UvicornWorker,这是让 gunicorn 用异步 worker 的关键,写错成默认 sync worker,所有异步代码等于白写(这个坑后面细说)。
五、踩坑与优化
坑一:gunicorn worker 类型写错,性能纹丝不动。
第一次上线我沿用了 -k sync,代码是 async def,uvicorn 能跑,但每个请求实际上还是在同步 worker 里被 asyncio.run 包着执行,并发能力等于零。压测 QPS 还是 130 左右,我还以为是 asyncio 没用。换成 UvicornWorker 后 QPS 直接翻了十几倍。教训:异步代码 + 同步 worker = 白改。
坑二:连接池耗尽导致大量请求排队。
初期我把 max_connections 设成 100,压测到 1500 QPS 时开始出现 httpx.PoolTimeout。原因是一个请求并发发 3 个下游调用,200 并发 × 3 = 600 个并发连接需求,池子只有 100,全在排队。把 max_connections 提到 300、max_keepalive_connections 提到 100 后解决。经验值:连接池上限 ≈ 预期峰值并发 × 单请求下游调用数 × 1.2。
坑三:在 async 函数里写了同步代码,事件循环被卡死。
有个日志里顺手写了 requests.get 做埋点上报,还有一处用了同步的 redis 客户端。单看每次几毫秒,但事件循环是单线程的,这些同步调用会把整个 worker 卡住,P99 直接飙到 2s。排查用 asyncio.debug 模式(PYTHONASYNCIODEBUG=1)能打出慢回调告警。全部换成 httpx 和 redis.asyncio 后恢复。async 函数里不允许出现任何阻塞调用,这是铁律。
另外两个小优化:
- 给
httpx.Timeout单独设了connect=0.3,建连超时比读超时短,避免下游网络问题拖长尾。 - 商品信息是必需项,我加了个 30s 的本地 TTL 缓存(
cachetools.TTLCache),命中率约 35%,进一步压低了商品中心的压力。
六、效果数据
同一台 4 核 8G 机器,wrk -t4 -c200 -d60s,下游服务状态一致:
| 指标 | 改造前 (Flask+requests) | 改造后 (FastAPI+asyncio) | 变化 |
|---|---|---|---|
| QPS | 120 | 2100 | 17.5x |
| P50 延迟 | 240ms | 118ms | -51% |
| P95 延迟 | 920ms | 190ms | -79% |
| P99 延迟 | 1800ms | 210ms | -88% |
| 单请求下游耗时 | 260ms(串行) | 120ms(并发) | -54% |
| 4C8G 内存占用 | 约 600MB | 约 750MB | +25% |
内存涨了 25%,主要是连接池和协程栈的开销,完全可接受。CPU 使用率从改造前的 8% 涨到 45% 左右——这才正常,说明机器终于干活了。
下游视角:库存服务的峰值 QPS 从 120 涨到约 700(因为聚合接口整体吞吐上去了),但因为有 Semaphore 限流 100 并发,没有出现被打爆的情况,反而比改造前更平稳。
七、总结
这次改造的核心不是"换了个框架",而是把阻塞等待换成了并发等待。三条经验:
- 先判断瓶颈是不是 IO 等待。如果是 CPU 密集,asyncio 帮不了你,还得靠多进程。我们这个场景 90% 时间在等下游,asyncio 是正解。
- 异步化的收益全在细节里:worker 类型、连接池大小、超时分层、限流配额,任何一个配错,性能就回退到同步水平。上线前一定用真实压测验证。
- 降级比并发更重要。并发之后单点故障的放大效应更明显,必须给每个下游设超时和降级策略,否则一个慢下游能把整个接口拖死。
代码量没增加多少,但延迟和吞吐是两个数量级的差别。如果你的接口也是"一个请求串行调好几个下游",值得花半天改一下。