pgsql-22 MVCC多版本并发控制
1. 概述
MVCC(Multi-Version Concurrency Control,多版本并发控制)是现代数据库并发的基石,也是 PostgreSQL 面试出现频率最高的主题之一。它要解决的核心矛盾是:
如何让"读"和"写"互不阻塞?
传统的"读写加锁"方案下,一个事务在读某行时,其他事务不能写;写时不能读。并发一高就大量阻塞。MVCC 的思路是:每次修改都生成数据的一个新版本,读操作读取某个一致性时间点的旧版本。于是——
- 读不阻塞写,写不阻塞读(读到的是历史版本)。
- 每个事务看到的是一个一致的"数据库快照"。
PostgreSQL 和 MySQL(InnoDB) 都用 MVCC,但实现方式截然不同,这个差异是面试最爱考的点,本文会重点对比。
2. PostgreSQL 的 MVCC 实现
2.1 核心思想:多版本元组直接存在表里
PostgreSQL 最关键的设计:更新(UPDATE)不是原地修改,而是"标记旧行失效 + 插入新行"。同一行的多个版本(称为元组 tuple)直接存放在**堆表(heap)**中。
每个元组头部有几个隐藏系统字段:
| 系统列 | 含义 |
|---|---|
xmin |
插入该版本的事务 ID(XID) |
xmax |
删除/更新该版本的事务 ID(0 表示未被删除) |
ctid |
该元组的物理位置(块号, 偏移) |
cmin/cmax |
同一事务内的命令序号 |
-- 可以直接查看隐藏系统列
SELECT xmin, xmax, ctid, * FROM accounts;
2.2 增删改如何操作版本
- INSERT:写入新元组,
xmin=当前事务ID,xmax=0。 - DELETE:不真正删除,只把该元组的
xmax=当前事务ID(标记"我删了它")。 - UPDATE = DELETE + INSERT:把旧版本
xmax=当前事务ID,同时插入一个新版本元组xmin=当前事务ID。
所以 PostgreSQL 的 UPDATE 会产生一个新元组,旧元组变成"死元组(dead tuple)"。这些死元组不会自动消失,需要 VACUUM与表膨胀 来回收——这正是 PG 特有的"表膨胀"问题的根源。
2.3 事务快照与可见性判断
每个事务/语句执行时会拿到一个快照(snapshot),快照记录了"此刻哪些事务是活跃的(未提交)"。核心信息:
xmin(快照):当前最小活跃事务 ID,小于它的事务都已结束。xmax(快照):下一个将分配的事务 ID,大于等于它的都还没开始。xip_list:正在进行中的活跃事务列表。
可见性规则(简化版):一个元组对当前快照可见,当且仅当:
- 它的
xmin对应的事务已提交,且在快照看来是"过去"的;并且 - 它的
xmax为 0,或xmax对应的事务未提交/已回滚(即它还没被有效删除)。
简言之:这个版本是由「已经提交且我能看见」的事务创建的,
并且还没有被「已经提交且我能看见」的事务删除。
配合 t_infomask 里的提交状态标志位(Hint Bits)和 CLOG(事务提交日志)判断某个 XID 到底提交了没有。
2.4 HOT 更新(优化)
频繁 UPDATE 会产生大量死元组和索引更新。PostgreSQL 用 HOT(Heap-Only Tuple)优化:如果更新不涉及索引列,且新版本能放在同一个数据页内,就不需要更新索引——旧元组通过 ctid 链指向新版本,索引仍指向旧元组即可。
这大幅减少了索引膨胀。设计表时,把频繁更新的列排除在索引外能更好地触发 HOT。
3. 事务隔离级别
MVCC 是实现隔离级别的手段。PostgreSQL 支持标准的 4 个隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | PG 实际表现 |
|---|---|---|---|---|
| Read Uncommitted | ❌禁止 | 可能 | 可能 | PG 中等同于 Read Committed(无脏读) |
| Read Committed(默认) | ❌ | 可能 | 可能 | 每条语句取新快照 |
| Repeatable Read | ❌ | ❌ | ❌ | 事务开始时取一次快照;PG 下已无幻读 |
| Serializable | ❌ | ❌ | ❌ | SSI 可串行化快照隔离 |
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- ... 整个事务用同一个快照 ...
COMMIT;
两个易考点:
- PG 默认是 Read Committed:每条 SQL 语句开始时都取一个新快照,所以同一事务内两次查询可能看到别的事务已提交的新数据(不可重复读)。
- PG 的 Repeatable Read 已经消除了幻读(快照隔离天然如此),这比 SQL 标准要求的更强;而 MySQL 靠间隙锁(Next-Key Lock)在 RR 下防幻读,机制完全不同。
3.1 Serializable(SSI)
PostgreSQL 的 Serializable 用 SSI(Serializable Snapshot Isolation,可串行化快照隔离),在快照隔离基础上检测"读写依赖环",发现可能破坏串行性时让其中一个事务失败回滚(报 could not serialize access)。它是乐观的——不加读锁,靠冲突检测。
4. PostgreSQL vs MySQL(InnoDB) 的 MVCC(面试核心)
这是本文最重要的对比,务必掌握:
| 维度 | PostgreSQL | MySQL / InnoDB |
|---|---|---|
| 旧版本存哪 | 直接存在堆表里(新旧元组同表) | 存在 undo log(回滚段) |
| UPDATE 方式 | 旧元组标记失效 + 插入新元组 | 原地更新,旧值写入 undo log |
| 版本组织 | 表内多元组,靠 xmin/xmax + 可见性判断 | 行上有 DB_TRX_ID / DB_ROLL_PTR 指向 undo 版本链 |
| 回滚成本 | 极低(旧元组本就在,直接可见) | 需要用 undo 反向恢复 |
| 旧版本清理 | 需要 VACUUM 异步清理死元组 | Purge 线程清理不再需要的 undo |
| 典型副作用 | 表膨胀(死元组堆积)、需要 VACUUM | undo 膨胀(长事务导致 undo 无法回收) |
| 回滚段争用 | 无 | 高并发下 undo 可能成瓶颈 |
| 可见性判断 | 元组头 xmin/xmax + 快照 + CLOG | 行 trx_id 与 Read View 比较 + undo 回溯 |
一句话总结(面试金句):
PostgreSQL 把"旧版本"直接留在表里,代价是产生死元组、需要 VACUUM 回收,会有表膨胀;MySQL(InnoDB) 把"旧版本"放进 undo log 做版本链、原地更新,代价是回滚和一致性读要回溯 undo,长事务会撑大 undo。两者都用 MVCC 实现"读写不阻塞",只是"旧版本放哪、谁来清理"的取舍不同。
衍生问题:为什么 PG 的回滚很快,而 MySQL 回滚可能较慢? PG 回滚只需把当前事务创建的新元组标记为无效(它们本就不可见),几乎零成本;MySQL 回滚需要读 undo log 把数据一条条恢复原值。反过来,PG 为此付出的代价是需要 VACUUM。
5. 长事务的危害(PG 特别严重)
在 PG 中,只要有一个长时间不提交的事务,它的快照就"钉住"了一个旧的事务水平线(xmin),导致:
- VACUUM 无法清理那些"可能还被老事务看到"的死元组 → 表持续膨胀。
- 事务 ID 消耗加剧,逼近 **XID 回卷(wraparound)**风险。
-- 排查长事务/空闲事务(运维必备)
SELECT pid, state, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state <> 'idle' AND xact_start IS NOT NULL
ORDER BY duration DESC;
结论:PG 中要极力避免长事务和"idle in transaction"(开了事务却迟迟不提交)。
6. 面试高频问答
Q1:什么是 MVCC?解决了什么问题? 多版本并发控制,通过为数据保留多个版本,让读操作读取一致性快照的历史版本,从而实现"读不阻塞写、写不阻塞读",大幅提升并发。
Q2:PostgreSQL 的 MVCC 和 MySQL 的有什么区别?(最高频) PG 把旧版本直接存在堆表里,UPDATE 是"旧元组失效+插入新元组",靠 xmin/xmax 和快照判断可见性,需要 VACUUM 清理死元组、会表膨胀;MySQL 把旧版本存在 undo log,原地更新形成版本链,靠 trx_id 和 Read View 判断可见性,由 purge 线程清理 undo。(见第4节表)
Q3:xmin 和 xmax 是什么? 元组头的系统字段:xmin 是创建该版本的事务 ID,xmax 是删除/更新该版本的事务 ID(0 表示未删除)。可见性判断就是比较它们与当前快照。
Q4:PG 的 UPDATE 为什么会导致表膨胀? 因为 UPDATE 不是原地改,而是把旧元组标记失效、插入新元组,旧元组变成死元组不会立即消失,累积就是表膨胀,需要 VACUUM 回收。
Q5:PG 默认隔离级别是什么?RR 下有幻读吗? 默认 Read Committed(每条语句取新快照)。PG 的 Repeatable Read 基于快照隔离,已经消除了幻读,无需像 MySQL 那样用间隙锁。
Q6:为什么长事务在 PG 中危害大? 长事务的旧快照会阻止 VACUUM 回收死元组,导致表持续膨胀,并加剧事务 ID 回卷风险。
7. 下一步
学习 锁机制详解,了解 MVCC 之外 PG 如何用锁保证一致性。
xingliuhua