暂无图片
暂无图片
2
暂无图片
暂无图片
暂无图片

PG之路:PostgreSQL索引膨胀与MVCC--1

萃与查 2026-04-15
236
 要了解索引膨胀,先要学习普通数据的膨胀。PG采用多版本并发控制(MVCC)来实现高并发场景下的读写分离,为每一行数据保留多个版本,使得读不阻塞写,写不阻塞读,有很大的性能优势。但是,索引结构本身不存储 MVCC 可见性信息,索引扫描必须频繁"回表"验证元组的事务可见性;同时,MVCC 的追加写(Append-only)特性会导致索引条目持续膨胀,死条目清理不及时将严重拖累查询效率 。

MVCC的核心实现机制



    PG中为每一行都设置了系统字段,默认是隐藏的,用来实现mvcc。主要是下面4个。按术语即每个数据元组(tuple)头部设置了元数据。
系统列
存储内容
作用说明
xmin
创建该元组的事务ID
标识元组的"出生"事务
xmax
删除/更新该元组的事务ID 若元组有效则为0;
被删除或更新后指向终结事务
ctid
物理位置标识符(块号,偏移量)
元组被更新时,旧版本ctid指向新版本位置,形成版本链
infomask
位标记集合
缓存事务状态(Hint Bits),避免频繁查询CLOG

通过以下例子进一步了解这几个系统字段。创建表,随意插入数据
-- 创建测试表CREATE TABLE mvcc_products (    id SERIAL PRIMARY KEY,    name VARCHAR(100) NOT NULL,    price DECIMAL(10, 2) NOT NULL);
-- 插入初始数据INSERT INTO mvcc_products (name, price) VALUES('Laptop', 1200.00),('Mouse', 25.00),('Keyboard', 75.00);
pg可以显式查看系统列,如下,先忽略cmin和cmax,这两个是在子事务中判断数据可见性的。
SELECT id, name, price, xmin, xmax, cmin, cmax, ctiddb_mvcc-# FROM mvcc_products; id |   name   |  price  | xmin | xmax | cmin | cmax | ctid----+----------+---------+------+------+------+------+-------  1 | Laptop   | 1200.00 | 1128 |    0 |    0 |    0 | (0,1)  2 | Mouse    |   25.00 | 1128 |    0 |    0 |    0 | (0,2)  3 | Keyboard |   75.00 | 1128 |    0 |    0 |    0 | (0,3)(3 rows)
表中3行数据,都是一条sql插入的,该事务的id是xmin=1128  。当执行insert插入第4行后,看看数据变化
BEGIN;INSERT INTO mvcc_products (name, price) VALUES ('Monitor', 300.00);
-- 查询新插入行的系统列SELECT id, name, price, xmin, xmax, cmin, cmax, ctidFROM mvcc_products; id |   name   |  price  | xmin | xmax | cmin | cmax | ctid----+----------+---------+------+------+------+------+-------  1 | Laptop   | 1200.00 | 1128 |    0 |    0 |    0 | (0,1)  2 | Mouse    |   25.00 | 1128 |    0 |    0 |    0 | (0,2)  3 | Keyboard |   75.00 | 1128 |    0 |    0 |    0 | (0,3)  4 | Monitor  |  300.00 | 1132 |    0 |    0 |    0 | (0,4)(4 rows)
   新插入的第4行事务id为1132.接下来连续更新两次update
db_mvcc=# BEGIN;BEGINdb_mvcc=*# UPDATE mvcc_products SET price = 1150.00 WHERE name = 'Laptop';UPDATE 1db_mvcc=*# select txid_current(); txid_current--------------         1133(1 row)
db_mvcc=*# SELECT id, name, price, xmin, xmax, cmin, cmax, ctiddb_mvcc-*# FROM mvcc_products WHERE name = 'Laptop'; id |  name  |  price  | xmin | xmax | cmin | cmax | ctid----+--------+---------+------+------+------+------+-------  1 | Laptop | 1150.00 | 1133 |    0 |    0 |    0 | (0,5)(1 row)
db_mvcc=*# UPDATE mvcc_products SET price = 333.00 WHERE name = 'Laptop';UPDATE 1db_mvcc=*# SELECT id, name, price, xmin, xmax, cmin, cmax, ctidFROM mvcc_products WHERE name = 'Laptop'; id |  name  | price  | xmin | xmax | cmin | cmax | ctid----+--------+--------+------+------+------+------+-------  1 | Laptop | 333.00 | 1133 |    0 |    1 |    1 | (0,6)(1 row)
db_mvcc=*# COMMIT;COMMIT  
注意,这里是在事务中执行的,cmin,cmax会有显示。最后commit,表中最后的数据如下
SELECT id, name, price, xmin, xmax, cmin, cmax, ctidFROM mvcc_products ; id |   name   | price  | xmin | xmax | cmin | cmax | ctid----+----------+--------+------+------+------+------+-------  2 | Mouse    |  25.00 | 1128 |    0 |    0 |    0 | (0,2)  3 | Keyboard |  75.00 | 1128 |    0 |    0 |    0 | (0,3)  4 | Monitor  | 300.00 | 1132 |    0 |    0 |    0 | (0,4)  1 | Laptop   | 333.00 | 1133 |    0 |    1 |    1 | (0,6)(4 rows)
Laptop这个数据被更新后,xmin为新insert的数据,ctid列指向了新的数据。     
下面执行delete操作,在另一个终端,delete 事务未提交时,过程中看到的如下
begin:
delete from mvcc_products where id=2;
如下数据,可以看到Mouse行的xmax出现新事务id,这个时候回滚该delete事务
db_mvcc=# SELECT id, name, price, xmin, xmax, cmin, cmax, ctidFROM mvcc_products ; id |   name   | price  | xmin | xmax | cmin | cmax | ctid----+----------+--------+------+------+------+------+-------  2 | Mouse    |  25.00 | 1128 | 1134 |    0 |    0 | (0,2)  3 | Keyboard |  75.00 | 1128 |    0 |    0 |    0 | (0,3)  4 | Monitor  | 300.00 | 1132 |    0 |    0 |    0 | (0,4)  1 | Laptop   | 333.00 | 1133 |    0 |    1 |    1 | (0,6)(4 rows)
delete事务回滚后,Mouse行数据如下,即使事务回滚了,该行的xmax仍然保留了xmax记录。该行数据是有效的,对后续的新事务可见。(这里xmax有数据但并不一定表示 数据已经删除了,取决于xmax=1134的事务状态,由于上文执行了回滚,在clog 或者infomask会记录该事务状态,后文会讲解该问题)
SELECT id, name, price, xmin, xmax, cmin, cmax, ctidFROM mvcc_products WHERE name = 'Mouse';  -- 返回空 id | name  | price | xmin | xmax | cmin | cmax | ctid----+-------+-------+------+------+------+------+-------  2 | Mouse | 25.00 | 1128 | 1134 |    0 |    0 | (0,2)

delete 事务提交后,查询不到被删除的数据。这里就不贴演示结果了。

下面再演示下同一事务内多命令的cmin/cmax变化:
db_mvcc=# BEGIN;BEGINdb_mvcc=*# select txid_current(); txid_current--------------         1137(1 row)
db_mvcc=*# INSERT INTO mvcc_products (name, price) VALUES ('Webcam', 50.00);INSERT 0 1db_mvcc=*# SELECT id, name, price, xmin, xmax, cmin, cmax, ctiddb_mvcc-*# FROM mvcc_products WHERE name = 'Webcam'; id |  name  | price | xmin | xmax | cmin | cmax | ctid----+--------+-------+------+------+------+------+-------  6 | Webcam | 50.00 | 1137 |    0 |    0 |    0 | (0,9)(1 row)
db_mvcc=*# UPDATE mvcc_products SET price = 45.00 WHERE name = 'Webcam';UPDATE 1
db_mvcc=*# SELECT id, name, price, xmin, xmax, cmin, cmax, ctiddb_mvcc-*# FROM mvcc_products WHERE name = 'Webcam'; id |  name  | price | xmin | xmax | cmin | cmax |  ctid----+--------+-------+------+------+------+------+--------  6 | Webcam | 45.00 | 1137 |    0 |    1 |    1 | (0,10)(1 row)db_mvcc=*# commit;COMMITdb_mvcc=#
 
SELECT id, name, price, xmin, xmax, cmin, cmax, ctidFROM mvcc_products; id |  name   | price  | xmin | xmax | cmin | cmax |  ctid----+---------+--------+------+------+------+------+--------  4 | Monitor | 300.00 | 1132 |    0 |    0 |    0 | (0,4)  1 | Laptop  | 333.00 | 1133 |    0 |    1 |    1 | (0,6)  6 | Webcam  |  45.00 | 1137 |    0 |    1 |    1 | (0,10)

Infomask 位标记与 CLOG 提交日志

    元组头部的 t_infomask 字段包含多个关键位标记,用于缓存事务状态(Hint Bits),从而避免每次可见性判断都访问提交日志:
位标记
十进制值
含义
HEAP_XMIN_COMMITTED
256
xmin事务已提交
HEAP_XMIN_INVALID
512
xmin事务已回滚
HEAP_XMAX_COMMITTED
1024
xmax事务已提交(元组已删除)
HEAP_XMAX_INVALID
2048
xmax事务已回滚/无效
HEAP_HOT_UPDATED
8192
该元组已被HOT更新
HEAP_ONLY_TUPLE
32768
该元组是HOT链中的非根元组

CLOG(Commit Log) 是 PostgreSQL 维护的专用提交日志结构,记录每个事务的状态(IN_PROGRESS、COMMITTED、ABORTED、SUB_COMMITTED)。当 infomask 未设置 Hint Bits 时,系统需查询 CLOG 确定事务状态。首次查询后,Hint Bits 会被回写到元组头部,后续访问即可跳过 CLOG 查询。可通过 pageinspect 扩展的 heap_page_items 函数查看这些内部字段 。

源码定义文件 src/include/access/htup_details.h

#define HEAP_XMIN_COMMITTED             0x0100  /* t_xmin committed */#define HEAP_XMIN_INVALID               0x0200  /* t_xmin invalid/aborted */#define HEAP_XMAX_COMMITTED             0x0400  /* t_xmax committed */#define HEAP_XMAX_INVALID               0x0800  /* t_xmax invalid/aborted */#define HEAP_HOT_UPDATED                0x4000  /* tuple was HOT-updated */#define HEAP_ONLY_TUPLE                 0x8000  /* this is heap-only tuple */

根本矛盾:为什么索引不存储 MVCC 信息

        设计权衡:在 PostgreSQL 的架构中,堆表存储了完整的元组版本信息,而索引条目仅存储键值(Key)和指向堆元组的物理指针(TID)。这种设计并非疏忽,而是存储密度与更新成本之间的刻意权衡。如果索引也存储 MVCC 信息,那么每次事务提交或回滚都可能引发索引页面的级联更新,导致索引体积急剧膨胀并产生巨大的写放大效应。通过将可见性逻辑集中在堆表,索引可以保持相对精简的结构。

        强制回表的性能代价:由于索引本身不具备判断元组可见性的能力,标准索引扫描必须执行"回表"操作。索引扫描获取到 TID 后,必须定位到对应的堆页面,读取元组头部的 t_xmin、t_xmax 以及 infomask 标记,并结合当前事务的快照进行逻辑判断 。这种机制将原本连续的索引顺序扫描变成了对堆表的随机 I/O 访问,即使索引键值完全匹配查询条件,回表产生的物理读也会显著拖慢查询速度。

索引膨胀形成机制

    追加写特性与死条目堆积:PostgreSQL 的 MVCC 实现基于"追加写"模式。当执行 UPDATE 操作时,系统不会原地修改数据,而是标记旧元组为过期并插入一个新版本的元组 。这一过程形成了索引膨胀的完整链条:
    1. UPDATE 触发:产生新堆元组,分配新的 TID
    2. 索引插入:由于 TID 改变,必须在索引中插入指向新元组的新条目
    3. 旧条目残留:指向旧版本堆元组的索引条目依然留在索引页中,直到被 VACUUM 清理

    无法及时清理的原因:如果存在长事务(Long-running Transactions),其快照可能仍需要访问旧版本,导致 VACUUM 无法回收这些"死条目"。此外,在高并发写入场景下,VACUUM 的清理速度可能赶不上死条目的产生速度,形成恶性循环。


    原文链接 https://mp.weixin.qq.com/s/cxoE0oix6ZZPanpo9M9TBA

    获取系列文章入口:

    最后修改时间:2026-04-16 09:39:21
    文章转载自萃与查,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

    评论