知识全景图/ 软件工程与系统/ 如何设计一个数据库/ 如何设计 MVCC

如何设计 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 和 Repeatable Read 的差别从快照时机长出来

隔离级别不要先背定义。先看快照什么时候创建:每条语句一个快照,还是整个事务一个快照。

策略快照时机读者会看到什么代价
Read Committed每条语句开始时创建新快照第二次 select 可能看到别人刚提交的新值更贴近最新提交状态,但同一事务内读不稳定
Repeatable Read事务开始时创建一次快照同一事务多次读取同一行结果稳定长事务会保留更多旧版本
Serializable还要防止更复杂的读写依赖更像串行执行结果需要额外冲突检测,本页不完整展开
阶段验收: 在 T1 两次 select 中间让 T2 update + commit,你能解释 Read Committed 和 Repeatable Read 为什么结果不同。
六、第六版:写写冲突不能靠 MVCC 自动消失

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 不会免费提升性能。它把读写阻塞的一部分成本转移到了空间、可见性判断和后台清理。
九、第九版:MVCC 仍然需要 WAL 和事务状态

版本创建、删除标记、事务提交、回滚和清理都不能脱离 WAL。否则崩溃后版本链可能处于半更新状态。

动作需要记录什么崩溃后怎么解释
创建新版本新版本内容、created_by、旧版本指针提交则 Redo,未提交则 Undo
标记旧版本 deleted_by旧版本从哪个事务开始失效未提交删除不能让旧版本消失
事务 commitcommit LSN 和 tx 状态可见性判断依赖事务是否提交
vacuum 删除旧版本删除哪些版本和索引项清理动作本身也要可恢复
阶段验收: 在新版本写入后、commit 前 kill 进程,恢复后其他事务不能看见这个未提交版本。
十、把 MVCC 设计线收起来
阶段遇到的问题长出的设计真正吸收点
读写锁长读卡住写入多版本读者可以读旧世界
多版本不知道该读哪一版事务快照可见性来自时间边界
快照规则容易混乱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恢复后未提交版本不可见,版本链仍完整
十二、回到主线
如何设计一个数据库
如果你想回到 MiniDB 总主线,继续看 如何设计一个数据库
如何设计事务系统
如果你还没理解事务状态、锁、隔离和普通回滚为什么重要,先回到 如何设计事务系统
数据库锁、隔离级别与死锁治理
如果你已经理解快照和版本链,但要处理生产业务里的写写冲突、锁等待、死锁、长事务和隔离级别选择,继续看 如何设计数据库锁、隔离级别与死锁治理
如何设计查询优化器
当存储、索引、事务和 MVCC 都具备以后,下一步看 如何设计查询优化器:同一句 SQL 到底应该走表扫描、索引扫描还是 Join 计划。
总结: MVCC 不是概念堆,而是从读写互相阻塞里长出来的设计:长读卡住写入,于是保留旧版本;版本多了不知道读哪版,于是有事务快照和可见性规则;隔离级别差异来自快照时机;写写冲突仍然要锁或检测;旧版本不能无限保留,于是有 vacuum;版本链本身也要接入 WAL 和恢复。