mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1730 字
5 分钟
MVCC 与版本管理
2025-10-03

事务 A 正在更新一行数据,事务 B 同时读取同一行——如果用锁,B 必须等 A 提交后才能读。但数据库的读请求远多于写请求,读等写意味着大量查询被阻塞。MVCC(Multi-Version Concurrency Control)换了一种思路:每个事务看到数据的一个一致性快照,读不阻塞写,写也不阻塞读。

但多版本带来了新问题:旧版本什么时候可以清理?InnoDB 用 Undo Log 回滚段追踪版本链,PostgreSQL 用 Append-Only 追加新版本再 VACUUM 清理旧版本——两种策略各有代价。

一、MVCC 核心思想#

1.1 为什么需要 MVCC#

graph TB subgraph 基于锁["基于锁的并发控制"] W1["写事务 T1<br/>锁住行 X"] --> BLOCK["读事务 T2<br/>等待锁释放"] end subgraph MVCC["MVCC 并发控制"] W2["写事务 T1<br/>创建新版本"] --> READ["读事务 T2<br/>读旧版本<br/>不阻塞"] end style BLOCK fill:#ffcdd2,stroke:#c62828 style READ fill:#c8e6c9,stroke:#2e7d32
维度基于锁MVCC
读-写冲突互阻塞不阻塞
写-读冲突互阻塞不阻塞
一致性快照一致
空间开销多版本存储
清理开销需要回收旧版本

1.2 MVCC 的两种实现#

实现代表修改方式旧版本存储清理方式
Undo Log MVCCInnoDB、Oracle原地更新Undo LogPurge 线程
Append-Only MVCCPostgreSQL追加新版本表内版本链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 版本链#

graph LR subgraph 版本链["InnoDB 版本链"] CURRENT["当前行<br/>name='Bob'<br/>age=30<br/>DB_TRX_ID=103<br/>DB_ROLL_PTR→"] UNDO1["Undo Log 记录 1<br/>name='Alice'<br/>age=25<br/>DB_TRX_ID=101<br/>DB_ROLL_PTR→"] UNDO2["Undo Log 记录 2<br/>name='Alice'<br/>age=20<br/>DB_TRX_ID=100<br/>DB_ROLL_PTR=NULL"] end CURRENT --> UNDO1 --> UNDO2 style CURRENT fill:#c8e6c9,stroke:#2e7d32 style UNDO1 fill:#fff9c4,stroke:#f9a825 style UNDO2 fill:#ffe0b2,stroke:#e65100

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 UndoINSERT 的主键值事务提交后立即清理回滚段
Update UndoUPDATE/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_size300每次清理的 Undo Log 页数
innodb_purge_rseg_truncate_frequency128回滚段截断频率
innodb_max_purge_lag0Purge 延迟阈值(0=不限)
innodb_max_purge_lag_delay0延迟 DML 的最大毫秒数
Warning

如果长事务持有 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 版本链#

graph LR subgraph PG版本链["PostgreSQL 版本链"] V1["版本 1<br/>name='Alice'<br/>age=20<br/>xmin=100<br/>xmax=101<br/>t_ctid→"] V2["版本 2<br/>name='Alice'<br/>age=25<br/>xmin=101<br/>xmax=103<br/>t_ctid→"] V3["版本 3<br/>name='Bob'<br/>age=30<br/>xmin=103<br/>xmax=0<br/>t_ctid→self"] end V1 --> V2 --> V3 style V1 fill:#ffe0b2,stroke:#e65100 style V2 fill:#fff9c4,stroke:#f9a825 style V3 fill:#c8e6c9,stroke:#2e7d32

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 核心差异#

维度InnoDBPostgreSQL
修改方式原地更新追加新版本
旧版本存储Undo Log(回滚段)表内(同一表文件)
版本链方向当前行 → 旧版本旧版本 → 新版本
清理方式Purge 线程VACUUM
表膨胀无(旧版本在 Undo Log)有(旧版本在表内)
回滚快(沿 Undo Log 回退)慢(需要标记 xmax)
长事务影响Undo Log 膨胀表膨胀 + VACUUM 延迟

4.2 长事务的影响#

graph TB subgraph InnoDB长事务["InnoDB:长事务影响"] LONG1["长事务 T1<br/>Read View 包含旧版本"] --> UNDO_GROW["Undo Log 无法清理<br/>空间膨胀"] end subgraph PG长事务["PostgreSQL:长事务影响"] LONG2["长事务 T1<br/>快照引用旧版本"] --> TABLE_BLOAT["表内旧版本无法 VACUUM<br/>表膨胀"] end style UNDO_GROW fill:#ffcdd2,stroke:#c62828 style TABLE_BLOAT fill:#ffcdd2,stroke:#c62828

4.3 快照隔离与幻读#

快照隔离(Snapshot Isolation, SI)解决了读-写冲突,但并未完全解决幻读问题:

graph TB subgraph 幻读问题["快照隔离下的幻读"] T1["事务 T1<br/>SELECT COUNT WHERE age>25<br/>返回 3 行"] --> T1_INSERT["事务 T1<br/>INSERT age=30"] T2["事务 T2<br/>INSERT age=30"] --> T2_COMMIT["T2 提交"] T1_INSERT --> T1_COMMIT["T1 提交<br/>成功(SI 不检测)"] end subgraph InnoDB解决["InnoDB REPEATABLE READ 的 Next-Key Lock"] T1_RR["事务 T1<br/>SELECT FOR UPDATE"] --> GAP_LOCK["Gap Lock<br/>锁住 age>25 的间隙"] GAP_LOCK --> BLOCK_T2["T2 INSERT 被阻塞<br/>直到 T1 提交"] end style T1_COMMIT fill:#ffcdd2,stroke:#c62828 style BLOCK_T2 fill:#c8e6c9,stroke:#2e7d32
隔离级别脏读不可重复读幻读实现方式
READ UNCOMMITTED可能可能可能无锁
READ COMMITTED不会可能可能MVCC + 行锁
REPEATABLE READ不会不会InnoDB 不会MVCC + Next-Key Lock
SERIALIZABLE不会不会不会完全加锁
Note

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)
维度InnoDBPostgreSQL
锁类型行锁(记录锁 + 间隙锁)行级锁(xmax 标记)
死锁检测等待图(Wait-for Graph)超时检测
锁存储锁表(内存中)行头 xmax 字段
锁开销集中式锁表,高并发有争用分散在行头,无争用

五、VACUUM 机制#

5.1 为什么需要 VACUUM#

PostgreSQL 的 Append-Only 设计导致表持续膨胀,VACUUM 负责回收空间:

VACUUM 类型说明速度
Regular VACUUM标记死行为可用空间SHARE 锁
VACUUM FULL重写整表,回收所有空间ACCESS EXCLUSIVE 锁
Autovacuum自动触发的 Regular VACUUMSHARE 锁

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_age
FROM pg_class
WHERE relkind = 'r'
ORDER BY xid_age DESC
LIMIT 10;
-- 当 age > 200,000,000 时,autovacuum 会触发冻结
-- 当 age > 2,000,000,000 时,数据库会强制停机
Important

PostgreSQL 的事务 ID 回绕是一个严重问题。如果 autovacuum 被禁用或长事务阻止冻结,事务 ID 回绕会导致数据库强制停机。永远不要禁用 autovacuum

六、MVCC 与索引#

6.1 索引中的版本信息#

数据库二级索引存储更新索引说明
InnoDB主键值修改主键时才更新索引不存事务信息
PostgreSQL堆元组指针每次更新都检查HOT 更新优化

6.2 PostgreSQL HOT 更新#

graph TB subgraph HOT更新["HOT(Heap Only Tuple)更新"] OLD["旧版本<br/>xmin=100, xmax=101"] --> NEW["新版本<br/>xmin=101, xmax=0"] INDEX["索引指针<br/>→ 旧版本"] --> OLD OLD -->|"t_ctid"| NEW end subgraph 非HOT更新["非 HOT 更新"] OLD2["旧版本"] --> NEW2["新版本"] INDEX2["索引指针 1<br/>→ 旧版本"] --> OLD2 INDEX3["索引指针 2<br/>→ 新版本"] --> NEW2 end style HOT更新 fill:#c8e6c9,stroke:#2e7d32 style 非HOT更新 fill:#ffe0b2,stroke:#e65100

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_query
FROM information_schema.innodb_trx
ORDER 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_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
-- 查看长事务
SELECT
pid,
now() - xact_start AS xact_duration,
now() - query_start AS query_duration,
query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER 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 的优化,不更新索引的行内更新行内更新, 索引不变

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

MVCC 与版本管理
https://blog.souloss.com/posts/storage/storage-mvcc-and-versioning/
作者
Souloss
发布于
2025-10-03
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时