# 先证明单机边界

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

---

“单机扛不住”必须是证据结论，而不是架构会议里的气氛。

最常见的误判有两类：

```text
局部问题被说成容量问题
  一条坏 SQL / 一个缺失索引 / 一次统计失真
  -> “PostgreSQL 不适合分析”

容量问题被说成局部问题
  工作集、写入、维护窗口或故障域已越过单节点
  -> “再调一个参数就好”
```

本节不预设答案。先把目标、负载和瓶颈拆成可测量的对象，再决定应该优化、
隔离、扩容，还是分布。

## 17.1.1 定义数据量、并发、延迟与新鲜度目标 {#item-17-1-1}

### “数据量”至少有六种尺寸

只报“十亿行”几乎没有决策价值。十亿个窄整数与十亿个宽 JSONB 的存储、
缓存和扫描成本不同；十亿行均匀访问与 99% 查询只看最近一天也不同。

基线至少记录：

| 维度 | 示例问题 | 可验证证据 |
|---|---|---|
| 逻辑规模 | 行数、租户数、时间跨度？ | `count(*)`、业务目录 |
| 物理规模 | heap、TOAST、索引各多大？ | `pg_relation_size`、`pg_total_relation_size` |
| 工作集 | 查询真正反复访问哪部分？ | 计划 buffers、时间谓词、缓存命中 |
| 增长 | 每日新增、更新、删除多少？ | 时序采样与容量预测 |
| 倾斜 | 最大租户/日期/键占多少？ | percentile、top-N、直方图 |
| 生命周期 | 热、温、冷数据如何变化？ | 保留、归档与访问统计 |

本章 fixture 的身份不是一句“24 万行”：

```text
8 tenants
400 accounts
2026-01-01 .. 2026-04-30
240,000 sales
2,000 heap pages in the verified run
local facts + daily summary + two remote shard copies
```

它还有明确限制：数据均匀、确定、合成，无法代表真实倾斜、缓存冷启动、
网络、WAL、vacuum 或生产并发。

### 并发不是 QPS 的同义词

分析系统常见四种并发：

```text
arrival concurrency
  同时到达多少请求

active database concurrency
  同时在 PostgreSQL 内执行多少语句

in-query parallelism
  一条语句使用多少 parallel workers

background concurrency
  autovacuum、checkpoint、备份、复制、ETL、刷新同时做什么
```

一个 dashboard 打开时发出 30 条 SQL，不等于数据库应该同时运行 30 个重
聚合。连接池可以排队，应用可以合并请求，汇总层可以复用结果。反过来，
“线上只有 20 个连接”也不表示压力小：每条查询可能启动多个 worker、多个
sort/hash 节点并产生大临时文件。

因此基线应同时记录：

```text
request rate
queue time
active sessions
parallel workers planned/launched
statements per request
rows scanned / returned
temporary bytes
CPU and I/O saturation
```

### 延迟要有分位数和查询类别

“平均 800ms”会掩盖两类事实：

- 99% 查询 10ms，1% 查询 80s；
- 所有查询稳定在 800ms。

两者的容量和用户体验完全不同。至少按 workload class 报告：

| 类别 | 典型目标 |
|---|---|
| 单租户交互明细 | P50/P95/P99 与超时率 |
| dashboard 聚合 | 首屏、完整加载与刷新周期 |
| 批量报表 | 完成窗口与失败重跑时间 |
| 数据导出 | 吞吐、并发上限与资源封顶 |
| ETL/刷新 | 截止时间、WAL/lag 与恢复点 |

不要把一次 `EXPLAIN ANALYZE` 的执行时间直接当 SLO。它只是一条 SQL 在某个
缓存、数据、参数和系统负载下的一次观察。SLO 需要在代表性并发、冷暖缓存和
运行周期下统计。

### 新鲜度独立于查询速度

分析请求常把两个目标混成一个：

```text
query latency: 用户发出查询后多久返回
data freshness: 返回的数据距离真实业务现在有多旧
```

一个物化汇总可以在 20ms 返回昨天的数据；一条扫描原表的查询可以在 2s
返回刚提交的数据。谁更好取决于合同，而不是毫秒数。

新鲜度目标应写成可验证形式：

```text
event-time freshness <= 5 minutes at P99
daily financial close complete by 02:00 UTC
late events within 24 hours must be included in next rebuild
dashboard may lag primary commit by 60 seconds
```

若使用副本，还要区分：

```text
source event lag
ingestion lag
replication replay lag
summary refresh lag
cache lag
```

只看其中一个指标会把旧数据误报成“查询很快”。

### 正确性是第一项 SLO

所有候选必须在相同输入下得到相同业务结果。本章冻结 32 行月报，并同时固定：

```text
business checksum = 42fb8ab5444469eba1f104a8e1e529dd
monthly checksum  = 644d45544ebbc2a80c42270c38ac6885
CSV SHA-256       = 64b045809e10364fd84a587121d919e8562a15335c4c6c015e91a0ead3a44323
```

四条计算路径逐字节比较：

```bash
cmp frozen-monthly.csv monthly-local.csv
cmp frozen-monthly.csv monthly-summary.csv
cmp frozen-monthly.csv monthly-distributed.csv
cmp frozen-monthly.csv monthly-two-stage.csv
```

如果某个候选“快很多”但少一个租户，它不是优化，而是错误。

### 用目标表替代形容词

一个可评审的初始目标可以长这样：

| 指标 | 当前 | 目标 | 测量条件 |
|---|---:|---:|---|
| 单租户明细 P95 | 1.8s | < 500ms | 50 并发、30 日窗口 |
| 全局月报完成时间 | 24min | < 10min | 冷缓存、完整月 |
| dashboard 新鲜度 P99 | 12min | < 5min | 按事件时间 |
| temp write/小时 | 800GB | < 100GB | 正常峰值 |
| primary CPU P95 | 92% | < 70% | OLTP+分析同时 |
| replica replay lag P99 | 9min | < 60s | 报表窗口 |

当前值未知时写 `unknown`，随后安排测量。不要用“应该没问题”填表。

### 工作负载清单

选型前收集每类查询：

```text
SQL fingerprint
business owner
read/write
frequency and concurrency
parameters and selectivity
rows scanned / returned
latency distribution
temporary I/O
lock behavior
freshness requirement
retry/idempotency behavior
failure consequence
```

同时固定 schema、统计信息、参数、数据生成方式和版本。否则两次跑分比较的
可能不是同一个系统。

本章的
[`fixture-manifest.json`](/labs/ch17/fixture-manifest.json)
保存生成器、行数、分片、校验和与限制；生产基线还应保存脱敏 workload
manifest 和运行环境 manifest。

## 17.1.2 区分 CPU、I/O、内存、锁与计划瓶颈 {#item-17-1-2}

### 先问“时间花在哪里”

慢查询的第一层分类：

```text
waiting
  lock / I/O / client / WAL / remote / worker

running
  CPU expression / decompression / hash / sort / aggregation

planned badly
  row estimate / join order / access path / partition pruning

doing too much work
  wrong grain / no predicate / repeated calculation / data transfer
```

分类不是互斥的。错误估算可能选择大量随机 I/O；内存不足可能产生 temp I/O；
锁等待可能让 CPU 很空但延迟很高。

### 用执行计划建立因果链

推荐从：

```sql
EXPLAIN (
  ANALYZE,
  BUFFERS,
  WAL,
  SETTINGS,
  VERBOSE
)
SELECT ...;
```

开始，但要理解风险：

- `ANALYZE` 会真正执行语句；
- 对写语句使用时会真的修改数据，除非放在可回滚且外部副作用可控的事务中；
- `BUFFERS` 展示 PostgreSQL buffer/I/O 计数，不等于操作系统层面的完整因果；
- 一次计划不是延迟分布；
- planner estimate 与 actual 的差距比节点名字本身更重要。

PostgreSQL 官方
[Using EXPLAIN](https://www.postgresql.org/docs/18/using-explain.html)
解释 plan tree、cost、actual rows、loops、buffers 与不同节点的读法。

先检查：

```text
actual rows × loops
estimated rows versus actual rows
rows removed by filter
heap fetches
sort method / memory / disk
hash batches
shared/local/temp buffers
workers planned / launched
partition subplans actually visited
remote SQL and returned rows
```

### CPU 瓶颈

常见信号：

- runnable CPU 长期接近可用核心上限；
- 查询主要读取 cached buffers，物理 I/O 不高；
- 大量表达式、JSON、正则、排序、哈希、聚合或 JIT 消耗；
- 增加并发只增加排队，吞吐不再提高；
- parallel worker 增加后单查询变快、系统总吞吐却下降。

CPU 证据必须区分：

```text
database process CPU
kernel CPU
steal/throttling
per-query CPU
background maintenance CPU
```

不能从 PostgreSQL `Execution Time` 单独推导 CPU 时间。

可尝试的方向：

- 减少扫描和返回行；
- 改善连接顺序与聚合粒度；
- 避免对每行重复做昂贵表达式；
- 使用预计算/物化；
- 审计并行度与并发；
- 扩大单机 CPU；
- 只有工作可被安全分片时再横向扩 CPU。

### I/O 瓶颈

常见信号：

- cache miss 后读取延迟高；
- shared read 与系统块设备队列共同上升；
- 顺序大扫描把 OLTP 热页挤出缓存；
- temp read/write 大量增长；
- checkpoint、backup、vacuum 与分析抢同一存储；
- 增加 CPU 不改善吞吐。

要区分三类 I/O：

```text
base relation/index I/O
temporary spill I/O
WAL/checkpoint/backup/replication I/O
```

它们的修复不同。缺索引与低选择性扫描不是同一问题；给全表聚合增加 B-tree
也未必比顺序扫描好。

### 内存与 spill

本章用同一排序证明：

```sql
SET work_mem = '64kB';
-- external merge, temp read/write

SET work_mem = '32MB';
-- quicksort in memory
```

计划来自：

- [`spill-low-plan.sql`](/labs/ch17/spill-low-plan.sql)
- [`spill-high-plan.sql`](/labs/ch17/spill-high-plan.sql)

冻结 24 万行上观察到：

```text
64kB: external merge, Disk about 5920kB
32MB: quicksort, Memory about 13645kB
```

不要据此设置：

```conf
work_mem = 32MB
```

然后乘上几百连接。`work_mem` 是许多执行节点各自可以使用的预算，不是整个
查询或实例的硬上限；并行查询还会放大消费者。正确步骤是：

1. 找到真实 spill 的 SQL 和节点；
2. 判断能否通过索引、过滤、聚合顺序减少数据；
3. 估算峰值并发 × 每查询节点 × worker；
4. 优先用角色、数据库、会话或任务级设置；
5. 同时设置超时、并发和 `temp_file_limit` 一类护栏；
6. 用压力回放核对实例 RSS、OOM 与总吞吐。

PostgreSQL 的
[Resource Consumption](https://www.postgresql.org/docs/18/runtime-config-resource.html)
是 `work_mem`、`hash_mem_multiplier`、maintenance memory 与 huge pages 等
参数的版本基准。

### 锁瓶颈

分析查询通常只读，不等于不会造成并发问题：

- 长事务延长 snapshot 生命周期，阻碍 vacuum 清理；
- DDL 等待或被 `ACCESS SHARE` 阻塞；
- `REFRESH MATERIALIZED VIEW` 的锁行为影响读者；
- 报表函数可能隐含写临时/业务表；
- 导出事务可能持有 snapshot 很久；
- standby 上长查询可能与 WAL replay 冲突。

诊断要同时看：

```sql
SELECT
  pid,
  wait_event_type,
  wait_event,
  xact_start,
  query_start,
  state,
  application_name
FROM pg_catalog.pg_stat_activity
WHERE datname = current_database();
```

以及 blocking graph，而不是只数连接。第 10、12 章的事务、锁与慢查询诊断
方法在这里继续适用。

### 计划瓶颈

错误计划常见来源：

```text
stale or insufficient statistics
correlated columns not represented
parameter-sensitive selectivity
implicit casts/collations
function-wrapped predicates
partition key not exposed
wrong join cardinality
generic plan versus custom plan
foreign table statistics drift
```

本章对外表执行 `ANALYZE`。官方 `postgres_fdw` 文档指出：本地统计可以减少
远端估算开销，但远端频繁变化时会很快过期；`use_remote_estimate` 则会增加
远端 planning 往返。两者都不是无条件更好。

### “做太多工作”比节点选择更根本

原始月报与日汇总都得到 32 行：

```text
raw plan:
  Parallel Seq Scan on sales_fact
  240,000 facts contribute

summary plan:
  Seq Scan on daily_tenant_summary
  2,880 summaries contribute
```

即使原始扫描计划完全正确，它仍在重复计算已经稳定的历史粒度。若业务允许
分钟或日级新鲜度，汇总可能比继续微调原表扫描更有效。

同理，分布式计划若把 240,000 行传到协调端再聚合，远端每个 Seq Scan 都
可能是“正确计划”，整体数据流却仍不合理。

### 一张瓶颈—证据—动作表

| 怀疑 | 至少需要的证据 | 优先动作 |
|---|---|---|
| CPU | CPU 饱和、每查询 CPU、计划工作量 | 少做工作、审计并行与表达式 |
| base I/O | buffer/read、设备延迟、访问形状 | 索引、裁剪、缓存/存储 |
| temp I/O | sort/hash 方法、temp bytes | 减少输入、局部内存与并发 |
| 锁 | blocker、wait event、事务年龄 | 缩短事务、调度/锁语义 |
| 计划 | estimate/actual、统计、参数 | 统计、SQL、索引、版本基线 |
| 重复计算 | 相同历史范围反复聚合 | 汇总、缓存、批处理 |
| 远端传输 | Remote SQL、返回行、网络 | 下推、局部聚合、分布键 |

## 17.1.3 单机未被正确使用前不急于分布式 {#item-17-1-3}

### “单机优先”是一条证据顺序

合理的升级阶梯：

```text
1. 业务口径与 SQL 正确
2. 统计、索引、分区裁剪正确
3. 内存与并行在并发预算内
4. 重复分析有汇总/批处理
5. OLTP 与 OLAP 有资源隔离
6. 单节点纵向容量仍不足
7. 分布键与主要查询天然对齐
8. 团队能承担分布式运维
9. 才进入横向分布
```

这不是要求永远把单机压到 100%。生产需要安全余量、维护窗口和故障容忍。
“正确使用”是达到经过评审的安全上限，而不是让事故替你找到极限。

### 先拒绝伪瓶颈

一个值得写进 ADR 的反例：

```text
症状：
  租户 3 的 4 月明细聚合慢

错误推断：
  表有 24 万行，因此需要分片

证据：
  合适 covering index 后只读 7,500 个索引项
  Index Only Scan
  Heap Fetches: 0

结论：
  当前问题是访问路径，不是节点容量
```

索引定义：

```sql
CREATE INDEX sales_fact_tenant_day_idx
ON shop_ch17.sales_fact (
  tenant_id,
  occurred_on,
  account_id
)
INCLUDE (amount, units, channel);
```

fixture 重建结束后显式：

```sql
VACUUM (ANALYZE) shop_ch17.sales_fact;
```

这是计划合同的一部分。刚装载的 heap 尚未有足够 all-visible 位时，PostgreSQL
可能选择 Bitmap Heap Scan；不能把之前一次 autovacuum 留下的状态当可重复
前置条件。

### BRIN 是相关性工具，不是“更小的 B-tree”

本章还创建：

```sql
CREATE INDEX sales_fact_day_brin_idx
ON shop_ch17.sales_fact
USING brin (occurred_on)
WITH (pages_per_range = 16);
```

BRIN 对“列值与物理位置天然相关”的大表按 block range 保存摘要，索引很小，
但返回候选 page range 后仍需 recheck，是 lossy 路径。它适合追加顺序与时间
大体一致的巨大事实表，不适合替代每种选择性 B-tree。

冻结小表只验证目录中存在 `date_minmax_ops`，并观察 BRIN 比 covering B-tree
小；不宣称这个查询上 BRIN 更快。官方
[BRIN Indexes](https://www.postgresql.org/docs/18/brin.html)
说明 block range、物理相关性、lossy recheck、`pages_per_range` 与
summarization 行为。

### 单机边界应是曲线，不是一个点

容量实验应逐级增加：

```text
data scale
concurrency
query mix
ingest rate
background maintenance
cache state
```

记录：

```text
throughput
P50/P95/P99
queueing
CPU
read/write IOPS and latency
temp bytes
WAL
checkpoint
vacuum debt
replica lag
error/timeout rate
```

理想结果是一组曲线：

```text
低并发：延迟稳定，吞吐线性增长
接近饱和：排队上升，吞吐增幅变小
过载：延迟和错误率急升，吞吐可能下降
```

生产容量线应位于拐点之前，并包含节点故障、维护和增长余量。

### 什么时候单机证据足以支持“继续单机”

可以暂缓分布式，当：

- 调优后 SLO 在峰值与故障演练下满足；
- 未来容量预测仍位于安全余量内；
- 物化/批处理的新鲜度合同可接受；
- offline replica 能隔离读负载；
- 主要风险是可通过纵向扩容或存储升级解决；
- 业务需要大量跨实体事务与灵活 JOIN，分片会显著破坏局部性；
- 团队尚未具备分片备份、恢复、再平衡与值班能力。

### 什么时候不能再用“继续调优”拖延

应正式进入分布式评审，当代表性证据显示：

- 单节点 CPU、内存、存储容量或 I/O 已越过安全上限；
- 维护、vacuum、备份或恢复无法在窗口内完成；
- 即使隔离到副本，分析吞吐仍受单节点资源限制；
- 业务故障域或地域要求不能由一个集群满足；
- 主要访问天然按租户/实体局部化，跨分片比例可控；
- 硬件纵向升级的边际成本和上限不再可接受；
- 团队已经定义跨分片事务、部分失败、重平衡和退出流程。

### 本节的停止条件

在以下问题没有答案前，不进入“选哪个分布式产品”：

```text
目标是什么？
当前瓶颈是哪一种资源？
哪条 SQL、哪个粒度、哪类并发造成？
单机优化后曲线在哪里拐弯？
未来多久越过安全容量？
哪些查询可以按一个分布键局部化？
哪些事务一定跨边界？
如果一个节点不可用，业务允许什么结果？
```

下一节先把 PostgreSQL 单节点内部可用的并行、索引、BRIN、分区、物化和
负载隔离工具讲透，再讨论真正的分布式门槛。

---

[返回本章目录](../) · [下一节：单机分析能力](../02/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
