一例由 invalid 索引引发的诡异 UPDATE 报错,从 _ccnew 索引到悬空 CTID 的完整排查。
关键词:Invalid Index、PostgreSQL 14、bottom-up index deletion、悬空 CTID、REINDEX CONCURRENTLY、amcheck 盲区、
_mdfd_getseg
TL;DR:一条普通的 UPDATE报出could not open file ... previous segment is only N blocks,看起来像数据文件损坏,但真正的根因不是 PG 内核 bug,而是用户侧的状态遗留——失败的REINDEX CONCURRENTLY留下了一个_ccnew后缀的 invalid 索引(indisvalid=false / indisready=true),里面残留了指向已被vacuum truncate截断的 heap block 的悬空 CTID。这种 invalid 索引残骸在所有 PG 版本上都可能埋下定时炸弹。本文从报错现象出发,经过 8 轮推断与反证,最终通过 gdb 直接抓出凶手索引 OID,并用一行DROP INDEX CONCURRENTLY修复。沿途揭示了两个跨版本反直觉的事实:①bt_index_check(heapallindexed=true)通过不能证明索引干净——它在所有 PG 版本上都会直接跳过 invalid 索引;以及PG14 特有的一个放大器:② PG14 的indexUnchanged计算被一个 workaround 简化成= update(任何 UPDATE 都为 true,PG15 才真正修复),导致 bottom-up deletion 在 PG14 上几乎对所有 UPDATE 无条件激活,进而把"不在被改列里的 invalid 索引"也卷入悬空 CTID prefetch 路径,让本就有问题的索引在 PG14 上更容易暴雷。

某 PostgreSQL 14.x 实例上,业务方反馈一条非常普通的 UPDATE 报错:
UPDATE app_schema.t_biz_data
SET mtime = '1735689600000'::int8
WHERE pk_id = 9024681357004299 AND org_id = 300471;
ERROR: could not open file "base/38500067/42079104.1"
(target block 157529): previous segment is only 111382 blocks第一眼看到这种 previous segment is only N blocks 错误,几乎所有 PG 老手的反应都是"数据文件物理损坏 / 备份恢复不一致"。但在做完物理层检查后,事实开始反常:
$ ls -l base/38500067/42079104*
-rw------- 1 pg pg 912441344 Jun 16 16:23 base/38500067/42079104
-rw------- 1 pg pg 245760 Jun 13 00:45 base/38500067/42079104_fsm
-rw------- 1 pg pg 32768 Jun 13 00:45 base/38500067/42079104_vm
# 第 0 段大小 = 912441344 字节 = 111382 × 8192 个 block
# .1 段(即第 1 段)确实不存在更诡异的是,pg_class 系统目录里的元信息完全正常:
SELECT relname, relkind, relfilenode, relpages, reltuples, relispartition
FROM pg_class WHERE relfilenode = 42079104;
relname | relkind | relpages | reltuples | relispartition
-----------------+---------+----------+--------------+----------------
t_biz_data_p001 | r | 111382 | 1.51e+06 | tpg_class.relpages = 111382 与磁盘文件实际大小完全一致。这意味着:
relpages 不会跟磁盘同步)relpages 也对不上)那报错里说的"想访问 block 157529",到底是从哪儿来的?
直觉指向"某个索引里残留了过时的 CTID"。先看 SQL 实际走的是哪条路径——EXPLAIN 表明这条 UPDATE 走的是主键索引扫描:
EXPLAIN UPDATE app_schema.t_biz_data
SET mtime = '1735689600000'::int8
WHERE pk_id = 9024681357004299 AND org_id = 300471;
QUERY PLAN
----------
Update on t_biz_data
Update on t_biz_data_p001
-> Index Scan using pk_biz_data_p001 on t_biz_data_p001
Index Cond: ((org_id = 300471) AND (pk_id = '9024681357004299'::bigint))
既然走的是主键索引,第一嫌疑就是主键里残留了悬空 CTID。我们立刻用 amcheck 扩展对主键做完整性检查:
SELECT bt_index_check('pk_biz_data_p001', heapallindexed => true);
SELECT bt_index_parent_check('pk_biz_data_p001', heapallindexed => true);
bt_index_check
----------------
(1 row)主键索引完全通过,包括 heapallindexed=true 的高级检查(heap 中每条 visible tuple 都能在索引找到对应项)。
我们继续用一个反直觉的实验进一步排除索引嫌疑——用 pk_id + 0 强制走 Seq Scan 绕过索引:
EXPLAIN UPDATE app_schema.t_biz_data
SET mtime = '1735689600000'::int8
WHERE pk_id + 0 = 9024681357004299 AND org_id = 300471;
QUERY PLAN
----------
Update on t_biz_data
Update on t_biz_data_p001
-> Seq Scan on t_biz_data_p001
Filter: ((org_id = 300471) AND ((pk_id + 0) = '9024681357004299'::bigint))执行结果:
ERROR: could not open file "base/38500067/42079104.1"
(target block 157529): previous segment is only 111382 blocksSeq Scan 路径仍然报同样的错,target block 仍是 157529。
走到这里,常见的几个候选根因都可以"看似排除":
但事实之后会证明,前两条"看似排除"的判断都是错的——它们正是 PG14 内核里两个反直觉机制留下的诊断陷阱(FSM 那条判断本身正确,与本案无关)。
既然 SQL 层面已经走不下去,直接上 gdb attach 到正在报错的 backend,断点打在 errfinish 或 _mdfd_getseg,复现 UPDATE,抓栈(下面所有的源文件行号来自本案实例所用的 PG14.x 内核,与社区上游版本可能略有差异,但函数名、调用关系完全一致):
#0 _mdfd_getseg () at md.c:1214
#1 mdprefetch (reln=..., forknum=MAIN_FORKNUM, blocknum=157529) at md.c:565
#2 PrefetchSharedBuffer (smgr_reln=..., forkNum=MAIN_FORKNUM, blockNum=157529) at bufmgr.c:535
#3 index_delete_prefetch_buffer (rel=..., prefetch_state=..., prefetch_count=1) at heapam.c:7760
#4 heap_index_delete_tuples (rel=..., delstate=...) at heapam.c:7959
#5 table_index_delete_tuples (...)
#6 _bt_delitems_delete_check () at nbtpage.c:1541
#7 _bt_bottomupdel_pass (rel=..., buf=421337, heapRel=..., newitemsz=188) at nbtdedup.c:406
#8 _bt_delete_or_dedup_one_page () at nbtinsert.c:2763调用链揭示了关键事实:报错来自 PG14 引入的 bottom-up index deletion 路径。这是个非常重要的转折点——说明索引层确实参与其中。
继续抓 index_delete_prefetch_buffer 帧的运行时数据:
(gdb) frame 3
(gdb) p prefetch_state->ndeltids
$ = 9
(gdb) p i
$ = 2
(gdb) p prefetch_state->deltids[i]
$ = {tid = {ip_blkid = {bi_hi = 2, bi_lo = 26457}, ip_posid = 3}, id = 30}把 ItemPointer 解码成 BlockNumber:
BlockNumber = (bi_hi << 16) | bi_lo
= (2 << 16) | 26457
= 131072 + 26457
= 157529 ★ 与报错完全一致这条 ItemPointer 是从某个 b-tree 索引的 leaf 页里读出来的——索引里确实残留了一条指向 block 157529 的悬空 CTID。amcheck 通过和 Seq Scan 报错那两条"反证"是怎么回事?暂时先按下,回到栈帧上溯找凶手索引:
(gdb) frame 7 _bt_bottomupdel_pass 帧
(gdb) p ((Relation)rel)->rd_id
$ = 42081573
(gdb) p ((Relation)rel)->rd_node
$ = {spcNode = 1663, dbNode = 38500067, relNode = 42081573}回到 SQL:
SELECT 42081573::regclass;
regclass
-----------------------------------------------------------
app_schema.idx_biz_multi_col_ccnew凶手索引名字带着 _ccnew 后缀——这是个非常关键的信号。
PG 中索引名以 _ccnew 结尾,只有一个来源:REINDEX CONCURRENTLY 命令的中间产物。
REINDEX CONCURRENTLY 内部步骤(PG12+):
_ccnew 后缀),与旧索引并行接受新插入_ccnew 改名为正式索引名,把旧索引改名为 _ccold,最后 drop 旧索引_ccnew 索引处于 invalid 状态我们立即查这个索引的状态:
SELECT c.oid, c.relname, i.indisvalid, i.indisready, i.indislive
FROM pg_class c JOIN pg_index i ON c.oid = i.indexrelid
WHERE c.oid = 42081573;
relname | indisvalid | indisready | indislive
------------------------------+------------+------------+-----------
idx_biz_multi_col_ccnew | f | t | tindisvalid=false / indisready=true / indislive=true——这是 invalid 状态的 _ccnew 索引的典型特征:
字段 | 值 | 含义 |
|---|---|---|
indisvalid | false | 读路径不使用它(查询不会命中) |
indisready | true | 写路径必须维护它(INSERT/UPDATE 必须更新它) |
indislive | true | 仍然出现在系统目录里 |
我们之前还跑过一次 amcheck 想检查这个索引:
SELECT bt_index_check('idx_biz_multi_col_ccnew', true);
ERROR: cannot check index "idx_biz_multi_col_ccnew"
DETAIL: Index is not valid.amcheck 直接拒绝检查 invalid 索引——这一点意味着:即使我们后来想到要检查 _ccnew,也无法通过 amcheck 验证它的内部正确性,它就是一个"无人能体检"的盲区。
回过头看,最初的判断错误其实更早:当时我们只对主键跑了 amcheck,就以"主键通过"推论出"索引层没问题"。但表上不止主键一个索引,invalid 状态的 _ccnew 当时根本没被检查到——主键 amcheck 通过 ≠ 表上所有索引都干净。把这两层叠加起来:
_ccnew_ccnew,amcheck 也会拒绝这是一个"流程漏 + 工具盲"的双层陷阱,缺一个就不会变成本案这种"看似排查彻底实则放过真凶"的局面。
至此故事的一半已经清楚——剩下的问题是:为什么 _ccnew 索引里会有指向已被截断 block 的 CTID?
回到 REINDEX CONCURRENTLY 内部第 1 步的 index_concurrently_build 实现:在某个一致性快照下扫整个 heap,把现有 visible tuple 全部插入 _ccnew。
某次执行 REINDEX CONCURRENTLY 试图重建索引 idx_biz_multi_col:
T0:创建空的 _ccnew 索引(OID=42081573, indisvalid=f, indisready=f)
T1:index_concurrently_build 扫整张 heap
此时 heap ≥ 157530 blocks(曾经更大)
block 157529 上有一条业务 tuple → CTID (157529, 3) 写进 _ccnew leaf 页
T2:标记 indisready=true,等老事务结束
T3:第二次扫表追平 indisready=true 期间的并发变更
并行:vacuum 在另一个进程运行,发现 heap 末尾大量空页
触发 RelationTruncate,heap 缩到 111382 blocks
vacuum truncate 不主动清理 invalid 索引
_ccnew 中 (157529, 3) 这条 CTID 永久悬空
T4:REINDEX CONCURRENTLY 失败(很可能就是因为撞上同一个错误)
T5:_ccnew 索引被永久留下,处于 indisvalid=false / indisready=true 状态这条因果链解释了所有反常之处:
pg_class.relpages 与磁盘吻合)_ccnew 副本索引(amcheck 跳过)indisready=true)理解了"是哪个索引"之后,还要回答一个机制问题:报错里那个 previous segment is only 111382 blocks 的硬校验到底是怎么算出来的?
PG 把每个 relation(heap/index)的主 fork 切成多个 1GB 的 segment 文件存储。文件命名规则:
<relfilenode> ← 第 0 段(block 0 ~ 131071)
<relfilenode>.1 ← 第 1 段(block 131072 ~ 262143)
<relfilenode>.2 ← 第 2 段(block 262144 ~ 393215)
...
<relfilenode>.N ← 第 N 段(block N×131072 ~ (N+1)×131072 - 1)131072 这个数字来自编译时常量:
RELSEG_SIZE = 1 GB / BLCKSZ = 1073741824 / 8192 = 131072 blocksPG 内核 src/backend/storage/smgr/md.c 里定位某个 block 在哪个段的核心一行:
targetseg = blkno / ((BlockNumber) RELSEG_SIZE);代入 block 157529:
targetseg = 157529 / 131072 = 1 ← 第 1 段
段内偏移 = 157529 % 131072 = 26457所以:
.1 → base/38500067/42079104.126457 × 8192 = 216,793,088(≈ 207 MB)回到 gdb 抓到的 ItemPointer:
ip_blkid = {bi_hi = 2, bi_lo = 26457}, ip_posid = 3注意 bi_hi << 16 计算出来的高位部分(2 × 65536 = 131072)正好等于一个 segment 的容量!但这只是数字上的巧合:
bi_hi/bi_lo 是 PG 把 32 位 BlockNumber 拆成两个 16 位字段存储(避免对齐问题)_mdfd_getseg 在 IO 层做_mdfd_getseg 源码里关键的硬校验(位于一个 if/else if 链中,前一分支处理 EXTENSION_CREATE 写入扩展,本分支处理只读访问时遇到 size 异常的情况):
else if (!(behavior & EXTENSION_DONT_CHECK_SIZE) &&
nblocks < ((BlockNumber) RELSEG_SIZE))
{
/*
* When not extending (or explicitly including truncated
* segments), only open the next segment if the current one is
* exactly RELSEG_SIZE. If not (this branch), either return NULL
* or fail.
*/
if (behavior & EXTENSION_RETURN_NULL)
{
errno = ENOENT;
return NULL;
}
ereport(ERROR,
(errcode_for_file_access(),
errmsg("could not open file \"%s\" (target block %u): "
"previous segment is only %u blocks",
_mdfd_segpath(reln, forknum, nextsegno),
blkno, nblocks)));
}这个分支为何如此严格?同函数前一个分支(recovery / EXTENSION_CREATE 扩展路径)的注释把整个 _mdfd_getseg 共同遵守的不变量讲得很清楚:
We have to maintain the invariant that segments before the last active segment are of size RELSEG_SIZE; therefore, if extending, pad them out with zeroes if needed.
PG 内核强制要求:除最后一段外,前面所有段必须正好 RELSEG_SIZE = 131072 blocks。这个不变量保证了从任意 block N 反推它在哪个段的计算永远是 N / RELSEG_SIZE,不需要去 stat 每个段文件实际大小。
如果出现"第 0 段只有 111382 blocks 但有人想访问 block 157529(理应在第 1 段)",PG 认为这是数据库元数据严重不一致,宁可报错也不创建一个空洞的 .1 文件。
完整的"地址翻译链":
B-tree IndexTuple.t_tid 字节存储:
ip_blkid.bi_hi = 0x0002 (2 字节)
ip_blkid.bi_lo = 0x6759 (2 字节, = 26457)
ip_posid = 0x0003 (2 字节, slot 内位置)
↓ ItemPointerGetBlockNumber 解码
逻辑 BlockNumber = 157529
↓ _mdfd_getseg 段拆分
targetseg = 157529 / 131072 = 1
段内 blkno = 157529 % 131072 = 26457
↓ 物理文件定位
物理文件 = base/38500067/42079104.1
↓ PG 检查 "前一段必须满 RELSEG_SIZE"
发现第 0 段 = 111382 < 131072
↓
ereport(ERROR, "previous segment is only 111382 blocks")最让人头疼的一个机制问题——出问题的 _ccnew 索引根本不包含 mtime 列:
\d+ idx_biz_multi_col_ccnew
Column | Type | Key? | Definition
------------+------------------------+------+------------
org_id | bigint | yes | org_id
obj_id | bigint | yes | obj_id
obj_name | character varying(255) | yes | obj_name
ctime | bigint | yes | ctime
btree, for table "t_biz_data_p001", invalid为什么 update mtime 这条语句会让 PG 去维护一个跟 mtime 无关的索引?
PG 的 UPDATE 在写路径上有两种走法:
如果被修改的所有列都不在任何索引上,且原页有空间放新 tuple,PG 会走 HOT 优化:
t_ctid chain 链接到旧版本但 HOT 的触发条件极其苛刻——任何被任意一个索引覆盖的列被改,HOT 立刻失败。
只要 HOT 失败,就走源码 heap_update() → ExecUpdate → 关键的这一段(src/backend/executor/execIndexing.c::ExecInsertIndexTuples):
List *
ExecInsertIndexTuples(ResultRelInfo *resultRelInfo,
TupleTableSlot *slot,
EState *estate,
bool update, /* ← UPDATE 还是 INSERT */
bool noDupErr,
bool *specConflict,
List *arbiterIndexes)
{
...
/* 遍历表上所有索引!没有"列是否被改"的过滤 */
for (i = 0; i < numIndices; i++)
{
Relation indexRelation = relationDescs[i];
IndexInfo *indexInfo;
bool indexUnchanged;
...
if (indexRelation == NULL) continue;
indexInfo = indexInfoArray[i];
if (!indexInfo->ii_ReadyForInserts) continue; /* 跳过没准备好的索引 */
...
FormIndexDatum(indexInfo, slot, estate, values, isnull);
/*
* XXX We always assume that the hint should be passed for an UPDATE.
* This is a workaround for a bug in PostgreSQL 14. In practice this
* won't make much difference for current users of the hint.
*/
indexUnchanged = update; /* ★ PG14 关键 bug workaround:直接等于 update */
index_insert(indexRelation, values, isnull, tupleid, heapRelation,
checkUnique,
indexUnchanged, /* ← 这个 flag 直接进 b-tree */
indexInfo);
}
}没有任何"被修改的列是否在这个索引中"的判断——只要索引 indisready=true(包括 invalid 的 _ccnew!),每条非 HOT UPDATE 都会给它插一个新的索引 entry。
因为 PG 的 MVCC 模型:
UPDATE 之前:
heap tuple v1: ctid=(N, k)
index1 entry: (key1, ctid=(N, k))
index2 entry: (key2, ctid=(N, k))
UPDATE 之后(非 HOT):
heap tuple v1: ctid=(N, k) ← 旧版本仍存在(给老 snapshot 看)
heap tuple v2: ctid=(N+1, m) ← 新版本
index1 entry: (key1, ctid=(N, k)) ← 旧的还在
index1 entry: (key1, ctid=(N+1, m)) ← 新插入!即使 key1 没变
index2 entry: (key2, ctid=(N, k)) ← 旧的还在
index2 entry: (key2, ctid=(N+1, m)) ← 新插入!即使 key2 没变每个版本的 heap tuple 都需要在每个索引里有对应的索引项指向它。否则一个事务从 index2 走索引扫描时,找不到 v2 的索引项就会漏读这个新版本——这是 MVCC 正确性的硬要求。
PG14 在 index_insert 调用时新增了 indexUnchanged 提示参数,本意是:如果某个索引覆盖的所有列都没变,就把这个提示传给 b-tree,让它知道"新插入的索引项很可能是 dup,可以激进做 bottom-up deletion"。
但翻开 PG14.15 真实源码(execIndexing.c:409),这条本应"按列精细判断"的逻辑被简化成了一行:
indexUnchanged = update;注释里上游开发者明确写道:"This is a workaround for a bug in PostgreSQL 14"——意思是 PG14 中这条优化的精细判定有 bug,作为权宜之计直接对所有 UPDATE 一律传 true。
直到 PG15 才真正修复,引入 index_unchanged_by_update() 做按列精细判断:
/* PG15+ 的正确实现 */
indexUnchanged = update && index_unchanged_by_update(resultRelInfo, ...);这个细节解释了本案最反直觉的部分:
/* nbtinsert.c::_bt_delete_or_dedup_one_page */
if ((indexUnchanged || uniquedup) &&
_bt_bottomupdel_pass(rel, buffer, heapRel, insertstate->itemsz))
return;在 PG14 里,任何 UPDATE 写入任何 b-tree 索引时 indexUnchanged 都是 true——这意味着只要这条索引页接近满,bottom-up deletion 路径就几乎无条件激活。"反直觉"不在于"被改列不在索引里反而触发",而在于:PG14 把按列精细判断"workaround 掉"了,所有 UPDATE 都激活这条 prefetch 路径。
这就是本案触发链最后的临门一脚:
update mtime (走非 HOT,因为 mtime 在表上某个其他索引里)
↓
ExecInsertIndexTuples 遍历所有 indisready=true 索引
↓
对 _ccnew (indisvalid=false 但 indisready=true) 也调用 index_insert
↓
indexUnchanged = update = true (PG14 bug workaround:所有 UPDATE 都为 true)
↓
btinsert → _bt_doinsert → 找目标 leaf 页发现页快满
↓
_bt_delete_or_dedup_one_page: if (indexUnchanged || uniquedup) ... ★ 命中!
↓
_bt_bottomupdel_pass(rel=_ccnew, buf=421337, heapRel, ...)
↓ 收集 leaf 页 9 条候选 ItemPointer 进 deltids[]
↓ 第 2 条恰好是 (157529, 3) 悬空 CTID
table_index_delete_tuples → heap_index_delete_tuples
↓
index_delete_prefetch_buffer → PrefetchBuffer(heapRel, MAIN_FORKNUM, 157529)
↓
mdprefetch → _mdfd_getseg
↓
ereport(ERROR): previous segment is only 111382 blocks最反直觉的事实:PG14 的设计原本想用 indexUnchanged 做精细优化,但 workaround 把所有 UPDATE 一刀切传 true,反而让 bottom-up deletion 在 PG14 的触发频率远高于 PG15+。这是个"优化 flag 退化为强制开关"的特殊版本现象——也是为什么 invalid 索引悬空 CTID 在 PG14 上更容易被触发的根本机制原因。
读到这里有个自然的问题:这个 _ccnew 索引可能已经在表上躺了好几个月了,业务一直在 UPDATE,为什么偏偏今天这条 UPDATE 才爆?
严格回答:bottom-up deletion 在每次进入时会一次性收集当前 leaf 页的全部有效 ItemPointer(你看到的 ndeltids=9 就是该 leaf 页的全集),一旦进入路径,必然会撞上其中的悬空 CTID。所以"突然"的关键不在收集是否撞,而在何时第一次进入 _bt_bottomupdel_pass——也就是何时第一次满足下列全部条件:
ExecInsertIndexTuples 遍历到 _ccnew 索引(因为 indisready=true,必维护)_ccnew 时找到的目标 leaf 页快满,进入 _bt_delete_or_dedup_one_pageindexUnchanged=true(PG14 中所有 UPDATE 都满足;PG15+ 仅当被改列不在该索引时满足)或 uniquedup=true5 条同时成立才会爆。任何一条不满足,UPDATE 都安然通过——这就解释了三件事:
pk_id + org_id 正好命中了这个组合pk_id 能 update、有些不能,按业务键看是"零散"分布——这正是 invalid 索引悬空 CTID 故障的典型特征更进一步:在 PG14 之前的版本里,
_bt_bottomupdel_pass这个函数根本不存在(PG14 引入),上述触发链条第 3 步永远不可能进入——同一份 invalid 索引在 PG13 上永远不会暴露这个错误,悬空 CTID 长期沉睡无症状。升级到 PG14 之后,原本无害的"陈年残骸"才变成了定时炸弹。叠加 PG14 上indexUnchangedworkaround 的"一刀切传 true"特性,PG14 是这一类问题的最高风险版本——PG15+ 修复了 workaround、按列精细判断后,相同 invalid 索引被触发的概率会显著下降,但不为零(仍可被 unique dup 路径触发)。
既然出问题的是失败的 REINDEX CONCURRENTLY 留下的孤儿索引,根本不需要 REINDEX 整个表,直接 DROP 它就行:
DROP INDEX CONCURRENTLY app_schema.idx_biz_multi_col_ccnew;执行后立即验证:
BEGIN;
UPDATE app_schema.t_biz_data
SET mtime = '1735689600000'::int8
WHERE pk_id + 0 = 9024681357004299 AND org_id = 300471;
-- UPDATE 1
ROLLBACK;UPDATE 1 一次成功——闭环。
这种 _ccnew 残骸很可能不止一个分区——如果之前的批量 reindex 是对所有分区执行的,每个分区都有自己的 _ccnew 候选;按业务键分布,每个分区里又有若干个含悬空 CTID 的 leaf 页等待被命中。每一个 _ccnew 残骸都是一颗等待引爆的炸弹,是否引爆只取决于业务何时 UPDATE 到对应行。强烈建议做一次全库普查:
-- 1. 全库扫所有 invalid 索引
SELECT n.nspname || '.' || c.relname AS index_name,
i.indrelid::regclass AS table_name,
pg_size_pretty(pg_relation_size(c.oid)) AS size,
i.indisvalid, i.indisready, i.indislive
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid
AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
ORDER BY n.nspname, c.relname;
-- 2. 如果出现多个 _ccnew/_ccold 索引,批量生成 DROP 命令
SELECT 'DROP INDEX CONCURRENTLY ' || n.nspname || '.' || c.relname || ';' AS drop_cmd
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid
AND (c.relname LIKE '%\_ccnew%' ESCAPE '\' OR c.relname LIKE '%\_ccold%' ESCAPE '\')
ORDER BY n.nspname, c.relname;整轮排查从一条 UPDATE 报错出发,经过 8 轮推断与反证,最终落到 OID 级别的精确定位 + 一行 SQL 修复。沉淀的几条可复用诊断教训:
这条函数有两个常被忽略的盲区:
indisvalid=false 的索引:直接报 cannot check ... Index is not valid 后中断。失败的 _ccnew / _ccold 残骸是它的天然盲区所以"主键 amcheck 通过"完全不能推论"表上所有索引都干净"。
UPDATE 即使读路径走 Seq Scan,写路径仍要 ExecInsertIndexTuples 无差别地维护所有 indisready=true 的索引(包括 invalid 的 _ccnew/_ccold 残骸)。bottom-up deletion 的触发条件是"索引页快满",与扫描路径无关。
heap_index_delete_tuples(Relation rel, ...) 第一个参数是 heapRel,不是 indexRel。要拿凶手 indexRel 必须回到 _bt_bottomupdel_pass 帧(栈帧 #7):
(gdb) frame 7
(gdb) p ((Relation)rel)->rd_id
$ = 42081573再回 SQL 用 42081573::regclass 反查名字——这是从 gdb 现场到具体索引名最快的路径。
按设计意图,indexUnchanged=true 应该只在"被改列都不在该索引"时设置,作为给 b-tree 的一个 hint,让它激进做 bottom-up deletion 优化。但 PG14(包括到 PG14.x 末位版本)实际实现是 indexUnchanged = update——只要是 UPDATE 就一律传 true,注释明说这是 "PostgreSQL 14 的一个 bug 的 workaround"。这意味着在 PG14 上,任何 UPDATE 写入任何 b-tree 索引时,只要索引页快满,就几乎无条件触发 bottom-up deletion。这条 workaround 在 PG15 才被 index_unchanged_by_update() 正式替换——所以同样一份 invalid 索引,PG14 是高危版本,PG15+ 风险显著降低但不为零。
修复版本边界(重要):这条 workaround 仅在 PG15.0+ 修复,没有 backport 到 PG14 任何小版本。已确认 PG14.15 仍保留
indexUnchanged = update;一刀切实现——也就是说 PG14 整个生命周期(直到官方 EOL)都不会修复这条 workaround,任何 PG14.x 实例都和本案同等高危。关于"是不是 bug"的严格定性:本案是多个非 bug 的设计选择 + 一个被上游承认的 PG14 bug 在特定时序下叠加触发——
REINDEX CONCURRENTLY失败留下 invalid 索引、vacuum truncate 不清理 invalid 索引、_mdfd_getseg撞墙就ereport(ERROR)都是设计选择,社区不打算"修复";只有indexUnchanged = update这一项被上游注释明确自陈为 PG14 bug,且仅 PG15+ 修复。运维上唯一可靠的防御是主动清理 invalid 索引,不能依赖任何上游修复。PG15+ 仍会触发的场景:本案被改列
mtime不在出问题的_ccnew(org_id, obj_id, obj_name, ctime)4 列内,所以 PG15 的index_unchanged_by_update()按列判断结果仍为true——意味着在 PG15+ 实例上同样这条 UPDATE 仍会复现报错。PG15 真正豁免的是"改的列正好在该索引列内"那一类 UPDATE;本案这种"改索引列外字段"的场景,PG15+ 触发概率与 PG14 等同。
PG14 引入的 bottom-up deletion 是个非常出色的优化——它通过主动按索引项 CTID prefetch heap 来批量回收"指向同一死 tuple 的多个索引项",显著降低写密集场景下索引膨胀。但作为代价,它会主动按索引项 CTID 去访问 heap 物理页,与历史遗留的悬空 CTID 撞在一起才暴露。
更关键的是,本案在第七节揭示的 PG14 indexUnchanged workaround——上游为了规避一个 bug,把"按列精细判断索引是否未变"的优化简化为"所有 UPDATE 一律传 true"——让 bottom-up deletion 在 PG14 几乎成了"任何 UPDATE 写入页快满索引时的标配触发器"。这条 workaround 直到 PG15 才被正式修复(PG15+ 的 index_unchanged_by_update() 真正按列判断),所以同样一份 invalid 索引:
版本 | bottom-up deletion 是否存在 | indexUnchanged 取值 | 触发概率 |
|---|---|---|---|
≤ PG13 | ❌ 无该函数 | — | 零(沉睡) |
PG14 | ✅ 存在 | 所有 UPDATE 都为 true(workaround) | 最高(任何 UPDATE 都可能撞上) |
PG15+ | ✅ 存在 | 仅当被改列不在该索引时为 true | 显著降低,但不为零(仍可被 unique dup 路径触发) |
在更早的 PG 版本里,这些悬空 CTID 长期沉睡无症状;升级到 PG14 后,原本无害的"陈年残骸"变成了定时炸弹;升到 PG15+ 后部分缓解但仍不能彻底排除风险。如果你的实例有过:
REINDEX CONCURRENTLY 历史_ccnew/_ccold 残骸强烈建议跑一次本文第九节的"全库 Invalid Index 普查"——把那些可能定时爆炸的 _ccnew / _ccold 孤儿索引先清扫一遍。问题本身并不可怕,可怕的是你不知道自己的实例里到底躺着多少颗这样的炸弹,以及它们何时会被业务 UPDATE 命中。如果跑完没有发现 invalid 索引,可以暂时安心;如果发现一坨,按本文第八节一行 DROP INDEX CONCURRENTLY 逐个清理即可,不需要更激进的处理。
本文记录的是真实生产实例的诊断过程,所有表名、索引名、业务字段名、OID、relfilenode、buffer ID、文件路径、业务数据值均已脱敏,仅保留 BlockNumber、RELSEG_SIZE、字节计算等公开内核常量级数据,以便读者可以独立复现推导链条。
本文分享自 PostgreSQL运维之道 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!