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 的快照读 “帮你看到最新”。