巧夺天工:索引设计与效果验证
9 巧夺天工:索引设计与效果验证
索引不是“给字段加速”的装饰,而是为一组操作符、谓词、排序与返回形状维护的额外数据结构。每增加一个索引,读取路径多一个候选,写入路径也多一份维护、WAL、缓存和生命周期成本。
因此索引设计从第 8 章的已证实 workload 开始:
“出现 Index Scan”不等于成功,“仍是 Seq Scan”也不等于失败。小表、低选择率、大范围、缓存与成本参数都可能让 Seq Scan 合理;一个被 planner 使用的索引也可能只优化冷门参数,却让所有写入变贵。
本章目标
完成本章后,读者应当能够:
- 把 access method、data type、operator class 与 query operator 对应起来;
- 解释 B-tree、Hash、GiST、SP-GiST、GIN、BRIN 与 Bloom 的边界;
- 从等值、范围、连接、排序、Top-N 与返回列推导 key order;
- 知道“最具选择性的列放最前”不是通用多列索引算法;
- 说明 PostgreSQL 18 B-tree skip scan 的能力及 14–17 的版本边界;
- 正确设计 expression index,并处理函数 volatility、collation 与语义一致性;
- 解释 partial index 的 predicate implication 与 generic parameter 陷阱;
- 使用
INCLUDE,同时理解 visibility map、heap fetch 与宽 payload 成本; - 量化索引的空间、写放大、WAL、cache、vacuum 与复制代价;
- 解释 HOT 的两个条件,以及 indexed/update column 如何使 HOT 失效;
- 区分重复、重叠、未使用、无效与约束支撑索引;
- 设计数据、cache、参数、并发一致的 before/after 实验;
- 选择普通或 concurrent build,监控阶段并处理 INVALID 残留;
- 在 Pigsty 中关联 query、table/index、WAL、锁、I/O 与复制延迟;
- 为订单、库存、全文搜索与时间序列给出 retain/reject 决策;
- 把索引收益与代价证据追加到
PREF-PLAN-005。
实验边界
实验基线为 PostgreSQL 18.6、Pigsty v4.5.0、Ubuntu 24.04 L1;主体 SQL 保持 PostgreSQL 14–18 可用,PG18 skip scan 单独标注。实验只在 shop_private 建七张带 marker 的 ch09_* fixture:
setup 在 marker 完全匹配后重建,属于 R1。reset 删除专属对象,属于 R2,需要 action/target 双 token。实验不为 shop 业务表新增或删除任何索引。
候选索引使用 CREATE INDEX CONCURRENTLY,但这只是 L1 机制演练:生产上线还要独立评估长事务、两次扫描、CPU/I/O、WAL、磁盘峰值、复制延迟、唯一语义与失败恢复。
下载资产:
- 实验合同
- 上下文 guard
- 机器计划上下文
- 确定性 fixture
- 订单查询
- 订单参数计划
- 库存查询
- 全文检索查询
- 事件范围查询
- 候选索引生命周期
- 候选 VACUUM
- 无额外索引写对照
- 有 volatile 索引写对照
- 写统计快照
- 写 fixture 恢复
- 并发失败注入
- 索引 catalog 快照
- 索引决策账本
- 语义分析器
- v0.4 规则提案
- 状态验收
- 双 token reset
- 任务入口
本章目录
9.1 索引方法与操作符类
- 9.1.1 B-tree 与 Hash 的适用查询
- 9.1.2 GiST、SP-GiST 与空间、范围、近邻问题
- 9.1.3 GIN 与数组、JSONB、文本检索
- 9.1.4 BRIN 与物理相关的大表;Bloom 的扩展边界
9.2 从谓词、连接与排序推导索引
9.3 表达式、部分与覆盖索引
9.4 索引也有写入和生命周期成本
9.5 验证而不是“加完就快”
9.6 实战:为订单、库存与搜索入口设计索引
实测摘要
一次 PostgreSQL 18.6 全量验收得到:
时间、cost、buffers、WAL 精确值与节点组合不是 golden;它们受硬件、cache、checkpoint、版本和数据布局影响。稳定断言是 partial/generic 语义、结果行数、index-only heap fetch、BRIN 相对空间、HOT 失效方向、WAL 增长方向、INVALID 生命周期和最终 catalog。
章节验收
- 每个候选先有 query/parameter/order/return shape 与 SLO;
- 能从 operator class 证明某个 clause 可被索引,而非只看列名;
- 多列顺序由 equality/range/order/workload 推导;
- PG18 skip scan 不被写成 PG14–17 的通用前提;
- partial index 的 query predicate 可在 planning time 蕴含 index predicate;
- generic parameter 不能证明任意值满足 partial predicate;
- covering index 同时检查 projection、VM/heap fetch 与 payload 宽度;
- GIN/BRIN 的 lossy/recheck、write 与物理相关边界明确;
- 索引评审包含 size、WAL、HOT、write latency 与 cache;
- 不以
idx_scan=0单独删除索引; - constraint、replica identity、rare critical query 与统计 epoch 已排除;
- A/B 使用同一数据、统计、参数、cache、并发和重复方法;
- concurrent build 的阶段、额外扫描、长事务与磁盘水位已评估;
- build 失败后查询
pg_index.indisvalid/indisready并精确回收; - Pigsty 面板结论能落回 query、catalog、plan 与 WAL/复制证据;
task.sh all与双 token reset 均通过,业务 checksum 不变。
下一章 ch10《顾此失彼:并发控制与隔离异常》 将验证即使单条查询和索引都正确,并发交错仍可能破坏业务不变量。
参考资料
- PostgreSQL 18:Indexes
- PostgreSQL 18:Index Types
- PostgreSQL 18:Multicolumn Indexes
- PostgreSQL 18:Partial Indexes
- PostgreSQL 18:Index-Only Scans
- PostgreSQL 18:CREATE INDEX
- PostgreSQL 18:Heap-Only Tuples
- Pigsty:PostgreSQL Dashboards
上一章:抽丝剥茧:慢 SQL 诊断方法论 · 返回上卷导读 · 下一章:顾此失彼:并发控制与隔离异常 · 查看全书目录 · 查看索引中心
9.1 索引方法与操作符类
判断一个 clause 能否使用索引,要同时回答:
“这个列有索引”不够。payload @> ...、name LIKE ...、point <-> ... 分别需要与其操作符语义匹配的 operator class;同一数据类型也可能有不止一种索引语义。
可查询当前环境的 operator class:
扩展可新增 type、operator 与 opclass,所以最终答案来自目标环境 catalog 和扩展文档,而不是一张静态“索引类型速查表”。
9.1.1 B-tree 与 Hash 的适用查询
B-tree:默认不是偶然
B-tree 支持有全序关系的数据,核心 operator 为:
BETWEEN、IN、IS NULL/IS NOT NULL 等可以转成相应搜索;它还可以按索引顺序输出,支持 uniqueness、多列、expression、partial、INCLUDE 与 index-only scan。因此以下 workload 通常先考虑 B-tree:
单列 B-tree 能正向或反向扫描,所以仅为了 ORDER BY occurred_at DESC 通常不必再建一个 DESC 单列索引。多列混合顺序才有区别:
它可以直接提供 ORDER BY tenant_id ASC, occurred_at DESC;普通 (tenant_id, occurred_at) 的整体反向扫描会得到两列同时反向,不能产生“一升一降”。
前缀 pattern search 要特别看 collation/operator class。LIKE 'foo%' 有机会变成范围扫描,LIKE '%foo' 不能靠普通 B-tree 从左定位。非 C locale 下,可能需要 text_pattern_ops/varchar_pattern_ops;但 pattern opclass 不替代普通 locale ordering,需要范围比较时可能要保留默认 opclass 索引。不要看到 LIKE 就盲目加普通 B-tree。
Hash:只有等值
Hash index 保存值的 32-bit hash code,只处理简单等值:
它不能提供范围、排序、unique、多 key 组合或 INCLUDE。PostgreSQL 14–18 的 Hash index 已是 WAL-logged、crash-safe 的正式能力,不应继续引用早期版本“hash index 不可靠”的旧结论;但 B-tree 也能处理 equality,并有更广能力,所以 Hash 需要实测证明 size/cache/lookup 收益,而不是因字段名叫 hash 就选 Hash。
Hash 适合候选的条件通常很窄:
- 只有一个宽值的 equality lookup;
- 不需要排序、范围、unique 或 covering;
- 真实数据与 cache 下比 B-tree 有明确收益;
- hash collision recheck 与额外 heap access 可接受;
- write、WAL、build、backup 与维护成本已比较。
如果 equality 本身已经由 PK/unique B-tree 支持,再建 Hash 多半只是重复成本。
9.1.2 GiST、SP-GiST 与空间、范围、近邻问题
GiST 和 SP-GiST 都是扩展索引策略的基础设施,不是“一种固定的空间树”。是否可用取决于 operator class。
GiST
GiST 可承载平衡树式的广义搜索。核心与扩展生态常见:
- 几何/空间 overlap、containment;
- range/multirange overlap 与 containment;
btree_gist提供 B-tree-like GiST opclass;pg_trgm的相似/模糊匹配;- PostGIS geometry/geography 空间 operator;
- 支持的 opclass 上做 K-nearest-neighbor ordering。
核心 point 示例:
这里 <-> 是该 operator class 的 distance ordering operator。把表达式改成未经索引支持的自定义距离函数,GiST 不会因“语义看起来一样”自动使用。
GiST 也用于 exclusion constraint:
它表达“同一房间的时间范围不得重叠”。这是约束语义,不只是性能;删除此类索引可能破坏 constraint,不能按 idx_scan 清理。
SP-GiST
SP-GiST 支持非平衡、space-partitioned 结构,例如 radix tree、quadtree、k-d tree。常见候选:
- 有前缀/层次分割特征的数据;
- core point/quadtree;
- inet prefix;
- text prefix 与特定 operator class;
- 支持 distance ordering 的 opclass 上做 KNN。
GiST 与 SP-GiST 谁更好不能由“空间数据”四字决定。要对照:
第 17 章会用 PostGIS 具体讨论 bounding box、distance、SRID 与 exact recheck;本章只固定索引方法的选择方式。
9.1.3 GIN 与数组、JSONB、文本检索
GIN 是 inverted index:把一个值拆成多个 component/token,再从 token 找到包含它的行。典型数据:
- array elements;
tsvectorlexemes;- JSONB keys/values/path tokens;
pg_trgmtrigrams;- 扩展定义的可分解值。
数组:
全文检索:
查询必须沿用同一 text search configuration 和 document 构造。索引 to_tsvector('english', title)、查询 to_tsvector('simple', title) 并非同一语义。生产常把 document 做成 stored generated column,使构造、统计与索引合同可见。
JSONB 有两个常用 core GIN opclass:
哪一个更好由实际 operator 与数据决定。给任意大 JSONB 建默认 GIN,可能索引大量从不查询的 token,增加 size、pending list、写入和 vacuum 成本。
GIN 不提供有序输出,也不能 index-only 返回原值,因为 entry 通常只保存 component。结果经常是 Bitmap Index Scan → Bitmap Heap Scan,并对 lossy/候选项 recheck。出现 Recheck Cond 是实现证据,不自动表示索引坏。
GIN 的 fastupdate 默认把更新先放 pending list,以批量合并摊薄写成本;这可能让个别读或清理出现尖峰。评审需观察 pending-list 行为、autovacuum、写入 burst、index size 和 WAL,而不是只测静态查询。
9.1.4 BRIN 与物理相关的大表;Bloom 的扩展边界
BRIN:索引 block range summary
BRIN 不为每行保存精确 key,而为连续 heap block range 保存 min/max 等 summary。因此它依赖“值与物理行顺序相关”:
适合:
- append-only/mostly append 时间序列;
- 单调增长 ID;
- 极大关系、宽范围查询;
- 能接受 lossy bitmap + heap recheck;
- B-tree 空间/cache/write 成本不划算。
不适合:
- 物理顺序已与值随机化;
- 每次只查极少行且需要精确 point latency;
- range 内大量无关行的 recheck 不可接受;
- 误以为 BRIN 能提供
ORDER BY——有序输出仍只有 B-tree。
pages_per_range 越大,索引通常越小但 summary 越粗;越小则更精确、索引和维护更大。新页范围还需要 summarization,可用 autosummarize、vacuum 或 BRIN 函数管理。
本章 400000 行按 occurred_at 物理写入。相同 600 行范围:
具体字节不是通用阈值;稳定结论是这种分布下 BRIN 以显著更小的空间支持范围,B-tree 的额外精度对声明 workload 不值成本。
BRIN 不是 partitioning。它不会改变 retention、约束、每分区索引或 drop lifecycle;partition pruning 与 BRIN filtering 可以并用,但解决不同层次问题。
Bloom:contrib extension,不是 core 默认方法
bloom 是随 PostgreSQL 提供的 extension access method,需安装扩展后使用。它把多列 equality 特征编码成 lossy signature:
适合“很多列、查询任意 equality 组合、维护所有 B-tree 组合过贵”的特定问题。false positive 必须回 heap recheck;signature 越大,误报少但索引更大。其限制包括:
- core module 只带有限类型 opclass;
- 只支持 equality;
- 不支持 unique;
- 不支持 NULL lookup;
- 不能排序、范围或替代约束。
Bloom 与 BRIN 都可能很小且 lossy,但机制不同:BRIN 按物理 block range summary,Bloom 为每行/索引项保存 signature。选择前先写 query operators 和数据布局。
返回本章目录 · 下一节:从谓词、连接与排序推导索引 · 查看全书目录 · 查看索引中心
9.2 从谓词、连接与排序推导索引
索引设计的输入不是表结构,而是查询合同。面对一条 SQL,先把它改写成下面这张工作单:
然后才提出:
这样做会自然排除“这个字段经常查,所以单独给它建索引”一类脱离操作符、组合方式和代价的建议。
9.2.1 等值、范围与多列顺序
B-tree 的有效搜索区间
对多列 B-tree (a, b, c),传统且跨 PostgreSQL 14–18 都成立的基本推导是:
- 从最左侧开始的等值条件不断缩小连续索引区间;
- 第一个没有等值、但有不等式的列确定该区间的起止边界;
- 更右侧条件仍可在索引内检查,却不一定进一步减少需要扫描的索引项;
- 排序能否直接复用,还取决于等值前缀、列顺序、方向和
NULLS规则。
例如:
一个自然候选是:
两个 equality key 固定前缀,created_at 同时承担 range 与 ordering。若把 created_at 放到 state 前面,进入时间范围后,右侧的 state 通常只是过滤条件;它仍可能减少 heap visit,却不能像等值前缀那样缩短该时间范围本身。
这不是要求把所有等值列机械放在所有范围列之前。真正的问题是:
- 哪些条件总是一起出现,哪些只是某个 query family 才有;
- 哪个条件在 planning time 可见;
- 是否要支持某个
ORDER BY ... LIMIT; - 同一个索引还要服务哪些前缀查询;
- 写入是否频繁改变这些列;
- 一个较短、可复用索引是否已足够。
“最具选择性的列放最前”不是算法
设 tenant_id=$1 选出全表 1%,state='open' 选出 10%。对总是同时出现的两个等值条件,(tenant_id, state) 与 (state, tenant_id) 最终都能把搜索收敛到相同组合;不能只凭全局选择率宣布第一种必然更快。顺序更应考虑:
- 单独按
tenant_id与单独按state的真实 workload; - 后续 range/order 列如何衔接;
- distinct 数、数据倾斜和参数分布;
- 是否能省掉另一个索引;
- index tuple、prefix compression/dedup 与 write cost 的实测结果。
“选择率最高在前”最多是一条需要上下文的启发式,不是 PostgreSQL 多列索引的正确性规则。
从订单查询推导,而不是从订单表推导
本章订单 query family 是:
fixture 有 200000 行,placed 占 5%,目标客户恰有 10 行已下单记录。候选不是把所有 WHERE 列都当普通 key,而是:
推导逐项对应:
| 查询合同 | 索引设计 |
|---|---|
order_status 是稳定 literal,且只关心少数 placed rows |
partial predicate,不再把它重复存成 key |
customer_id = 42 |
第一个 search key |
ORDER BY placed_at DESC LIMIT 20 |
第二个 key,直接输出 Top-N 顺序 |
返回窄的 order_no, amount_minor |
候选 payload,是否保留还要验证 VM 与大小 |
这只是“有资格”的设计;9.3 会证明 generic parameter 可能无法使用这个 partial predicate,9.4–9.5 还要证明它值得让写入长期维护。
PostgreSQL 18 的 B-tree skip scan:能力,不是默认设计借口
本章库存表已有主键:
但查询是:
在 PostgreSQL 14–17,不能把“后导列也在联合主键里”当成高效定位的通用保证;通常要么扫描大量索引项,要么选择其他路径。因此反向 query family 的自然候选是:
PostgreSQL 18 引入 B-tree skip scan。若前导列 distinct 很少、后导列条件足够有用,planner 可以为若干可能的前导值重复发起 index search,跳过不可能匹配的大段索引。本章 30 个 warehouse、300000 行的 fixture 上,before plan 确实对 warehouse-first 主键使用了 skip scan;这是一条真实的 PG18 路径。
边界必须同时保留:
- skip scan 是 PostgreSQL 18 新能力,不能倒写成 14–17 的前提;
- 它由 cost model 选择,不保证每次出现;
- 前导 distinct 很大时,重复搜索可能不划算;
- 即使 before 已能 skip scan,专用
(sku_id, warehouse_id)仍可能更直接、更小或更容易覆盖; - 最终保留哪一个由读收益、索引大小和写成本决定,不由节点名决定。
因此章节验收不把“before 必须出现 Skip Scan”设为跨版本 golden,只要求 after 候选能正确支持 declared SKU lookup。
9.2.2 连接键、排序、分组与 Top-N
连接索引建在被反复探测的一侧
“JOIN 列要建索引”同样太粗。以下 nested loop 中,外侧每产生一个 customer,内侧就按 order.customer_id 探测:
若外侧很小而内侧很大,orders(customer_id) 可能让每次探测便宜。若两侧都要读很大比例,planner 可能选择 hash join 或 merge join;此时新索引未必有价值。评审至少记录:
主键或 UNIQUE 约束会创建唯一索引,PostgreSQL 不会自动为外键的引用列创建索引。外键索引的理由不是“约束要求”,而是两类真实动作:
- 从父表删除/更新 key 时,快速检查子表引用;
- 应用从子表按 parent key 查询或连接。
例如 order_item(order_id) 常常值得索引,但应由 delete/update parent 的风险和查询频率验证。不要重复创建一个已经由复合索引左前缀覆盖的 order_id 单列索引。
排序是一种可被索引提供的属性
B-tree 能按 key order 输出,planner 可在三种路径间权衡:
对返回大部分表的查询,顺序 index scan 仍可能产生大量随机 heap access,Seq Scan + Sort 反而更便宜。对 Top-N,索引价值通常更高,因为它可能在找到前 N 行后停止:
候选 (customer_id, placed_at DESC) 能在固定 customer 前缀内直接取前 20 行。若没有 ORDER BY,LIMIT 20 只是任意 20 行,不能把偶然的索引输出顺序当业务语义。
方向需要按整组 key 判断:
单列 B-tree 可正反扫描;多列索引整体反向会同时翻转各列,因此 (tenant_id ASC, recorded_at ASC) 的反向扫描不能提供 tenant_id ASC, recorded_at DESC。NULLS FIRST/LAST 也属于 order contract。只有查询要求的 order 与一种扫描方向吻合,才可省掉 Sort。
分组、去重与窗口不能只看关键字
有序输入可能帮助 GroupAggregate、DISTINCT、merge join、窗口函数或 incremental sort,但不保证 planner 一定利用索引:
如果时间范围覆盖很多行,按 (occurred_at, tenant_id) 扫描再聚合未必比 Seq Scan + HashAggregate 好;若查询需要按 tenant 分组且只读少量 tenant,另一个 key order 才可能合适。为 GROUP BY 新建索引前,要比较:
- 过滤后实际行数;
- 现有输入是否已排序;
- hash aggregate 的内存与 spill;
- sort/incremental sort 的内存、磁盘与并行;
- 最终是否还有 order/limit;
- 这个 query 的频率是否能抵消写成本。
同样,窗口函数的 PARTITION BY/ORDER BY 是完整序列需求,不是见到某列就建单列索引。
9.2.3 选择率、相关性与访问路径
选择率属于“谓词 + 值”,不只属于列
state='failed' 可能命中 0.01%,state='success' 可能命中 99%。同一 prepared query 的 hot/cold 参数可能对应完全不同的最佳路径。先看统计对 planner 描述了什么:
MCV 捕获常见值,histogram 描述其余分布,n_distinct 描述 distinct 规模;多列相关则需要第 7 章的 extended statistics 或更合适的数据模型。统计是抽样模型,不是精确计数,数据漂移后必须 ANALYZE,但也不能把无限提高 statistics target 当第一反应。
判断一个索引路径时,应同时看:
若 cardinality 根本错了,节点选择往往只是后果。
物理相关性改变 heap 访问代价
pg_stats.correlation 近似描述列逻辑顺序与 heap 物理顺序的相关程度。高度相关的 range scan 往往按邻近 heap page 读取;随机分布的相同行数可能触碰更多 page。相关性不是永久属性:
- append 时间列通常天然相关;
- UPDATE、乱序导入和长期 churn 会改变布局;
CLUSTER可重写表,但不会自动持续维持物理顺序;- BRIN 依赖 block range summary,相关性漂移会扩大 recheck;
- partitioning 能缩小关系范围,却不等同于每个分区内部有序。
所以不能把另一个环境的 random_page_cost 或 correlation 照搬为本环境真相。
Index、Bitmap 与 Seq Scan 各有合理区间
可以用一个粗略模型理解三类路径:
| 路径 | 倾向的 workload | 主要风险 |
|---|---|---|
| plain Index Scan | 少量、高选择率;或必须保序/Top-N | 随机 heap page 多,低选择率时昂贵 |
| Bitmap Index + Heap Scan | 中等命中量;需合并多个 index | bitmap 可能 lossy,需要 recheck;丢失 index order |
| Seq Scan | 大比例、表小、顺序读便宜 | 扫描全部 page,不适合严格 point latency |
PostgreSQL 能用 BitmapAnd/BitmapOr 组合多个索引。这有时让两个短索引胜过一个专用复合索引,也可能因丢失 ordering 而需要 Sort。不能据此为每列各建一个索引:组合仍有 bitmap 建立、heap recheck、排序和所有单列索引的写成本。
EXPLAIN 的 cost 是在当前统计、参数、settings 与硬件成本假设下比较候选,不是毫秒。enable_seqscan=off 之类 GUC 可以做“是否存在某路径”的诊断,不得作为让 planner 听话的长期修复。正确闭环是:
- 用代表参数捕获 before plan 与结果;
- 提出能从 operator/order 证明的 candidate;
- 在相同数据与统计下捕获 after;
- 比较 read、write、size 与生命周期;
- 即使 after 仍选 Seq Scan,也判断其是否符合真实成本;
- 只有收益覆盖长期代价才保留。
本章实验正是这个闭环,而不是“让四条查询都出现 Index Scan”的演示。
延伸阅读
- PostgreSQL 18:Multicolumn Indexes
- PostgreSQL 18:Indexes and
ORDER BY - PostgreSQL 18:Combining Multiple Indexes
- PostgreSQL 18:Planner Statistics
- PostgreSQL 18 Release Notes:B-tree skip scan
上一节:索引方法与操作符类 · 返回本章目录 · 下一节:表达式、部分与覆盖索引 · 查看全书目录 · 查看索引中心
9.3 表达式、部分与覆盖索引
普通索引把表列作为 key;expression、partial 与 covering index 分别回答三个更精确的问题:
三者可以组合,但每加一层都扩大合同:查询语义必须吻合,写入必须维护更多内容,验证必须覆盖更多失效条件。
9.3.1 表达式必须与查询语义一致
索引表达式与查询表达式要能被 planner 对应
大小写无关的登录查找可以写成:
索引 key 是 lower(email),不是原始 email。下面的查询有不同语义,不能因为“都在处理邮箱”就期待复用:
planner 能识别一些等价变换,但不会证明任意业务函数、cast 或字符串处理“效果一样”。设计时应让规范化规则只有一个权威表达:
- 在 SQL 与索引中复用同一表达式;
- 或把它做成 generated column,再查询和索引该列;
- 若它定义身份唯一性,明确原值能否保留多个展示形式;
- 通过真实 parameter、collation 与 locale 做 correctness 测试。
UNIQUE(lower(email)) 表达“规范化后不得重复”,这已是数据约束,不再只是性能。不能按 unused index 清理。
volatility 是正确性边界
PostgreSQL 要求 index definition 中用到的函数和操作符是 IMMUTABLE。原因很直接:同一行的 index key 必须在未来仍表示同一个值。依赖当前时间、会话时区、配置、外部表或可变环境的函数不能安全成为 key。
典型陷阱是:
服务器会拒绝非 immutable 表达式。正确方案不是把自定义函数随手标成 IMMUTABLE,而是先固定业务语义:
如果合同是固定 UTC 日,可以用明确、可验证的 UTC 派生值;如果每租户时区不同,往往应在写入时保存业务日期或按租户和 UTC range 查询。错误声明 volatility 会让 planner 相信一个并不成立的不变量,结果可能是漏行,而不仅是变慢。
还要检查:
- collation 版本升级后的排序/相等语义;
- ICU/libc locale 差异;
- extension 或自定义函数升级;
- implicit cast 是否改变 operator/opclass;
- expression 的返回类型和长度;
- 函数 schema qualification 与受控
search_path。
表达式通常只在插入及非 HOT 更新时计算,读取可直接用已保存 key;代价因此从读侧转移到写侧。复杂表达式要同时测 CPU、WAL、index size 与 build 时间。
expression 不能修复错误的数据模型
下面这些候选要先问是否应该改模型:
若 JSONB key 实际是高频连接键,生成强类型列或普通列通常比反复 cast 更可审计;若“未删除”是稳定热点子集,partial predicate 可能比把 infinity 混入 key 更清晰。expression index 是精确工具,不是把所有 schema 欠账藏进 planner 的办法。
9.3.2 部分索引的谓词蕴含与参数陷阱
查询必须在 planning time 蕴含 index predicate
部分索引只保存满足 predicate 的行:
它有资格服务:
因为查询条件明确蕴含 state='open'。PostgreSQL 能处理完全匹配及少量简单不等式蕴含,例如 x < 1 可蕴含 x < 2;它没有通用定理证明器,也不会在 runtime 取到值以后再重新证明 arbitrary predicate。
因此这些看似接近的条件可能不能使用同一 partial index:
设计 partial index 时,把 predicate 连同 query text、parameterization 和 plan mode 一起写入合同。只保存一个手工 literal 的 EXPLAIN 不够。
generic parameter 为什么是确定性反例
本章候选:
literal query 可以证明 predicate:
实验随后准备带两个参数的语句:
在 force_custom_plan 下,planner 为本次 EXECUTE (42, 'placed') 看见具体值,可以证明并使用 partial index;在 force_generic_plan 下,它必须生成适用于任意 $2 的计划,无法假设所有值都是 placed,因此不能使用该 partial index。
force_* 只用于构造确定性 A/B,不是生产修复。生产是否得到 custom/generic plan 还受 prepared statement 执行历史、driver/pool 协议和 planner 判断影响。可选方案按语义权衡:
- 让稳定状态保留为 SQL literal,只参数化 customer;
- 使用 custom plan,但要比较 planning cost 和所有参数桶;
- 建普通索引,接受索引更大、写成本更高;
- 为不同状态使用明确的 query family;
- 若状态集合与生命周期已成为数据分区问题,重新审视 schema/partitioning。
不要通过伪造 IMMUTABLE 函数、强制全局 plan mode 或复制大量近似 partial index 绕过合同。
partial index 也会漂移
“只索引 5% 活跃行”今天很划算,若状态分布变成 70%,大小和维护成本会完全不同。定期观察:
partial unique index 还能表达“只在满足条件的行中唯一”,例如每个用户最多一个 active token。这是业务约束,必须给并发写入做失败测试。partial index 不是 partition:它不提供 retention、独立 vacuum、partition pruning 或 detach/drop 生命周期。
9.3.3 INCLUDE、index-only scan 与可见性图
covering 是查询与索引的共同属性
考虑:
候选:
customer_id, placed_at 是 search/order key;order_no, amount_minor 只是 payload:
- 它们不参与 B-tree 定位或排序;
- unique index 的唯一性只作用于 key,不包括
INCLUDE; - payload 可以是 access method 不理解的类型,因为只需原样保存;
- query 若再读取一个未保存列,便不再 covered。
PostgreSQL 14–18 中 B-tree 总能支持 index-only scan;GiST/SP-GiST 只在部分 opclass 上能重建原值,GIN 不能。INCLUDE 本身只受支持它的 access method 接受,不能把“有 INCLUDE”与“本次一定 Index Only Scan”等同。
为什么仍可能访问 heap
MVCC 可见性信息不保存在每个 index tuple 中。执行器必须确认当前 snapshot 下 heap tuple 是否可见;只有对应 heap page 的 visibility map all-visible bit 已设置,才能跳过 heap。
所以 index-only scan 有两层条件:
EXPLAIN (ANALYZE, BUFFERS) 中的:
是这次执行没有回 heap 的证据,不是索引永久保证。INSERT/UPDATE/DELETE 会清除相关 page 的 all-visible bit,VACUUM 在满足条件后再设置。高 churn 表即使 covered,也可能频繁 heap fetch;为一次 benchmark 手动 VACUUM 只能证明静态上限,不能模拟生产稳态。
本章在候选创建后执行受控 VACUUM (ANALYZE),订单和库存 after plan 都要求 Index Only Scan + Heap Fetches=0。这条断言只属于确定性 fixture;生产验收要在真实写入、autovacuum 和 snapshot 条件下看 heap fetch 比例。
payload 不是免费的
增加 INCLUDE 会:
- 复制 payload,增大 leaf tuple、index size 与 cache footprint;
- 增加 INSERT/UPDATE 的 WAL 和维护;
- 更新 included column 时需要维护该索引,也会影响 HOT;
- 让 build、backup、restore、replication 和 vacuum 多付成本;
- 宽值可能超过 index tuple 大小上限,导致写入失败;
- B-tree 只要有 non-key column,就不会使用 deduplication。
虽然 B-tree upper level 会移除 non-key payload,使导航层保持较小,leaf 层成本仍真实存在。不要 INCLUDE (*),也不要为了“可能以后少一次 heap visit”复制 JSON、正文或频繁变化的状态。
一个可保留的 covering candidate 应同时满足:
- declared query 高频且返回列稳定、窄;
- 定位 key 与 ordering 已正确;
- 实际 plan 使用 index-only,而非只在理论上可用;
- 真实 VM/all-visible 状态下 heap fetch 明显减少;
- size/cache/write/WAL/HOT 代价可接受;
- payload 变化不会让维护成本压过读取收益;
- 不与另一个更短索引形成无意义重叠。
如果表频繁更新或 query 本来就要访问 heap 中的宽列,普通短索引往往更好。
延伸阅读
- PostgreSQL 18:Indexes on Expressions
- PostgreSQL 18:Partial Indexes
- PostgreSQL 18:Index-Only Scans and Covering Indexes
- PostgreSQL 18:Function Volatility Categories
- PostgreSQL 18:Visibility Map
上一节:从谓词、连接与排序推导索引 · 返回本章目录 · 下一节:索引也有写入和生命周期成本 · 查看全书目录 · 查看索引中心
9.4 索引也有写入和生命周期成本
索引把一次读取节省的工作,变成所有相关写入都要长期承担的工作。一份完整收益表至少有两边:
只保存 after query 的执行时间,等于只记收益、不记负债。
9.4.1 写放大、缓存占用与 WAL
一次逻辑写会触碰多少物理结构
插入一行时,heap、每个相关 index、visibility/free-space metadata 和 WAL 都可能变化。更新在 MVCC 下创建新 row version;若不满足 HOT,它还要为各索引写新 tuple。删除先留下 dead version,之后 vacuum 才清理 heap/index 可回收空间。
索引越多,常见代价越大:
- 更多 access method/operator expression 计算;
- 更多 buffer 被读入、锁定并标脏;
- B-tree page split、GIN pending list、BRIN summary 等各自维护;
- 更多 WAL 传到 archive、streaming replica 与 logical decoding;
- checkpoint 写出更多 dirty page;
- base backup、restore、
pg_upgrade --link之外的重建与磁盘巡检范围更大; - autovacuum/index cleanup 和故障修复窗口更长。
这不是说“索引数量越少越好”,而是每个索引都必须有消费者和证据。
用同一条写路径量化
PostgreSQL 可直接给 data-changing statement 取执行证据:
EXPLAIN ANALYZE 会真的执行写语句。安全实验应使用专属 fixture,或在能够完全回滚且不涉及 sequence/外部副作用的事务中运行。生产不能为了看 plan 对一条未知 DML 随手加 ANALYZE。
比较前后至少保存:
WAL bytes 会受 full-page image、checkpoint 时点、page 初始状态、compression 与版本影响,不能把本机某个精确数值写成阈值。A/B 的稳定断言通常是方向和相对幅度,并要重复、交替顺序。
索引大小也是 cache 决策
查看关系分解:
查看单个索引:
一个 9 GB 索引不等于需要 9 GB shared_buffers,操作系统 page cache 也参与;但热 working set 彼此竞争是真实的。增加大索引可能让某条 query 更快,却把另一条热路径的数据页挤出 cache。Pigsty 的 table/index、buffer、I/O 和 instance 指标应在同一时间窗关联,而不是孤立看 idx_scan。
本章事件候选正是空间决策:400000 行物理时间相关数据上,BRIN 为 24576 bytes,对照 B-tree 为 9003008 bytes,比例约 0.00273。字节值只属于本次 fixture;保留 BRIN、拒绝 B-tree 的理由是 declared range workload 不需要为额外精度和 cache footprint 付费。
9.4.2 HOT 更新、页分裂与填充因子
HOT 省掉什么
Heap-Only Tuple update 让新 row version 留在旧 row 所在 heap page,并沿 page 内 HOT chain 查找,因此无需为该更新创建普通 index tuple。它同时减少索引写入和以后清理旧 index entry 的负担。
在 PostgreSQL 16–18,HOT 的关键条件可表述为:
- 新 tuple 能放进旧 tuple 所在 heap page;
- 更新没有改变任何 non-summarizing index 引用的列。
这里“引用”包括 key、expression、INCLUDE payload 和 partial-index predicate。核心 BRIN 是 summarizing access method;PostgreSQL 16 起,如果只改变 BRIN-indexed key,仍可允许 HOT,但若改变 partial predicate 引用列仍会阻止 HOT。
版本边界:PostgreSQL 14–15 还没有“只更新 BRIN 列仍可 HOT”的改进,应按更保守的规则理解:更新任何索引引用列都会阻止 HOT。它是 PostgreSQL 16 引入的能力,不能回写到整个 14–18 范围。
监控:
统计是累计观测,先记录 reset epoch 和时间窗;不同写 workload 混在一起时,整体 ratio 不能解释某条 UPDATE。
本章 HOT/WAL 对照
两个 50000 行表结构、数据和 table fillfactor=50 相同,唯一差别是:
随后分别执行:
一次 PostgreSQL 18.6 实测:
精确 WAL 会漂移;稳定关系是:
这个 counter 没有 declared read query,所以候选被拒绝。若未来确有关键 point lookup,评审要比较读取收益与 HOT/WAL 代价,而不是把“阻止 HOT”当绝对禁令。
table fillfactor 与 index fillfactor 不同
降低 table fillfactor 会在 heap page 预留空间,提高后续 row version 留在同页、形成 HOT 的机会:
它不会把现有 page 自动重写成 80% 装载;要等待 churn 或受控重写,并承担表更大、顺序扫描更多 page 的代价。
降低 B-tree index fillfactor 则在 build 时给 leaf page 留空间,可能减少后续 insertion/page split,但会让索引更大、cache density 更低。它不创造 HOT 所需的 heap page 空间。两个同名参数作用在不同结构,不能混为一谈。
page split 不是“索引损坏”,是 B-tree 正常维护;真正要评估的是:
- insert key 是否随机、单调或集中在热点;
- page split/WAL 与 tail latency 是否成为问题;
- 低 fillfactor 的空间成本是否值得;
REINDEX CONCURRENTLY/重建是否有真实 bloat 证据;- 去重、key width 与 payload 是否可优化。
不要把周期性重建所有索引当保养仪式。
9.4.3 重复、未使用与失效索引的判断
idx_scan=0 只能生成调查清单
pg_stat_user_indexes.idx_scan=0 不能单独授权 DROP INDEX,因为它可能表示:
- statistics 刚 reset,观察窗太短;
- rare but critical 月结、故障切换或合规查询尚未发生;
- 该索引只在 replica 被读,primary 本地统计看不到;
- planner 用另一条等价路径只是暂态;
- 它支撑
PRIMARY KEY、UNIQUE、exclusion constraint; - 它用于 foreign-key parent delete/check 或运维任务;
- 它是 logical replication 的 replica identity;
- 应用版本/feature flag 尚未完整覆盖;
- partition child 各自 workload 不同;
- 统计语义和计数方式在版本间有差异。
至少把数据库统计 reset 时点一并保存:
然后覆盖一个能代表周、月、批处理和故障流量的时间窗,并查 primary、read replicas 与 query history。
“重复”要比较完整定义与职责
两个索引列名相似,不代表重复。审查结构至少包含:
可先取 catalog:
(a, b) 可以支持一部分 a lookup,但不等价于 (a):它更宽,可能有不同排序/payload/uniqueness,也可能让短索引更适合 cache。反过来,若所有 (a) consumers 都被 (a,b) 等价覆盖,短索引才进入候选合并清单。必须用 before/after workload 验证。
INVALID 是状态,不是自动删除理由
并发创建过程中,catalog 会先出现尚未 valid 的 index。失败可能留下:
某些 INVALID index 仍会被写路径维护,却不能被查询采用;它既有成本又没有读收益,需要处置。但先确认:
- 是否仍有合法 build/reindex 正在运行;
- index 与 table 的 exact OID/schema/name;
- 是否由 constraint/partition operation 管理;
- 失败 SQLSTATE、phase 与原始 DDL;
- 是否已有人在做恢复;
- drop/recreate 的锁、磁盘、唯一性与 replica 风险。
本章用故意重复数据执行 CREATE UNIQUE INDEX CONCURRENTLY,要求:
这不是“定时删除所有 INVALID”的脚本模板。生产应由变更单绑定 exact identity、owner、证据和回退;若失败对象承担约束语义,还要先恢复约束正确性。
删除本身也要 A/B 和回退
安全清理流程是:
- 生成 candidate,不执行 drop;
- 排除 constraint、replica identity、partition 与 rare critical consumers;
- 保存完整 definition、owner、size、usage epoch 和依赖;
- 在可代表的 primary/replica workload 中验证替代路径;
- 评估
DROP INDEX或DROP INDEX CONCURRENTLY的限制与窗口; - 一次处理少量对象,观察 query latency、CPU/I/O 与 write;
- 保留可审计的 recreate DDL 与停止条件。
索引生命周期的终点不是“catalog 更干净”,而是正确性不变、关键 SLO 不退化、写入和空间确有改善。
延伸阅读
- PostgreSQL 18:Heap-Only Tuple Updates
- PostgreSQL 16 Release Notes:BRIN-indexed columns and HOT
- PostgreSQL 18:Monitoring Statistics
- PostgreSQL 18:System Catalog
pg_index - PostgreSQL 18:Routine Reindexing
上一节:表达式、部分与覆盖索引 · 返回本章目录 · 下一节:验证而不是“加完就快” · 查看全书目录 · 查看索引中心
9.5 验证而不是“加完就快”
“建完出现 Index Scan”只能证明 planner 在一次条件下选了它。索引验收必须回答四组问题:
| 维度 | 要证明的事实 |
|---|---|
| 正确性 | 返回集合、排序、唯一/约束语义不变 |
| 读取 | 哪些参数桶、并发与 cache 状态改善,tail 是否达标 |
| 写入 | INSERT/UPDATE/DELETE、HOT、WAL、CPU/I/O 和 replica 是否可接受 |
| 生命周期 | build、失败、磁盘峰值、监控、回退与以后清理是否可控 |
只要其中一列为空,结论就是 candidate,不是可上线变更。
9.5.1 计划、缓冲区、延迟分布与写入代价
保存机器可读 before/after
对可安全执行的只读查询:
JSON 便于保留完整 node tree 并自动断言。证据包还要保存:
阅读计划时按因果顺序:
- root actual rows 与业务结果是否正确;
- 每个节点
Plan Rows/Actual Rows × Actual Loops是否偏离; - predicate 是
Index Cond、Recheck Cond还是Filter; - 是否有
Rows Removed by Filter; - shared/local/temp blocks 的 hit/read/dirtied/written;
- sort method、memory、disk spill;
- index-only 的
Heap Fetches; - planning 与 execution time;
- 写语句的 WAL records/FPI/bytes。
节点名不是最终 KPI。Index Scan 读取大量随机 heap page 可能比 Seq Scan 慢;Bitmap Heap Scan 带 recheck 可能正是最合理路径;BRIN 本来就是 lossy。稳定结论来自结果、资源与延迟关系。
EXPLAIN ANALYZE 增加测量开销,且对 DML 会执行真实写入。节点很多时可用 TIMING OFF 降低逐节点计时开销,但不能消除 instrumentation 本身。调查生产 DML 时优先看已有 pg_stat_statements、日志、采样计划和 replica/L1 重放,不要直接执行未知副作用。
单次 elapsed 不是延迟分布
一次 warm-cache、单连接执行无法代表:
pg_stat_statements 可提供 query family 的 calls、总/均值执行时间、rows、block 与 WAL 累计;它不是逐请求 percentile 存储。tail latency 应来自应用 tracing、指标 histogram 或负载工具,并与相同 query identity 和时间窗关联。
索引可能把 hot parameter 从 2 s 降到 20 ms,却让占 99% 流量的写入多 10%;也可能只优化 cache 已热的 microbenchmark。评审要用 traffic weight 算总体收益:
公式不要求伪装成精确货币值,作用是迫使评审记录频率,而不只比较最好看的样本。
Pigsty 提供时间窗,SQL/catalog 提供语义
在 Pigsty 中,把同一 UTC 窗口的观测串起来:
不同 Pigsty 版本的仪表盘名称和布局会变化,本书不冻结点击路径;以当前 PostgreSQL Dashboard 文档 和实际变量为准。面板负责说明“何时、影响多大”,最终仍要落回:
- query text/parameters;
EXPLAIN与统计估算;pg_index/pg_classdefinition 与 validity;- WAL、锁和 replica 证据;
- correctness/SLO 验收。
没有 query identity 的 CPU 曲线不能证明某个索引有效;没有时间窗的 plan 也不能证明它解释了事件。
9.5.2 数据规模和缓存状态一致的 A/B 对照
一次只改变候选索引
一个可复核 A/B:
推荐流程:
- 固定 query family、参数分桶和 SLO;
- 保存目标 relation checksum/row counts 与 baseline catalog;
ANALYZE后捕获 before plan;- 创建一个 candidate,等待/执行与生产可比的统计和 vacuum 条件;
- 捕获 after plan;
- 交替执行 A/B 或在等价环境重复,避免永远 before 冷、after 热;
- 验证结果集合、顺序与业务不变量;
- 测同样的 read concurrency;
- 测代表性的 INSERT/UPDATE/DELETE 与 WAL/HOT;
- 给出 retain、merge 或 reject,拒绝对象也要清理并验证。
不能在同一生产表上随意来回 drop/create 只为跑 A/B。可选机制包括独立 L1 clone、可恢复 staging、同数据快照、hypothetical index 作早期筛选,以及受控 shadow workload;真正上线前仍要用真实索引验证 build 和写成本。
cache 不是“清掉才公平”
至少区分:
- cold-ish:工作集尚未被本次 query 预热;
- warm:稳定重复访问后;
- mixed/production:与其他 workload 共同竞争 cache。
不要在共享服务器用 Linux drop_caches,它会全局影响其他进程且仍不能模拟真实 workload;重启 PostgreSQL 也改变连接、checkpoint、background worker 等大量变量。DISCARD ALL 只清会话状态,不清 shared buffers 或 OS page cache。
更可靠的方法是:
- 独立 disposable 实例做受控 cold 测试;
- A/B 交替顺序并多轮;
- 报告 buffers 的 hit/read,而不是只说“冷/热”;
- 在生产相似 mixed workload 中验证 cache displacement;
- 不把首次 build 后的缓存副作用算成稳态收益。
数据与参数必须能代表真实分布
小表上 Seq Scan 合理,大表才出现索引价值;全均匀合成数据会掩盖 hot tenant、MCV、相关性与 null skew。fixture 应固定并公开:
本章故意设置:
- 订单
placed=5%且目标 customer 返回 10 行; - 库存只有 30 个 warehouse,暴露 PG18 skip scan;
- 搜索目标命中 100/100000;
- 事件按时间物理写入,目标范围 600/400000;
- 两个 write twin 完全等价,只改变 volatile index。
这些数字让机制可重复,不宣称代表每个生产库。迁移结论前,用真实 pg_stats、query 参数桶和 workload 重做。
optimizer GUC 只能用于反事实
enable_seqscan=off、enable_bitmapscan=off、force_custom_plan 可回答:
它们不能证明强制路径在生产更快,更不能作为全局长期修复。实验必须把 SETTINGS 保存到 plan,避免一个被强制出来的节点冒充自然选择。本章只用 plan_cache_mode 构造 partial/generic 的语义反例,最终 candidate plan 仍由正常 cost model 验收。
9.5.3 线上创建、失败回收与监控窗口
普通与 concurrent build 的真实差别
普通:
可在一次 table scan 中完成,通常比 concurrent build 更快、更省总工作;构建期间允许普通读取,但会阻塞会修改该表的写入。因此适合维护窗口、空表、新分区或能明确停写的场景。
并发:
不会用同样方式阻塞日常 INSERT/UPDATE/DELETE,但它绝不是“无锁、无影响”:
它要做两次扫描,持续更久,并产生 CPU、I/O、WAL、磁盘和 replica 压力;长事务/旧 snapshot 可让某个 phase 长时间等待。一个表同一时刻只能有一个 concurrent index build。命令不能放在 transaction block 内。
唯一索引还有额外边界:在第二次扫描开始时,系统已可能对其他事务执行 uniqueness enforcement;其他 session 可能在该索引正式 valid 前收到 uniqueness violation。若 build 最终失败,INVALID 对象仍可能继续执行唯一性检查。上线前必须先做 duplicate preflight,并理解这段时间的应用错误语义。
对 partitioned table,PostgreSQL 18 仍不支持直接 concurrent build 整个 partitioned index。可以在各 leaf partition 上分别 CREATE INDEX CONCURRENTLY,再用短暂的 parent metadata 操作 attach/建立 partitioned index;具体 DDL、锁和失败恢复必须在目标版本演练。
开始前定义水位与停止线
生产变更单至少写:
“磁盘够放最终索引”不等于够用:并发 build、WAL、temp、失败对象和 replica 都可能需要峰值空间。也不要让一个普通应用连接在不可控 timeout、pool recycle 或网络中断下承担数小时 DDL。
用 progress view 观察阶段,不猜百分比
不同 phase 只有部分计数有意义,blocks_done/blocks_total 不能代表整个 concurrent lifecycle 的统一完成率。要同时查:
pg_stat_activity的 session identity、state/wait event;pg_locks与 exact blocker edge;- long transaction/snapshot;
- host and PostgreSQL I/O;
- WAL/archive/replica lag;
- target index
pg_indexflags 与 size。
Pigsty 负责把这些指标放入统一时间轴,catalog/progress view 决定当前语义。取消也只能针对 PID + backend_start + database + application/DDL identity 精确命中;不能看到“建索引慢”就取消任意 backend。
失败后先辨认状态,再精确回收
检查:
若确认是本次失败遗留且无约束/partition/其他 owner 依赖,按 exact schema-qualified identity 回收:
DROP INDEX CONCURRENTLY 也有约束:不能放进 transaction block,不能配 CASCADE,且 partitioned parent 有额外限制。失败对象是否 drop、reindex 或重新 build 取决于 phase、依赖和变更计划,不能用全库 WHERE NOT indisvalid 自动删除。
本章 failure injection 让 5000 对重复 key 触发 SQLSTATE 23505,先把 INVALID 的 flags/size 保存为证据,再精确 drop,最后断言同名对象为 0。这才是可复核的失败闭环。
延伸阅读
- PostgreSQL 18:Using
EXPLAIN - PostgreSQL 18:
CREATE INDEX - PostgreSQL 18:CREATE INDEX Progress Reporting
- PostgreSQL 18:
pg_stat_statements - Pigsty:PostgreSQL Dashboards
上一节:索引也有写入和生命周期成本 · 返回本章目录 · 下一节:实战:为订单、库存与搜索入口设计索引 · 查看全书目录 · 查看索引中心
9.6 实战:为订单、库存与搜索入口设计索引
本节把前五节合并成一个完整评审:
实验只在带 marker 的 shop_private.ch09_* fixture 上执行。它不会触碰 shop 业务表的索引,也不会模拟生产点击动作。
9.6.1 从真实查询清单提出候选索引
先确认目标与身份
准备一个只包含本地 L1 凭据、权限为 0600 的 service file:
然后:
只在已确认可写、可重建的 L1/本地数据库继续。脚本还会执行 ch05 model verification,要求数据库、schema、角色、fixture marker 与业务 checksum 均符合前章合同。没有 PGSERVICEFILE、action 非法或 context 不符时 fail closed。
下载并阅读:
setup 在七个同名关系全部缺失,或全部带精确 marker 时重建;任何同名异物都会拒绝。它属于 R1 fixture rebuild,不需要 reset token,但只能在 disposable L1 使用。
四份 query contract
实验不是先给答案,而是先保存 before:
| family | predicate/order/result | fixture 特征 | 候选 |
|---|---|---|---|
| order | customer equality + literal placed + time DESC Top-N;10 行 | 200000 行,placed 5% | partial B-tree + INCLUDE |
| inventory | SKU equality + warehouse order;30 行 | 300000 行,30 warehouses,现有 warehouse-first PK | reverse covering B-tree |
| search | generated tsvector @@ tsquery;100 行 |
100000 行,同一 simple config |
GIN |
| event | 10 分钟 timestamptz range;600 行 | 400000 行,物理时间相关 | BRIN;另建 B-tree 对照 |
原始查询分别在:
候选由 create-candidates.sh 以独立的 CREATE INDEX CONCURRENTLY 创建:
创建后对专属 fixture 做 VACUUM (ANALYZE),再捕获 after。这里 vacuum 是为了制造稳定的 all-visible 教学条件;生产 index-only 收益必须按真实 autovacuum/churn 复测。
候选要允许被拒绝
event B-tree 也是实际创建的 candidate:
脚本保存它的 plan 与 size 后,证明 declared workload 已由小得多的 BRIN 满足,于是按 exact table/index identity 执行:
“创建成功并被使用”不等于必须保留。能输出 reject 且清理干净,是索引设计实验的重要能力。
9.6.2 在 Pigsty L1 保留、合并或拒绝并记录证据
一键运行完整闭环
执行顺序:
PG36_EVIDENCE_DIR 应使用每次唯一的目录。脚本以 umask 077 创建证据,不覆盖旧结果;manifest.txt 保存 server/client/Python 版本、target identity 与所有 source hash。
一次 PostgreSQL 18.6 实测输出:
不要把 WAL 字节、cost、elapsed、buffer 个数或 exact node tree 当 golden。分析器只断言:
- 结果行数与语义不漂移;
- literal/custom 能用 partial,generic status parameter 不能;
- 订单/库存 after 是 index-only 且 heap fetch 为 0;
- 搜索使用专属 GIN;
- event BRIN/B-tree 都能返回 600 行,BRIN 至少小一个数量级;
- unindexed volatile update 有高 HOT ratio,indexed twin 为 0 且 WAL 更多;
- unique concurrent build 以 23505 失败,INVALID 被观察并精确删除;
- rejected index=0、worker=0、业务 checksum 不变。
PG18 before inventory 在本机使用 warehouse-first PK skip scan。分析器记录该事实但不要求 exact node:PG14–17 没有该能力,PG18 也可能因 cost/data 改选其他路径。
读、写、失败三类 artifact
证据目录包含:
raw plan/catalog 用于复核,index-summary.json 用于稳定关系,不能只保留最后一行 status=ok。
4 个保留,4 个拒绝
index-decisions.json 为每个结论绑定 query 和 artifact:
| candidate | 结论 | 依据 |
|---|---|---|
| order partial covering | retain | literal workload 稳定、Top-N、index-only;同时记录 generic 边界 |
| inventory reverse covering | retain | SKU-first 是 declared access path,返回 30 个 warehouse 且 heap fetch=0 |
| search document GIN | retain | document/query 使用相同 simple config 与 @@ 语义 |
| event occurred BRIN | retain | 物理相关范围可用,约为 B-tree 大小的 0.27% |
| event occurred B-tree | reject | 对同一 workload 的额外精度/空间不值 |
| warehouse-only B-tree | reject | 现有 PK 已以 warehouse 为左前缀 |
| generic attributes JSONB GIN | reject | 没有 declared containment consumer,却会索引大量 token |
| volatile counter B-tree | reject | 无读取消费者,HOT 1→0 且 WAL 增加 |
本 fixture 没有需要 merge 的 pair,但生产 ledger 应允许 merge:例如一个较完整候选在验证后替代两个真正重叠索引。merge 仍需先排除 constraint/replica identity,并验证所有 consumer,不能只做字符串前缀比较。
在 Pigsty 中复核相同关系
实验运行时或生产 shadow 验证时,在同一 UTC 窗口观察:
面板结论回链 manifest + plan JSON + catalog CSV + query identity。L1 的“retain”是机制验收,不自动授权生产上线;生产必须另建时间窗、磁盘/replica 水位、审批与回退。
9.6.3 将索引审查规则追加到规约
从“应验证计划”升级为可运行规则
baseline-v0.4-proposal.json 不改写第 6 章的不可变 v0.1 baseline,而是为 PREF-PLAN-005 追加 candidate evidence:
提案绑定三层 provenance:
analyze_indexes.py 每次 all/review 都重新 canonicalize JSON 并核对 checksum、依赖顺序、rule id 和 artifact existence。只改 proposal 中的声明、不同步依赖内容会 fail;这避免一条后续规则悄悄引用已漂移的前章证据。
它仍标记为:
只有以下条件完成后才可晋升:
- PostgreSQL 14–18 compatibility matrix 通过并保留 PG18 skip-scan 差异;
- 至少一个真实 Pigsty workload 窗口复测 read/write/build 水位;
- 先审查并晋升依赖的 v0.2/v0.3;
- 生成新的不可变 release artifact 和 canonical checksum。
章节成功不等于治理基线已发布。
reset 需要两个独立确认
all 最终保留七个 ch09 fixture 和四个 retained candidate,便于复核。若要删除,只在已确认的 L1 执行 R2:
两个 token 各自防一类误操作:
- action token 证明调用者明确要求 reset;
- target token 绑定 database/schema/chapter。
脚本还会逐一核对 marker;同名异物存在时,即使 token 正确也拒绝。成功后必须看到:
并再次运行 ch05 verification,业务 checksum 仍为:
负向验收同样重要:空 token、错 action token、错 target、无 service file 和非法 action 都必须非零退出,并保持对象与业务 checksum 不变。
最终复现清单
验收通过后,团队得到的不是“索引速查表”,而是一套可以迁移到真实 query review 的方法:先证明 operator/predicate/order,再证明收益覆盖写入与生命周期成本,最后让 retain/reject 都有可审计证据。
上一节:验证而不是“加完就快” · 返回本章目录 · 下一章:顾此失彼:并发控制与隔离异常 · 查看全书目录 · 查看索引中心