如何设计 MVCC
从 MiniDB 的“报表读锁卡住写入”开始,长出版本链、快照、可见性判断、垃圾回收和写写冲突处理
阅读定位: 这页只讲单机关系型数据库里 MVCC 的最小主线:怎样让读者不挡写者、写者不破坏读者看到的稳定世界。它不展开所有隔离级别理论、分布式快照、SSI 全细节或具体数据库源码差异。
一句话原则: MVCC 不是“多存几份数据”这么简单。它真正要解决的是:同一行在不同事务眼里可以有不同版本,但每个事务看到的世界必须自洽、可解释、可回收。
MiniDB 已经有事务、WAL 和写锁。现在一个报表事务要扫一百万行,另一个在线交易要更新其中一行。保守锁模型下,读锁和写锁会互相挡住。
| 时间线 | 只靠读写锁会怎样 | 为什么这会逼出 MVCC |
| T1 开始跑报表,读很多行 | T1 持有大量读锁 | 报表越慢,锁持有越久 |
| T2 要更新其中一行 | T2 等待读锁释放 | 在线写入被分析查询拖慢 |
| T2 先拿到写锁 | T1 又可能等待写锁 | 读者也被写者卡住 |
阶段验收: 你能说清:MVCC 第一动机不是“更高级隔离级别”,而是降低读写互相阻塞。
如果写者不直接覆盖旧值,而是把旧版本留下、写出新版本,那么老读者可以继续读旧版本,新读者可以看到新版本。
before:
row id=10086 amount=100
T2 update amount = 99:
new version: amount=99, created_by=T2
old version: amount=100, expired_by=T2
| 设计变化 | 解决什么 | 马上暴露的问题 |
| 一行有多个版本 | 读者可以继续读旧值 | 读者到底应该看哪个版本 |
| 更新变成 append new version | 写者不用破坏老读者 | 版本链会越来越长 |
| 旧版本暂时保留 | 长事务还能看到旧世界 | 什么时候能安全清理旧版本 |
这一步的教训: MVCC 的难点不在“复制一份旧数据”,而在“谁在什么时候应该看见哪一份”。
为了判断可见性,MiniDB 给每个事务分配递增 tx_id。事务开始时,记录一个快照:当时哪些事务已经提交,哪些事务还活着。
Snapshot for T10:
my_tx_id: 10
committed_before: tx_id < 10
active_at_start: [7, 9]
Version:
value: amount=99
created_by: 8
deleted_by: null
| 元数据 | 回答什么问题 | 没有它会怎样 |
| created_by / xmin | 这个版本是谁创建的 | 不知道新版本是否应该被当前事务看见 |
| deleted_by / xmax | 这个版本从哪个事务开始失效 | 不知道旧版本是否已经被删除或更新 |
| active transaction list | 开始时谁还没提交 | 可能读到当时还未确定的修改 |
| snapshot boundary | 快照边界在哪里 | 同一事务两次读可能看到不同世界 |
阶段验收: 你应该能拿一条版本链,手工判断 T10 应该读哪个版本。
现在 MiniDB 读一行时,不再只取“最新物理记录”,而是沿着版本链找第一个对当前事务可见的版本。
| 判断 | 含义 | 结果 |
| 创建者是当前事务 | 自己刚写的内容,自己应该能看到 | 可见,除非自己又删除 |
| 创建者在快照前已提交 | 事务开始时它已经是稳定事实 | 可见 |
| 创建者在快照开始时还活着 | 事务开始时它还没定案 | 不可见 |
| 删除者在快照前已提交 | 事务开始前这个版本已经失效 | 不可见 |
| 删除者是快照中的活跃事务 | 事务开始时删除还没定案 | 旧版本仍可见 |
read_visible(row_versions, snapshot):
for version in newest_to_oldest:
if is_visible(version, snapshot):
return version
return not_found
不要误读: MVCC 不是“永远读旧值”。它读的是对当前快照一致的版本。
隔离级别不要先背定义。先看快照什么时候创建:每条语句一个快照,还是整个事务一个快照。
| 策略 | 快照时机 | 读者会看到什么 | 代价 |
| Read Committed | 每条语句开始时创建新快照 | 第二次 select 可能看到别人刚提交的新值 | 更贴近最新提交状态,但同一事务内读不稳定 |
| Repeatable Read | 事务开始时创建一次快照 | 同一事务多次读取同一行结果稳定 | 长事务会保留更多旧版本 |
| Serializable | 还要防止更复杂的读写依赖 | 更像串行执行结果 | 需要额外冲突检测,本页不完整展开 |
阶段验收: 在 T1 两次 select 中间让 T2 update + commit,你能解释 Read Committed 和 Repeatable Read 为什么结果不同。
MVCC 主要减少读写互相阻塞,不等于两个写者可以同时改同一行。写写冲突仍然必须处理。
| 失败场景 | 如果不处理会怎样 | MiniDB 第一版怎么做 |
| T1 和 T2 同时更新同一行 | 两个新版本互相覆盖语义,最后谁赢不清楚 | 更新前拿行写锁 |
| T1 基于旧余额扣款,T2 也基于旧余额扣款 | 丢失更新 | 检查当前可见版本是否已被别人更新 |
| T1 删除,T2 更新同一行 | 删除和更新顺序不可解释 | 同一 RID 的写操作串行化 |
边界说明: MVCC 可以让读不挡写,但写写冲突仍然要靠锁、版本检查或冲突检测解决。
B+Tree 索引通常帮你找到某个 key 对应的记录入口,但 MVCC 下“记录入口”后面可能挂着版本链。索引命中不等于版本可见。
| 问题 | 为什么会出现 | 设计处理 |
| 索引指向的新版本对当前事务不可见 | 新版本由快照之后的事务创建 | 沿版本链找旧版本 |
| 旧版本的索引 key 被更新 | 更新索引字段可能产生新索引项 | 旧索引项不能立刻删,老快照还可能需要 |
| 索引扫描遇到已删除版本 | 删除只是让版本对新事务不可见 | 读取时做可见性过滤 |
| 版本链过长 | 长事务阻止旧版本回收 | 需要 vacuum / purge |
阶段验收: 按索引查到一行后,你不能直接返回物理最新值,必须通过快照可见性判断。
MVCC 把阻塞换成了空间和清理问题。如果长事务一直不结束,旧版本就不能删,版本链会变长,索引也会膨胀。
| 回收条件 | 为什么安全 | 如果判断错会怎样 |
| 没有任何活跃快照还可能看到旧版本 | 旧版本已经对所有未来读者不可见 | 过早删除会让长事务读不到该看的旧值 |
| 删除事务已经提交 | 删除结果已经稳定 | 未提交删除被清理会破坏回滚 |
| 版本前后链能重新整理 | 避免版本链越来越长 | 链断了会导致可见版本丢失 |
vacuum_safe_point:
min active snapshot tx_id
old version can be removed only if:
deleted_by committed
deleted_by < vacuum_safe_point
现实代价: MVCC 不会免费提升性能。它把读写阻塞的一部分成本转移到了空间、可见性判断和后台清理。
版本创建、删除标记、事务提交、回滚和清理都不能脱离 WAL。否则崩溃后版本链可能处于半更新状态。
| 动作 | 需要记录什么 | 崩溃后怎么解释 |
| 创建新版本 | 新版本内容、created_by、旧版本指针 | 提交则 Redo,未提交则 Undo |
| 标记旧版本 deleted_by | 旧版本从哪个事务开始失效 | 未提交删除不能让旧版本消失 |
| 事务 commit | commit LSN 和 tx 状态 | 可见性判断依赖事务是否提交 |
| vacuum 删除旧版本 | 删除哪些版本和索引项 | 清理动作本身也要可恢复 |
阶段验收: 在新版本写入后、commit 前 kill 进程,恢复后其他事务不能看见这个未提交版本。
| 阶段 | 遇到的问题 | 长出的设计 | 真正吸收点 |
| 读写锁 | 长读卡住写入 | 多版本 | 读者可以读旧世界 |
| 多版本 | 不知道该读哪一版 | 事务快照 | 可见性来自时间边界 |
| 快照 | 规则容易混乱 | created_by / deleted_by | 版本是否可见必须可计算 |
| 不同读语义 | 同一事务内是否稳定 | Read Committed / Repeatable Read | 隔离级别来自快照时机 |
| 两个写者 | 仍然可能丢失更新 | 写锁 / 冲突检测 | MVCC 不取消写写冲突 |
| 旧版本堆积 | 空间膨胀和扫描变慢 | vacuum / purge | 性能问题会转移到清理 |
| 练习 | 故意制造什么失败 | 通过标准 |
| 长读不挡写 | T1 长事务扫描,T2 更新并提交 | T2 不被 T1 普通读长期阻塞,T1 仍看到稳定旧值 |
| 快照可见性 | T1 开始后 T2 插入并提交 | Repeatable Read 下 T1 不看到新行 |
| Read Committed | 两次 select 之间 T2 提交更新 | 第二次 select 可以看到新提交版本 |
| 写写冲突 | T1、T2 同时更新同一 RID | 不能静默丢失更新,必须等待、失败或检测冲突 |
| 索引可见性 | 索引命中新版本但快照不可见 | 返回旧版本或 not found,不能直接返回最新物理值 |
| 长事务膨胀 | 长事务期间持续更新同一行 | 版本链增长可观察,长事务结束后可清理 |
| 崩溃恢复 | 新版本写入后 commit 前 kill | 恢复后未提交版本不可见,版本链仍完整 |
如何设计事务系统
如果你还没理解事务状态、锁、隔离和普通回滚为什么重要,先回到
如何设计事务系统。
如何设计查询优化器
当存储、索引、事务和 MVCC 都具备以后,下一步看
如何设计查询优化器:同一句 SQL 到底应该走表扫描、索引扫描还是 Join 计划。
总结: MVCC 不是概念堆,而是从读写互相阻塞里长出来的设计:长读卡住写入,于是保留旧版本;版本多了不知道读哪版,于是有事务快照和可见性规则;隔离级别差异来自快照时机;写写冲突仍然要锁或检测;旧版本不能无限保留,于是有 vacuum;版本链本身也要接入 WAL 和恢复。