事务 A 正在更新一行数据,事务 B 同时读取同一行——如果用锁,B 必须等 A 提交后才能读。但数据库的读请求远多于写请求,读等写意味着大量查询被阻塞。MVCC(Multi-Version Concurrency Control)换了一种思路:每个事务看到数据的一个一致性快照,读不阻塞写,写也不阻塞读。
但多版本带来了新问题:旧版本什么时候可以清理?InnoDB 用 Undo Log 回滚段追踪版本链,PostgreSQL 用 Append-Only 追加新版本再 VACUUM 清理旧版本——两种策略各有代价。
一、MVCC 核心思想
1.1 为什么需要 MVCC
| 维度 | 基于锁 | MVCC |
|---|---|---|
| 读-写冲突 | 互阻塞 | 不阻塞 |
| 写-读冲突 | 互阻塞 | 不阻塞 |
| 一致性 | 强 | 快照一致 |
| 空间开销 | 无 | 多版本存储 |
| 清理开销 | 无 | 需要回收旧版本 |
1.2 MVCC 的两种实现
| 实现 | 代表 | 修改方式 | 旧版本存储 | 清理方式 |
|---|---|---|---|---|
| Undo Log MVCC | InnoDB、Oracle | 原地更新 | Undo Log | Purge 线程 |
| Append-Only MVCC | PostgreSQL | 追加新版本 | 表内版本链 | VACUUM |
二、InnoDB Undo Log MVCC
2.1 隐藏列
InnoDB 为每行添加三个隐藏列:
// InnoDB 行的隐藏列struct RowHiddenColumns { trx_id_t DB_TRX_ID; // 6 字节:最后修改该行的事务 ID roll_ptr_t DB_ROLL_PTR; // 7 字节:指向 Undo Log 的回滚指针 trx_id_t DB_ROW_ID; // 6 字节:行 ID(无主键时自动生成)};
// 版本链:// 当前行 → Undo Log 记录 1 → Undo Log 记录 2 → ...// 通过 DB_ROLL_PTR 串联2.2 版本链
2.3 可见性判断
# InnoDB MVCC 可见性判断def is_visible(row, read_view): """判断行版本对当前事务是否可见""" trx_id = row.DB_TRX_ID
# Read View 包含: # m_ids: 创建 Read View 时的活跃事务列表 # m_up_limit_id: 活跃事务的最小 ID # m_low_limit_id: 下一个分配的事务 ID
# 规则 1:事务 ID < m_up_limit_id # 事务在 Read View 创建前已提交,可见 if trx_id < read_view.m_up_limit_id: return True
# 规则 2:事务 ID >= m_low_limit_id # 事务在 Read View 创建后才开始,不可见 if trx_id >= read_view.m_low_limit_id: return False
# 规则 3:事务 ID 在 m_ids 中 # 事务在 Read View 创建时仍活跃,不可见 if trx_id in read_view.m_ids: return False
# 规则 4:事务 ID 不在 m_ids 中 # 事务在 Read View 创建时已提交,可见 return True
# 读取流程def mvcc_read(row, read_view): """MVCC 读取""" # 沿版本链查找可见版本 current = row while current is not None: if is_visible(current, read_view): return current # 找到可见版本 # 不可见,沿 Undo Log 链查找旧版本 current = follow_roll_ptr(current.DB_ROLL_PTR)
return None # 行对所有快照都不可见(已被删除)2.4 Read View 创建时机
| 隔离级别 | Read View 创建时机 | 说明 |
|---|---|---|
| READ UNCOMMITTED | 不创建 | 直接读最新版本 |
| READ COMMITTED | 每次 SELECT 创建 | 每次读取看到最新已提交数据 |
| REPEATABLE READ | 事务首次 SELECT 创建 | 事务内看到一致快照 |
| SERIALIZABLE | 不使用 MVCC | 加锁实现 |
2.5 InnoDB Undo Log 结构
InnoDB 的 Undo Log 存储在回滚段(Rollback Segment)中,每个回滚段包含多个 Undo Log Slot:
// InnoDB Undo Log 结构struct RollbackSegment { trx_id_t trx_id; // 事务 ID page_no_t space_id; // 表空间 ID page_no_t page_no; // 回滚段首页号 uint32_t max_size; // 最大页数 uint32_t curr_size; // 当前页数 UndoSlot slots[1024]; // Undo Log 槽位数组};
// 每个 Undo Log Slot 指向一个 Undo Log 链表// Insert Undo Log: 记录 INSERT 操作的回滚信息,事务提交后即可清理// Update Undo Log: 记录 UPDATE/DELETE 操作的回滚信息,需等待所有快照不再引用| Undo Log 类型 | 记录内容 | 清理时机 | 存储位置 |
|---|---|---|---|
| Insert Undo | INSERT 的主键值 | 事务提交后立即清理 | 回滚段 |
| Update Undo | UPDATE/DELETE 的旧值 | 所有快照不再引用后清理 | 回滚段 |
2.6 InnoDB Purge 线程
Purge 线程负责清理不再被任何快照引用的 Undo Log 和标记删除的索引记录:
# InnoDB Purge 线程工作流程def purge_thread_main(): while True: # 1. 找到最老的活跃 Read View oldest_view = get_oldest_read_view()
# 2. 清理该 Read View 之前的 Undo Log for undo_log in get_expired_undo_logs(oldest_view): # 清理 Update Undo Log(Insert Undo 已在提交时清理) free_undo_log(undo_log)
# 3. 清理标记删除的索引记录 for marked_delete_record in get_purge_records(oldest_view): remove_from_index(marked_delete_record) remove_from_clustered_index(marked_delete_record)
# 4. 回收 Undo Log 表空间 truncate_undo_tablespace_if_needed()
sleep(purge_interval) # 默认每 10ms 循环一次| Purge 参数 | 默认值 | 说明 |
|---|---|---|
| innodb_purge_batch_size | 300 | 每次清理的 Undo Log 页数 |
| innodb_purge_rseg_truncate_frequency | 128 | 回滚段截断频率 |
| innodb_max_purge_lag | 0 | Purge 延迟阈值(0=不限) |
| innodb_max_purge_lag_delay | 0 | 延迟 DML 的最大毫秒数 |
如果长事务持有 Read View 不释放,Purge 线程无法清理其之前的 Undo Log,导致回滚段持续膨胀。当 Undo 表空间写满时,InnoDB 会阻塞新事务的写入。监控 Innodb_undo_log_size 和长事务是运维的关键。
三、PostgreSQL Append-Only MVCC
3.1 xmin/xmax 机制
PostgreSQL 在每行头部存储事务信息:
// PostgreSQL 行头(HeapTupleHeaderData)struct HeapTupleHeaderData { TransactionId xmin; // 插入该行的事务 ID TransactionId xmax; // 删除/更新该行的事务 ID(0 表示未删除) CommandId cid; // 命令 ID(同一事务内的命令序号) ItemPointerData t_ctid; // 指向新版本的行指针(更新时)};
// 版本链:// 旧版本 (xmax=101, t_ctid→新版本) → 新版本 (xmin=101, xmax=0)3.2 版本链
3.3 PostgreSQL 可见性判断
# PostgreSQL MVCC 可见性判断def pg_is_visible(tuple, snapshot): """PostgreSQL 行可见性判断""" xmin = tuple.xmin xmax = tuple.xmax
# xmin 判断:插入是否可见 if xmin == snapshot.xid: # 同一事务插入 if tuple.cid >= snapshot.curcid: return False # 命令 ID >= 当前命令 ID,不可见 elif xmin in snapshot.active_xids: return False # 插入事务仍活跃,不可见 elif xmin > snapshot.xmax: return False # 插入事务在快照之后,不可见
# 到这里,插入可见
# xmax 判断:删除/更新是否可见 if xmax == 0: return True # 未被删除/更新
if xmax == snapshot.xid: return False # 被当前事务删除/更新,不可见 elif xmax in snapshot.active_xids: return True # 删除/更新事务仍活跃,行仍可见 elif xmax > snapshot.xmax: return True # 删除/更新事务在快照之后,行仍可见
return False # 被已提交事务删除/更新,不可见四、InnoDB vs PostgreSQL MVCC 对比
4.1 核心差异
| 维度 | InnoDB | PostgreSQL |
|---|---|---|
| 修改方式 | 原地更新 | 追加新版本 |
| 旧版本存储 | Undo Log(回滚段) | 表内(同一表文件) |
| 版本链方向 | 当前行 → 旧版本 | 旧版本 → 新版本 |
| 清理方式 | Purge 线程 | VACUUM |
| 表膨胀 | 无(旧版本在 Undo Log) | 有(旧版本在表内) |
| 回滚 | 快(沿 Undo Log 回退) | 慢(需要标记 xmax) |
| 长事务影响 | Undo Log 膨胀 | 表膨胀 + VACUUM 延迟 |
4.2 长事务的影响
4.3 快照隔离与幻读
快照隔离(Snapshot Isolation, SI)解决了读-写冲突,但并未完全解决幻读问题:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 无锁 |
| READ COMMITTED | 不会 | 可能 | 可能 | MVCC + 行锁 |
| REPEATABLE READ | 不会 | 不会 | InnoDB 不会 | MVCC + Next-Key Lock |
| SERIALIZABLE | 不会 | 不会 | 不会 | 完全加锁 |
InnoDB 的 REPEATABLE READ 通过 Next-Key Lock(行锁 + 间隙锁)在加锁读时防止幻读,这是 MySQL 对 SQL 标准的增强。PostgreSQL 的 REPEATABLE READ 仅依赖 SI,不防止幻读,需升级到 SERIALIZABLE 才能避免。
4.4 PostgreSQL 可见性映射
PostgreSQL 使用 Visibility Map(VM)加速 VACUUM 和索引扫描:
// Visibility Map 结构// 每个数据页对应 1 bit// bit=1: 该页所有行对所有快照可见(无需 VACUUM 扫描)// bit=0: 该页可能包含死行(需要 VACUUM 检查)
struct VisibilityMap { uint8_t map[]; // 位图,每 bit 对应一个数据页 // 1 byte = 8 个页的状态 // 1 MB VM 可管理 8 × 8192 = 65536 个页 = 512MB 数据};| VM bit | 含义 | 对 VACUUM 的影响 | 对索引扫描的影响 |
|---|---|---|---|
| all_visible=1 | 页内所有行对所有人可见 | 跳过此页,不扫描 | 索引扫描无需检查版本链 |
| all_visible=0 | 页内可能有死行 | 必须扫描此页 | 每行需检查 xmin/xmax |
| all_frozen=1 | 页内所有行已冻结 | 冻结扫描可跳过 | — |
| all_frozen=0 | 页内有未冻结行 | 需要冻结 | — |
VM 使 VACUUM 的扫描速度从全表扫描降低到只扫描有死行的页,对于大表效果显著——99% 的页通常都是 all_visible 的。
4.5 写-写冲突处理
MVCC 解决了读-写冲突,但写-写冲突仍需加锁:
# 写-写冲突处理def update_row(row, new_value, txn): # InnoDB: 用行锁(X Lock)串行化同一行的并发更新 if not acquire_exclusive_lock(row, txn): wait_or_deadlock_detect() # 等待或死锁检测 return
# 写入 Undo Log undo_record = create_undo_record(row, old_value) undo_log.append(undo_record)
# 原地更新行 row.value = new_value row.DB_TRX_ID = txn.id row.DB_ROLL_PTR = undo_record
# PostgreSQL: 用行级锁 + 等待def pg_update_row(tuple, new_value, txn): # 检查行是否被其他事务锁定 if tuple.xmax != 0 and tuple.xmax in active_txns: wait_for_txn(tuple.xmax) # 等待持锁事务结束
# 标记旧版本为已删除 tuple.xmax = txn.id tuple.t_ctid = new_tuple_location
# 插入新版本 new_tuple = create_new_tuple(new_value, xmin=txn.id, xmax=0)| 维度 | InnoDB | PostgreSQL |
|---|---|---|
| 锁类型 | 行锁(记录锁 + 间隙锁) | 行级锁(xmax 标记) |
| 死锁检测 | 等待图(Wait-for Graph) | 超时检测 |
| 锁存储 | 锁表(内存中) | 行头 xmax 字段 |
| 锁开销 | 集中式锁表,高并发有争用 | 分散在行头,无争用 |
五、VACUUM 机制
5.1 为什么需要 VACUUM
PostgreSQL 的 Append-Only 设计导致表持续膨胀,VACUUM 负责回收空间:
| VACUUM 类型 | 说明 | 锁 | 速度 |
|---|---|---|---|
| Regular VACUUM | 标记死行为可用空间 | SHARE 锁 | 快 |
| VACUUM FULL | 重写整表,回收所有空间 | ACCESS EXCLUSIVE 锁 | 慢 |
| Autovacuum | 自动触发的 Regular VACUUM | SHARE 锁 | 快 |
5.2 Autovacuum 触发条件
# Autovacuum 触发条件def should_autovacuum(table_stats, config): """判断是否需要 autovacuum""" # 基于死行数 dead_tuples = table_stats.n_dead_tup threshold = config.autovacuum_vacuum_threshold + \ config.autovacuum_vacuum_scale_factor * table_stats.n_live_tup
if dead_tuples > threshold: return True, "dead_tuples"
# 基于插入行数(用于冻结) insert_tuples = table_stats.n_ins_since_vacuum insert_threshold = config.autovacuum_vacuum_insert_threshold + \ config.autovacuum_vacuum_insert_scale_factor * table_stats.n_live_tup
if insert_tuples > insert_threshold: return True, "insert_tuples"
return False, None
# 默认配置# autovacuum_vacuum_threshold = 50# autovacuum_vacuum_scale_factor = 0.2# 即:当死行数 > 50 + 20% × 活行数时触发5.3 事务 ID 冻结
PostgreSQL 的事务 ID 是 32 位,会回绕(wraparound):
-- 查看事务 ID 冻结信息SELECT relname, age(relfrozenxid) AS xid_age, age(relminmxid) AS mxid_ageFROM pg_classWHERE relkind = 'r'ORDER BY xid_age DESCLIMIT 10;
-- 当 age > 200,000,000 时,autovacuum 会触发冻结-- 当 age > 2,000,000,000 时,数据库会强制停机PostgreSQL 的事务 ID 回绕是一个严重问题。如果 autovacuum 被禁用或长事务阻止冻结,事务 ID 回绕会导致数据库强制停机。永远不要禁用 autovacuum。
六、MVCC 与索引
6.1 索引中的版本信息
| 数据库 | 二级索引存储 | 更新索引 | 说明 |
|---|---|---|---|
| InnoDB | 主键值 | 修改主键时才更新 | 索引不存事务信息 |
| PostgreSQL | 堆元组指针 | 每次更新都检查 | HOT 更新优化 |
6.2 PostgreSQL HOT 更新
HOT 更新的条件:
- 更新的列不涉及任何索引列
- 新旧版本在同一个数据页内
HOT 更新的优势:不需要更新索引,减少 I/O 和索引膨胀。
七、实战:MVCC 观察
7.1 InnoDB MVCC 观察
-- 查看当前活跃事务SELECT * FROM information_schema.innodb_trx;
-- 查看 Undo Log 信息SHOW STATUS LIKE 'Innodb_undo%';
-- 查看长事务SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec, trx_queryFROM information_schema.innodb_trxORDER BY trx_started;7.2 PostgreSQL MVCC 观察
-- 查看表的膨胀情况SELECT relname, n_live_tup, n_dead_tup, n_dead_tup::float / NULLIF(n_live_tup, 0) AS dead_ratio, last_vacuum, last_autovacuumFROM pg_stat_user_tablesORDER BY n_dead_tup DESC;
-- 查看长事务SELECT pid, now() - xact_start AS xact_duration, now() - query_start AS query_duration, queryFROM pg_stat_activityWHERE xact_start IS NOT NULLORDER BY xact_start;八、总结
| 主题 | 核心要点 | 关键词 |
|---|---|---|
| MVCC 思想 | 多版本让读写互不阻塞,用空间换并发 | 读写不阻塞, 空间换并发 |
| InnoDB MVCC | 原地更新 + Undo Log 版本链,Purge 线程清理 | Undo Log, Purge |
| PostgreSQL MVCC | 追加新版本 + xmin/xmax,VACUUM 清理 | xmin/xmax, VACUUM |
| 可见性判断 | Read View 决定事务看到哪个版本 | Read View, 快照 |
| VACUUM | 回收死行空间,冻结事务 ID 防止回绕 | 死行回收, 事务 ID 冻结 |
| HOT 更新 | PostgreSQL 的优化,不更新索引的行内更新 | 行内更新, 索引不变 |
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






