9.4 索引也有写入和生命周期成本
索引把一次读取节省的工作,变成所有相关写入都要长期承担的工作。一份完整收益表至少有两边:
read benefit:
fewer heap/index blocks
no sort or earlier LIMIT stop
better latency/throughput/tail
lifetime cost:
insert/update/delete CPU and latency
extra index pages and cache displacement
WAL, archive, backup and replication
vacuum/analyze/build/reindex
lock, disk peak and failed-build recovery
lost HOT opportunities只保存 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,
BUFFERS,
WAL,
SETTINGS,
FORMAT JSON
)
UPDATE counter
SET value = value + 1
WHERE bucket_id BETWEEN 1 AND 1000;EXPLAIN ANALYZE 会真的执行写语句。安全实验应使用专属 fixture,或在能够完全回滚且不涉及 sequence/外部副作用的事务中运行。生产不能为了看 plan 对一条未知 DML 随手加 ANALYZE。
比较前后至少保存:
result/affected rows
execution time distribution, not one sample
shared/local/temp buffer hits/reads/writes
WAL records/FPI/bytes
table/index sizes
TPS and concurrent read/write latency
replica WAL receive/replay lag
checkpoint and I/O pressureWAL bytes 会受 full-page image、checkpoint 时点、page 初始状态、compression 与版本影响,不能把本机某个精确数值写成阈值。A/B 的稳定断言通常是方向和相对幅度,并要重复、交替顺序。
索引大小也是 cache 决策
查看关系分解:
SELECT
relid::regclass AS table_name,
pg_size_pretty(pg_relation_size(relid)) AS heap,
pg_size_pretty(pg_indexes_size(relid)) AS indexes,
pg_size_pretty(pg_total_relation_size(relid)) AS total
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC;查看单个索引:
SELECT
indexrelid::regclass AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS bytes,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relid = 'public.orders'::regclass
ORDER BY pg_relation_size(indexrelid) DESC;一个 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 范围。
监控:
SELECT
schemaname,
relname,
n_tup_upd,
n_tup_hot_upd,
CASE
WHEN n_tup_upd = 0 THEN NULL
ELSE n_tup_hot_upd::numeric / n_tup_upd
END AS hot_ratio
FROM pg_stat_user_tables
ORDER BY n_tup_upd DESC;统计是累计观测,先记录 reset epoch 和时间窗;不同写 workload 混在一起时,整体 ratio 不能解释某条 UPDATE。
本章 HOT/WAL 对照
两个 50000 行表结构、数据和 table fillfactor=50 相同,唯一差别是:
CREATE INDEX ch09_write_indexed_counter_idx
ON shop_private.ch09_write_indexed (volatile_counter);随后分别执行:
UPDATE ... SET volatile_counter = volatile_counter + 1;一次 PostgreSQL 18.4 实测:
without volatile index:
HOT ratio = 1.0
WAL bytes = 11257432
with volatile index:
HOT ratio = 0
WAL bytes = 11631392精确 WAL 会漂移;稳定关系是:
相同更新 + 同样预留 page space
→ 未引用 volatile_counter 的索引集合允许 HOT
→ 把 volatile_counter 放入普通 B-tree 后 HOT 消失
→ 本次 statement WAL 增加这个 counter 没有 declared read query,所以候选被拒绝。若未来确有关键 point lookup,评审要比较读取收益与 HOT/WAL 代价,而不是把“阻止 HOT”当绝对禁令。
table fillfactor 与 index fillfactor 不同
降低 table fillfactor 会在 heap page 预留空间,提高后续 row version 留在同页、形成 HOT 的机会:
ALTER TABLE hot_account SET (fillfactor = 80);它不会把现有 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 时点一并保存:
SELECT datname, stats_reset
FROM pg_stat_database
WHERE datname = current_database();然后覆盖一个能代表周、月、批处理和故障流量的时间窗,并查 primary、read replicas 与 query history。
“重复”要比较完整定义与职责
两个索引列名相似,不代表重复。审查结构至少包含:
access method
key expressions and order
operator classes and collations
ASC/DESC and NULLS
partial predicate
INCLUDE payload
unique/nulls-not-distinct/exclusion semantics
valid/ready/live state
partition attachment
constraint and replica-identity ownership可先取 catalog:
SELECT
i.indexrelid::regclass AS index_name,
am.amname,
i.indisunique,
i.indisprimary,
i.indisexclusion,
i.indisreplident,
i.indisvalid,
i.indisready,
i.indislive,
pg_get_indexdef(i.indexrelid) AS definition,
pg_get_expr(i.indpred, i.indrelid) AS predicate
FROM pg_index AS i
JOIN pg_class AS c
ON c.oid = i.indexrelid
JOIN pg_am AS am
ON am.oid = c.relam
WHERE i.indrelid = 'public.orders'::regclass;(a, b) 可以支持一部分 a lookup,但不等价于 (a):它更宽,可能有不同排序/payload/uniqueness,也可能让短索引更适合 cache。反过来,若所有 (a) consumers 都被 (a,b) 等价覆盖,短索引才进入候选合并清单。必须用 before/after workload 验证。
INVALID 是状态,不是自动删除理由
并发创建过程中,catalog 会先出现尚未 valid 的 index。失败可能留下:
indisvalid = false
indisready = true or false depending on failed phase某些 INVALID index 仍会被写路径维护,却不能被查询采用;它既有成本又没有读收益,需要处置。但先确认:
- 是否仍有合法 build/reindex 正在运行;
- index 与 table 的 exact OID/schema/name;
- 是否由 constraint/partition operation 管理;
- 失败 SQLSTATE、phase 与原始 DDL;
- 是否已有人在做恢复;
- drop/recreate 的锁、磁盘、唯一性与 replica 风险。
本章用故意重复数据执行 CREATE UNIQUE INDEX CONCURRENTLY,要求:
SQLSTATE 23505
→ catalog 观察到 exact INVALID unique index
→ 保存定义、大小与 flags
→ DROP INDEX CONCURRENTLY exact schema-qualified target
→ remaining=0这不是“定时删除所有 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
上一节:表达式、部分与覆盖索引 · 返回本章目录 · 下一节:验证而不是“加完就快” · 查看全书目录 · 查看索引中心