PostgreSQL 是世界上最先进的开源关系型数据库之一,其高并发性能的核心秘密在于 MVCC(Multi-Version Concurrency Control,多版本并发控制)。这种设计让 PostgreSQL 在高并发场景下实现了”读不阻塞写,写不阻塞读”的特性。本文将深入探讨 PostgreSQL 选择 MVCC 的设计哲学。
一、并发控制的挑战
在多用户并发访问数据库时,如果不加以控制,会产生一系列数据一致性问题。
1.1 经典的并发问题
数据库并发操作会带来三种经典问题:
三种问题的对比:
| 问题类型 | 描述 | 影响范围 |
|---|---|---|
| 脏读 | 读到其他事务未提交的数据 | 单行 |
| 不可重复读 | 同一事务内两次读取同一数据结果不同 | 单行 |
| 幻读 | 同一事务内两次查询返回的行数不同 | 多行范围 |
1.2 ANSI SQL 隔离级别
为了解决这些问题,ANSI SQL 定义了四种事务隔离级别:
隔离级别与问题的关系:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交(RU) | |||
| 读已提交(RC) | |||
| 可重复读(RR) | |||
| 可串行化(SER) |
注意:PostgreSQL 的可重复读隔离级别实际上也能防止幻读,这是通过 MVCC 的快照隔离机制实现的。
二、锁机制的局限性
在 MVCC 出现之前,数据库主要依赖锁机制来实现并发控制。
2.1 传统锁方案
锁模式的兼容性矩阵:
| S 锁(读) | X 锁(写) | |
|---|---|---|
| S 锁 | 兼容 | 冲突 |
| X 锁 | 冲突 | 冲突 |
2.2 锁机制的问题
传统锁机制在高并发场景下存在严重问题:
-- 场景:大量读操作阻塞写操作-- 事务 A:长时间读取BEGIN;SELECT * FROM orders WHERE status = 'pending'; -- 获取 S 锁-- 此时事务 B 想要更新数据-- 事务 B 被阻塞,直到事务 A 提交
-- 事务 B:等待更新UPDATE orders SET status = 'processing' WHERE id = 1; -- 等待 X 锁锁机制的主要问题:
| 问题 | 描述 | 影响 |
|---|---|---|
| 读写冲突 | 读操作阻塞写操作 | 吞吐量下降 |
| 写读冲突 | 写操作阻塞读操作 | 查询延迟增加 |
| 锁粒度 | 行锁开销大,表锁并发度低 | 性能权衡 |
| 死锁风险 | 多个事务互相等待 | 需要超时机制 |
| 锁管理开销 | 维护锁表需要内存和 CPU | 资源消耗 |
2.3 为什么需要更好的方案
传统锁机制无法同时满足”高并发”和”数据一致性”的需求,这正是 MVCC 要解决的核心问题。
三、MVCC 核心思想
3.1 什么是 MVCC?
MVCC(Multi-Version Concurrency Control)的核心思想是:为每个数据修改创建新版本,而不是直接覆盖旧数据。
3.2 MVCC 的核心优势
MVCC 的核心优势:
| 特性 | 描述 | 收益 |
|---|---|---|
| 读写不冲突 | 读操作读取历史版本,写操作创建新版本 | 高并发性能 |
| 无锁读取 | 读操作不需要获取任何锁 | 读性能优异 |
| 快照隔离 | 每个事务看到一致的数据快照 | 可重复读 |
| 非阻塞回滚 | 回滚只需标记版本无效 | 回滚快速 |
3.3 MVCC 的代价
MVCC 并非完美无缺,它也有自身的代价:
四、PostgreSQL 的 xmin/xmax 实现
PostgreSQL 的 MVCC 实现非常精妙,主要通过隐藏的系统列来管理版本。
4.1 隐藏的系统列
每张表都有四个隐藏的系统列:
-- 查看隐藏列SELECT xmin, xmax, ctid, cmin, cmask, *FROM usersLIMIT 5;| 列名 | 含义 | 说明 |
|---|---|---|
xmin | 插入事务 ID | 创建该行版本的事务 |
xmax | 删除事务 ID | 标记该行版本删除/更新的事务 |
ctid | 物理位置 (页号, 行号) | 行的物理地址 |
cmin | 命令序号(插入) | 同一事务内的命令顺序 |
cmax | 命令序号(删除) | 同一事务内的命令顺序 |
4.2 版本链示意图
4.3 版本可见性规则
PostgreSQL 使用复杂的可见性规则判断哪个版本对当前事务可见:
可见性判断的伪代码:
// 简化的可见性判断逻辑bool IsVisible(Row row, Snapshot snap) { // 1. 插入事务必须在快照之前提交 if (!TransactionIdDidCommit(row.xmin) || row.xmin > snap.xmax) { return false; }
// 2. 如果 xmax = 0 或未提交,则可见 if (row.xmax == 0 || !TransactionIdDidCommit(row.xmax)) { return true; }
// 3. 如果删除事务在快照之后,则可见 if (row.xmax > snap.xmax) { return true; }
return false;}4.4 实际案例演示
-- 事务 100: 插入数据BEGIN;INSERT INTO users (id, name) VALUES (1, 'Alice');-- xmin=100, xmax=0COMMIT;
-- 事务 101: 查询数据(看到 Alice)BEGIN;SELECT * FROM users WHERE id = 1;-- 结果: Alice(xmin=100, xmax=0 可见)COMMIT;
-- 事务 102: 更新数据BEGIN;UPDATE users SET name = 'Bob' WHERE id = 1;-- 旧行: xmin=100, xmax=102(被标记删除)-- 新行: xmin=102, xmax=0COMMIT;
-- 事务 103: 查询数据(看到 Bob)BEGIN;SELECT * FROM users WHERE id = 1;-- 结果: Bob(新行可见,旧行已被 xmax=102 标记)COMMIT;五、快照隔离级别
5.1 PostgreSQL 的快照机制
PostgreSQL 在事务开始时创建一个快照,记录当前活跃的事务 ID:
快照数据结构:
// PostgreSQL 快照结构(简化)typedef struct SnapshotData { TransactionId xmin; // 最小活跃事务 ID TransactionId xmax; // 下一个要分配的事务 ID
// 活跃事务 ID 数组 TransactionId *xip; uint32 xcnt;
// 时间戳 TimestampTz snapshottime;} SnapshotData;5.2 隔离级别实现
PostgreSQL 实现了三种隔离级别,底层都基于 MVCC:
隔离级别对比:
| 隔离级别 | 快照时机 | 特点 | 性能 |
|---|---|---|---|
| 读已提交(RC) | 每条 SQL | 能看到最新已提交数据 | |
| 可重复读(RR) | 事务开始 | 整个事务看到一致的数据视图 | |
| 可串行化(SER) | 事务开始 + SSI | 串行化冲突检测 |
5.3 快照隔离示例
-- 会话 A:使用可重复读BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;-- 快照创建,假设此时 xmax = 100
SELECT balance FROM accounts WHERE id = 1;-- 结果: 1000
-- 会话 B:更新数据并提交BEGIN;UPDATE accounts SET balance = 2000 WHERE id = 1;COMMIT;
-- 会话 A:再次查询SELECT balance FROM accounts WHERE id = 1;-- 结果: 1000(仍然是快照中的值,不是 2000)
COMMIT;六、Vacuum 清理机制
MVCC 的一个关键问题是如何清理不再需要的旧版本数据。
6.1 为什么需要 Vacuum?
死元组的产生场景:
| 操作 | 死元组产生原因 |
|---|---|
| DELETE | 被删除的行变成死元组 |
| UPDATE | 旧行版本变成死元组 |
| ROLLBACK | 未提交事务产生的所有行变成死元组 |
6.2 Vacuum 的工作原理
Vacuum 的主要任务:
-- 手动执行 VacuumVACUUM users; -- 清理死元组,标记空间可重用VACUUM FULL users; -- 重建表,回收空间(会锁表)VACUUM ANALYZE users; -- 清理 + 更新统计信息
-- 查看表的死元组数量SELECT relname, n_live_tup, n_dead_tupFROM pg_stat_user_tablesWHERE relname = 'users';6.3 Autovacuum 自动清理
PostgreSQL 提供了自动 Vacuum 机制:
Autovacuum 配置参数:
# postgresql.conf 配置autovacuum = on # 启用自动清理autovacuum_vacuum_threshold = 50 # 基础阈值autovacuum_vacuum_scale_factor = 0.2 # 死元组比例阈值
# 触发条件计算公式:# 触发阈值 = autovacuum_vacuum_threshold +# autovacuum_vacuum_scale_factor * 表行数
# 例如:100 万行的表# 触发阈值 = 50 + 0.2 * 1000000 = 200050 个死元组6.4 可见性映射与 Freeze
事务 ID 回卷问题:
-- 查看事务 ID 状态SELECT relname, age(relfrozenxid) as xid_age, pg_size_pretty(pg_total_relation_size(oid)) as sizeFROM pg_classWHERE relkind = 'r'ORDER BY age(relfrozenxid) DESCLIMIT 10;
-- 年龄接近 20 亿时,需要手动冻结VACUUM FREEZE tablename;七、与 MySQL MVCC 对比
MySQL(InnoDB)也实现了 MVCC,但实现方式与 PostgreSQL 有显著差异。
7.1 实现方式对比
核心差异对比表:
| 特性 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 新版本存储位置 | 表空间(新元组) | Undo Log(旧版本) |
| 版本链方向 | 新→旧(通过 xmax) | 旧→新(通过回滚指针) |
| 读操作 | 直接读可见版本 | 构造 ReadView + 回滚 |
| 更新操作 | INSERT 新元组 | 原地更新 + Undo Log |
| 清理机制 | Vacuum(需要手动/自动) | Purge(自动后台线程) |
| 空间回收 | 需要 VACUUM FULL | 自动回收 Undo 空间 |
| 二级索引 | 不存储事务信息,需回表 | 不存储事务信息,需回表 |
7.2 更新操作的实现差异
7.3 读操作的实现差异
PostgreSQL 读操作:
-- PostgreSQL: 直接扫描堆表,找到可见版本SELECT * FROM users WHERE id = 1;
-- 扫描过程:-- 1. 找到 id=1 的所有版本(可能有多个)-- 2. 根据快照判断哪个版本可见-- 3. 返回可见版本MySQL 读操作:
-- MySQL: 通过 Undo Log 回滚到可见版本SELECT * FROM users WHERE id = 1;
-- 读取过程:-- 1. 读取最新版本-- 2. 检查 TRX_ID 是否在 ReadView 中可见-- 3. 若不可见,通过回滚指针找 Undo Log-- 4. 回滚到可见版本7.4 性能特点对比
| 场景 | PostgreSQL 表现 | MySQL 表现 |
|---|---|---|
| 大量更新 | 表膨胀,需要 Vacuum | Undo Log 增长,Purge 回收 |
| 长事务 | 阻塞 Vacuum,表膨胀风险高 | Undo Log 无法清理,空间膨胀 |
| 只读查询 | 优秀,无需回滚 | 较好,可能需要回滚 |
| 二级索引查询 | 需要回表检查可见性 | 需要回表检查可见性 |
| 空间管理 | 需要定期维护 | 相对自动化 |
八、优缺点权衡分析
8.1 MVCC 的优势
详细优势分析:
| 优势 | 说明 |
|---|---|
| 读性能优异 | 读操作不需要获取锁,不会被写操作阻塞 |
| 一致性读 | 每个事务看到一致的数据快照,无需额外锁机制 |
| 非阻塞回滚 | 回滚只需标记 xmax,无需恢复操作 |
| 时间旅行 | 可以查询历史版本(需要保留旧版本) |
8.2 MVCC 的劣势
8.3 最佳实践建议
PostgreSQL MVCC 优化建议:
-- 1. 配置合理的 Autovacuum 参数ALTER SYSTEM SET autovacuum_vacuum_scale_factor = 0.1;ALTER SYSTEM SET autovacuum_vacuum_threshold = 100;
-- 2. 对高更新表单独配置ALTER TABLE hot_table SET ( autovacuum_vacuum_scale_factor = 0.05, autovacuum_vacuum_cost_delay = 2);
-- 3. 监控表膨胀SELECT schemaname, relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuumFROM pg_stat_user_tablesWHERE n_dead_tup > 10000ORDER BY n_dead_tup DESC;
-- 4. 避免长事务SELECT pid, now() - pg_stat_activity.query_start AS duration, query, stateFROM pg_stat_activityWHERE (now() - pg_stat_activity.query_start) > interval '5 minutes';应用层建议:
| 建议 | 原因 |
|---|---|
| 避免长事务 | 长事务会阻塞 Vacuum,导致表膨胀 |
| 批量操作分批提交 | 减少单次事务产生的大量死元组 |
| 定期监控表膨胀 | 及时发现并处理膨胀问题 |
| 合理设置填充因子 | 预留空间给 UPDATE,减少页分裂 |
| 使用 HOT 更新 | 符合条件时,Heap-Only Tuple 更新更高效 |
8.4 适用场景分析
场景适用性评估:
| 场景类型 | 适用度 | 说明 |
|---|---|---|
| OLTP 读密集型 | 读不阻塞写,性能优异 | |
| OLTP 写密集型 | 需要合理配置 Vacuum | |
| OLAP 分析型 | 快照隔离适合分析查询 | |
| 长事务场景 | 需要特别关注 Vacuum 和膨胀问题 |
九、总结
PostgreSQL 选择 MVCC 作为并发控制机制,是基于高并发场景下读写性能的综合考量。
设计决策总结:
| 因素 | 分析 |
|---|---|
| 核心目标 | 实现高并发下的读写不冲突 |
| 技术选择 | 多版本 + 快照隔离 + Vacuum 清理 |
| 性能收益 | 读性能优异,写不被读阻塞 |
| 运维成本 | 需要 Vacuum 维护,关注表膨胀和事务 ID 回卷 |
| 设计哲学 | 以空间换时间,用额外存储换取并发性能 |
PostgreSQL 的 MVCC 设计体现了经典的工程权衡:没有完美的解决方案,只有最适合特定场景的选择。理解 MVCC 的原理和权衡,才能更好地使用和优化 PostgreSQL 数据库。
参考引用
- PostgreSQL Documentation - Concurrency Control — PostgreSQL 官方文档
- PostgreSQL Internals — PostgreSQL 内部实现
- The Internals of PostgreSQL — Hironobu Suzuki 著
- PostgreSQL 14 Internals — Egor Rogov 著
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






