《Python编程实战》16.3 压测、容量评估与限流降级

从「单个请求快」到「并发不雪崩」:用 concurrent.futures 与 time.perf_counter 自写小压测器打出 QPS 与延迟分位数,讲清容量曲线与 Little Law 的容量评估,真跑令牌桶限流(含 429 端到端)与超时兜底的降级策略,收成上线前的容量清单。

本节目标:把「单个请求快」升级为「并发下不雪崩」——用标准库自写压测器打出容量曲线,用 Little’s Law 做容量评估,用令牌桶真跑限流,用超时兜底做降级,最后收成一份上线前的容量清单。
适用版本:Python 3.12+(实测 3.14.6);uvicorn 0.54.0、fastapi 0.143.0

16.3 压测、容量评估与限流降级

16.2 优化的是「一个请求有多快」,但生产系统的故障往往不是「单请求慢」,而是并发一上来就雪崩:吞吐不升反降、延迟指数级恶化、连锁拖垮下游。要防住它,必须先知道系统的容量边界在哪,再在边界处装上限流与降级两道闸。

16.3.1 压测要回答的三个问题

压测不是「把 QPS 打到最高」这么简单,它要回答三个具体问题:

  1. 容量有多大:在当前资源下,稳定吞吐的上限是多少?
  2. 拐点在哪:并发加到多少,延迟开始失控、吞吐开始下降?
  3. 代价是多少:达到那个吞吐时,P99 延迟是多少?用户能不能接受?

本机未安装 locust,所以本节不用它。 下面用标准库 concurrent.futures + time.perf_counter 自写一个几十行的小压测器——机制和 locust 完全一致(固定并发、持续施压、统计分位数),只是没有 Web UI。

先起一个被测服务,用 FastAPI + uvicorn(单 worker),一个「耗时 10ms」的接口:

# app.py
import asyncio
from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
async def health():
    return {"status": "ok"}

@app.get("/items/{item_id}")
async def get_item(item_id: int):
    await asyncio.sleep(0.01)          # 模拟 10ms 下游/DB 耗时
    return {"item_id": item_id, "name": f"item-{item_id}"}

启动(单进程,模拟最小部署单元):

python -m uvicorn app:app --host 127.0.0.1 --port 8000 --log-level warning

16.3.2 自写小压测器

压测器的骨架:每个工作线程持有一条长连接,在「截止时间」前不断发请求并逐次计时,最后汇总所有延迟求分位数。

import http.client, statistics, time
from concurrent.futures import ThreadPoolExecutor

HOST, PORT = "127.0.0.1", 8000

def worker(path: str, stop_at: float) -> list[float]:
    latencies = []
    conn = http.client.HTTPConnection(HOST, PORT)
    conn.request("GET", "/health"); conn.getresponse().read()   # 预热连接
    while time.perf_counter() < stop_at:
        t0 = time.perf_counter()
        conn.request("GET", path)
        conn.getresponse().read()
        latencies.append((time.perf_counter() - t0) * 1000.0)   # ms
    conn.close()
    return latencies

def run_load(path: str, concurrency: int, duration: float) -> dict:
    stop_at = time.perf_counter() + duration
    latencies: list[float] = []
    t_start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=concurrency) as pool:
        for part in pool.map(lambda _: worker(path, stop_at), range(concurrency)):
            latencies.extend(part)
    elapsed = time.perf_counter() - t_start
    latencies.sort()
    pct = lambda p: latencies[min(int(len(latencies) * p), len(latencies) - 1)]
    return {"concurrency": concurrency, "requests": len(latencies),
            "qps": len(latencies) / elapsed, "p50": pct(0.50),
            "p95": pct(0.95), "p99": pct(0.99), "max": latencies[-1]}

三个设计点:用 http.client 长连接逐请求计时,避免连接建立开销污染数据;用 stop_at 截止时间而非固定请求数,保证每个并发档都压够时长;统计分位数而非平均值——平均值会掩盖长尾,P99 才是用户体验的真相。

16.3.3 读容量曲线

让并发从 1 递增到 200,每档压 3 秒,真跑结果(本机实测):

  并发     请求数      QPS      P50      P95      P99       最大
   1     197       66    13.7m    23.7m    40.3m    43.7m
  10    1951      647    13.7m    19.1m    73.8m   122.2m
  50    9587     3178    12.8m    16.3m   158.7m   168.4m
 100   10457     3450    24.6m    32.4m   182.3m   199.0m
 200    8633     2821    50.1m   161.4m   249.5m   319.8m

这张表里藏着全部答案,逐档读:

  • 并发 1 → 50,QPS 线性上涨(66 → 3178),P50 稳定在 13ms 左右——这是未饱和区,加并发就加吞吐,延迟不变。
  • 并发 50 → 100,QPS 几乎不再涨(3178 → 3450,+8%),但 P50 从 12.8ms 涨到 24.6ms——这是拐点(knee):吞吐见顶,多出来的并发只能排队,转化为延迟。
  • 并发 100 → 200,QPS 反而下降(3450 → 2821),P99 飙到 249ms、最大 320ms——这是过饱和崩塌:请求排队挤占资源,上下文切换开销反噬,系统进入恶性循环。

结论:这个服务的稳定容量约在 3200 QPS、并发 50 左右;超过 100 并发就该限流,而不是继续加压。 这就是容量评估的核心产出——一条带拐点的曲线,而不是一个孤零零的「最大 QPS」。

16.3.4 Little’s Law:容量评估的公式

压测给出的是「点」,要推算和解释需要一把公式——Little’s Law:

L = λ × W
  • L:系统中的在途请求数(in-flight),稳态下约等于并发数;
  • λ:吞吐(QPS);
  • W:平均延迟(秒)。

三者知道两个就能算第三个。用它校验上面的数据:并发 50、QPS 3178 时,W = L / λ = 50 / 3178 ≈ 15.7 ms——和实测 P50 12.8ms、P95 16.3ms 吻合,说明系统在该点接近稳态。

Little’s Law 最大的用处是反推容量:如果业务要求 P95 ≤ 50ms,且单请求平均耗时 15ms,那么单实例能支撑的并发 L = λ × W——给定目标延迟 W=0.05s 和单实例吞吐上限 λ=3200,L = 3200 × 0.05 = 160。当在途请求超过 160,延迟必然突破 50ms。这个「160」就是该给限流器设的阈值,也是「该扩几个实例」的依据。容量评估不是拍脑袋,而是用 L = λW 把延迟目标翻译成并发上限。

16.3.5 令牌桶限流

既然知道了上限,就要在入口处装一道闸。令牌桶(Token Bucket)是最常用的限流算法:桶以固定速率 rate 补充令牌,容量上限 capacity,每个请求取走一个令牌,取不到就拒绝。它同时表达两个语义:平均速率由 rate 决定,突发能力由 capacity 决定。

import time
from dataclasses import dataclass, field

@dataclass
class TokenBucket:
    rate: float                        # 每秒补充令牌数
    capacity: float                    # 桶容量(突发上限)
    tokens: float = field(init=False)
    last: float = field(init=False)

    def __post_init__(self):
        self.tokens = self.capacity    # 初始满桶
        self.last = time.monotonic()

    def allow(self, n: float = 1.0) -> bool:
        now = time.monotonic()
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
        self.last = now
        if self.tokens >= n:
            self.tokens -= n
            return True
        return False

真跑:桶设为 rate=100, capacity=100,然后以约 1000 QPS(每 1ms 一个)冲击 1 秒:

时长 1.52s
放行 251  拒绝 749
理论上限 = 初始 100 + 100/s x 1.52s = 252,实测放行 251

结果精确对上理论:初始满桶一次性放行 100(突发),之后按 100/s 匀速补充,1.52 秒共放行 251 ≈ 252。这就是令牌桶的精髓——它允许短时突发(100 个瞬时请求),但把长期速率锁死在 rate。对比之下,「固定窗口计数器」无法处理突发边界,「漏桶」则完全抹平突发。给用户接口留一点突发余量(capacity)但锁死平均速率(rate),令牌桶是默认选择。

16.3.6 端到端限流:把 429 打进服务

把令牌桶挂到 FastAPI 的接口上,超限返回 HTTP 429:

from fastapi import FastAPI, HTTPException

app = FastAPI()
bucket = TokenBucket(rate=200, capacity=200)     # 200 QPS

@app.get("/limited")
async def limited():
    if not bucket.allow():
        raise HTTPException(status_code=429, detail="rate limited")
    return {"ok": True}

真跑:50 个并发线程各打 40 个请求(共 2000 个),统计状态码分布:

状态码分布: {200: 344, 429: 1656}
总请求: 2000

2000 个请求里 344 个放行、1656 个被限流拒绝(429)。344 这个数字正是「初始 200 + 压测期间按 200/s 补充」的结果——限流器在服务被打爆之前,把多余的流量挡在了门外,保护了后端的真实处理能力。

生产里限流的实现要点:阈值要按实例数分配(4 个实例各限 200,总 800,而不是每实例都限 800);多实例要用共享存储(Redis 原子计数,本机可用 fakeredis 模拟);被限流要返回 Retry-After,让客户端知道何时重试。

16.3.7 降级:超时 + 兜底

限流挡住的是「过多的请求」,但有时少量请求本身就慢——下游超时、依赖抖动。这时需要降级:给下游调用设一个时间预算,超时就返回兜底值(缓存数据、默认值、精简结果),保证主流程不被拖死。

import asyncio

CACHE = {"home": {"banner": "welcome", "hot": [1, 2, 3]}}

async def slow_downstream(name, delay):
    await asyncio.sleep(delay)
    return {"banner": "fresh", "hot": [9, 8, 7], "name": name}

async def get_page(name, budget):
    try:
        async with asyncio.timeout(budget):     # 3.11+ 时间预算
            return await slow_downstream(name, delay=0.5)
    except TimeoutError:
        return {**CACHE.get(name, {}), "degraded": True}   # 兜底:返回缓存 + 标记

真跑(本机实测):

预算 1.0s  -> {'banner': 'fresh', 'hot': [9, 8, 7], 'name': 'home'}  (耗时 502 ms)
预算 0.05s -> {'banner': 'welcome', 'hot': [1, 2, 3], 'degraded': True}  (耗时 52 ms)

时间预算充足时返回新鲜数据;预算收紧到 50ms 时,在 52ms 内返回了缓存的兜底数据并打上 degraded 标记,而不是死等 500ms。这就是降级的关键:宁可返回「旧但可用」的数据,也不让一个慢依赖拖垮整个页面。配套手段还有:熔断(连续失败到阈值就直接短路,不再尝试)、降级开关(配置中心一键关掉非核心功能)。

16.3.8 上线前的容量清单

把本节结论固化成一份可勾选的清单,上线前逐条过:

项要回答的问题本节的工具
容量稳定 QPS 上限?拐点在哪个并发?自写压测器 + 容量曲线
延迟目标延迟对应的并发上限?Little’s Law L = λW
限流阈值设多少?超限返回什么?令牌桶 + 429 + Retry-After
降级慢依赖的时间预算?兜底数据?asyncio.timeout + 缓存兜底
熔断连续失败几次短路?计数窗口 + 半开重试
观测线上 QPS/延迟/拒绝率可见吗?第 17 章的指标与追踪

一条最容易被忽略的原则:限流和降级的阈值,必须来自压测数据,而不是拍脑袋。没有容量曲线的限流阈值,要么太松(没挡住雪崩)要么太紧(误伤正常流量)。

延伸阅读

小结

  • 压测要回答容量、拐点、代价三个问题,产出是一条带拐点的曲线,不是一个最大 QPS。
  • 自写压测器(concurrent.futures + perf_counter + 长连接)几十行就能打出 QPS 与 P50/P95/P99;locust 本机未装。
  • 容量曲线三段:未饱和区吞吐随并发线性涨;拐点处吞吐见顶、延迟抬头;过饱和区吞吐反降、P99 爆炸。
  • Little’s Law L = λW 把延迟目标翻译成并发上限——它是容量评估与实例数估算的公式基础。
  • 令牌桶用 rate 锁平均速率、用 capacity 留突发;实测放行数精确等于「初始容量 + rate × 时长」。
  • 限流 + 降级是一对闸门:令牌桶挡住过量请求(429),超时兜底让慢依赖不拖垮主流程(返回缓存 + degraded 标记)。

第 16 章到此收束:16.1 找到热点、16.2 分类优化、16.3 守住容量。但「守容量」的前提是能看见线上到底发生了什么——下一章进入可观测性,用 OpenTelemetry 把一次请求跨服务的完整链路追踪下来,让性能问题在发生时就暴露,而不是等用户投诉。

阅读导航:上一节:CPU·内存·I/O 三类瓶颈的定位与优化 · 下一节:OpenTelemetry 追踪 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时