# 建立而不是猜测假设

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

---

假设不是“可能是磁盘”“可能缺索引”的清单。它必须把机制写成一条可被事实推翻的预测：

```text
因为 tenant 1 的真实选择率为 90%，generic plan 仍估 0.1%，
所以 hot parameter 会读取远多于估算的行；
若强制 custom plan 且其他条件不变，estimate/actual 应接近，
访问路径或资源量应随之改善。
```

优先级由“现有证据支持度 × 用户影响 × 最小验证成本 ÷ 风险”决定，而不是由团队最熟悉什么决定。

## 8.4.1 计划与估算问题 {#item-8-4-1}

计划假设应在 wait 排查之后进入。一个正在 `Lock` 等待的 backend，即使计划里有 Seq Scan，当前不返回的直接原因仍是锁；一个 `ClientWrite` backend 可能已经算出大量结果，planner cost 又不包含把结果传给客户端的时间。

确认计划方向时，从第一个显著偏差节点而非根节点名称开始：

```text
query semantics and representative parameter
  → estimated rows vs actual rows × loops
  → filter/recheck rows and join multiplicity
  → buffers/WAL/temp/settings
  → custom vs generic plan
  → statistics age/distribution/extended statistics
  → predicate/index/partition expression match
```

可量化 cardinality error：

$$
E = \max\left(
\frac{\text{estimate}}{\text{actual}},
\frac{\text{actual}}{\text{estimate}}
\right)
$$

若 actual 为 0，应单独描述“估算 N、实际 0”，不要用无穷大排序掩盖业务含义。高误差是调查入口，不是固定阈值自动修复；它是否影响路径选择、内存分配、join order 或响应目标，还要看对照。

常见假设与反证：

| 假设 | 预测 | 最小对照 | 反证 |
|---|---|---|---|
| 统计陈旧 | estimate 偏离当前分布 | 安全副本/fixture `ANALYZE` 前后 | estimate 与路径不变且数据分布本就一致 |
| 跨列相关缺失 | 多 predicate 近似独立相乘 | extended statistics 前后 | 单列条件就已偏离，或相关统计不改善 |
| 参数敏感 generic plan | hot/cold 共用 estimate/shape | force generic/custom 对照 | 两类参数 estimate/资源均相近 |
| predicate 不可用于索引/裁剪 | 条件落到 Filter，扫描范围扩大 | 语义等价、可 sargable 的表达式 | 扫描范围未变化 |
| index 缺失 | 选择性高且 heap/blocks 成本主导 | 第 9 章 hypo index/安全建索引实验 | 路径已合适，时间主要在 wait/client |

禁用 planner 方法（如 `enable_seqscan=off`）最多是受控诊断探针，不是生产修复；它也不能绝对禁止所有路径。不要把“强迫 Index Scan 后这一次更快”直接推广为长期结论，必须覆盖参数分布、cache、并发、写成本和磁盘占用。

计划 change 同样不是根因。统计、参数、配置、数据量或版本变化可能让 planner 合理换路；判断回归要比较结果正确性、SLO、estimate、资源与 workload，而不是 diff 节点名。

## 8.4.2 锁、I/O、CPU、内存与临时文件 {#item-8-4-2}

wait event 是“backend 在采样瞬间等待哪里”，不是完整时间账本。把它与 blocker、查询计划、累计资源和 OS 指标组合：

| 候选机制 | PostgreSQL 证据 | 外部/对照证据 | 常见误判 |
|---|---|---|---|
| heavyweight lock | `Lock/*`、`pg_blocking_pids()`、`pg_locks` | blocker 事务/应用身份 | 只取消 waiter；把长 SQL 当 blocker |
| I/O wait | `IO/*`、plan buffers、I/O timing、`pg_stat_io` | device latency/queue、kernel/cache | 一次 IO sample 就断言磁盘故障 |
| CPU 饱和 | 多次 `active` 且无稳定 wait，calls/rows/plan 工作量 | CPU、run queue、steal/throttle | “无 wait”等于 CPU；CPU 高就归目标 SQL |
| temp/spill | plan sort/hash temp、`temp_blks_*`、temp file log | memory pressure、并发 | 直接全局增大 `work_mem` |
| shared memory contention | `LWLock/*`、`BufferPin` 等 | 并发/版本/具体 wait 名 | 把所有 Lock/LWLock 当行锁 |
| checkpoint/WAL pressure | WAL/checkpoint/I/O 指标、query WAL | storage 与写 workload | 只凭时间相关归因某个 query |

### 锁

锁诊断必须保存等待边两端的：

```text
PID + backend_start
user/database/application/client
xact_start/query_start/state
wait_event and lock modes
current/last query
transaction owner and business action
```

真正修复通常是缩短事务、统一锁顺序、避免事务中等外部 I/O、减少过宽写集合，或把冲突转为显式业务协议。增加 statement timeout 只是限制损失，不能替代根因修复。

### I/O

`EXPLAIN (ANALYZE, BUFFERS)` 的 shared read/hit 是 executor 访问证据；`track_io_timing` 开启后可补 read/write time；`pg_stat_io` 给出 backend type/context/target 维度。它们都不能单独证明物理盘读取：PostgreSQL miss 仍可能由 kernel page cache 满足。应与设备层 latency、queue、吞吐和同主机 control workload 对照。

### CPU

CPU 没有一个叫 `CPU` 的 wait event。backend 在执行用户态工作时往往 `active` 且 wait 为 NULL，但短采样也可能恰好落在两个 wait 之间。证明 CPU 瓶颈需要：

- 多次采样而非一行 activity；
- query calls/rows/plan work 与 CPU 时间窗同范围；
- 主机 run queue、利用率、throttling/steal；
- 限制并发或减少工作量后吞吐/延迟按模型变化。

### 内存与临时文件

sort/hash spill 是“该节点的内存预算与数据规模/并发不匹配”的证据。全局提高 `work_mem` 很危险，因为它不是整个实例固定池，而可能被一条 query 的多个节点、多个并发 backend 分别使用。优先：

1. 确认 rows/width estimate 与返回规模；
2. 减少不必要数据、改善计划；
3. 对单一 role/session 做对照；
4. 计算最坏并发内存；
5. 观察 spill 改善、RSS/pressure 与其他 workload 副作用。

## 8.4.3 客户端取数、网络与连接池排队 {#item-8-4-3}

数据库算得快，不代表用户收得快。第 8 章实验用一个大 `COPY TO STDOUT` 和受控慢 reader 复现：

```text
state=active
wait_event_type=Client
wait_event=ClientWrite
blocking_pid_count=0
```

这组证据表示 server 正在尝试把数据写给客户端，而 socket backpressure 让它等待。可能原因包括：

- 客户端逐行做昂贵处理，读取速度低；
- 客户端线程暂停、GC 或 event loop 被阻塞；
- 网络丢包、拥塞或带宽受限；
- 返回行/列过多、payload 过大；
- 游标/fetch size 与消费方式不合理；
- 下游已放弃请求但连接尚未及时取消。

`ClientRead` 则表示 server 等客户端发数据。普通 idle backend 经常在 `ClientRead` 等下一条命令，这不是慢 SQL；active session 在 COPY FROM、协议交互等场景也可能等客户端。必须结合 state、query、协议阶段与应用 trace。

客户端慢消费的修复候选是限制返回规模、分页/流式语义、修复 consumer、调整驱动读取方式、网络与超时传播，而不是先建索引。索引也许能缩短产生第一批行的时间，却不能让慢 reader 更快接收 400 MB。

连接池排队位于另一个边界：

```text
request arrives
  → waits for application/PgBouncer pool slot
  → obtains PostgreSQL backend/transaction
  → statement becomes visible in pg_stat_activity
```

未获得 slot 的请求不会出现在 `pg_stat_activity`。如果应用 p99 高、数据库 active sessions 刚好打满 pool size、server 单条执行仍快，应同时看：

- application pool acquire duration、waiter count、timeout；
- PgBouncer client/server active/waiting 与 pool mode；
- HAProxy/service route、连接拒绝与 backend health；
- PostgreSQL `max_connections`、可用连接与角色/database 限额；
- 事务长度、连接泄漏、重试风暴与并发上限。

“把 pool size 加倍”也是需要实验的假设。若数据库已经 CPU/I/O 饱和，更多并发会增加排队与上下文切换；吞吐不升而尾延迟更差。连接池的作用是排队和保护下游，不是消灭容量边界。

一棵够用的初始假设树可以写成：

```text
请求慢
├─ 尚未进入 PostgreSQL
│  ├─ 应用队列/连接池
│  ├─ 路由/建连/认证
│  └─ 上游重试或限流
├─ backend 正等待
│  ├─ Lock → blocker edge
│  ├─ IO/LWLock/BufferPin → 具体 wait + 资源
│  └─ ClientWrite/Read → client/protocol
├─ backend 正执行
│  ├─ estimate/path/join/scan
│  ├─ CPU/JIT/expression
│  └─ sort/hash/temp/WAL
└─ server 已完成
   ├─ 结果传输/消费
   └─ 应用后处理/下游
```

这棵树不是固定排障脚本。它的价值是强迫每个解释声明边界和证据，下一节再从中选一个最小、可逆的实验。

---

[上一节：关联日志、指标与计划](../03/) · [返回本章目录](../) · [下一节：设计受控实验](../05/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
