# 实战：`pg36_shop` 容量基线

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

---

本节把前五节收敛成一份可执行实验。目标不是发布一个炫目的 TPS，而是完整回答：

> 在记录过的 Pigsty 教学沙箱上，`pg36_shop` 的固定读写混合，面对三档合成
> 数据规模和一、八两个并发档位时，吞吐、延迟、CPU、I/O、WAL 与空间怎样变化？

实验边界刻意很窄：

```text
environment   Pigsty disposable teaching sandbox
server        pg-test-1, primary, 1 vCPU, about 2 GiB
client        pg-meta-1, separate host, 2 vCPU, about 4 GiB
PostgreSQL    18.6
path          direct primary :5432
workload      synthetic shop-mix-v1
arrival       closed-loop, zero think time
protocol      prepared, persistent connections
scale         S / M / L
clients       1 / 8
repetitions   5 per cell
```

它不测 HAProxy、PgBouncer、应用、WAN，也不审批生产容量。完整合同在
[`lab-contract.md`](/labs/ch26/lab-contract.md)，公共参考结果在
[`capacity-run.json`](/labs/ch26/capacity-run.json)。

## 26.6.1 运行三个规模和两个并发档位 {#item-26-6-1}

### 先辨认风险等级

本章 runner 有六个动作：

| 动作 | 等级 | 行为 |
|---|---:|---|
| `lint` | L0 | 本地校验合同和反例 |
| `capture` | L0 | 只读采集目标身份与环境 |
| `exercise` | L2 bounded | 创建、装载、压测、清理专用 fixture |
| `verify` | L0 | 验证已有证据 |
| `review` | L0 | 独立复算与审阅已有证据 |
| `all` | L2 bounded | 顺序执行完整流程 |

`exercise`/`all` 会真实消耗 CPU、I/O、WAL、复制与归档资源。只允许在 disposable
teaching sandbox 运行，绝不能把“只创建测试库”误判为 L0。

runner 只接受：

```text
cluster       pg-test
primary       pg-test-1 / 10.10.10.11
database      pg36_capacity
role          dbuser_pg36bench
environment   pg36-l2-vagrant disposable teaching sandbox
```

若目标身份、primary、marker、预存对象或 session 状态不符合合同，它会 fail
closed，而不是“尽量继续”。

### fixture 与权限边界

实验创建两个一次性对象：

```text
database  pg36_capacity
login     dbuser_pg36bench, non-superuser
```

库和角色都带本次 run 的 shared-object comment marker。清理前必须重新读回 marker，
并确认没有非本章会话；清理不用：

```sql
DROP DATABASE ... WITH (FORCE)
```

也不终止其他 session。

Pigsty 当前内网 HBA 通过 `+dbrole_readonly` 角色组识别业务连接。runner 不修改 HBA；
它把临时 login 加入 `dbrole_readwrite`，后者已间接属于 readonly 组，同时把临时
membership 设置为：

```text
INHERIT FALSE
SET FALSE
```

这个 membership 只参与 HBA member 判断，不允许 benchmark login 继承或切换成平台
角色。实验结束后角色与 membership 一并删除。

这条细节揭示了压测中常见的错误：为了“让测试先跑起来”修改 HBA、使用超级用户，
最终测到的 connection/auth path 与生产不同，还留下永久权限。

### 数据规模

`setup.sql` 用固定 seed 生成三档 synthetic data：

| scale | factor | customers | products | historical orders |
|---|---:|---:|---:|---:|
| S | 1 | 10,000 | 2,000 | 100,000 |
| M | 8 | 80,000 | 16,000 | 800,000 |
| L | 32 | 320,000 | 64,000 | 3,200,000 |

参考 run 实际初始化结果：

| scale | schema bytes | database bytes | initialization |
|---|---:|---:|---:|
| S | 29,704,192 | 37,869,247 | 2.17 s |
| M | 235,175,936 | 243,594,943 | 17.09 s |
| L | 942,268,416 | 950,982,335 | 84.82 s |

三档不是简单复制行数。每次切换 scale 都重新生成、`ANALYZE` 并捕获 row count、
relation size 与统计 provenance。压测报告必须保存 observed size，而不是只写
`scale=32`。

### 事务合同

| operation | weight | key distribution | transaction |
|---|---:|---|---|
| `read-product` | 50% | product Zipf 1.15 | 商品与库存点查 |
| `read-order` | 30% | customer Zipf 1.08 | 最近五个历史订单 |
| `place-order` | 20% | customer uniform、product Zipf 1.10 | 行锁、扣库存、追加订单 |

三个 pgbench script 分开保存：

- [`read-product.sql`](/labs/ch26/read-product.sql)
- [`read-order.sql`](/labs/ch26/read-order.sql)
- [`place-order.sql`](/labs/ch26/place-order.sql)

每个 script 自己定义 transaction 边界。`place-order` 的锁和库存检查是 workload
语义的一部分，不应为了提高 TPS 删除。

### 实验矩阵

每个 scale 分别运行 c1、c8，每个 cell 五次：

```text
3 scales × 2 client counts × 5 repetitions = 30 measured runs
```

每次：

```text
5 s natural warm-up
8 s measured window
prepared protocol
persistent connection
zero think time
250 ms latency limit
max tries = 1
```

并发顺序做 counterbalance，seed 由公开公式从 scale、clients、repetition 推导。
这不能消除所有 background noise，但能避免总是先跑 c1、后跑 c8 的固定时间偏差。

runner 明确禁止：

```text
statistics reset
OS cache drop
forced checkpoint
PostgreSQL restart
failover
autovacuum pause
archive/replica/backup pause
fsync or synchronous_commit disable
unlogged production-equivalent tables
```

如果 baseline 只有在关掉 durability 后才“通过”，它没有测量目标系统。

### 运行方法

先做不连接目标的本地检查：

```bash
static/labs/ch26/task.sh lint
```

完整实验要使用一个不存在或为空的 **私密绝对路径**：

```bash
export PG36_EVIDENCE_DIR=/absolute/private/new-empty/ch26-run
static/labs/ch26/task.sh all
```

也可分步：

```bash
export PG36_EVIDENCE_DIR=/absolute/private/new-empty/ch26-run
static/labs/ch26/task.sh capture
static/labs/ch26/task.sh exercise
static/labs/ch26/task.sh verify
static/labs/ch26/task.sh review
```

`capture`/`all` 拒绝覆盖非空 evidence directory。`verify` 与 `review` 可以反复读取
已有 evidence，不重跑 workload。

不要把私密目录放进 web root 或 Git。它包含 raw transaction log、system sample、
SQL snapshot 和监控响应；公共仓库只提交经过 allowlist 的摘要。

### 参考 run 的结果

正式参考 run：

```text
run id          6c44ebdb-2206-48c3-8089-d90fdff45204
transactions    511,709
measured runs   30
failures        0
late            0
deadlocks       0
temp bytes      0
```

每个 cell 的 TPS 是五次 repetition 的中位数；括号是 repetition bootstrap 95%
区间。latency quantile 从该 cell 的 raw transaction sample 合并后重算，**不是**
五个 p95 再取平均。

| cell | TPS median (bootstrap 95%) | p50 | p95 | p99 | max |
|---|---:|---:|---:|---:|---:|
| S-c1 | 1,508.5 (1,446.5–1,546.3) | 0.334 | 2.152 | 2.595 | 20.434 |
| S-c8 | 2,920.5 (2,869.3–3,005.1) | 1.334 | 9.448 | 12.927 | 56.481 |
| M-c1 | 1,555.8 (1,462.0–1,570.1) | 0.330 | 2.123 | 2.598 | 10.774 |
| M-c8 | 2,911.5 (2,755.4–2,930.3) | 1.370 | 9.398 | 12.571 | 41.174 |
| L-c1 | 1,423.6 (1,226.1–1,431.4) | 0.360 | 2.259 | 2.847 | 30.307 |
| L-c8 | 2,774.3 (2,216.1–2,824.4) | 1.447 | 10.087 | 14.494 | 94.330 |

latency 单位均为 ms。L-c8 的第一次 repetition 约 2,216 TPS，后续约
2,740–2,824 TPS；实验没有把它删成“异常值”。它可能包含初始化/cache/background
效应，宽区间本身就是证据，下一轮应延长 duration 并增加 repetition。

PostgreSQL 官方
[`pgbench good practices`](https://www.postgresql.org/docs/18/pgbench.html)
明确提醒，数秒测试不能得到可信的平均值，且 client machine 也可能成为瓶颈。
所以这组 `8 s × 5` 结果用于教学如何建立证据管道和发现下一问题，不是 production
characterization。

## 26.6.2 用 Pigsty 与原生视图解释饱和点 {#item-26-6-2}

### 四层证据各回答一个问题

每个 measured run 同时采集：

```text
pgbench transaction log
  -> client observed completion and latency distribution

PostgreSQL start/end snapshots
  -> committed work, blocks, temp, deadlock, WAL delta

PostgreSQL wait samples
  -> active session currently executing or waiting for what

client/server OS samples
  -> CPU work/iowait and load-generator saturation

Pigsty time series
  -> full exercise context and independent corroboration
```

不能用其中一层替代所有层。例如 p95 增加并不能单独证明 CPU 饱和；CPU 84% 也不能
单独证明 p95 来自 CPU queue。

### sampler 必须严格裁剪 measured window

原始 sampler 从 warm-up 前启动、pgbench 后结束是正常的，但 cell arithmetic 必须
只使用：

```text
measured pgbench start <= sample timestamp <= measured pgbench end
```

参考实现保存 start/end monotonic timestamp，并对 client/server sample 严格裁剪。
如果把 pgbench 结束后两秒 idle tail 算入 CPU median，单位事务 CPU 会被系统性低估。
增加 repetition 无法修复这种 systematic error。

Pigsty query 的 full exercise window 则故意保留：

```text
initialization
warm-up
measured runs
between-run gaps
cleanup
```

因此：

```text
native per-run evidence
  authoritative for cell arithmetic

Pigsty full-window evidence
  authoritative for context and corroboration
```

两个窗口不能直接做逐项相等断言。

### server 已进入高 CPU 区域，client 没有饱和

参考结果：

| cell | server work median | server iowait median | client work median |
|---|---:|---:|---:|
| S-c1 | 48.94% | 7.66% | 17.74% |
| S-c8 | 84.19% | 0.21% | 29.50% |
| M-c1 | 50.10% | 7.52% | 17.79% |
| M-c8 | 83.96% | 0.20% | 29.07% |
| L-c1 | 46.31% | 8.20% | 16.17% |
| L-c8 | 84.22% | 0.31% | 27.81% |

client 是独立 2-vCPU 主机，c8 work ratio 仍低于 30%；当前证据反对“load generator
已先饱和”这一解释。server 只有 1 vCPU，c8 约 84% work，CPU pressure 是合理的
候选瓶颈。

但不能立即下结论：

```text
root cause = CPU
```

还要检查 active/wait、lock、WAL sync、I/O、context switch 与 plan。单核上的
84% aggregate work 也不等于每一毫秒都有可运行 SQL。

### throughput 增长约 1.9 倍，p95 放大约 4.4 倍

| scale | c8/c1 TPS | c8/c1 p95 |
|---|---:|---:|
| S | 1.936x | 4.390x |
| M | 1.871x | 4.427x |
| L | 1.949x | 4.465x |

解释：

```text
c1 -> c8
  throughput still rises materially
  tail latency rises much faster
  server CPU moves toward high utilization
```

这说明 queue/competition 已增加，却仍不能定位精确 knee。只有两个并发点：

```text
exact_knee_known = false
interpretation = knee-not-bracketed-by-one-and-eight-clients
```

下一轮至少补：

```text
c2 c4 c8 c12 c16 c24 c32
```

并用更长 duration。若目标是 arrival SLO，再做 open-loop rate sweep，记录 offered、
achieved、late、skipped、failure 和 schedule lag。

### 数据规模开始改变 I/O 行为

| cell | block reads | block hits |
|---|---:|---:|
| S-c1 | 0 | 675,976 |
| S-c8 | 0 | 1,323,054 |
| M-c1 | 0 | 733,198 |
| M-c8 | 0 | 1,326,143 |
| L-c1 | 2,500 | 629,335 |
| L-c8 | 14,491 | 1,344,632 |

S/M 的 measured window 没记录到 PostgreSQL block read，L 则开始读 block。这里的
边界是：

- 自然 warm cache，不代表 cold start；
- PostgreSQL block read 可能由 OS page cache 服务；
- 短窗口未覆盖完整 working set；
- aggregate hit ratio 会掩盖特定 table/index；
- 虚拟磁盘不能代表 production storage。

下一轮可以选择：

```text
保持自然状态，延长到跨越多个 working-set cycle
增加 XL 数据量，使 working set 显著超过 RAM
分别测试 steady warm 和 restart-recovery 场景
从 pg_stat_io 分解 relation/temp/WAL context
```

不要为了“可重复 cold cache”在共享主机随意 `drop_caches`。那是高影响系统动作，
而且同时改变整台主机。

### WAL 与持久化证据

| cell | WAL bytes/mixed tx | durable bytes/place-order |
|---|---:|---:|
| S-c1 | 160.33 | 415.21 |
| S-c8 | 159.53 | 409.12 |
| M-c1 | 175.95 | 412.81 |
| M-c8 | 161.88 | 405.97 |
| L-c1 | 157.56 | 411.50 |
| L-c8 | 196.18 | 407.88 |

这些值是窗口 delta 除以相应分母，不是 PostgreSQL 配置常量。L-c8 的 WAL/tx 较高
可能与 page state、full-page images 或背景活动有关；仅凭 aggregate delta 不能
定位。下一轮应同时比较：

```text
wal_records
wal_fpi
wal_bytes
checkpoint timing
per-operation successful count
```

实验没有关闭 `fsync`、`full_page_writes` 或 `synchronous_commit`，也没有强制
checkpoint。若要研究 checkpoint phase，应把它作为显式 factor，而不是偷偷在每次
run 前 checkpoint。

### lock、failure 与 temp 的反证

30 个 measured run 中：

```text
failed transactions  0
late transactions    0
deadlocks            0
temp bytes           0
```

这反对“当前点因 deadlock/temp spill 失效”，但不能证明：

```text
there is no lock wait
there can never be a deadlock
all queries are memory-safe
250 ms SLO will hold at higher offered load
```

deadlock counter 只计被 detector 处理的 deadlock；普通 row-lock queue 不是
deadlock。`temp_bytes=0` 也只针对本窗口与当前 plan/data。

### 在 Pigsty 中做独立旁证

Pigsty 的
[PGSQL dashboards](https://pigsty.io/docs/pgsql/dashboard/)
把 Overview、Activity、Persist、Database、Table、Query 等视角连接起来。实验时
至少对齐：

```text
PGSQL Activity
  active/wait/backend state

PGSQL Persist
  WAL/checkpoint/archive/replication

PGSQL Database
  commit/rollback/cache/temp/deadlock

PGSQL Table / Query
  relation workload and statement behavior

NODE / disk dashboards
  client and server CPU, disk, network
```

参考 run 的 Pigsty full-window 摘要：

| signal | median | maximum |
|---|---:|---:|
| server CPU busy | 64.37% | 100.00% |
| server CPU iowait | 2.77% | 5.10% |
| client CPU busy | 23.00% | 28.97% |
| server disk read | 0.41 MiB/s | 13.34 MiB/s |
| server disk write | 4.69 MiB/s | 77.45 MiB/s |
| WAL generation | — | 44.04 MiB/s |
| database commit | — | 2,459.53/s |
| replica replay byte gap | 0 median | 3,471,848 max |

full-window 含初始化和间隔，所以 commit max 不应等于 cell TPS，disk write max 也不应
除以 measured transaction。它们用于确认“什么时候发生了什么”和检查观测层是否
互相矛盾。

replica replay byte gap 最大约 3.47 MiB，不能直接转译为：

```text
replica stale for N milliseconds
```

byte gap、replay time lag、应用 freshness 与 synchronous durability 是不同语义。
容量实验只记录这一旁证，没有声称 replica correctness。

### 建立 hypothesis table

这次实验后的调查表：

| 假设 | 支持证据 | 反证/缺口 | 下一实验 |
|---|---|---|---|
| c8 有 CPU pressure | server work ~84%、client <30% | 无 run queue/更密曲线 | c2–c32 sweep |
| client 先饱和 | 无 | client work <30% | 增大 clients 并监控 client queue |
| L 进入数据读路径 | block reads >0 | 可能命中 OS cache | XL、`pg_stat_io`、更长运行 |
| lock 是主瓶颈 | 写事务会行锁 | 无 deadlock，wait 需更细分 | 提高热点/写比例 |
| WAL 限制 throughput | L-c8 WAL/tx 增加 | iowait 低，缺 WAL fsync tail | write-only + checkpoint factor |
| exact knee 已知 | 无 | 仅 c1/c8 | 密集 closed/open-loop sweep |

这张表比“CPU 是瓶颈”更有用，因为它告诉下一笔实验预算该花在哪里。

## 26.6.3 输出可复现实验报告、容量模型和未知项 {#item-26-6-3}

### 私密 evidence bundle

完整运行产生：

```text
preflight-evidence.json
remote-benchmark.log
remote/
  capacity-evidence.json
  initialize-{S,M,L}.txt
  runs/<cell-run>/
    pgbench.stdout
    pgbench.stderr
    transactions.*
    stats-before.json
    stats-after.json
    client-system.jsonl
    server-system.jsonl
    database-waits.jsonl
remote-cleanup.json
validation-report.json
negative-report.json
public-summary.json
review.txt
```

证据包目录权限为 0700，文件为 0600。review 会检查 mode，避免 raw evidence 因默认
umask 变成多用户可读。

raw transaction log 对 latency 重算必不可少，却可能含时间、client/session 与
workload 行为；SQL snapshot、queryid 明细和监控 payload 也不应直接公开。发布流程
是：

```text
private raw evidence
  -> schema and invariant validation
  -> independent recomputation
  -> explicit allowlist
  -> public capacity-run.json
```

不是“从 raw JSON 删除几个看起来敏感的键”。

### 报告中的 provenance

一份可复现实验报告至少固定：

```text
run id and timestamps
target identity and topology
client/server hardware
PostgreSQL/Pigsty/OS version
settings with source
schema and source hashes
data generator and seeds
connection path and protocol
workload scripts and weights
arrival model and retry policy
warm-up/duration/repetition/order
measured-window boundaries
raw artifact inventory
cleanup evidence
known unknowns and claim gate
```

参考 run 验证了 255 个 raw artifact，并对约 21.8 MB evidence 做 secret scan。这个
数字不是质量本身；它的意义在于 manifest 能证明报告引用的 sample 没有悄悄缺失。

### 对抗性验证

`negative-cases.json` 定义了 26 个变体，validator 必须逐一拒绝。类别包括：

```text
wrong target / primary / environment
pre-existing or marker-mismatched fixture
client and server accidentally same host
missing runs or unbalanced matrix
wrong script weight / seed / protocol
statistics reset
sampler outside measured window
client saturation hidden
quantiles averaged instead of recomputed
failed/late/skipped hidden
cleanup unproven
raw/query/secret fields leaked into public summary
exact knee or production TPS fabricated
```

“positive fixture 能通过”只证明 happy path；错误 evidence 也能通过的 validator
不能保护结论。

参考 run 的验收：

```text
positive validation       passed
counterexamples rejected  26 / 26
database cleanup          verified
role cleanup              verified
remote temp cleanup       verified
nonchapter sessions killed 0
production gate           pending
```

### 容量模型只发布条件结论

公共报告给出的 65% CPU 投影：

| scale | CPU-s/mixed tx | conditional TPS at 65% CPU |
|---|---:|---:|
| S | 0.000291 | 2,236.79 |
| M | 0.000292 | 2,222.96 |
| L | 0.000307 | 2,116.98 |

它依赖四个显式假设：

```text
50/30/20 mix unchanged
CPU demand near observation scales linearly
cache/IO/lock/client/background work does not become tighter
same recorded sandbox
```

所以报告写：

```json
{
  "production_sustainable_tps": null,
  "exact_knee_known": false
}
```

`null` 不是实验失败，而是 claim 与 evidence 对齐。

### 未知项就是下一轮 backlog

本轮没有回答：

1. c8 左右的精确 knee 在哪里；
2. open-loop offered rate 下的 completion、late、skipped 与 queue；
3. 真实应用经 HAProxy/PgBouncer/TLS 的端到端成本；
4. realistic think time、connection churn、retry storm；
5. 真实 query 和 tenant/key skew；
6. cold/restart、working set 超 RAM 与 production storage；
7. checkpoint、autovacuum、backup overlap；
8. replica loss、failover、rebuild、archive outage；
9. 长稳态下 bloat、WAL、slot 与空间增长；
10. production hardware 的 N+1 SLO envelope。

将它们排成下一轮最小实验：

```text
Experiment A
  same sandbox, M scale
  c2/c4/c8/c12/c16/c24/c32
  5 min × repetitions
  purpose: bracket knee

Experiment B
  rates around accepted SLO point
  open-loop, realistic timeout/retry
  purpose: offered-load envelope

Experiment C
  direct / PgBouncer / HAProxy / application path
  same workload and target
  purpose: attribute service-path cost

Experiment D
  accepted load + maintenance/failure scenarios
  purpose: validate headroom

Experiment E
  production-like hardware and data
  N0 and N1
  purpose: production capacity decision
```

一次只改变少量 factor，保留 control；否则结果发生变化时无法归因。

### 独立练习

**练习一：改变 mix。**

把写比例从 20% 提到 40%，保持总权重 100%。运行 lint，解释哪些合同 hash、WAL 模型
和 capacity claim 必须失效。不要直接修改并覆盖正式参考结果。

**练习二：增加并发点。**

复制 experiment contract 到个人分支，加入 c2/c4/c12/c16，设计 counterbalanced order。
先写出“怎样才算 bracket knee”的 machine-checkable rule，再运行。

**练习三：检查窗口偏差。**

从一个私密 evidence bundle 取某次 run，分别计算：

```text
all sampler records CPU median
strict measured-window CPU median
```

解释 idle tail 对 CPU-s/transaction 和 65% 投影的方向性影响。

**练习四：设计 production gate。**

为自己的应用补齐：

```text
SLO
operation catalog
arrival forecast
N+1 topology
maintenance overlap
lead time
pass/fail rule
stop condition
```

如果仍写不出 production sustainable TPS，保留 `null`，并列出最小缺失证据。

### 完成检查表

- [ ] 能区分 business request、database transaction 与 SQL statement。
- [ ] workload mix、arrival model、key distribution 和 connection path 已固定。
- [ ] client 与 server 分离，且两侧都监控。
- [ ] warm-up、duration、repetition、seed 与 run order 可复现。
- [ ] per-run sample 严格裁剪 measured window。
- [ ] quantile 从 raw sample 重算，没有平均 p99。
- [ ] PostgreSQL counter 以 snapshot delta 计算，没有 reset shared stats。
- [ ] Pigsty full-window 指标没有冒充 cell arithmetic。
- [ ] failure、late、skipped、deadlock、temp 与 cleanup 均显式报告。
- [ ] unit resource cost 的分母是明确 operation。
- [ ] exact knee 不由两个并发点伪造。
- [ ] production TPS 在生产证据不足时保持 `null`。
- [ ] 未知项已变成带目的和 pass/fail rule 的下一实验。

做到这些，容量压测才从“跑过一张 TPS 截图”变成可重复、可反驳、可用于决策的工程
过程。

---

[上一节：从测量推导容量与成本](../05/) · [返回本章目录](../) · [下一章：精益求精：参数调优与资源治理](/configuration-tuning/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
