一、问题背景:一个"看起来没毛病"的接口
事情是这样的。我们有个商品详情聚合接口 /api/v1/product/{id},逻辑很朴素:拿商品ID,分别去用户服务查卖家信息、去订单服务查最近成交、去库存服务查实时库存,拼一起返回。
代码大概长这样(简化版):
# before: app/api/product.py
import requests
from fastapi import FastAPI
app = FastAPI()
USER_SVC = "http://user-svc:8001"
ORDER_SVC = "http://order-svc:8002"
STOCK_SVC = "http://stock-svc:8003"
@app.get("/api/v1/product/{pid}")
def get_product(pid: int):
user = requests.get(f"{USER_SVC}/user/by-product/{pid}", timeout=2).json()
orders = requests.get(f"{ORDER_SVC}/recent/{pid}", timeout=2).json()
stock = requests.get(f"{STOCK_SVC}/stock/{pid}", timeout=2).json()
return {"pid": pid, "seller": user, "recent_orders": orders, "stock": stock}
本地测没问题,上线后监控也"看起来还好"——平均延迟 120ms 左右。直到大促前压测,P99 直接飙到 380ms,QPS 卡在 47 上不去,一看机器 CPU 才 8%,内存也闲得很。
这就是典型的 I/O 阻塞型瓶颈:三个下游调用是串行的,每个平均 40ms,加起来 120ms 起步,而且每个请求都占着一个 worker 线程干等网络。FastAPI 默认跑在 uvicorn 上,但因为我用的是 def 而不是 async def,它会被丢到 threadpool 里执行,threadpool 默认 40 个线程(anyio 的默认值),40 个线程全在等 I/O,新请求就得排队。
47 QPS ≈ 40 线程 / (120ms 串行 + 排队开销),算得通。
二、环境与版本
先把家伙什列清楚,后面所有数字都基于这套:
- Python 3.11.6
- FastAPI 0.109.0
- uvicorn 0.27.0(
--workers 4,--loop uvloop) - httpx 0.26.0(异步客户端)
- 下游服务:3 个内部 HTTP 服务,单次 P50 约 35-45ms
- 压测机:wrk 4.2.0,
-t 8 -c 200 -d 60s - 被测机:4C8G 容器,同一内网
改造前用的是 requests 2.31.0。
三、方案设计:async 化 + 并发聚合 + 限流
思路不复杂,但要拆成三层:
第一层:把 def 改成 async def,把 requests 换成 httpx.AsyncClient。 这一步是让请求真正跑在事件循环上,而不是 threadpool。
第二层:三个下游调用没有依赖关系,用 asyncio.gather 并发。 串行 120ms → 并发取 max(40ms) ≈ 45ms。
第三层:加 asyncio.Semaphore 做下游保护。 并发上去了,如果下游某个服务抖动,雪崩会更快。给每个下游服务一个信号量,限制同时打过去的请求数,配合 httpx 的连接池上限。
关于客户端生命周期,我一开始图省事在函数里 async with httpx.AsyncClient(),每次请求新建连接池——这是个大坑,后面踩坑小节细说。正确做法是全局单例 + lifespan 管理。
四、核心实现
先看改造后的完整代码:
# after: app/api/product.py
import asyncio
import httpx
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
USER_SVC = "http://user-svc:8001"
ORDER_SVC = "http://order-svc:8002"
STOCK_SVC = "http://stock-svc:8003"
# 每个下游一个信号量,限制并发打过去的请求数
SEM_USER = asyncio.Semaphore(200)
SEM_ORDER = asyncio.Semaphore(200)
SEM_STOCK = asyncio.Semaphore(100) # 库存服务比较脆,压小一点
class Downstream:
def __init__(self):
self.client: httpx.AsyncClient | None = None
async def start(self):
# 连接池参数很关键,见下文
self.client = httpx.AsyncClient(
timeout=httpx.Timeout(2.0, connect=0.5),
limits=httpx.Limits(
max_connections=300,
max_keepalive_connections=100,
keepalive_expiry=30.0,
),
headers={"Connection": "keep-alive"},
)
async def stop(self):
if self.client:
await self.client.aclose()
downstream = Downstream()
@asynccontextmanager
async def lifespan(app: FastAPI):
await downstream.start()
yield
await downstream.stop()
app = FastAPI(lifespan=lifespan)
async def fetch_user(pid: int):
async with SEM_USER:
r = await downstream.client.get(f"{USER_SVC}/user/by-product/{pid}")
r.raise_for_status()
return r.json()
async def fetch_orders(pid: int):
async with SEM_ORDER:
r = await downstream.client.get(f"{ORDER_SVC}/recent/{pid}")
r.raise_for_status()
return r.json()
async def fetch_stock(pid: int):
async with SEM_STOCK:
r = await downstream.client.get(f"{STOCK_SVC}/stock/{pid}")
r.raise_for_status()
return r.json()
@app.get("/api/v1/product/{pid}")
async def get_product(pid: int):
try:
# return_exceptions=False:任一失败直接抛,由外层统一处理
user, orders, stock = await asyncio.gather(
fetch_user(pid),
fetch_orders(pid),
fetch_stock(pid),
)
except httpx.HTTPStatusError as e:
raise HTTPException(status_code=502, detail=f"downstream error: {e.response.status_code}")
except (httpx.ConnectTimeout, httpx.ReadTimeout) as e:
raise HTTPException(status_code=504, detail=f"downstream timeout: {type(e).__name__}")
return {"pid": pid, "seller": user, "recent_orders": orders, "stock": stock}
启动命令:
uvicorn app.main:app --host 0.0.0.0 --port 8080 \
--workers 4 --loop uvloop --http httptools --no-access-log
几个参数说明下为什么这么选:
max_connections=300:单 worker 最多 300 个 TCP 连接,4 个 worker 就是 1200,够 200 并发打满。太小会导致排队等连接,太大容易把下游打挂。keepalive_expiry=30.0:下游服务的 nginxkeepalive_timeout是 60s,设 30s 保证连接在被对端关掉前主动回收,避免Connection reset by peer。connect=0.5:内网连接 500ms 超时足够了,快速失败比傻等好。SEM_STOCK=100:库存服务是共享的、比较脆,单独压小,这是业务上的保护,不是技术上的。
另外注意 asyncio.gather 默认 return_exceptions=False,第一个异常就抛出来。但有个细节:gather 抛异常时,其它协程并不会被自动取消,它们会继续跑完(只是结果被丢弃)。如果需要"一个失败全部取消",得用 TaskGroup(Python 3.11+)或者手动 asyncio.wait(FIRST_EXCEPTION)。这里下游都是幂等查询,让它跑完无所谓,所以没处理。
五、踩坑与优化
坑 1:每次请求新建 AsyncClient。 第一版我写的是 async with httpx.AsyncClient() as client:,压测 QPS 反而比改之前还低。原因很简单:每次新建连接池就要重新 TCP 三次握手 + TLS(我们内部虽然是 HTTP,但握手也是成本),连接复用完全没了。改成 lifespan 管理全局单例后,QPS 直接翻倍。这个坑很经典,httpx 官方文档其实有写,但我当时没仔细看。
坑 2:asyncio.Semaphore 在跨事件循环复用。 一开始我把 semaphore 定义成模块级变量,Python 3.10 之前 semaphore 会绑定创建时的 loop,多 worker 或者测试环境里复用会报 got Future attached to a different loop。Python 3.10+ 好一些,但稳妥起见还是在 lifespan 里创建,或者确保模块级创建时机在 loop 起来之后。我们跑 3.11,实测模块级没问题,但为了清晰还是保留了模块级(semaphore 本身不依赖 loop,只有内部 waiter 才依赖)。
坑 3:超时没设,一个慢下游拖垮全局。 改造前 requests 有个 timeout=2,改造后我一开始忘了给 httpx 设超时,默认是 5s 而且只是总超时。结果库存服务某次抖动 4s,gather 就等 4s,P99 直接爆。后来改成 httpx.Timeout(2.0, connect=0.5),并且把库存的 semaphore 压到 100,双保险。
坑 4:worker 数量和 semaphore 的关系。 我一开始以为 semaphore 是全局的,设 200 就是全机器 200 并发。实际是 每个 worker 进程独立一份,4 个 worker 就是 800 并发。这个认知偏差导致我一开始把 semaphore 设太小,白白限制了吞吐。后来按"单 worker 并发上限"来算,才调对。
优化:uvloop + httptools。 默认 asyncio 的 loop 性能一般,换 uvloop 后同压测条件下 QPS 大概再涨 15% 左右。httptools 是 uvicorn 的 HTTP 解析器,比默认的 h11 快,--http httptools 开上。
六、效果数据
压测条件统一:wrk -t 8 -c 200 -d 60s,4 worker,同一台 4C8G 容器,下游服务不变。
| 指标 | before (requests + def) | after (httpx + async) | 变化 |
|---|---|---|---|
| QPS | 47 | 2100 | 44.7x |
| P50 延迟 | 118ms | 41ms | -65% |
| P99 延迟 | 380ms | 62ms | -84% |
| CPU 使用率 | 8% | 55% | — |
| 内存 | 210MB | 260MB | +24% |
P99 从 380ms 到 62ms,主要收益来自并发聚合(串行 120ms → 并发 45ms)加上连接复用减少了握手开销。QPS 涨 44 倍,是因为原来 40 个 threadpool 线程全在阻塞等 I/O,现在一个事件循环能同时挂几千个协程。
CPU 从 8% 涨到 55%,这个不是"变差了",是原来根本没把机器用起来。55% 是个比较健康的水位,再往上压 QPS 还能涨,但下游先扛不住了——压到 2500 QPS 时库存服务开始超时,所以 2100 是我们认为的安全水位。
内存涨了 50MB,主要是连接池和协程栈,可以接受。
七、总结
这次改造说复杂不复杂,核心就三件事:async 化、并发聚合、连接池复用。但真正让数字好看的是那些细节——全局 client 单例、超时分层设置、semaphore 按 worker 算、uvloop 加速。
几个可以直接抄走的经验:
- FastAPI 里凡是调外部 HTTP 的接口,一律
async def+httpx.AsyncClient,别用def+requests,threadpool 是隐形的并发天花板。 AsyncClient必须是全局单例,用 lifespan 管理生命周期,别在函数里async with。asyncio.gather适合无依赖的并发聚合,但要注意异常时其它协程不会自动取消;需要"全有或全无"语义就用 3.11 的TaskGroup。- semaphore 是 per-process 的,算并发上限要乘以 worker 数。
- 超时一定要分层设:connect 短、read 适中、总超时兜底。没有超时的异步代码比同步代码更危险,因为你以为它在并发,其实它在堆积。
最后说句实话:异步不是银弹。如果瓶颈在 CPU 或者数据库连接数,async 救不了你。但如果是 I/O 等待型,且下游能扛住并发,那这个改造的性价比极高——我们这次改动不到 100 行代码,机器没加一台,QPS 翻了 40 多倍。