纲要
Heap Tuple元组内部结构t_xmin/t_xmax/t_ctid/t_infomask/t_hoffcmin/cmax与t_cid共用体- NULL 值位图 (
t_bits)
MVCC核心操作INSERT写入流程DELETE删除流程UPDATE更新流程(插入+删除组合)
CLOG(Commit Log) 提交日志- 事务状态:
IN_PROGRESS/COMMITTED/ABORTED/SUB_COMMITTED - 事务状态查询:
pg_xact_status()/txid_status() - CLOG 损坏与修复思路
- 事务状态:
Snapshot快照机制SnapshotData结构:xmin/xmax/xip列表pg_current_snapshot()查看当前快照
- 可见性判断规则 (
HeapTupleSatisfiesMVCC)- 基于
t_xmin状态的分支判断 - 基于
t_xmax的删除判断 - Hint Bits 优化
- 基于
- 事务隔离级别
READ COMMITTED:每条语句获取新快照(默认)REPEATABLE READ:事务开始时获取快照SERIALIZABLE:严格可串行化READ UNCOMMITTED:在 PostgreSQL 中等同于READ COMMITTED
VACUUM与死元组清理postgres_fdw与pg_dump中的隔离级别
元组内部结构
在 PostgreSQL 中,每一条数据行(Heap Tuple)除了存储业务数据外,还在行头部(Tuple Header)中存储了大量元数据,用于实现 MVCC 机制。
// 简化自 src/include/access/htup_details.h
typedef struct HeapTupleHeaderData
{
union
{
HeapTupleFields t_heap;
DatumTupleFields t_datum;
} t_choice;
ItemPointerData t_ctid; /* 当前元组的物理位置 (块号, 偏移量) */
/* 以下字段用于 NULL 值位图等 */
uint16 t_infomask2; /* 属性数量 + 标志位 */
uint16 t_infomask; /* 各种标志位 */
uint8 t_hoff; /* 头部长度 */
/* 后面紧跟着 NULL 位图 (t_bits) 和实际数据 */
} HeapTupleHeaderData;
其中 t_choice.t_heap 结构包含:
typedef struct HeapTupleFields
{
TransactionId t_xmin; /* 插入此元组的事务 ID */
TransactionId t_xmax; /* 删除/更新此元组的事务 ID,0 表示未删除 */
union
{
CommandId t_cid; /* 同一事务内的命令序号 (cmin/cmax) */
TransactionId t_xvac; /* VACUUM 操作使用 */
} t_field3;
} HeapTupleFields;
关键字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
t_xmin | TransactionId | 创建(插入)此元组版本的事务 ID |
t_xmax | TransactionId | 删除或更新此元组的事务 ID,0 表示当前版本有效 |
t_ctid | ItemPointerData | 当前元组在表中的物理位置,格式为 (块号, 偏移量) |
t_cid | CommandId | 同一事务内语句的序列号,用于同一事务内的可见性判断 |
t_infomask | uint16 | 标志位,记录元组属性(是否有 NULL、是否有变长列、Hint Bits 等) |
t_bits | 可变长度 | NULL 值位图,每一位对应一列是否为 NULL |
NULL 值存储
PostgreSQL 并不在数据区域中直接存储 NULL 值,而是通过元组头部的 NULL 位图(t_bits)来标识。每个 NULL 位图占用 1 bit,按列顺序排列:
- 位 = 1:该列为 NULL
- 位 = 0:该列非 NULL
这种设计使得 NULL 值不占用实际数据存储空间,同时也避免了定长字段因 NULL 导致的存储碎片。
cmin / cmax 与 t_cid 共用体
cmin(插入命令 ID)和 cmax(删除命令 ID)用于同一事务内多个语句之间的可见性判断。在源码实现中,t_cid 是一个共用体(union),在同一事务内:
- 插入操作时,
t_cid记录的是cmin - 删除/更新操作时,
t_cid记录的是cmax
由于同一元组在同一个事务中不可能同时被插入和删除(除非在同一事务内更新),因此这两个字段可以共用存储空间。
MVCC 核心操作
INSERT 写入
当执行 INSERT 操作时:
- 新元组的
t_xmin设置为当前事务 ID t_xmax保持为 0(无效)t_ctid指向自身的物理位置t_cid记录当前命令序号(cmin)
-- 示例表
CREATE TABLE test (
id INT,
info TEXT
);
-- 开启事务
BEGIN;
SELECT pg_current_xact_id(); -- 假设返回 12345
INSERT INTO test VALUES (1, 'hello');
-- 查看元组头信息(需安装 pageinspect 扩展)
SELECT lp, t_xmin, t_xmax, t_ctid, t_infomask
FROM heap_page_items(get_raw_page('test', 0));
执行结果示意:
lp | t_xmin | t_xmax | t_ctid | t_infomask
----+--------+--------+--------+------------
1 | 12345 | 0 | (0,1) | ...
DELETE 删除
DELETE 操作并不物理移除数据行,而是在目标元组的 t_xmax 字段中记录删除事务的 ID:
- 定位目标元组
- 将
t_xmax设置为当前事务 ID - 更新
t_cid为当前命令序号(cmax) - 元组变为“死元组”(Dead Tuple),通过可见性规则使其对其他事务不可见
-- 继续上述事务,假设当前事务 ID 为 12345
DELETE FROM test WHERE id = 1;
-- 查看元组状态
SELECT lp, t_xmin, t_xmax, t_ctid, t_infomask
FROM heap_page_items(get_raw_page('test', 0));
执行结果示意:
lp | t_xmin | t_xmax | t_ctid | t_infomask
----+--------+--------+--------+------------
1 | 12345 | 12345 | (0,1) | ...
UPDATE 更新
UPDATE 在 PostgreSQL 中本质上是 DELETE + INSERT 的组合操作:
- 将旧元组的
t_xmax设置为当前事务 ID(标记为删除) - 将旧元组的
t_ctid更新为指向新元组的物理位置 - 插入新元组,
t_xmin设置为当前事务 ID,t_xmax设置为 0 - 新元组的
t_ctid指向自身
-- 继续上述事务
UPDATE test SET info = 'world' WHERE id = 1;
-- 查看元组状态
SELECT lp, t_xmin, t_xmax, t_ctid, t_infomask
FROM heap_page_items(get_raw_page('test', 0));
执行结果示意:
lp | t_xmin | t_xmax | t_ctid | t_infomask
----+--------+--------+--------+------------
1 | 12345 | 12345 | (0,2) | (旧元组,指向新元组)
2 | 12345 | 0 | (0,2) | (新元组)
多次更新同一行会产生多个元组版本,形成一条版本链(Version Chain),通过 t_ctid 字段串联。
CLOG 提交日志
CLOG 概述
CLOG(Commit Log)是 PostgreSQL 中记录事务最终状态的核心系统组件。每个事务的状态占用 2 个 bit,支持四种状态:
| 状态 | 值 | 说明 |
|---|---|---|
IN_PROGRESS | 00 | 事务正在运行中 |
COMMITTED | 01 | 事务已提交 |
ABORTED | 02 | 事务已回滚 |
SUB_COMMITTED | 03 | 子事务已提交 |
CLOG 以 8KB 页为单位存储,每个页可存储 8192 × 4 = 32768 个事务状态。当可见性判断需要获取事务状态时,如果 t_infomask 中的 Hint Bits 未设置,系统会从 CLOG 中读取。
查询事务状态
PostgreSQL 提供了 pg_xact_status() 函数(旧版本为 txid_status())用于查询事务状态:
-- 查看当前事务 ID
SELECT pg_current_xact_id();
-- 查询指定事务的状态
SELECT pg_xact_status('12345');
CLOG 损坏与修复
CLOG 文件损坏时,可能会出现类似以下错误:
ERROR: could not access status of transaction 12345
DETAIL: Could not open file "pg_xact/0001": No such file or directory
修复思路:在紧急情况下,可以通过手动构造 CLOG 文件来恢复数据库访问。基本方法是通过 dd 命令创建对应大小的空文件,但这种方法仅适用于紧急恢复场景,不推荐在生产环境中使用。建议优先从备份恢复。
快照(Snapshot)机制
快照的概念
快照类似于在某个时刻对数据库状态“拍照”。快照一旦生成,就固定了该时刻的可见事务集合,后续查询基于这个快照判断数据的可见性。
快照数据结构
// 简化自 src/include/utils/snapshot.h
typedef struct SnapshotData
{
SnapshotSatisfiesFunc satisfies; /* 可见性判断函数 */
TransactionId xmin; /* 所有 < xmin 的事务均可见 */
TransactionId xmax; /* 所有 >= xmax 的事务均不可见 */
TransactionId *xip; /* 快照时刻正在运行的事务 ID 列表 */
uint32 xcnt; /* 运行中事务数量 */
/* ... 其他字段 */
} SnapshotData;
快照的可见性规则:
xid < xmin:事务已完结,可见(若已提交)xmin <= xid < xmax:需检查xip列表xid >= xmax:事务尚未开始,不可见
查看当前快照
-- 查看当前快照
SELECT pg_current_snapshot();
-- 输出示例: 12345:12348:12346,12347
-- 格式: xmin:xmax:xip_list
-- xmin = 12345, xmax = 12348, 运行中事务为 12346, 12347
快照格式说明:
xmin:最早仍在运行的事务 IDxmax:第一个尚未分配的事务 IDxip_list:快照时刻正在运行的事务 ID 列表(逗号分隔)
可见性判断规则
核心判断逻辑
PostgreSQL 通过 HeapTupleSatisfiesMVCC() 函数判断元组对给定快照是否可见。其核心逻辑可归纳为:
Hint Bits 优化
为了减少频繁访问 CLOG 的开销,PostgreSQL 在 t_infomask 中缓存了事务状态的 Hint Bits:
| Hint Bit | 含义 |
|---|---|
HEAP_XMIN_COMMITTED | t_xmin 事务已提交 |
HEAP_XMIN_INVALID | t_xmin 事务已回滚 |
HEAP_XMAX_COMMITTED | t_xmax 事务已提交 |
HEAP_XMAX_INVALID | t_xmax 事务未提交或不存在 |
当 Hint Bits 已设置时,可直接判断而无需访问 CLOG,大幅提升性能。
可见性判断示例
-- 创建测试表
CREATE TABLE t2 (id INT);
-- 事务 A: 插入数据并提交
BEGIN;
INSERT INTO t2 VALUES (10);
COMMIT;
-- 事务 B: 查询可见性
BEGIN;
SELECT * FROM t2; -- 可见 10
-- 事务 C: 插入数据但不提交
BEGIN;
INSERT INTO t2 VALUES (20);
-- 不提交
-- 事务 D: 查询
BEGIN;
SELECT * FROM t2; -- 只能看到 10,看不到 20
COMMIT;
事务隔离级别
隔离级别概览
PostgreSQL 支持 SQL 标准中定义的四种隔离级别,但 READ UNCOMMITTED 在实现上等同于 READ COMMITTED:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 快照获取时机 |
|---|---|---|---|---|
READ UNCOMMITTED | 不可能 | 可能 | 可能 | 每条语句 |
READ COMMITTED | 不可能 | 可能 | 可能 | 每条语句 |
REPEATABLE READ | 不可能 | 不可能 | 可能(PG 中通过 SSI 避免) | 事务开始时 |
SERIALIZABLE | 不可能 | 不可能 | 不可能 | 事务开始时 |
READ COMMITTED(默认)
READ COMMITTED 是 PostgreSQL 的默认隔离级别。在该级别下,每条语句执行时都会获取一个新的快照:
-- 查看当前隔离级别
SHOW default_transaction_isolation;
-- 开启事务(默认 RC)
BEGIN;
SELECT pg_current_snapshot(); -- 快照 A
-- 其他事务插入数据并提交
SELECT pg_current_snapshot(); -- 快照 B(与 A 不同)
COMMIT;
REPEATABLE READ
在 REPEATABLE READ 隔离级别下,事务在开始时获取快照,并在整个事务期间保持不变:
-- 开启 RR 事务
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT pg_current_snapshot(); -- 快照 X
-- 其他事务插入数据并提交
SELECT pg_current_snapshot(); -- 快照 X(不变)
COMMIT;
隔离级别与 MVCC 的关系
- 不同事务间:通过
t_xmin/t_xmax+ CLOG + 快照判断可见性 - 同一事务内:通过
t_cid(cmin/cmax)判断同一事务内不同语句的可见性 - 隔离级别:决定快照的获取时机和生命周期
VACUUM 与死元组清理
由于 UPDATE 和 DELETE 并不物理移除数据,死元组会持续占用存储空间。PostgreSQL 通过 VACUUM 机制清理死元组:
-- 手动 VACUUM 指定表
VACUUM test;
-- 手动 VACUUM 并回收空间(会持有更强锁)
VACUUM FULL test;
-- 查看表膨胀情况
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
n_dead_tup
FROM pg_stat_user_tables
WHERE n_dead_tup > 0;
VACUUM 的核心工作:
- 扫描表中的死元组
- 将死元组占用的空间标记为可重用
- 更新
pg_stat_user_tables中的统计信息 - 更新
t_infomask中的 Hint Bits
建议:在生产环境中启用 autovacuum,避免死元组堆积导致表膨胀和性能下降。
API 速览
系统信息函数
pg_current_xact_id()
所属:PostgreSQL 系统函数(PG 13+,旧版为 txid_current())
签名:pg_current_xact_id() → xid8
说明:返回当前事务的事务 ID(64 位扩展事务 ID)
SELECT pg_current_xact_id();
-- 示例输出: 12345
pg_current_snapshot()
所属:PostgreSQL 系统函数(PG 13+)
签名:pg_current_snapshot() → pg_snapshot
说明:返回当前事务的快照,格式为 xmin:xmax:xip_list
SELECT pg_current_snapshot();
-- 示例输出: 12345:12348:12346,12347
pg_xact_status()
所属:PostgreSQL 系统函数(PG 13+,旧版为 txid_status())
签名:pg_xact_status(xid xid8) → text
说明:返回指定事务的状态:in progress、committed、aborted
SELECT pg_xact_status('12345');
-- 示例输出: committed
pg_snapshot_xmin(), pg_snapshot_xmax(), pg_snapshot_xip()
所属:PostgreSQL 系统函数(PG 13+)
签名:
pg_snapshot_xmin(snapshot pg_snapshot) → xid8pg_snapshot_xmax(snapshot pg_snapshot) → xid8pg_snapshot_xip(snapshot pg_snapshot) → setof xid8
说明:解析快照的各组成部分
SELECT pg_snapshot_xmin(pg_current_snapshot());
SELECT pg_snapshot_xmax(pg_current_snapshot());
SELECT pg_snapshot_xip(pg_current_snapshot());
pageinspect 扩展
所属:PostgreSQL 贡献扩展(需手动安装)
安装:
CREATE EXTENSION pageinspect;
核心函数:
| 函数 | 说明 |
|---|---|
get_raw_page(relname text, blkno int) | 获取指定块的原始页面数据 |
heap_page_items(page bytea) | 解析页面中的元组信息 |
tuple_data_split(reloid oid, t_data bytea, ...) | 拆分元组数据 |
-- 查看表 test 第 0 块的所有元组头部信息
SELECT
lp,
t_xmin,
t_xmax,
t_ctid,
t_infomask,
t_infomask2,
t_hoff
FROM heap_page_items(get_raw_page('test', 0));
隔离级别相关
SHOW default_transaction_isolation
说明:显示当前会话的默认事务隔离级别
SHOW default_transaction_isolation;
-- 输出: read committed
SET TRANSACTION ISOLATION LEVEL
说明:设置当前事务的隔离级别
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 执行查询...
COMMIT;
SHOW transaction_isolation
说明:显示当前事务的隔离级别(PG 14+)
SHOW transaction_isolation;
Demo 完整示例
环境准备
# 确保 PostgreSQL 已安装并运行
psql --version
# 创建测试数据库
createdb mvcc_demo
psql -d mvcc_demo
启用 pageinspect 扩展
CREATE EXTENSION IF NOT EXISTS pageinspect;
完整演示脚本
-- ============================================
-- 1. 创建测试表
-- ============================================
DROP TABLE IF EXISTS demo;
CREATE TABLE demo (
id INT PRIMARY KEY,
name TEXT,
age INT
);
-- ============================================
-- 2. 插入数据
-- ============================================
BEGIN;
SELECT pg_current_xact_id() AS txid_insert; -- 记录事务 ID
INSERT INTO demo VALUES (1, 'Alice', 25);
INSERT INTO demo VALUES (2, 'Bob', 30);
-- 查看元组头信息
SELECT
lp AS line_pointer,
t_xmin,
t_xmax,
t_ctid,
t_infomask::bit(16) AS infomask_bits
FROM heap_page_items(get_raw_page('demo', 0));
COMMIT;
-- ============================================
-- 3. 更新数据(产生死元组)
-- ============================================
BEGIN;
SELECT pg_current_xact_id() AS txid_update;
UPDATE demo SET age = 26 WHERE id = 1;
-- 再次查看元组头,观察新旧两个版本
SELECT
lp,
t_xmin,
t_xmax,
t_ctid,
t_infomask::bit(16) AS infomask_bits
FROM heap_page_items(get_raw_page('demo', 0));
COMMIT;
-- ============================================
-- 4. 删除数据(产生死元组)
-- ============================================
BEGIN;
SELECT pg_current_xact_id() AS txid_delete;
DELETE FROM demo WHERE id = 2;
SELECT
lp,
t_xmin,
t_xmax,
t_ctid,
t_infomask::bit(16) AS infomask_bits
FROM heap_page_items(get_raw_page('demo', 0));
COMMIT;
-- ============================================
-- 5. 查看表膨胀情况
-- ============================================
SELECT
schemaname,
tablename,
n_dead_tup,
n_live_tup,
pg_size_pretty(pg_total_relation_size('demo')) AS table_size
FROM pg_stat_user_tables
WHERE tablename = 'demo';
-- ============================================
-- 6. VACUUM 清理死元组
-- ============================================
VACUUM demo;
-- 再次查看死元组数量
SELECT
schemaname,
tablename,
n_dead_tup,
n_live_tup,
pg_size_pretty(pg_total_relation_size('demo')) AS table_size
FROM pg_stat_user_tables
WHERE tablename = 'demo';
-- ============================================
-- 7. 查看当前快照
-- ============================================
SELECT
pg_current_snapshot() AS snapshot,
pg_snapshot_xmin(pg_current_snapshot()) AS xmin,
pg_snapshot_xmax(pg_current_snapshot()) AS xmax;
运行说明
- 在
psql或任意 PostgreSQL 客户端中执行上述脚本 - 观察每个步骤中
heap_page_items的输出,理解t_xmin、t_xmax、t_ctid的变化 - 对比
VACUUM前后pg_stat_user_tables中死元组数量的变化
技术点总结
| 技术点 | Demo 中的体现 |
|---|---|
t_xmin / t_xmax | INSERT 设置 t_xmin,UPDATE/DELETE 设置 t_xmax |
t_ctid 版本链 | UPDATE 后旧元组的 t_ctid 指向新元组 |
| 死元组 | DELETE 后元组仍存在于页面中,t_xmax 被设置 |
VACUUM | 清理死元组,回收空间 |
| 快照 | pg_current_snapshot() 查看当前快照 |
| pageinspect | 直接查看页面级别的元组头信息 |
项目难点与解决方案
核心难点
可见性判断的复杂性:PostgreSQL 的可见性判断需要综合考虑 t_xmin / t_xmax 的状态、CLOG 中的事务状态、当前快照以及 Hint Bits 的缓存状态,逻辑分支较多,容易出错。
解决方案
- 理解
HeapTupleSatisfiesMVCC()的核心判断流程 - 善用
pageinspect扩展直接观察页面级数据 - 通过
pg_xact_status()验证事务状态 - 关注
t_infomask中的 Hint Bits,理解其缓存作用
广度
- 涉及存储引擎(页面/元组结构)
- 事务管理(CLOG、事务 ID)
- 并发控制(快照、隔离级别)
- 性能优化(VACUUM、Hint Bits)
深度
- 深入到页面级别的元组头结构
- 理解 CLOG 的存储格式和状态编码
- 掌握快照数据结构的完整语义
复杂度
- MVCC 是 PostgreSQL 最核心的机制之一
- 可见性判断涉及多个系统组件的协同
- 隔离级别的差异直接影响快照获取策略
- CLOG 损坏等异常场景需要深度理解才能恢复
官方文档
官方文档
参考链接
总结
PostgreSQL 的 MVCC 机制通过在每个元组头部存储 t_xmin(创建事务 ID)和 t_xmax(删除/更新事务 ID),结合 CLOG(提交日志)中记录的事务最终状态以及事务快照(Snapshot),实现了高效的多版本并发控制。
UPDATE 和 DELETE 操作产生死元组,需通过 VACUUM 机制定期清理以避免表膨胀。事务隔离级别决定了快照的获取时机——READ COMMITTED 每语句获取新快照,REPEATABLE READ 和 SERIALIZABLE 在事务开始时获取快照。理解这一机制是掌握 PostgreSQL 性能调优和问题诊断的基础。
331



被折叠的 条评论
为什么被折叠?



