关键词:Invalid Index、PostgreSQL 14、bottom-up index deletion、悬空 CTID、REINDEX CONCURRENTLY、amcheck 盲区、 _mdfd_getseg
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
$ 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 段)确实不存在
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 | t
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))
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)
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 blocks
#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
(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}
BlockNumber = (bi_hi << 16) | bi_lo = (2 << 16) | 26457 = 131072 + 26457 = 157529 ★ 与报错完全一致
(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}
SELECT 42081573::regclass; regclass ----------------------------------------------------------- app_schema.idx_biz_multi_col_ccnew
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 | t
SELECT bt_index_check('idx_biz_multi_col_ccnew', true);
ERROR: cannot check index "idx_biz_multi_col_ccnew"
DETAIL: Index is not valid.
某次执行 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 状态
← 第 0 段(block 0 ~ 131071) .1 ← 第 1 段(block 131072 ~ 262143) .2 ← 第 2 段(block 262144 ~ 393215) ... .N ← 第 N 段(block N×131072 ~ (N+1)×131072 - 1)
RELSEG_SIZE = 1 GB / BLCKSZ = 1073741824 / 8192 = 131072 blocks
targetseg = blkno / ((BlockNumber) RELSEG_SIZE);
targetseg = 157529 / 131072 = 1 ← 第 1 段 段内偏移 = 157529 % 131072 = 26457
ip_blkid = {bi_hi = 2, bi_lo = 26457}, ip_posid = 3
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)));
}
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.
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")
\\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
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);
}
}
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 没变
indexUnchanged = 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;
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 之前的版本里, _bt_bottomupdel_pass 这个函数 根本不存在(PG14 引入),上述触发链条第 3 步永远不可能进入——同一份 invalid 索引在 PG13 上 永远不会暴露这个错误,悬空 CTID 长期沉睡无症状。 升级到 PG14 之后,原本无害的"陈年残骸"才变成了雷。叠加 PG14 上 indexUnchanged workaround 的"一刀切传 true"特性,PG14 是这一类问题的 最高风险版本——PG15+ 修复了 workaround、按列精细判断后,相同 invalid 索引被触发的概率会显著下降,但 不为零(仍可被 unique dup 路径触发)。
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;
-- 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;
(gdb) frame 7 (gdb) p ((Relation)rel)->rd_id $ = 42081573
修复版本边界(重要):这条 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 等同。
本文记录的是真实生产实例的诊断过程,所有表名、索引名、业务字段名、OID、relfilenode、buffer ID、文件路径、业务数据值均已脱敏,仅保留 BlockNumber、RELSEG_SIZE、字节计算等公开内核常量级数据,以便读者可以独立复现推导链条。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70033867/viewspace-3174765/ ,如需转载,请注明出处,否则将追究法律责任。