# 如实测量

> 对 Postgres 做基准测试，让数字真正有意义。正确使用 pgbench，记录包含停顿的延迟百分位点，并准备特征贴近生产环境的测试数据。

---

LLMS 索引： [llms.txt](/llms.txt)

---

<span id="basics-chapter-29"></span>

下面是同一台服务器执行同一条查询的两次测量。第二次的吞吐量是第一次的 80%，负载更轻。第一次的第 99 百分位点的延迟是 0.12 ms，第二次是 2,038 ms。两次之间 Postgres 没有任何变化。一次基准测试问服务器做完工作用了多久，另一次问用户等了多久。

大多数基准测试都会撒谎，而且几乎从来不是故意的。它们在热缓存上运行十秒，使用能放进内存的均匀随机数据；服务器一停顿，客户端就礼貌地停止发送请求。然后，这个数字被写进设计文档。

基准测试是一项实验：一个问题、一个对照、足够多的运行次数以看见噪声，以及与要预测的对象具有相似特征的数据。

## 只问一个问题 {#ask-one-question}

运行之前，先把问题写下来。问题大致有几类，每一类都需要自己的设置：

| 问题 | 固定不变的内容 | 报告什么 |
|---|---|---|
| 能做到多少？ | 负载组合 | 峰值吞吐量，以及该峰值下的延迟 |
| 在我们的负载下有多快？ | 到达速率（`-R`） | 该速率下的延迟百分位点 |
| 变更 B 比 A 好吗？ | 除 B 以外的一切 | 差异，并列出不同运行之间的噪声 |
| 这个计划能撑过生产吗？ | 数据特征和统计信息 | 计划及其缓冲区 |

<span id="tbl-29-1"></span>

**Table 29-1.** 只问一个问题


“Postgres 有多快”不是一个问题。“这台机器能否以每秒 2,000 个请求为我们的结账端点服务，同时使 p99 低于 20 ms”才是。

这也是每次申请更大服务器背后的问题。一个在完成低成本修复后仍然如实回答这个问题的基准测试，才能让这项申请得到肯定（[用数据争取更大的机器](/37-scale/#earn-the-bigger-box)）。

基准测试撒谎的方式（太短、太热或太冷、太均匀、闭环、只运行一次、同时改两件事），也是阅读别人发布的基准测试的方法：供应商的图表，是你没有运行过、也没有选择过负载的基准测试（[读基准测试数字前，先问是谁跑的](/35-providers/#ask-who-ran-a-benchmark-before-you-read-its-numbers)）。

## pgbench 已经安装好了 {#pgbench-is-already-installed}

`pgbench` 随 PostgreSQL 一起提供；只要有 `psql`，就有它。它可以用你指定数量的客户端，把脚本运行在任意数据库上，并报告吞吐量和延迟。前面的章节用它测量过[提交代价](/08-wal/#commit-means-flush)和[连接建立](/28-connections/#setup-reconnecting-cost-a-factor-of-seventy)。我们的数字来自一台共享笔记本；实验要证明的是它们之间的关系。

它还附带一个类似 TPC-B 的模式和脚本（`pgbench -i`）。不要引用它的数字。在我们的机器上，只读模式每秒运行约 99,000 个事务，而默认脚本每秒只有 8 个，因为每个默认事务都在等待一块忙于服务其他数据库的磁盘执行[fsync](/43-glossary/#fsync)。后一个数字主要反映邻居的速度。

<span id="what-the-built-in-scripts-do" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-29-what-the-built-in-scripts-do" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：内置脚本做了什么
>
> `pgbench -i -s 5` 会创建 `pgbench_branches`（5 行）、`pgbench_tellers`（50 行）、`pgbench_accounts`（500,000 行）以及空的 `pgbench_history`，每个规模单位约占 15 MB。用 `-b` 选择的内置脚本包括 `tpcb-like`（默认）、`simple-update` 和 `select-only`（`-S`）。在信任它之前先读一遍（`pgbench --show-script=tpcb-like`）：每个类似 TPC-B 的事务都会更新一条账户记录、一条柜员记录和 `-s` 条分行记录中的一条，最后提交。客户端多于分行记录时，它们会在这些行上排队，而你测量的其实是[行锁争用](/25-concurrency/#basics-chapter-25)；规模至少要达到客户端数量。每次运行前，pgbench 会清理 `pgbench_tellers` 和 `pgbench_branches`，并截断 `pgbench_history`。`-n`（`--no-vacuum`）会跳过这一步；对自己的表使用自定义脚本时可以使用它，因为那些 pgbench 表并不存在。
>
> > [!EXAMPLE]- 动手验证 · lab `28-benchmarking.sh`
> >
> > ```bash
> > # the lab runs each pgbench in the server's container; $script holds:
> > pgbench --show-script=tpcb-like 2>&1
> > prove "the default TPC-B-like script updates pgbench_branches, one row per branch" \
> >   grep -q "UPDATE pgbench_branches" <<<"$script"
> > # $tpcb, then $sel (tps_of keeps the tps figure):
> > pgbench -n -M prepared -c 4 -j 4 -T 5 bench 2>&1 | tps_of
> > pgbench -n -S -M prepared -c 4 -j 4 -T 5 bench 2>&1 | tps_of
> > prove "select-only runs at least 5x the transactions per second of TPC-B-like" \
> >   is "$sel > 5 * $tpcb"
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/28-benchmarking.sh)

### 为自己的查询做基准测试，使用倾斜分布的键 {#benchmark-your-own-queries-with-skewed-keys}

内置脚本只是冒烟测试。真正重要的基准测试运行的是你的查询。pgbench 脚本是一个由 SQL 和客户端执行的反斜杠命令组成的文件；每次执行完整文件，都算作一个“事务”：

```text
-- endpoint.sql: one request to a project dashboard
\set u random(1, 100000)
\set p random_exponential(1, 20000, 5)
select id, email, name from users where id = :u;
select kind, count(*) from events where project_id = :p group by kind;
```

`\set` 会把表达式计算成一个变量；`:u` 会替换成它。使用倾斜分布的键：`random()` 是均匀分布，而生产流量几乎从来不是。脚本中的 `random_exponential(1, 20000, 5)` 只是轻微倾斜：约 5% 的抽样会落在项目 ID 的前 1% 中。`random_zipfian(1, 20000, 1.1)` 会把 68% 的抽样送到那里，更接近有少数超大租户的多租户 SaaS。选择符合你流量的那个。

把 `-j`（线程数）设为等于 `-c`（客户端数）：一个线程无法驱动许多快速客户端；在我们的机器上，8 个只读客户端使用 `-j 8` 时，速度约为使用 `-j 1` 的 6 倍。如果 pgbench 一侧的 CPU 已经打满，你测量的就是 pgbench。

<span id="the-skewed-generators-measured" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-29-the-skewed-generators-measured" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：测量倾斜分布生成器
>
> pgbench 有三个：
>
> - `random_zipfian(lb, ub, s)`：抽到 k 的次数是抽到 k+1 的 `((k+1)/k)^s` 倍，其中 `s` 取值范围为 1.001 到 1000。
> - `random_exponential(lb, ub, p)`：大致来说，范围最低的 1% 会在 `p`% 的抽样中被选中。
> - `random_gaussian(lb, ub, p)`：约 67% 的抽样会落在范围中间的 `1/p` 区间内。
>
> 针对 20,000 个项目 ID，用每种分布各抽样 40,000 次，并由 pgbench 脚本（`insert into picks values (:z, :e, :g)`）记录到一张表中：
>
> ```text
>   zipfian top 1%: 68.4%  exponential top 1%: 4.8%  gaussian middle quarter: 68.5%
> ```
>
> 分布总是向范围的低端倾斜（高斯分布则朝中间）；如果模式中的低 ID 有特殊含义，就用 `permute(value, size)` 打乱。把 `-c` 的大小[当作连接池大小](/28-connections/#size-the-pool-from-cores-not-users)；而真正做负载测试时，应在靠近服务器的另一台机器上运行 pgbench。
>
> > [!EXAMPLE]- 动手验证 · lab `28-benchmarking.sh`
> >
> > ```bash
> > # with synchronous_commit=off, in the server's container
> > pgbench -n -c 4 -j 4 -t 10000 -f /tmp/picks.sql book
> > prove "random_zipfian(1, 20000, 1.1) sends 55 to 80% of picks to the first 1% of ids" \
> >   holds book "select count(*) filter (where z <= 200) * 1.0 / count(*) between 0.55 and 0.80 from picks"
> > prove "random_exponential(1, 20000, 5) sends about 5% to the first 1% (3 to 7%)" \
> >   holds book "select count(*) filter (where e <= 200) * 1.0 / count(*) between 0.03 and 0.07 from picks"
> > prove "random_gaussian(1, 20000, 4) puts about 67% in the middle quarter (62 to 72%)" \
> >   holds book "select count(*) filter (where g between 7501 and 12500) * 1.0 / count(*) between 0.62 and 0.72 from picks"
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/28-benchmarking.sh)

## 预备语句更快，而且快得超过噪声范围 {#prepared-statements-are-faster-by-more-than-the-noise}

`-M` 决定 pgbench 如何与服务器通信。`simple` 和 `extended`（大多数驱动程序使用的模式）每次都会让服务器解析和规划；`prepared` 每个连接只解析一次，还可能切换到缓存的[通用计划](/20-planner/#prepared-statements-switch-plans-after-five-runs)。对于只需几微秒的查询，这占了很大的比例。对一行进行查找，运行三轮、每轮 8 秒，同时运行这些模式进行比较，让机器噪声同样作用于三者：

```text
  simple: min 11822  max 14407  mean 13289 tps  spread 21.9%
  extended: min 10980  max 13266  mean 12277 tps  spread 20.8%
  prepared: min 15563  max 18773  mean 17420 tps  spread 20.6%
```

预备模式快约三分之一。再看看离散程度：三次相同运行之间的差异约为 20%，所以任何更小的差异都不能算结果。但这一次可以：最慢的一次预备运行也超过了最快的一次简单运行。

这里得到两条规则。使用应用实际使用的协议做基准测试（[事务池模式](/28-connections/#transaction-pooling-breaks-session-state)可能使预备语句失效）。比较结果要报告范围，每侧运行三次并交错进行；只有范围不重叠时，差异才算数。

<span id="why-the-runs-had-to-be-side-by-side" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-29-why-the-runs-had-to-be-side-by-side" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：为什么必须同时运行这些模式
>
> 在我们繁忙的笔记本上依次运行时，各模式的范围经常重叠，有一次简单模式的平均值甚至更好。因此实验先执行清理和检查点，然后在有空闲核心的机器上同时运行三种模式，每种一个客户端。扩展模式并不比简单模式快：它只是在更多协议消息中做同样的解析和规划。
>
> > [!EXAMPLE]- 动手验证 · lab `28-benchmarking.sh`
> >
> > ```bash
> > for round in 1 2 3; do
> >   # each round runs modes.sh in the container: the three modes at once
> >   for m in simple extended prepared; do
> >     pgbench -n -S -M $m -c 1 -T 8 bench > mode_$m.txt 2>&1 &
> >   done
> >   wait
> >   # then reads each mode's tps
> > done
> > prove "the slowest prepared run beats the fastest simple run" is "$pmin > $smax"
> > prove "prepared is at least 15% faster than simple for a one-row lookup (mean of 3)" \
> >   is "$pavg > 1.15 * $savg"
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/28-benchmarking.sh)

## 闭环会隐藏停顿 {#closed-loops-hide-the-stalls}

回到开头的例子。两次运行都使用 4 个只读客户端，持续 15 秒。运行到第 5 秒时，另一个会话取得了持续两秒的 `ACCESS EXCLUSIVE` 锁，目标是 `pgbench_accounts`，用来代表任意停顿：排在迁移后面的锁、故障切换、检查点 fsync，或应用中的垃圾收集暂停。

第一次运行是 **闭环**，也是 pgbench 的默认方式：每个客户端发送查询，等待答案，然后再发送下一条。第二次把 `-R` 设置为第一次达到速率的 80%，这样 pgbench 会按时间线调度事务，并从每个事务**预定**的开始时刻起测量其延迟：

```text
                                      tps      p50      p95      p99       max
  closed loop                       64385     0.05     0.09     0.12    2012.0
  -R 51507, from schedule           51509     3.28  1879.23  2038.44    2079.1
  -R 51507, minus lag                         0.05     0.10     0.12    2077.2
```

所有延迟都以毫秒计。负载更轻，停顿相同，但 p99 相差超过一万倍。

闭环的问题在于它没有记录某些东西。停顿期间，4 个客户端各有一条查询卡住：一百万个样本中只有 4 个慢样本，不会改变这里的百分位点。

但真实用户不会因为服务器忙就停止到达。在这两秒内，本来会有约 100,000 个请求到达，而且每一个都会等待。基准测试却和服务器协调，跳过了对它们的测量。这就是 **协调遗漏（coordinated omission）**，这个术语由 Gil Tene 推广；这种遗漏是大多数负载生成器和许多监控代码本身就有的问题。

使用 `-R` 后，这些请求会留在时间表上。它们会晚启动，而迟到的时间（**调度延迟**）会计入延迟。影响会持续到停顿之后：处理积压时，超过 5% 的请求等待了 1.8 秒以上，中位数从 0.05 ms 上升到 3 ms。现在看第三行：减去调度延迟，就又得到闭环的数字。服务器做得很快，但用户仍然等了。

> [!EXAMPLE]- 动手验证 · lab `28-benchmarking.sh`
>
> ```bash
> # co.sh, both runs: 4 clients, 15 s, a 2 s exclusive lock at the 5 s mark
> pgbench -n -S -M prepared -c 4 -j 4 -T 15 --log --log-prefix="co_$name" "$@" bench
> closed=$(sh_in "bash co.sh closed")
> # the open loop asks for 80% of the closed loop's tps
> rate=$(awk -v t="$c_tps" 'BEGIN { printf "%d", t * 0.8 }')
> open=$(sh_in "bash co.sh open -R $rate")
> prove "the -R run delivered the rate it was asked for (within 10%), 80% of the closed loop's" \
>   is "$o_tps > 0.9 * $rate && $o_tps < 1.1 * $rate"
> prove "with -R, p99 is more than 10x the closed-loop p99" is "$o99 > 10 * $c99"
> prove "subtracting the lag gives back a closed-loop-sized p99 (within 5x)" is "$s99 < 5 * $c99 + 0.5"
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/28-benchmarking.sh)

所以，要回答“在我们的负载下有多快”，请以现实的速率使用 `-R`，并且远低于峰值；然后从带有调度延迟的 `--log` 中报告百分位点（每个事务一行）。如果调度延迟持续增长，服务器就无法维持该速率，而这就是答案。在相信任何人的 p99 之前，也包括你自己的监控，先问它是不是从预定开始时刻计时的。

<span id="why-80-percent-and-how-to-get-percentiles-out-of-the-log" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-29-why-80-percent-and-how-to-get-percentiles-out-of-the-log" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：为什么是 80%，以及如何从日志得到百分位点
>
> 在完整的闭环速率下，停顿之后没有余量可以追赶，pgbench 可能永远落后于时间表，因此实验检查 `-R` 运行是否达到了它的速率（请求到达服从平均速率为该值的泊松过程）。`--latency-limit` 与 `-R` 一起使用时，会把本来会太晚开始的事务计作跳过，这样“p99 低于 20 ms”就变成了可以测试的数字。
>
> `--log` 为每个事务写一行，每个线程一个文件：
>
> ```text
> client_id  transaction_no  time  script_no  time_epoch  time_us  [schedule_lag]
> 0          1               1389  0          1791261418  503984   96
> ```
>
> `time` 是以微秒计的延迟（在 `-R` 下从计划开始时间计，因此包含 `schedule_lag`）。对它排序，再从中取出所需位置：
>
> ```bash
> cat pgbench_log.* | awk '{ print $3 }' | sort -n | awk '
>   { a[NR] = $1 }
>   END { printf "p50 %.2f  p95 %.2f  p99 %.2f  max %.1f ms\n",
>         a[int(NR*.50)]/1000, a[int(NR*.95)]/1000,
>         a[int(NR*.99)]/1000, a[NR]/1000 }'
> ```
>
> 对于长时间运行，`--sampling-rate=0.1` 会保留十分之一的行；`--aggregate-interval=1` 每秒写一行汇总，这正适合回答“当时发生了什么”。
>
> > [!EXAMPLE]- 动手验证 · lab `28-benchmarking.sh`
> >
> > ```bash
> > prove "in the -R log, every transaction's time includes its schedule lag (time >= lag)" is "$bad == 0"
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/28-benchmarking.sh)

## 先预热，再完整经过一个检查点 {#warm-up-then-run-through-a-checkpoint}

一次基准测试的前几秒和第十分钟，测量的是不同的服务器。

### 冷缓存和热缓存是不同的服务器 {#cold-and-warm-caches-are-different-servers}

第一次执行均匀的 `events` 查找时，速度是每秒 2,700 次；几分钟后，同一条命令达到每秒 52,900 次：变化的只有缓存。要测量稳定状态，就预热到 `-P`（pgbench 的周期性进度报告）显示吞吐量趋于平坦，然后再测量。对于故障切换和部署问题，用户看到的是冷启动数字，所以要有意识地测量它，并同时清除[操作系统缓存](/03-latency/#a-page-lives-in-one-of-three-places)。

<span id="evicting-a-table-on-purpose" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-29-evicting-a-table-on-purpose" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：有意驱逐一张表
>
> 像重启一样把 `users` 及其索引从共享缓冲区驱逐出去，然后连续两次运行同一条查找：冷运行读取了 1,401 页（96,014 tps），热运行没有读取页（111,236 tps）。差距之所以不大，只是因为这些页仍在操作系统缓存中。`pg_buffercache_evict_relation()`（PostgreSQL 18+）会把一个关系从共享缓冲区中清空；在 Linux 主机上，以 root 执行 `echo 3 > /proc/sys/vm/drop_caches` 还会清除操作系统缓存。`blocks read` 来自 `pg_stat_statements`，而[命中与读取](/19-explain/#buffers-are-the-evidence)在这里和计划中的含义相同。
>
> > [!EXAMPLE]- 动手验证 · lab `28-benchmarking.sh`
> >
> > ```bash
> > # sql <db>: psql on the lab's server
> > sql book -c "select pg_buffercache_evict_relation('users'), pg_buffercache_evict_relation('users_pkey')"
> > # cold
> > pgbench -n -M prepared -c 4 -j 4 -T 5 -f /tmp/lookup.sql book
> > # warm
> > pgbench -n -M prepared -c 4 -j 4 -T 5 -f /tmp/lookup.sql book
> > prove "the cold run reads over 1,000 blocks from outside shared buffers" is "$cold_read > 1000"
> > prove "the warm run of the same command reads under a tenth of that" is "$warm_read * 10 < $cold_read"
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/28-benchmarking.sh)

### 60 秒基准测试可能错过每一个检查点 {#a-60-second-benchmark-can-miss-every-checkpoint}

一个[检查点](/43-glossary/#checkpoint)每隔 `checkpoint_timeout` 运行一次（默认 5 分钟）；它开始后，每一页的第一次变更都会把一张[整页镜像](/08-wal/#the-first-change-after-a-checkpoint-logs-the-whole-page)写进 WAL。60 秒的基准测试可能错过所有检查点，于是它的数字会超过生产环境能看到的一切。下面是一个类似 TPC-B 的运行，按秒列出；各列分别是距检查点开始的秒数、每秒事务数、平均延迟、整页镜像数和每事务 WAL 字节数：

```text
    -1 s   11035 tps    0.362 ms       0 fpi     420 B/tx
    +0 s   11238 tps    0.356 ms       0 fpi     408 B/tx   <- checkpoint starts
    +1 s   11608 tps    0.344 ms    6650 fpi    4948 B/tx
    +2 s   10349 tps    0.386 ms    1969 fpi    1966 B/tx
    +5 s   11511 tps    0.347 ms     118 fpi     477 B/tx
```

有一秒钟，同样的事务写出的 WAL 是平时的 12 倍。在这里磁盘吸收了它；早先的一次运行中，磁盘繁忙，吞吐量连续两秒减半。不包含检查点的基准测试无法告诉你面对的是哪一种服务器。

我们的规则是：用于预测生产环境的运行，至少持续两个 `checkpoint_timeout` 周期，使用生产环境的设置，并且长到足以让 autovacuum 访问大表。之后检查[检查点进程计数器](/31-observability/#checkpoints-should-be-timed)和 `last_autovacuum`，确认两者都发生在测量窗口内。

<span id="the-minute-that-saw-a-checkpoint-and-two-autovacuums" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-29-the-minute-that-saw-a-checkpoint-and-two-autovacuums" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：看见一个检查点和两次 autovacuum 的那一分钟
>
> 实验服务器设置 `checkpoint_timeout = 30s`（最小值），让检查点落在一分钟的测试运行内，同时设置 `synchronous_commit = off`，排除提交 fsync 的影响；采样器每秒读取一次 `pg_stat_wal` 和 `pg_stat_checkpointer`（scale 5，4 个客户端）。在它之前，每个事务写入 383 到 420 字节；之后的五秒约为其四倍，随着更多页第一次被触碰，数值经过 897 和 561 字节后逐渐稳定。在接近性能极限的存储设备上，长时间运行的 `tps` 会随检查点周期呈锯齿状变化。
>
> 同一分钟内，autovacuum 处理了 `pgbench_tellers` 和 `pgbench_branches`（小而繁忙的表很快就会越过其[阈值](/10-vacuum/#autovacuum-fires-on-dead-rows-inserts-or-age)），但从未处理 500,000 行的 `pgbench_accounts`：它约有 25,000 个死行版本，而需要约 100,000 个。在生产环境中，这次大清理会在你的流量中间运行。
>
> > [!EXAMPLE]- 动手验证 · lab `28-benchmarking.sh`
> >
> > ```bash
> > PGOPTIONS="-c synchronous_commit=off" pgbench -n -M prepared -c 4 -j 4 -T 60 -P 10 \
> >   --log --log-prefix=ck_ --aggregate-interval=1 bench
> > prove "a checkpoint started inside the one-minute run" test -n "$starts"
> > prove "full-page images jump after the checkpoint starts (5 s after > 5x the 5 s before)" \
> >   is "$fpi_after > 5 * $fpi_before + 100"
> > prove "the same transactions write more than twice the WAL each in the 5 s after it starts" \
> >   is "$wal_after > 2 * $wal_before"
> > prove "autovacuum processed pgbench_tellers during the run" is "$av_after > $av_before"
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/28-benchmarking.sh)

## 只改一件事，并端到端测量 {#change-one-thing-and-measure-it-end-to-end}

相同的数据、相同的脚本、相同的时长，中间只改变一件事；如果差异不大，每侧运行三次。[运行手册中的实验](/36-playbook/#the-proof-one-app-eight-changes)就是把这个方法连续应用八次。

然后从两端测量。`-r` 报告客户端看到的脚本每一行的平均延迟。下面是给 `events (project_id)` 建索引前后的仪表盘脚本：

```text
           latency (ms)  statement
  before:         0.339  select id, email, name from users where id = :u;
  before:       100.617  select kind, count(*) from events where project_id = :p ...
  after:          0.094  select id, email, name from users where id = :u;
  after:          0.562  select kind, count(*) from events where project_id = :p ...
```

吞吐量从每秒 39 个事务提高到 6,083 个。但没有改变的主键查找的速度也变成原来的三倍，因为客户端延迟包含了在一台被顺序扫描压满的机器上等待 CPU 的时间。客户端的数字告诉你用户感受到了什么，而不是为什么。

[`pg_stat_statements`](/43-glossary/#pg_stat_statements) 会告诉你原因（在每次运行前后[做快照并比较](/31-observability/#snapshot-and-diff-dont-reset)）：

```text
  pg_stat_statements, before -> after
  report:  99.690 ms, 35193.6 blocks/call  ->  0.470 ms, 102.8 blocks/call
  lookup:   0.047 ms,     3.0 blocks/call  ->  0.014 ms,   3.0 blocks/call
```

报告查询从每次读取整张表，变成每次约一百页。查找前后都恰好访问 3 个块。每次调用的块数说明是哪条语句在工作，时间说明谁在承受代价，而只有块数在不同运行之间保持稳定。计时有噪声时，就比较缓冲区。数页。

<span id="the-before-and-after-run" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-29-the-before-and-after-run" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：前后运行
>
> 两次运行把索引建立夹在中间，因此索引建立本身的 I/O 不在任何一个窗口内。客户端数字是 `-r` 的逐行平均值。服务器端数字来自每次运行前后对 `pg_stat_statements` 做快照并相减：时间使用 `total_exec_time / calls`，块数使用 `(shared_blks_hit + shared_blks_read) / calls`。客户端数字会随机器正在做的其他事情波动：早先在同一台笔记本上的一次运行中，查找的 `-r` 延迟从 27 ms 变成了 0.2 ms，但每次调用的块数都是 3。
>
> > [!EXAMPLE]- 动手验证 · lab `28-benchmarking.sh`
> >
> > ```bash
> > # before
> > pgbench -n -M prepared -c 4 -j 4 -T 10 -r -f /tmp/endpoint.sql book
> > # sql <db>: psql on the lab's server
> > sql book -c "create index events_project_id_idx on events (project_id)"
> > # after
> > pgbench -n -M prepared -c 4 -j 4 -T 10 -r -f /tmp/endpoint.sql book
> > prove "before the index the report reads over 30,000 blocks per call (the whole table)" \
> >   is "$rep_blk_b > 30000"
> > prove "after the index it reads under 1,000 blocks per call" is "$rep_blk_a < 1000"
> > prove "the report's mean time in pg_stat_statements fell more than 20x" \
> >   is "$rep_ms_b > 20 * $rep_ms_a"
> > prove "the untouched lookup does the same 3 blocks of work before and after" \
> >   is "$usr_blk_b == $usr_blk_a && $usr_blk_a == 3"
> > prove "yet its client-side latency (-r) fell over 1.5x, because the box stopped being saturated" \
> >   is "$lk_b > 1.5 * $lk_a"
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/28-benchmarking.sh)

## 均匀数据会撒谎 {#uniform-data-lies}

这本书的种子数据有意采用均匀分布，因此每章的数字都能复现：最繁忙的 1% 项目持有不到 2% 的事件。生产系统没有一个是这样的。

**倾斜会改变缓存。** 在一张 275 MB、共享缓冲区为 128 MB 的表上按 ID 查找事件：均匀 ID 的速度是每秒 40,168 次，命中率为 81.4%；Zipf ID 的速度是每秒 97,107 次，命中率为 98.4%。在这里使用均匀基准测试，会让你去购买并不需要的 RAM。（对于写入，倾斜的影响相反：热门行会制造锁队列。）

**倾斜会改变计划。** 取 500,000 个事件做两份，一份使用种子数据，另一份重新生成项目 ID，使项目 1 占约 7% 的行。索引相同，查询也相同（项目 1 最新的 20 个事件）：

```text
-- uniform: about 25 rows for project 1
         ->  Bitmap Heap Scan on ev_uniform  (cost=4.62..100.94 rows=25 width=112)
-- skewed: about 34,000
   ->  Index Scan Backward using ev_skewed_created_at_idx on ev_skewed
         (cost=0.42..23091.40 rows=33483 width=111)
         Filter: (project_id = 1)
```

有 25 行时，规划器会把它们全部取出并排序。有 34,000 行时，它会从最新端反向扫描 `created_at` 索引，找到第 20 个匹配项就停止，因为[pg_stats](/20-planner/#pg_stats-is-the-planners-entire-picture)已经告诉了它数据分布。用均匀数据测试，结果大租户拿到的是没人检查过的计划。

**一个月的更新可能让表接近翻倍。** 每次更新都会留下一个[死行版本](/06-mvcc/#vacuum-frees-space-inside-the-table-not-on-disk)，而清理很少把空间还回去。连续 30 个“天”每天更新 `projects` 的三分之一、每周清理一次，最终让同样的 20,000 行从 224 页增长到 399 页。基准测试要使用六周后你会拥有的表，而不是今天早上刚加载的表。

贴近实际的测试数据库应当具有生产环境的行数、倾斜、值大小（4 KB 的 JSON 数据远远超过约 2 KB 的行大小，此时[TOAST](/04-storage/#basics-chapter-4)会接管），以及空闲空间。获得这四样东西最便宜的办法，是从生产环境开始。

<span id="making-skewed-and-aged-test-data-in-sql" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-29-making-skewed-and-aged-test-data-in-sql" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：用 SQL 制作倾斜且老化的测试数据
>
> 重新按对数均匀分布生成项目 ID 只需一行：
>
> ```sql
> update ev_skewed set project_id = floor(exp(random() * ln(20000)))::bigint;
> ```
>
> `exp(random() * ln(n))` 在对数空间内是均匀分布：项目 1 会得到约 7% 的行，前 1% 的项目合计超过一半。`power(random(), 3)` 会把值堆向 0，而 `random_normal()`（PostgreSQL 16+）会给出钟形曲线。两份副本都在 `project_id` 和 `created_at` 上建立了索引并执行了 `analyze`；均匀数据的计划是该位图扫描上方的 `Sort`，并且位于 `Limit` 下。
>
> 老化过程每个“天”执行一条语句（psql 的 `\gexec` 会让每条语句各自运行，因为 `VACUUM` 不能在函数或 `DO` 块中运行）：
>
> ```sql
> select format('update projects_aged set name = name || %L where id %% 3 = %s',
>               '', d % 3),
>        case when d % 7 = 0 then 'vacuum projects_aged' end
> from generate_series(1, 30) d
> order by d
> \gexec
> ```
>
> `pgstattuple('projects_aged')` 随后报告 49% 的空闲空间。
>
> > [!EXAMPLE]- 动手验证 · lab `28-benchmarking.sh`
> >
> > ```bash
> > prove "zipfian lookups hit shared buffers at least 10 points more often than uniform ones" \
> >   is "$z_ratio > $u_ratio + 10"
> > prove "in the seed, the busiest 1% of projects hold under 2% of events" \
> >   holds book "select sum(c) * 1.0 / 2000000 < 0.02
> >               from (select count(*) as c from events group by project_id order by c desc limit 200) t"
> > prove "on uniform data, project 1 plans a scan of the project_id index and a sort (about 25 rows)" \
> >   grep -q "Index Scan on ev_uniform_project_id_idx" <<<"$u_plan"
> > prove "on skewed data, the same query walks the created_at index backward and filters (about 35,000 rows)" \
> >   grep -q "Index Scan Backward using ev_skewed_created_at_idx" <<<"$s_plan"
> > prove "the aged table holds the same rows in at least 1.5x the pages" is "$aged >= 1.5 * $fresh"
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/28-benchmarking.sh)

## 复制生产数据，隐去个人信息 {#copy-production-without-copying-people}

不要把真实个人数据放在笔记本电脑、CI 运行器，或访问控制比生产环境更弱的预发布服务器上。复制数据特征，并对人员信息做掩码。可靠的做法是：在生产环境旁边的克隆中完成掩码，然后导出这个克隆。

使用带密钥的哈希（`hmac`，来自 `pgcrypto`）。它是确定性的，因此电子邮件仍然彼此不同，唯一约束仍然成立，连接也仍然匹配；不带密钥的电子邮件哈希可以通过对一组可能的地址逐一哈希来反推出原文。不要抹掉数据特征：把所有 `created_at` 替换成 `now()`，会毁掉你想保留的倾斜分布。

克隆本身并不干净：旧值以死行版本的形式留在页上，也留在 WAL 中。这就是它应留在生产一侧并随后被删除的原因。它的 `pg_dump` 是干净的，因为导出只读取仍然存活的行。通过恢复备份来创建克隆：只要还有人连接，以正在使用的数据库为模板创建副本就会失败。

<span id="masking-a-clone-and-proving-the-dump-is-clean" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-29-masking-a-clone-and-proving-the-dump-is-clean" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：为克隆做掩码，并证明导出内容干净
>
> ```sql
> create database staging template book;   -- in production: restore a backup
> \c staging
> create extension pgcrypto;
> update users
>    set email = encode(hmac(email, 'secret-kept-on-server', 'sha256'), 'hex')
>                || '@example.invalid',
>        name  = 'User ' || id;
> ```
>
> > [!EXAMPLE]- 动手验证 · lab `28-benchmarking.sh`
> >
> > ```bash
> > prove "masking kept every email distinct (100,000), so unique constraints and joins still work" \
> >   holds staging "select count(distinct email) = 100000 from users"
> > prove "the masked clone still has the original email in a dead row version on disk" \
> >   holds staging "select exists (select 1 from generate_series(0, (pg_relation_size('users') / 8192)::int - 1) b
> >                                 where position(convert_to('user1000@example.com', 'UTF8')
> >                                                in get_raw_page('users', 'main', b)) > 0)"
> > # $hits, in the server's container:
> > pg_dump -t users staging | grep -c '@example.com' || true
> > prove "but a pg_dump of the clone contains no original email address" is "$hits == 0"
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/28-benchmarking.sh)
>
> 如果列不止几列，可以使用 [PostgreSQL Anonymizer](https://postgresql-anonymizer.readthedocs.io/en/stable/)，这是 Dalibo 维护的扩展；它可以在列上声明掩码规则，并生成匿名化导出或做过掩码的克隆。另外，`pg_dump --exclude-table-data=sessions` 会保留表定义而不保留其中的行（[pg_dump](/32-backups/#pg_dump-is-portable-small-and-slow-to-restore)）。

## 测试计划，而不只是测试速度 {#test-the-plan-not-just-the-speed}

有时你只需要计划。PostgreSQL 18 的 `pg_dump --statistics-only` 会导出规划器统计信息，因此很容易产生这样的想法：把模式和统计信息加载进一个空数据库，然后在那里生成计划。但这行不通。我们让两边都估计告警数量：

```text
-- in book, the full database
 Seq Scan on events  (cost=0.00..60172.84 rows=245732 width=112)
-- in shape, empty tables with book's statistics restored
 Seq Scan on events  (cost=0.00..0.00 rows=1 width=112)
```

统计信息完整到达了。但规划器估算表大小时，会检查表在磁盘上的实际大小，并只把 `reltuples / relpages` 当成密度，再按实际大小缩放。零页、零行，向上调整为一行。

优先顺序应当是：完整大小的掩码副本；行数和倾斜分布符合生产环境的合成数据；生产副本上的 `EXPLAIN`（普通 `EXPLAIN` 不会执行查询）。无论使用哪种方式，都要比较计划，而不只是比较计时（[慢查询的方法](/19-explain/#a-method-for-slow-queries)）。如果计划不同，计时比较就没有意义。

<span id="the-statistics-only-experiment" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-29-the-statistics-only-experiment" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：只导出统计信息的实验
>
> ```bash
> createdb shape
> pg_dump --schema-only book | psql -d shape
> pg_dump --statistics-only book | psql -d shape
> ```
>
> 导出内容是对 `pg_restore_relation_stats()` 和 `pg_restore_attribute_stats()` 的调用：包括 `relpages`、`reltuples` 和每一列的 `pg_stats` 行，也包括 `most_common_vals`。列统计信息仍然会影响选择率，但行数估计不会照搬过来。只导出统计信息在有数据时才真正有用：用 `--statistics` 恢复完整导出，意味着恢复的数据库在第一次 `analyze` 完成前就能生成良好计划。
>
> > [!EXAMPLE]- 动手验证 · lab `28-benchmarking.sh`
> >
> > ```bash
> > prove "pg_dump --statistics-only carried relpages and reltuples into the empty copy" \
> >   holds shape "select relpages = $(sql book -c "select relpages from pg_class where relname = 'events'")
> >                and reltuples > 1900000 from pg_class where relname = 'events'"
> > prove "the full database estimates over 200,000 alert rows" is "$est_book > 200000"
> > prove "the empty copy with the same statistics estimates fewer than 10" is "$est_shape < 10"
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/28-benchmarking.sh)

## 本章要点 {#the-short-version}

- 先把问题写下来：峰值吞吐量、给定速率下的延迟，还是 A 与 B 的比较。
- 使用自定义 pgbench 脚本（`-n -f`）、倾斜分布的键以及驱动程序使用的协议，测试自己的查询。
- 测量延迟时，以现实速率使用 `-R`，并从 `--log` 中取得 p50、p95 和 p99。闭环百分位点会隐藏所有停顿。
- 每个变体交错运行三次，只有范围不重叠时才相信差异。一次只改一件事。
- 为了稳定状态先预热；有意识地测量冷启动；运行足够长的时间，包含一个检查点和一次 autovacuum。
- 计时有噪声时，比较 `pg_stat_statements` 的每次调用块数。
- 在特征符合生产环境的数据上测试：真实行数、倾斜、值大小和空闲空间。在生产一侧为克隆做掩码并导出克隆；没有数据的统计信息无法复现生产计划。

## 延伸阅读 {#further-reading}

- [pgbench](https://www.postgresql.org/docs/18/pgbench.html)，PostgreSQL 文档。所有选项、脚本语言及其函数、日志格式，以及 “Good Practices” 小节。
- [pg_stat_statements](https://www.postgresql.org/docs/18/pgstatstatements.html)，PostgreSQL 文档。前后对照的测量工具，包括定向重置。
- [pg_dump](https://www.postgresql.org/docs/18/app-pgdump.html)，PostgreSQL 文档。`--statistics-only`、`--exclude-table-data` 以及创建测试副本所需的其他选项。
- [wrk2](https://github.com/giltene/wrk2)，Gil Tene。其 README 对协调遗漏以及恒定速率负载生成器为何能修复它，给出了最清楚的简短解释。
- [How NOT to Measure Latency](https://www.infoq.com/presentations/latency-response-time/)，Gil Tene，QCon San Francisco 2015 年。延迟常见的错误测量和报告方式，以及正确做法。
