• Welcome to HiddenMerit - Clyde's Blog
  • Welcome to try the game Torn: Referral Link
  • If you are my relative, friend, or netizen, quickly press Ctrl+D to bookmark Clyde's Blog
  • This site has a like feature. If you read any article, please hit the like button so I know someone has visited
  • Email: hiddenmeritATgmail.com (replace AT with @)

PostgreSQL 事务与并发系列 · 第二期

DBA Clyde Jin 3周前 (04-24) 7次浏览 0个评论

PostgreSQL 事务与并发系列 · 第二期

深入 MVCC:可见性判断与事务快照揭秘

第一期我们建立了 ACID 和 MVCC 的基本概念。本期将掀开 MVCC 的引擎盖,剖析元组头部的 xmin/xmax、事务快照的内存结构,并通过实战案例看清“读已提交”与“可重复读”的本质差异。

一、回顾:MVCC 的核心思想

PostgreSQL 中,每一行数据(称为元组 tuple)在被修改时不会原地更新,而是插入一个新的行版本,旧的版本仍然留在数据页中。这些版本通过元组头部的两个事务 ID 字段来标记生命周期:

  • xmin – 插入该行版本的事务 ID
  • xmax – 删除或更新该行版本的事务 ID(0 表示未删除)

当事务开始时,系统会为其生成一个快照(Snapshot),里面记录了当前“哪些事务正在运行、哪些事务已经提交”等信息。随后,对于每一个可见性判断,PostgreSQL 会根据快照中的规则决定是否显示该行版本。


二、元组头中的秘密:xmin 与 xmax

查看一个普通表的元组头部信息,可以使用 pageinspect 扩展(需要超级用户权限):

CREATE EXTENSION pageinspect;

-- 创建一个测试表
CREATE TABLE test_mvcc (id int PRIMARY KEY, name text);
INSERT INTO test_mvcc VALUES (1, 'Alice'), (2, 'Bob');

-- 查看数据页的元组信息
SELECT lp, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('test_mvcc', 0));

输出示例:

lp t_xmin t_xmax t_ctid
1 730 0 (0,1)
2 730 0 (0,2)

这里 t_xmin=730 表示插入这两行的事务 ID 是 730,t_xmax=0 表示当前版本未被删除。

更新时的版本变化

-- 开启一个新事务(假设事务 ID 为 735)
BEGIN;
UPDATE test_mvcc SET name = 'Alice2' WHERE id = 1;

此时数据页内部会变成:

lp t_xmin t_xmax t_ctid
1 730 735 (0,3)
2 730 0 (0,2)
3 735 0 (0,3)
  • 旧版本(lp=1)的 t_xmax 被设置为 735,表示它已被事务 735 删除。
  • 新版本(lp=3)的 t_xmin = 735,表示它由事务 735 创建。
  • t_ctid 指向新版本的位置 (0,3),形成一条版本链。

当事务 735 提交后,旧版本对其他事务可能不可见,具体取决于隔离级别和快照。


三、事务快照(Snapshot)的内存布局

PostgreSQL 中,快照是一个结构体(SnapshotData),其核心成员包括:

  • xmin – 当前所有未提交事务中最小的 ID
  • xmax – 下一个将要被分配的事务 ID(即大于等于此值的所有事务,肯定未开始)
  • xip – 一个数组,存储所有活跃(未提交)的事务 ID
  • snapshot_type – 区分 SNAPSHOT_MVCC(普通快照)、SNAPSHOT_SELF(包含当前事务)等

快照是调用 GetSnapshotData() 函数生成的。简单流程:

  1. 记录当前系统活跃事务列表(从 ProcArray 中读取)
  2. 计算出最小的活跃事务 ID 作为 xmin
  3. nextXid(下一个未分配的事务 ID)作为 xmax
  4. 将活跃事务 ID 列表复制到 xip 数组

一个典型的快照内容(人为示例):

xmin = 100
xmax = 105
xip  = [101, 103]   (事务 102 已经提交,104 尚未开始)

四、可见性判断的核心规则

给定一个行版本,其 xmin 和 xmax,以及当前事务的快照,判断是否可见的简化逻辑如下:

对 xmin 的判断(该版本是否对当前事务可见?)

  1. 如果 xmin 是 已中止(aborted) 的事务 → 不可见(直接忽略)
  2. 如果 xmin 是 当前事务自己 且在事务未提交时 → 可见(自己的修改对自己可见)
  3. 如果 xmin 是 已提交 的事务:
    • 如果 xmin < 快照的 xmin → 可见
    • 如果 xmin >= 快照的 xmax → 不可见(该事务在快照生成之后才开始)
    • 如果 xmin 在快照的 xip 列表中(即活跃事务) → 不可见
    • 否则 → 可见

对 xmax 的判断(该版本是否已被删除/更新?)

  • 如果 xmax = 0 → 未被删除,通过 xmin 检查后即可见
  • 如果 xmax ≠ 0:
    • 如果 xmax 是已中止的事务 → 视为未删除
    • 如果 xmax 是当前事务自己 → 视为删除(如果当前事务删除了它,就不可见)
    • 如果 xmax 是已提交的事务且 xmax 在当前快照中可见 → 视为已删除 → 该版本不可见

核心结论:一个行版本可见的充要条件是 (xmin 有效且提交且不活跃) 并且 (xmax = 0 或 xmax 无效/未提交)

注意:上述规则是针对 普通 MVCC 快照SNAPSHOT_MVCC)的,也是 READ COMMITTEDREPEATABLE READ 隔离级别的基础。

五、读已提交 vs 可重复读:快照生成的差异

虽然两者都使用 MVCC 快照判断可见性,但快照的获取时机完全不同:

隔离级别 快照生成策略 效果
读已提交 事务中的每条 SQL 语句开始时,都会重新生成一个全新的快照 每条语句都能看到在它开始之前已经提交的所有数据
可重复读 事务中的第一条 SQL 语句开始时生成一个快照,整个事务期间复用该快照 事务中所有语句看到的是同一时刻的一致数据快照

实战案例:不可重复读现象

首先,创建一个表并插入初始数据:

CREATE TABLE accounts (id INT PRIMARY KEY, balance INT);
INSERT INTO accounts VALUES (1, 100);

场景演示(读已提交)

时间 事务 A(读已提交) 事务 B(读已提交)
T1 BEGIN; BEGIN;
T2 SELECT balance FROM accounts WHERE id=1; → 100
T3 UPDATE accounts SET balance=200 WHERE id=1; COMMIT;
T4 SELECT balance FROM accounts WHERE id=1; → 200
T5 COMMIT;

事务 A 在同一次事务内的两次查询结果不同(100 → 200),这就是“不可重复读”。

场景演示(可重复读)

时间 事务 A(可重复读) 事务 B(可重复读)
T1 BEGIN; BEGIN;
T2 SELECT balance FROM accounts WHERE id=1; → 100
T3 UPDATE accounts SET balance=200 WHERE id=1; COMMIT;
T4 SELECT balance FROM accounts WHERE id=1; → 仍为 100
T5 COMMIT;

事务 A 在整个事务期间看到的是同一个快照(T2 时刻的快照),因此第二次查询仍然返回 100,实现了“可重复读”。


六、实践:使用 pg_snapshot_xip 观察快照

PostgreSQL 提供 pg_current_snapshot() 函数,返回当前会话的快照文本表示,格式为 xmin:xmax:xip_list

-- 开启一个可重复读事务
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT pg_current_snapshot();
-- 输出示例: 720:724:720,722

-- 再开另一个会话,执行一些更新并提交
-- 回到原会话,再次获取快照
SELECT pg_current_snapshot();
-- 输出相同:720:724:720,722

-- 在读已提交事务中,每次查询前快照会变化
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT pg_current_snapshot();
-- 第一次
SELECT pg_current_snapshot();
-- 第二次(可能在另一个事务提交后)会看到不同内容
COMMIT;

通过这种方式可以直观感受快照的生命周期差异。


七、长事务与快照膨胀的风险

当一个事务以 可重复读可串行化 隔离级别运行很长时间时,它会一直持有一个旧的快照。这会导致:

  1. 表膨胀:所有在快照生成之后被更新/删除的旧版本,都不能被 VACUUM 清理,因为旧快照仍然需要看见它们。
  2. 事务 ID 回卷风险:长时间持有的快照可能会阻止 xid 冻结,导致数据库强制关闭。

监控长事务

SELECT pid, age(backend_xid) AS xid_age, 
       backend_xmin, state, query_start
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xid) DESC;

若发现长事务,应及时评估是否可以终止(pg_terminate_backend(pid))或让其结束。


八、总结与下期预告

本期我们完成了对 MVCC 机制的深度探索:

  • 元组头部 xmin / xmax 如何刻画行版本的生命周期
  • 事务快照的内存结构与生成原理
  • 可见性判断的核心逻辑(结合快照判断)
  • READ COMMITTEDREPEATABLE READ 的本质差异(快照获取时机不同)
  • 长事务带来的膨胀与事务 ID 回卷风险

这些知识是分析 PostgreSQL 并发问题的“透视镜”。

第三期预告:锁机制与死锁处理实战

我们将分析 PostgreSQL 的表级锁(ACCESS SHARE、ROW EXCLUSIVE、ACCESS EXCLUSIVE 等)、行级锁(FOR UPDATE、FOR NO KEY UPDATE…),以及死锁的检测与预防。同时会给出常用的排查脚本,帮助你在线解决“卡住的事务”问题。

敬请期待第三期!

如果你在使用 PSQL 过程中遇到任何与 MVCC 相关的困惑(比如明明更新了数据却查不到、表膨胀严重),欢迎在评论区交流。下期见!

绩隐金 , 版权所有丨如未注明 , 均为原创丨本网站采用BY-NC-SA协议进行授权
转载请注明原文链接:PostgreSQL 事务与并发系列 · 第二期
喜欢 (0)
发表我的评论
取消评论
表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址