25.2 MVCC

一句话

MVCC 让 “读不阻塞写、写不阻塞读”:每次写产生新版本,读按自己的快照挑可见版本;代价是旧版本要清理,而长事务会让清理永远等不到——于是表膨胀。

机制:版本 + 可见性

概念 说明
版本标记 每行带创建事务 ID / 删除事务 ID(或 undo 指针)
快照 读操作开始(或语句开始)时的 “活跃事务集合”
可见性规则 创建者已提交且不在活跃集合 → 可见;否则看 undo/旧版本
清理 无人可见的旧版本由后台回收(vacuum / purge)

RC 与 RR 的快照差别

隔离级别 快照时机 现象
Read Committed 每条语句取一次快照 同一事务内两次查询可能不同(不可重复读)
Repeatable Read 事务开始取一次快照 同事务内一致,但可能写偏斜(见 24.1 ACID 与本地事务)

代价:清理被长事务挡住

现象 原因 处理
表/索引膨胀 旧版本无法回收 查 pg_stat_activity 的长事务 / InnoDB 的 history list length
磁盘暴涨 同上 终止长事务、调 vacuum 参数
查询变慢 扫描要跳过大量死版本 清理后恢复

数字直觉:一个开了几小时的大事务,可能让几小时的更新都无法清理;在高写入表上,膨胀速度是 “写入量 × 阻塞时长”。

代码:快照读与版本可见性

-- PostgreSQL:RR 事务内两次读到同一个快照
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id=1;   -- 快照在第一条语句时确定
-- 其他事务此时更新了 id=1 并提交
SELECT balance FROM accounts WHERE id=1;   -- 仍然读到旧值(同快照)
COMMIT;

-- 查看“谁挡住了清理”
SELECT pid, state, xact_start, now()-xact_start AS age, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL ORDER BY xact_start LIMIT 5;

反模式

反模式 后果
应用里开长事务(等用户输入) 清理停摆,表膨胀
用 RR + 大批量更新 undo/history 暴涨,IO 压力大
查询不设 statement_timeout 慢查询把自己变成 “长事务”
认为 MVCC 等于 “无锁” 写仍要行锁/冲突检测,只是读不阻塞

| 大事务里做批量 UPDATE | undo/history 暴涨,复制延迟飙升 | | 用 RR 做“跨请求”快照 | 事务边界过长,清理停摆 |

三条铁律

🔑 事务要短;长事务是 MVCC 系统的头号杀手。

🔑 读不阻塞写 ≠ 没有竞争;写与写仍会冲突。

🔑 监控 “最老快照” 与清理滞后,不是只看磁盘使用率。

自检

Q1 为什么 MVCC 下 “读不阻塞写”?

▸ 答案:因为读用的是快照:它按自己的可见性规则去读 “当时应该看到的那一版”,不需要等待正在写的新版本。写只追加新版本,不修改旧版本,所以读永远有数据可看。

Q2 长事务为什么会让表膨胀?

▸ 答案:因为清理只能删除 “对所有活跃快照都不可见” 的旧版本。长事务持有旧快照,意味着它之后的旧版本都 “可能还有人要读”,于是清理被迫停止,死版本不断堆积。

Q3 RC 与 RR 的快照差别会导致什么业务问题?

▸ 答案:RC 下同一事务内两次查询可能看到不同数据(别人已提交),适合 “单语句一致性”;RR 下事务内一致,适合 “读-判断-写” 的复合逻辑。选错会出现 “校验通过但写入时数据已变” 的错觉——本质是需要 RR 或显式加锁。

Q4 MVCC 能完全替代锁吗?

▸ 答案:不能。MVCC 只解决 “读-写” 冲突;写-写冲突仍要靠行锁/冲突检测(先写者持有行锁),唯一约束也要靠索引与锁。所谓 “无锁读” 是读侧的优化,不是全局无锁。

Q5 怎样发现 “清理被挡住”?

▸ 答案:PostgreSQL 看 pg_stat_activity 里 xact_start 最老的事务、pg_stat_user_tables.n_dead_tup;MySQL 看 SHOW ENGINE INNODB STATUS 的 History list length 与长事务列表。阈值化告警比 “看磁盘” 更早发现问题。

Q7 为什么 RR 下 “第二次读不到新值” 有时反而是 bug 的来源?

▸ 答案:因为业务常把它当成 “最新数据”:在 RR 事务里先查询余额、再基于它扣减,中间别人已改了余额,你的扣减仍基于旧快照 → 逻辑上覆盖了他人的更新(写偏斜)。要么升到 Serializable,要么用 SELECT ... FOR UPDATE 显式加锁,别指望 RR 的快照读 “帮你看到最新”。

关联