← 返回题目列表

Python 怎么做性能基准测试?微基准有哪些陷阱?

中等 第 27 / 27 题 更新于 2026/08/03
性能测试benchmarktimeitpytest-benchmark性能回归

简化版

性能测试要先分清三个层次① 微基准(microbenchmark)——测单个函数或一行代码有多快,用 timeit/pytest-benchmark② 剖析(profiling)——找出「时间花在哪」,用 cProfile/py-spy③ 负载测试(load testing)——测系统在并发下的表现,用 locust/wrk/k6三者解决的问题完全不同,最常见的错误是用微基准去回答本该用剖析回答的问题(「哪里慢」不是靠猜再基准验证,而是先 profile)。微基准的陷阱特别多① 忘了预热(第一次调用有 import、缓存未命中、JIT 未生效的开销);② 被优化掉(结果没被使用,可能整段代码被跳过);③ 测量了噪音(其他进程、CPU 频率调节、GC、内存布局都会影响,同一段代码两次运行差 20% 是常态);④ 数据不真实(用 10 个元素的列表测出来的结论,在 100 万元素时可能完全相反);⑤ 只看平均值(应该看中位数和最小值——最小值最接近「无噪音的真实耗时」,而平均值会被偶发的抖动拉高)。性能回归测试是另一回事:它不是「测出绝对性能」,而是「发现相对退化」——pytest-benchmark 可以保存基线并在新版本变慢超过阈值时失败,但由于机器噪音,阈值要设得宽松(比如 20%)否则会 flaky;更可靠的做法是看趋势而不是单次对比最重要的一条原则性能优化必须以真实场景的测量为依据——在微基准里快 3 倍的写法,放进真实系统可能因为不是瓶颈而毫无收益。核心记忆:微基准/剖析/负载测试三个层次先 profile 再优化,别猜微基准要预热、防优化、看最小值回归测试看相对退化和趋势

详细版

三个层次的定位

层次回答什么问题工具
微基准这个函数多快?A 和 B 哪个快?timeitpytest-benchmark
剖析时间花在哪?谁是瓶颈?cProfilepy-spypyinstrument
负载测试并发下 QPS/延迟如何?locustwrkk6
回归测试这次改动变慢了吗?pytest-benchmark --compare
# ① ★★timeit:标准库的微基准★★
import timeit
# ★命令行(最方便)★
# python -m timeit ★-s "setup 代码"★ "被测代码"
# python -m timeit -s "xs=list(range(1000))" "sum(xs)"
# ★★-n 次数 -r 重复轮数(默认自动choose + 5 轮取最小)★★

# ★代码里★
t = timeit.timeit(
    stmt="f(data)",
setup="from __main__ import f, data"★,   # ★★setup 不计时★★
number=10000★,
)
# ★★推荐用 repeat 取最小值★★
ts = timeit.★repeat★(stmt="f(x)", setup="...", ★number=1000, repeat=7★)
print(★min(ts)★)                              # ★★最小值最接近真实耗时★★

# ② ★★pytest-benchmark:集成到测试套件★★
def test_sort_performance(★benchmark★):
    data = list(range(10000))
    random.shuffle(data)
    ★result = benchmark(my_sort, data)★        # ★★自动多次运行取统计★★
    assert result == sorted(data)             # ★★同时验证正确性★★

# ★带 setup(每轮重置数据)★
def test_with_setup(benchmark):
    def setup():
        return ([random.random() for _ in range(1000)],), {}
    benchmark.★pedantic★(my_func, ★setup=setup★,
                        ★rounds=100, iterations=10, warmup_rounds=5★)

# ★输出:min / max / mean / stddev / median / iqr / ops★
# pytest ★--benchmark-only★              只跑基准
# pytest ★--benchmark-skip★              跳过基准
# pytest ★--benchmark-save=baseline★     ★★保存基线★★
# pytest ★--benchmark-compare=baseline★  ★★和基线对比★★
# pytest ★--benchmark-compare-fail=mean:10%★  ★★慢 10% 就失败★★
# pytest ★--benchmark-histogram★         生成直方图

# ③ ★★微基准的正确姿势★★
# ✗ 常见错误
def bad_benchmark():
    start = ★time.time()★                      # ★★精度不够、受系统时间调整影响★★
    f()                                        # ★★只跑一次★★
    print(time.time() - start)
    # ★★没预热、没重复、没排除噪音★★

# ✓ 正确
def good_benchmark(func, *args, rounds=7, number=1000):
    # ★① 预热★
    for _ in range(10):
        func(*args)
    # ★② 多轮取最小★
    times = []
    for _ in range(rounds):
        ★gc.disable()★                          # ★③ 排除 GC 干扰(可选)★
        start = ★time.perf_counter()★            # ★★高精度、单调★★
        for _ in range(number):
            ★result = func(*args)★               # ★④ 用掉结果,防被优化★
        elapsed = time.perf_counter() - start
        ★gc.enable()★
        times.append(elapsed / number)
    returnmin(times)★                          # ★⑤ 最小值★

# ④ ★★对比两个实现(正确的写法)★★
import timeit
setup = "from mymodule import impl_a, impl_b; data = list(range(10000))"
a = min(timeit.repeat("impl_a(data)", setup=setup, number=100, repeat=7))
b = min(timeit.repeat("impl_b(data)", setup=setup, number=100, repeat=7))
print(f"A: {a*1000:.3f}ms  B: {b*1000:.3f}ms  ★比值: {a/b:.2f}x★")
# ★★关注比值而不是绝对值(绝对值换台机器就变了)★★

# ⑤ ★负载测试(locust)★
from locust import HttpUser, task, between
class ApiUser(HttpUser):
    ★wait_time = between(1, 3)★
    @task(★3★)                                  # ★权重★
    def list_items(self):
        self.client.get("/api/items?page=1")
    @task(1)
    def create_item(self):
        self.client.post("/api/items", json={"name": "x"})
# ★locust -f load.py --headless -u 100 -r 10 -t 60s★
# ★★关注:P50/P95/P99、错误率、RPS,不是平均值★★

⚠️ 三个必须记住的点:① 不要用微基准去找瓶颈——那是 profiling 的活。常见的错误流程是「我觉得这个函数慢 → 写个 benchmark 测一测 → 优化它 → 发现整体没变快」。正确的顺序是先 profile 真实场景py-spy recordpyinstrument)找出真正占时间的地方,再针对性地写微基准验证优化效果。一个函数即使快了 10 倍,如果它只占总耗时的 2%,整体也只会快 1.8%——这就是 Amdahl 定律。② 微基准必须预热、重复、并且看最小值而不是平均值。第一次调用包含 import、字节码缓存、CPU 缓存未命中、分支预测器未训练等一次性开销;而多轮测量中,最小值最接近「没有噪音干扰时的真实耗时」(噪音只会让它变慢、不会让它变快),平均值则会被偶发的 GC、系统调度、其他进程拉高。timeit 的默认行为就是取多轮的最小值pytest-benchmark 会同时报告 min/median/mean/stddev——看 min 和 median,警惕 stddev 大的结果(说明噪音严重、不可信)。③ 性能数字的绝对值几乎没有可比性,只有相对比值有意义。同一段代码在你的笔记本(有 CPU 降频、有其他程序)、CI 的共享 runner(有邻居干扰)、生产服务器上跑出来的数字可能差好几倍;甚至同一台机器上两次运行差 20% 都是常态。所以:对比 A 和 B 要在同一次运行、同一个环境里测性能回归的阈值要设得宽松(10% 以内的波动基本是噪音);跨时间的对比要看趋势而不是单点

完整版教学

一、三个层次

★ ★★用对工具比用好工具重要★★:
  ┌────────────────────────────────────────────────────┐
  │ ★微基准★:★"这个函数多快?A 比 B 快多少?"★           │
  │   → timeit / pytest-benchmark                       │
  │   → ★适合:算法对比、数据结构选型、优化前后验证★      │
  ├────────────────────────────────────────────────────┤
  │ ★剖析★:★★"时间到底花在哪?"★★                        │
  │   → cProfile / pyinstrument / py-spy                │
  │   → ★★适合:不知道瓶颈在哪时(★这是最常见的情况★)★★  │
  ├────────────────────────────────────────────────────┤
  │ ★负载测试★:★"并发 500 时 P99 是多少?"★              │
  │   → locust / wrk / k6                               │
  │   → ★适合:容量规划、上线前压测、SLA 验证★           │
  └────────────────────────────────────────────────────┘

★ ★★最常见的错误:顺序反了★★:
  ✗ ★"我觉得 X 慢" → 写 benchmark 测 X → 优化 X → 整体没变快★
  ✓ ★profile 真实场景 → 找到真正的热点 → 针对性优化 → 再测量验证★
  ★ ★"我觉得"几乎总是错的★——真实的瓶颈常常在意想不到的地方
    (序列化、日志、正则、一个循环里的 len()、意外的 N+1)

★ ★★Amdahl 定律(★优化的天花板★)★★:
  ★ 优化某部分后的整体加速比:
    ★S = 1 / ((1-p) + p/s)★
    p = ★这部分占总时间的比例★,s = ★这部分的加速倍数★
  ★ 算例:
    - ★某函数占 5%,优化 10 倍 → 整体只快 4.7%★
    - ★某函数占 80%,优化 2 倍 → 整体快 66%★
  ★ → ★★优化占比高的部分,即使加速倍数小,收益也大得多★★
  ★ → ★这就是"先 profile"的数学依据★

★ ★不同层次的典型问题★:
  ★微基准能回答★:
    - ★list vs deque 的 append 谁快★
    - ★f-string vs format vs % 谁快★
    - ★这个正则优化后快了多少★
  ★剖析能回答★:
    - ★为什么这个接口要 800ms★
    - ★CPU 都花在哪些函数上★
    - ★哪里在分配大量内存★
  ★负载测试能回答★:
    - ★这个服务能扛多少 QPS★
    - ★并发上去后 P99 怎么变★
    - ★哪个组件先成为瓶颈★

★ ★还有一类:性能回归测试★
  ★ ★目的不是"测出性能",而是"发现变慢了"★
  ★ ✓ 集成到 CI,作为门禁
  ★ ✗ ★受机器噪音影响大 → 阈值要宽松、看趋势★

用对工具比用好工具重要微基准回答「这个函数多快、A 比 B 快多少」、剖析回答「时间花在哪」、负载测试回答「并发下的表现」最常见的错误是顺序反了——「我觉得 X 慢 → 写 benchmark 测 X → 优化 → 整体没变快」,而「我觉得」几乎总是错的(真实瓶颈常在序列化、日志、正则、意外的 N+1 这些意想不到的地方)。Amdahl 定律是「先 profile」的数学依据某函数占 5% 时优化 10 倍,整体只快 4.7%;占 80% 时优化 2 倍,整体快 66%——优化占比高的部分,即使加速倍数小,收益也大得多

二、微基准的陷阱

★ ★★陷阱 ①:没有预热★★:
  ★ 第一次调用包含:
    - ★import 和模块初始化★
    - ★函数的字节码首次执行(缓存未命中)★
    - ★CPU 指令缓存/分支预测器未训练★
    - ★惰性初始化的数据结构★
  ✓ ★先跑几十次丢弃结果,再开始计时★
  ★ pytest-benchmark 的 ★warmup_rounds★ 参数

★ ★★陷阱 ②:结果被优化掉★★:
  # ✗ 结果没被使用
  for _ in range(100000):
      ★compute(x)★                            # ★可能被跳过部分工作★
  # ✓ 用掉结果
  ★total = 0★
  for _ in range(100000):
      ★total += compute(x)★
  # ★或者★
  ★result = compute(x)★                       # 至少赋值
  ★ ★Python 的优化不如 C/Java 激进,但仍要注意★
  ★ ✗ 更常见的问题:★被测代码内部有缓存★
    @lru_cache
    def f(x): ...
    → ★★第二次调用直接命中缓存,测的是缓存速度★★
    ✓ ★每次用不同的输入,或清缓存★

★ ★★陷阱 ③:测量了噪音★★:
  ★ 干扰源:
    - ★其他进程(浏览器、IDE、杀毒)★
    - ★★CPU 频率调节(睿频/降频)★★
    - ★★热节流(笔记本跑久了降频)★★
    - ★GC 在随机时刻触发★
    - ★内存布局/对齐的随机性★
    - ★CI 的共享 runner(邻居干扰)★
  ★ ★同一段代码两次运行差 20% 是常态★
  ✓ ★对策:
    - ★多轮取最小值★(噪音只会让它变慢)
    - ★看 stddev,大就说明不可信★
    - ★关掉其他程序、插电源、固定 CPU 频率★
    - ★Linux: taskset 绑核、nice -20 提高优先级★
    - ★对比测试放在同一次运行里★

★ ★★陷阱 ④:数据不真实★★:
  ✗ ★用 10 个元素测排序算法★
    → ★★插入排序可能比快排还快(常数因子)★★
  ✗ ★用全是唯一值的数据测去重★
    → 真实数据可能大量重复
  ✗ ★用短字符串测正则★
    → ★回溯问题只在长输入下爆炸★
  ✓ ★用接近生产的数据规模和分布★
  ✓ ★测多个规模看趋势(★算法复杂度才是关键★)★:
    for n in [100, 1000, 10000, 100000]:
        t = bench(func, make_data(n))
        print(f"n={n}: {t:.4f}s  ★t/n={t/n*1e6:.2f}µs★")
    # ★★t/n 基本不变 = O(n);随 n 增长 = 更差★★

★ ★★陷阱 ⑤:只看平均值★★:
  ★ ★平均值会被少数极端值拉高★
  ✓ ★★min★★:最接近"无干扰时的真实耗时"(★微基准首选★)
  ✓ ★★median★★:稳健,反映典型情况
  ✓ ★★stddev / IQR★★:★衡量结果可信度★
  ✓ ★★P95/P99★★:★负载测试里最重要(用户体验)★
  ★ ✗ ★mean 只在分布对称时才有代表性★

★ ★★陷阱 ⑥:忽略了测量本身的开销★★:
  ★ time.perf_counter() 调用本身有开销(~50-100ns)
  → ★★测一个 10ns 的操作时,测量开销比它还大★★
  ✓ ★循环多次再除★(timeit 的 number 参数就是干这个的)
  ✓ ★或用专门的工具(pyperf 会做校准)★

★ ★陷阱 ⑦:环境不一致★
  ✗ ★本地测 A、CI 测 B 然后对比★
  ✗ ★不同 Python 版本对比★(3.11/3.12 有大量性能改进)
  ✗ ★debug 模式 vs release★
  ✓ ★同一环境、同一次运行、交替测量★

微基准有七个陷阱没预热会把 import、缓存未命中、分支预测器未训练的开销算进去。结果被优化掉——更常见的是被测代码内部有 lru_cache,第二次调用命中缓存,测的是缓存速度噪音来源包括 CPU 频率调节、热节流、GC、CI 的邻居干扰——同一段代码两次运行差 20% 是常态,对策是多轮取最小值、看 stddev、对比测试放在同一次运行里。数据不真实很致命——用 10 个元素测排序,插入排序可能比快排还快;正确做法是测多个规模看趋势t/n 基本不变说明是 O(n))。只看平均值也不对——微基准首选 min(最接近无干扰的真实耗时),负载测试看 P95/P99。

三、pytest-benchmark 与回归检测

★ ★★基本用法★★:
  def test_perf(★benchmark★):
      result = ★benchmark(func, arg1, arg2)★
      ★assert result == expected★              # ★★同时验证正确性★★
  ★ ★benchmark fixture 会:★
    ① 自动预热
    ② ★自动决定运行多少轮(校准)★
    ③ ★统计 min/max/mean/stddev/median/iqr/ops★
    ④ 输出对比表格

★ ★pedantic 模式(精确控制)★:
  benchmark.★pedantic★(
      func, args=(x,), kwargs={},
      ★setup=setup_func★,                     # ★★每轮前重置状态★★
      ★rounds=100★,                           # 轮数
      ★iterations=10★,                        # 每轮内的迭代
      ★warmup_rounds=5★,
  )
  ★ ★setup 的返回值是 (args, kwargs)★
  ★ ✓ ★需要"每次用干净的数据"时必须用它★
    (否则第一次调用后数据被修改,后续测的不是同一件事)

★ ★★性能回归检测(CI 门禁)★★:
  # ★① 在主干上保存基线★
  pytest ★--benchmark-save=main★
  # → 存到 .benchmarks/

  # ★② PR 上对比★
  pytest ★--benchmark-compare=main★
  pytest ★--benchmark-compare-fail=★★median:20%★★
  # ★★中位数比基线慢 20% 就失败★★

  # ★③ 也可以对比多个历史★
  pytest --benchmark-compare=0001,0002

★ ★★阈值怎么设(★关键★)★★:
  ✗ ★5% → 会天天 flaky★(噪音就有这么大)
  ✓ ★20~30% → 能抓住真正的退化★
  ✓ ★或者:不做门禁,只记录趋势 + 人工看★
  ★ ★现实:CI 机器的噪音让"精确的性能门禁"很难做★
  ★ ✓ ★更可靠的方案:★
    - ★用专用的性能测试机(不共享)★
    - ★同一次运行里对比新旧实现(消除环境差异)★
    - ★看多次运行的趋势图而不是单点★
    - ★codspeed / airspeed velocity 这类专门的服务★

★ ★★CodSpeed 的思路(值得了解)★★:
  ★ 问题:CI 机器噪音大,wall time 不可靠
  ★ 方案:★★用 CPU 指令数(instruction count)代替时间★★
    → ★★指令数几乎不受机器噪音影响,可重复性极高★★
    → 能检测到 1% 的退化
  ★ ✗ 不反映缓存未命中、IO 等真实开销
  ★ ✓ ★对"纯计算的回归检测"非常有效★

★ ★把基准和普通测试分开★:
  # pyproject.toml
  [tool.pytest.ini_options]
  addopts = ★"--benchmark-skip"★              # ★★默认跳过基准★★
  markers = ["benchmark"]
  # ★只跑基准★
  pytest ★--benchmark-only★
  # ★CI 里分成独立的 job★
  ★ ✓ ★基准测试慢,不该拖累每次提交的快速反馈★

★ ★asv(airspeed velocity)★:
  ★ ✓ ★专为"长期跟踪性能趋势"设计★
  ★ ✓ ★能跨多个 git commit 自动跑基准★
  ★ ✓ ★生成交互式的趋势网页★
  ★ ✓ ★numpy/scipy/pandas 都在用★
  ★ ✗ 配置比 pytest-benchmark 复杂
  ★ ★适合:库项目的长期性能看板★

pytest-benchmarkbenchmark fixture 会自动预热、自动校准轮数、给出完整统计,而且能在同一个测试里验证正确性(性能测试也该断言结果对)。需要「每轮用干净数据」时必须用 pedantic 模式的 setup——否则第一次调用后数据被修改,后续测的不是同一件事。性能回归检测的关键是阈值5% 会天天 flaky(噪音就有这么大),20~30% 才能抓住真正的退化更可靠的方案是用专用机器、在同一次运行里对比新旧实现、或看趋势而不是单点CodSpeed 的思路值得了解——用 CPU 指令数代替时间,几乎不受机器噪音影响,能检测到 1% 的退化基准测试要和普通测试分开(默认 --benchmark-skip,CI 里跑独立 job),因为它慢、不该拖累快速反馈。

四、剖析与真实场景

★ ★★profiling 才是找瓶颈的正道★★:
  # ★① 快速看调用树(可读性最好)★
  ★python -m pyinstrument script.py★
  # ★输出★
  ★2.5s main★
  ★└─ 2.4s process_batch★
  ★   ├─ 2.1s serialize     ← ★★84% 在序列化★★
  ★   └─ 0.3s validate★

  # ★② 精确统计★
  ★python -m cProfile -s cumtime script.py | head -30★
  # ★关键列:ncalls / tottime(自身)/ cumtime(含子调用)★

  # ★③ 生产环境(不改代码)★
  ★py-spy record -o flame.svg --pid <PID> --duration 60★

  # ★④ 行级★
  ★kernprof -l -v script.py★               # @profile 装饰

★ ★★火焰图怎么读★★:
  ★ ★横轴 = 时间占比(不是时间顺序)★
  ★ ★纵轴 = 调用栈深度★
  ★ ★宽的格子 = 占时间多 → 优化目标★
  ★ ★平顶(宽而平的顶部)= 自身耗时多★
  ★ ✗ ★别看颜色(通常只是随机的)★

★ ★★在测试里做性能断言(★慎用★)★★:
  # ✗ 脆弱
  def test_fast():
      start = time.perf_counter()
      process(data)
      ★assert time.perf_counter() - start < 0.1★   # ★★CI 上必 flaky★★
  # ✓ ★用"操作次数"而不是"时间"★
  def test_no_n_plus_one(django_assert_num_queries):
      with ★django_assert_num_queries(3)★:
          list(get_articles_with_authors())
  # ✓ ★或用 pytest-benchmark 的相对比较★
  ★ ★★原则:功能测试里不要断言时间★★
    → ★时间断言属于专门的 benchmark 测试★

★ ★★"操作次数"类的性能断言(★推荐★)★★:
  ★ ✓ ★SQL 查询条数★(防 N+1)
  ★ ✓ ★HTTP 请求次数★
  ★ ✓ ★缓存命中/未命中次数★
  ★ ✓ ★对象分配次数★
  ★ ★优势:★确定性、不受机器影响、意图明确★★
  # ★自己实现 SQL 计数★
  @contextmanager
  def assert_max_queries(n):
      queries = []
      @event.listens_for(engine, "before_cursor_execute")
      def count(*a, **kw): queries.append(1)
      yield
      ★assert len(queries) <= n, f"执行了 {len(queries)} 条 SQL"★

★ ★★真实场景 vs 微基准的差异★★:
  ★ 微基准里快 3 倍的写法,真实系统里可能:
    ① ★不是瓶颈 → 整体没变化★
    ② ★破坏了缓存局部性 → 反而更慢★
    ③ ★增加了内存 → GC 压力上升★
    ④ ★代码复杂度上升 → 维护成本★
  ★ ★所以:优化后必须在真实场景验证★

★ ★端到端的性能验证★:
  ✓ ★压测(locust/k6)看 P95/P99 的变化★
  ✓ ★生产环境的 APM 指标对比★
  ✓ ★灰度发布 + 对比两组的延迟★
  ★ ★这才是"优化真的有效"的证据★

profiling 才是找瓶颈的正道pyinstrument 可读性最好(调用树一眼看出「84% 在序列化」)、cProfile 精确、py-spy 用于生产。火焰图的横轴是时间占比不是时间顺序宽的格子就是优化目标功能测试里不要断言时间assert elapsed < 0.1 在 CI 上必 flaky)——更好的是断言「操作次数」:SQL 查询条数(防 N+1)、HTTP 请求次数、缓存命中次数——优势是确定性、不受机器影响、意图明确。最后要清醒认识:微基准里快 3 倍的写法,真实系统里可能不是瓶颈、可能破坏缓存局部性反而更慢、可能增加 GC 压力——优化后必须在真实场景验证(压测看 P95/P99、APM 指标对比、灰度对比)。

五、负载测试

★ ★★负载测试关注的指标★★:
  ┌──────────────────────────────────────────────────┐
  │ ★RPS / QPS★      :吞吐量                          │
  │ ★★P50/P95/P99★★  :★★延迟分布(比平均值重要得多)★★ │
  │ ★错误率★         :★超过阈值说明已经过载★           │
  │ ★并发数★         :同时在处理的请求数               │
  │ ★资源使用★       :CPU/内存/连接数/IO              │
  └──────────────────────────────────────────────────┘
  ★ ★★为什么看 P99 而不是平均值★★:
    平均 100ms 听起来不错
    但 ★P99 = 3 秒 → 1% 的用户等 3 秒★
    → ★★一个页面 20 个请求 → 大部分用户会遇到至少一个慢请求★★

★ ★★几种负载模式★★:
  ★① 基准负载(baseline)★:正常流量,看常态表现
  ★② 压力测试(stress)★:★逐步加压直到崩溃,找到极限★
  ★③ 尖峰测试(spike)★:★瞬间大流量(秒杀、热点事件)★
  ★④ 浸泡测试(soak)★:★★长时间持续负载 → 发现内存泄漏、连接泄漏★★
  ★⑤ 容量测试★:确定「支持 X QPS 需要多少资源」

★ ★locust 示例★:
  from locust import HttpUser, task, between, events
  class User(HttpUser):
      wait_time = ★between(1, 3)★             # ★模拟用户思考时间★
      def ★on_start★(self):                   # ★每个虚拟用户开始时★
          r = self.client.post("/login", json={...})
          self.client.headers["Authorization"] = f"Bearer {r.json()['token']}"
      @task(3)
      def browse(self):
          with self.client.get("/api/items", ★catch_response=True★) as r:
              if r.elapsed.total_seconds() > 1:
                  ★r.failure("太慢")★           # ★自定义失败判定★
      @task(1)
      def create(self): ...
  # ★locust -f load.py --headless -u 500 -r 50 -t 5m --html report.html★

★ ★★负载测试的常见错误★★:
  ✗ ★★压测机器自己成了瓶颈★★
    → ★检查压测端的 CPU/网络/文件描述符★
    → ★分布式压测(locust master/worker)★
  ✗ ★所有虚拟用户用同一个账号/同一条数据★
    → ★★缓存全命中、锁竞争失真★★
    ✓ 用不同的用户和参数
  ✗ ★没有 think time(wait_time)★
    → ★不真实的极端并发★
  ✗ ★测试环境和生产差太多★
    → ★数据量、机器配置、网络都要接近★
  ✗ ★只看平均值★
  ✗ ★没有预热★
    → ★★JIT、连接池、缓存都需要预热★★
  ✗ ★忽略了错误率★
    → ★★"QPS 很高"但 50% 是 500 错误★★

★ ★★容量规划的算法★★:
  ★ Little's 定律:★★并发数 = 到达率 × 平均响应时间★★
    L = λ × W
  ★ 算例:
    - ★目标 1000 QPS,平均响应 50ms★
    - ★→ 需要同时处理 1000 × 0.05 = 50 个请求★
    - ★→ 如果每个 worker 一次处理一个 → 至少 50 个 worker★
    - ★→ 加上余量和 P99 → 通常按 2~3 倍配★

★ ★压测的正确流程★:
  ① ★确定目标★(目标 QPS、可接受的 P99)
  ② ★准备接近生产的环境和数据★
  ③ ★预热★
  ④ ★逐步加压★(不是一上来就打满)
  ⑤ ★观察拐点★(★QPS 不再上升但延迟飙升的点★)
  ⑥ ★定位瓶颈★(CPU?数据库?连接池?锁?)
  ⑦ ★优化后重测★

负载测试要看 P95/P99 而不是平均值——平均 100ms 但 P99 是 3 秒时,1% 的用户等 3 秒;而一个页面 20 个请求的话,大部分用户会遇到至少一个慢请求五种负载模式里,浸泡测试(长时间持续负载)能发现内存泄漏和连接泄漏,这是短时压测发现不了的。常见错误里最隐蔽的两个:压测机器自己成了瓶颈(要检查压测端的 CPU 和网络)、所有虚拟用户用同一个账号或同一条数据(缓存全命中、锁竞争失真);还有**「QPS 很高但 50% 是 500 错误」**。容量规划用 Little’s 定律并发数 = 到达率 × 平均响应时间——1000 QPS × 50ms = 50 个并发,加余量按 2~3 倍配。

六、实践清单

★ ★★优化的完整流程★★:
  ① ★★先确认"真的需要优化"★★
     - ★有明确的性能目标吗(P99 < 200ms)?★
     - ★当前值是多少?★
     - ★用户真的受影响吗?★
     → ★没有目标的优化是浪费时间★
  ② ★★profile 找瓶颈★★(不是猜)
  ③ ★★算一下 Amdahl 上限★★(优化它最多能快多少)
  ④ ★写微基准验证优化方案★
  ⑤ ★实施优化★
  ⑥ ★★在真实场景验证★★(压测/APM)
  ⑦ ★加回归测试防止退化★

★ 检查清单:
  【微基准】
  □ ★有预热★
  □ ★多轮取 min 或 median(不是 mean)★
  □ ★看 stddev 判断可信度★
  □ ★数据规模接近真实★
  □ ★测多个规模看复杂度趋势★
  □ ★对比在同一次运行里做★
  □ ★结果被使用(防优化掉)★
  □ ★注意被测代码内部的缓存★
  【回归测试】
  □ ★基准测试和功能测试分开跑★
  □ ★阈值宽松(20%+)或只看趋势★
  □ ★功能测试里不断言时间★
  □ ★用"操作次数"断言代替"时间"断言★
  【负载测试】
  □ ★看 P95/P99 不是平均值★
  □ ★关注错误率★
  □ ★压测端不是瓶颈★
  □ ★数据和用户有多样性★
  □ ★有预热和 think time★
  □ ★做过浸泡测试(找泄漏)★

★ ★★工具速查★★:
  ┌──────────────────────┬────────────────────────────┐
  │ ★timeit★              │ ★标准库,快速对比小片段★     │
  │ ★pytest-benchmark★    │ ★集成测试套件、回归检测★     │
  │ ★pyperf★              │ ★更严谨的微基准(校准/隔离)★│
  │ ★pyinstrument★        │ ★★调用树,可读性最好★★       │
  │ ★cProfile + snakeviz★ │ 精确统计 + 可视化           │
  │ ★py-spy★              │ ★★生产环境,不改代码★★       │
  │ ★line_profiler★       │ 行级耗时                    │
  │ ★memray / tracemalloc★│ ★内存★                      │
  │ ★locust / k6 / wrk★   │ ★负载测试★                  │
  │ ★asv★                 │ ★长期性能趋势看板★          │
  │ ★CodSpeed★            │ ★★指令数,抗噪音的回归检测★★ │
  └──────────────────────┴────────────────────────────┘

★ ★★性能优化的常见误区总结★★:
  ✗ ★过早优化★(没测量就优化)
  ✗ ★优化非瓶颈★(Amdahl)
  ✗ ★微基准结论直接推广到真实系统★
  ✗ ★为了性能牺牲可读性(收益不明确时)★
  ✗ ★只看平均值★
  ✗ ★在噪音大的环境做精确对比★
  ✓ ★★算法/架构层面的改进 >> 微观代码优化★★
    (★N+1 → 预加载:100 倍;改用 list comprehension:1.2 倍★)

★ 一句话总结:
  ★"性能测试分三层:微基准(这个函数多快)、剖析(时间花在哪)、
    负载测试(并发下什么表现)——最常见的错误是用微基准找瓶颈,
    正确顺序是先 profile 再优化(Amdahl 定律:优化占 5% 的部分
    再快 10 倍整体也只快 4.7%);
    微基准要预热、多轮取最小值、用真实规模的数据、
    并且只有相对比值有意义;
    回归检测的阈值要宽松(噪音就有 20%),
    功能测试里别断言时间——用『SQL 条数』这类确定性的断言代替。"★

优化的完整流程第一步是「先确认真的需要优化」——没有明确性能目标的优化是浪费时间。工具速查表里 pyinstrument 可读性最好、py-spy 用于生产、CodSpeed 用指令数抗噪音。最后那条总结很重要:算法和架构层面的改进远大于微观代码优化——修一个 N+1 是 100 倍,改用列表推导式只有 1.2 倍

记忆钩子:「性能测试要★先分清三个层次★:★微基准(timeit/pytest-benchmark,回答『这个函数多快、A 比 B 快多少』)★、★剖析(cProfile/py-spy/pyinstrument,回答『时间花在哪』)★、★负载测试(locust/k6,回答『并发下 P99 是多少』)★。★最常见的错误是用微基准去找瓶颈★——正确顺序是★先 profile 真实场景找到热点,再写微基准验证优化效果★;★『我觉得这里慢』几乎总是错的★(真实瓶颈常在序列化、日志、正则、意外的 N+1 这些地方)。★Amdahl 定律是『先 profile』的数学依据★:★某函数占 5% 时优化 10 倍整体只快 4.7%,占 80% 时优化 2 倍整体快 66%★。★微基准的七个陷阱★:★① 没预热★(首次调用含 import、缓存未命中、分支预测器未训练);★② 结果被优化掉★——更常见的是★被测代码内部有 lru_cache,第二次直接命中,测的是缓存速度★;★③ 测量了噪音★(CPU 频率调节、热节流、GC、CI 邻居干扰,★同一段代码两次运行差 20% 是常态★);★④ 数据不真实★(★用 10 个元素测排序,插入排序可能比快排还快★,要★测多个规模看 t/n 的趋势判断复杂度★);★⑤ 只看平均值★——★微基准首选 min(最接近无干扰时的真实耗时,因为噪音只会让它变慢)★、median 稳健、★stddev 大说明结果不可信★、★负载测试看 P95/P99★;★⑥ 忽略测量本身的开销★(perf_counter 调用约 50-100ns,测 10ns 的操作时开销比它还大 → 循环多次再除);★⑦ 环境不一致★。★性能数字的绝对值几乎没有可比性,只有相对比值有意义★——★对比 A 和 B 要在同一次运行同一环境里测★。★性能回归检测的阈值要宽松★:★5% 会天天 flaky(噪音就这么大),20~30% 才能抓真正的退化★;★更可靠的是用专用机器、在同一次运行里对比新旧、或看趋势而非单点★;★CodSpeed 的思路是用 CPU 指令数代替时间,几乎不受噪音影响能测到 1% 的退化★。★功能测试里绝不要断言时间(CI 上必 flaky),要用『操作次数』这类确定性断言代替★——SQL 查询条数(防 N+1)、HTTP 请求次数、缓存命中次数。负载测试要点:★看 P95/P99 不是平均值(平均 100ms 但 P99 三秒,一个页面 20 个请求时大部分用户都会遇到慢的)★、★关注错误率(QPS 高但 50% 是 500 就没意义)★、★压测端别自己成为瓶颈★、★浸泡测试才能发现内存和连接泄漏★、★容量规划用 Little’s 定律:并发数 = 到达率 × 平均响应时间★。★最后:算法和架构层面的改进远大于微观优化——修一个 N+1 是 100 倍,改用列表推导式只有 1.2 倍★。」

七、常见误区与追问

  • 误区:觉得某个函数慢,就写个 benchmark 测一测然后优化它。 顺序反了,而且大概率优化了不重要的地方。「我觉得这里慢」这个直觉的准确率非常低——真实的瓶颈经常出现在完全意想不到的地方:一次意外的 N+1 查询、日志里的 f-string 求值、一个正则的灾难性回溯、循环里重复调用的 len()、或者序列化一个比想象中大得多的对象。正确的流程是先 profile 真实场景(用 pyinstrument 看调用树、py-spy record 出火焰图),让数据告诉你时间花在哪,然后再针对性地写微基准来验证「我的优化方案确实更快」。这里还有个数学约束——Amdahl 定律:如果某个函数只占总耗时的 5%,你把它优化 10 倍,整体也只快 4.7%;而占 80% 的部分即使只优化 2 倍,整体就能快 66%。所以「优化占比高的部分」永远比「把某个函数优化到极致」更有价值
  • 误区:time.time() 前后一减就能测出函数耗时。 有三个问题。① 精度和稳定性——time.time() 返回的是墙上时钟,它会被 NTP 校时、夏令时调整、手动改系统时间影响(极端情况下可能算出负数),而且在某些平台精度只有毫秒级;应该用 time.perf_counter()(单调递增、高精度,专为测量时间间隔设计)。② 只跑一次毫无意义——第一次调用包含 import、字节码缓存未命中、CPU 指令缓存和分支预测器未训练等一次性开销,而且单次测量完全受随机噪音支配。③ 没有排除干扰——GC 可能恰好在你测量时触发、操作系统可能调度走你的进程。正确做法是:先预热几十次、然后多轮测量、取最小值(噪音只会让结果变慢,所以最小值最接近真实耗时)。这些正是 timeitpytest-benchmark 帮你做的事——别自己造轮子
  • 误区:benchmark 结果显示 A 比 B 快 3 倍,那就用 A。 要先确认这个结论在真实场景下成立。微基准的结论有四种失效方式:① 不是瓶颈——A 比 B 快 3 倍,但这段代码只占总耗时的 1%,换了之后整体没有可测量的变化;② 数据规模不同结论会反转——用 10 个元素测出「插入排序比快排快」是完全正常的(快排的常数因子更大),但到 10 万个元素就完全相反,所以要在接近真实规模的数据上测,最好测多个规模看趋势③ 破坏了其他因素——A 可能占用更多内存(增加 GC 压力)、破坏缓存局部性、或者在并发场景下引入锁竞争,这些在单线程微基准里完全看不出来④ 可维护性代价——如果 A 的写法晦涩难懂,为了 1% 的整体提升是不划算的。所以优化后必须在真实场景验证:压测看 P95/P99 的变化、对比生产环境的 APM 指标、或者灰度发布对比两组数据。
  • 误区:在测试里 assert elapsed < 0.1 可以防止性能退化。 这是 flaky test 的经典来源。CI 机器的性能差异极大:共享 runner 上有邻居进程抢 CPU、容器可能有 CPU 配额限制、不同时段的负载不同——同一段代码在 CI 上跑两次差 2~3 倍都可能。你设 0.1 秒它会偶尔失败,改成 0.5 秒又失去了检测意义(真的退化 3 倍也发现不了)。更好的替代是断言「操作次数」而不是「时间」:SQL 查询条数(django_assert_num_queries(3) 或自己用 SQLAlchemy 事件计数)能精确防住 N+1;HTTP 请求次数能防住循环里调 API;缓存命中次数能验证缓存真的生效了。这类断言的优势是完全确定性——不受机器性能影响、意图明确、失败时信息清晰(「执行了 47 条 SQL,期望不超过 3 条」)。真正的时间测量应该放到专门的 benchmark 测试里,和功能测试分开跑。
  • 误区:性能回归测试在 CI 里设个 5% 的阈值就能守住性能。 5% 完全在噪音范围内,会导致天天误报。CI 环境的时间测量波动通常在 10~30%,设置严格阈值的结果是:大家很快学会「性能测试挂了?重跑一下」——和 flaky test 一样的退化路径,最终这个门禁形同虚设。三个更可靠的方案:① 放宽阈值到 20~30%,只抓「真正的量级退化」(这也是它最有价值的地方——防止把 O(n) 写成 O(n²));② 消除环境差异——在同一次运行里同时测新旧两个实现,这样机器状态是一样的,比值就可靠得多;③ 用指令数代替时间——CodSpeed 这类服务通过 valgrind 统计 CPU 指令数,几乎不受机器噪音影响,可重复性极高,能检测到 1% 的退化(代价是不反映缓存未命中和 IO)。此外,看趋势图比看单次对比更有意义——asv(airspeed velocity)就是为此设计的,numpy、scipy、pandas 都在用。
  • 追问:timeit 为什么取最小值而不是平均值? 因为测量误差在时间测量里是单向的:任何干扰(其他进程抢 CPU、GC 触发、缓存被挤出、系统调度、频率调节)只会让测量结果变大,不会让它变小——代码不可能跑得比它实际需要的时间更快。所以在多轮测量中,最小值就是「最接近无干扰状态下的真实耗时」的估计,而平均值会被那些偶发的干扰系统性地拉高。这和物理测量中「多次测量取平均」的直觉相反,因为那里假设误差是对称的(有正有负)。实践建议:微基准看 min(最能反映代码本身的效率)、同时看 stddevIQR 判断结果是否可信(如果标准差很大,说明环境噪音严重,这次测量的结论要打折扣)、median 作为稳健的参考但要注意负载测试完全不同——那里我们关心的是「用户实际体验到的延迟分布」,必须看 P95/P99,因为那些「慢的情况」正是要解决的问题。
  • 追问:负载测试为什么强调 P99 而不是平均值? 因为平均值会掩盖尾部延迟,而尾部延迟才是用户实际感受到的痛点。举个具体的例子:一个接口平均响应 100ms 听起来很不错,但如果 P99 是 3 秒,意味着 1% 的请求要等 3 秒。这看起来只影响 1% 的用户?并不是——现代的一个页面往往要发起 20 个甚至更多的请求,任何一个慢请求都会拖慢整个页面,那么「至少遇到一个 P99 请求」的概率是 1 - 0.99^20 ≈ 18%——近五分之一的页面加载会明显变慢。如果是微服务调用链更严重:一次用户请求扇出到 10 个下游服务,P99 的影响会被逐级放大(这就是「尾部延迟放大」)。所以业界的 SLA 通常用 P95/P99 甚至 P99.9 来定义,而不是平均值。另一个必须同时看的指标是错误率——「QPS 达到 5000」听起来很好,但如果其中 50% 返回 500,那实际有效吞吐只有 2500,很多压测报告因为忽略错误率而给出了完全错误的结论
  • 追问:怎么判断该做哪种性能优化?收益量级排序,从大到小。① 架构和算法层面——改掉 N+1(100 倍)、给查询加索引(101000 倍)、把 O(n²) 改成 O(n log n)、引入缓存(数十倍)、异步化阻塞调用(数十倍)。这一层的收益通常是数量级的② 减少工作量——分页、只查需要的字段、懒加载、批量操作代替循环单条。③ 换更快的实现——用 orjson 代替 json(35 倍)、用 C 扩展库(numpy、polars)、用 __slots__ 减少内存。④ 微观代码优化——列表推导式代替 append 循环(1.2 倍)、局部变量缓存属性查找、避免不必要的函数调用。注意第 ④ 层的收益通常只有百分之几到几十个百分点,而且往往牺牲可读性——所以只应该在 profile 明确指出某段代码是热点、且前三层都做完之后才考虑。有个实用的判断:如果你在纠结「用 for 还是列表推导式更快」,说明你多半在优化不重要的地方——先去 profile 看看真正的时间花在哪里。

八、加强记忆

性能测试要先分清三个层次微基准timeit/pytest-benchmark,回答「这个函数多快、A 比 B 快多少」)、剖析cProfile/py-spy/pyinstrument,回答「时间花在哪」)、负载测试(locust/k6,回答「并发下 P99 是多少」)。最常见的错误是用微基准去找瓶颈——正确顺序是先 profile 真实场景找到热点,再写微基准验证优化效果「我觉得这里慢」几乎总是错的(真实瓶颈常在序列化、日志、正则、意外的 N+1 这些地方)。Amdahl 定律是「先 profile」的数学依据某函数占 5% 时优化 10 倍整体只快 4.7%,占 80% 时优化 2 倍整体快 66%微基准的七个陷阱① 没预热(首次调用含 import、缓存未命中、分支预测器未训练);② 结果被优化掉——更常见的是被测代码内部有 lru_cache,第二次直接命中缓存,测的是缓存速度③ 测量了噪音(CPU 频率调节、热节流、GC、CI 邻居干扰,同一段代码两次运行差 20% 是常态);④ 数据不真实用 10 个元素测排序,插入排序可能比快排还快,要测多个规模看 t/n 的趋势判断复杂度);⑤ 只看平均值——微基准首选 min(最接近无干扰时的真实耗时,因为噪音只会让结果变慢)、median 稳健、stddev 大说明结果不可信负载测试看 P95/P99⑥ 忽略测量本身的开销perf_counter 调用约 50~100ns,测 10ns 的操作时开销比它还大 → 循环多次再除);⑦ 环境不一致性能数字的绝对值几乎没有可比性,只有相对比值有意义——对比 A 和 B 要在同一次运行、同一环境里测性能回归检测的阈值要宽松5% 会天天 flaky(噪音就这么大),20~30% 才能抓住真正的退化更可靠的是用专用机器、在同一次运行里对比新旧、或看趋势而非单点CodSpeed 的思路是用 CPU 指令数代替时间,几乎不受噪音影响、能测到 1% 的退化功能测试里绝不要断言时间(CI 上必 flaky),要用「操作次数」这类确定性断言代替——SQL 查询条数(防 N+1)、HTTP 请求次数、缓存命中次数。负载测试的要点:看 P95/P99 而不是平均值(平均 100ms 但 P99 三秒时,一个页面 20 个请求的话大部分用户都会遇到慢的)、关注错误率(QPS 高但 50% 是 500 就没意义)、压测端别自己成为瓶颈浸泡测试才能发现内存和连接泄漏容量规划用 Little’s 定律:并发数 = 到达率 × 平均响应时间。最后记住:算法和架构层面的改进远大于微观优化——修一个 N+1 是 100 倍,改用列表推导式只有 1.2 倍