Python 怎么做性能基准测试?微基准有哪些陷阱?
简化版
性能测试要先分清三个层次:① 微基准(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 哪个快? | timeit、pytest-benchmark |
| 剖析 | 时间花在哪?谁是瓶颈? | cProfile、py-spy、pyinstrument |
| 负载测试 | 并发下 QPS/延迟如何? | locust、wrk、k6 |
| 回归测试 | 这次改动变慢了吗? | 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)
return ★min(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 record或pyinstrument)找出真正占时间的地方,再针对性地写微基准验证优化效果。一个函数即使快了 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-benchmark 的 benchmark 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 可能恰好在你测量时触发、操作系统可能调度走你的进程。正确做法是:先预热几十次、然后多轮测量、取最小值(噪音只会让结果变慢,所以最小值最接近真实耗时)。这些正是timeit和pytest-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(最能反映代码本身的效率)、同时看stddev或IQR判断结果是否可信(如果标准差很大,说明环境噪音严重,这次测量的结论要打折扣)、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 倍)、给查询加索引(10
1000 倍)、把 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 倍。