8.4 建立而不是猜测假设
假设不是“可能是磁盘”“可能缺索引”的清单。它必须把机制写成一条可被事实推翻的预测:
因为 tenant 1 的真实选择率为 90%,generic plan 仍估 0.1%,
所以 hot parameter 会读取远多于估算的行;
若强制 custom plan 且其他条件不变,estimate/actual 应接近,
访问路径或资源量应随之改善。优先级由“现有证据支持度 × 用户影响 × 最小验证成本 ÷ 风险”决定,而不是由团队最熟悉什么决定。
8.4.1 计划与估算问题
计划假设应在 wait 排查之后进入。一个正在 Lock 等待的 backend,即使计划里有 Seq Scan,当前不返回的直接原因仍是锁;一个 ClientWrite backend 可能已经算出大量结果,planner cost 又不包含把结果传给客户端的时间。
确认计划方向时,从第一个显著偏差节点而非根节点名称开始:
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、内存与临时文件
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 |
锁
锁诊断必须保存等待边两端的:
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 分别使用。优先:
- 确认 rows/width estimate 与返回规模;
- 减少不必要数据、改善计划;
- 对单一 role/session 做对照;
- 计算最坏并发内存;
- 观察 spill 改善、RSS/pressure 与其他 workload 副作用。
8.4.3 客户端取数、网络与连接池排队
数据库算得快,不代表用户收得快。第 8 章实验用一个大 COPY TO STDOUT 和受控慢 reader 复现:
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。
连接池排队位于另一个边界:
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 饱和,更多并发会增加排队与上下文切换;吞吐不升而尾延迟更差。连接池的作用是排队和保护下游,不是消灭容量边界。
一棵够用的初始假设树可以写成:
请求慢
├─ 尚未进入 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 已完成
├─ 结果传输/消费
└─ 应用后处理/下游这棵树不是固定排障脚本。它的价值是强迫每个解释声明边界和证据,下一节再从中选一个最小、可逆的实验。
上一节:关联日志、指标与计划 · 返回本章目录 · 下一节:设计受控实验 · 查看全书目录 · 查看索引中心