MVCC多版本并发控制
MVCC多版本并发控制
复习定位
MVCC是高性能并发控制的关键技术——它解决了"读-写冲突"问题。在MVCC中——读操作不会被写操作阻塞——写操作也不会被读操作阻塞——每个读操作看到一个"快照"——写作在数据上创建新版本。MVCC是PostgreSQL和MySQL InnoDB实现REPEATABLE READ和READ COMMITTED隔离级别的基础——不需要为读加锁就能实现一致性读。
MVCC的基本原理
MVCC的核心思想:每当一个事务修改某行数据时——不直接覆盖原行——而是创建一个新版本——将原版本保留在访问序列中(在InnoDB中通过undo log)。
每行数据上有两个隐藏列:DB_TRX_ID(最近修改该行的事务ID)和DB_ROLL_PTR(回滚指针——指向undo log中该行的上一个版本)。这样——同一行的多个版本在undo log中以链表形式串联起来。
ReadView的可见性判断
当一个事务需要读取一行数据时——它不能简单地读取该行的最新版本——因为最新的版本可能是另一个未提交或本不应该看到的事务修改的。MVCC使用ReadView来判断:
ReadView记录了事务开始时系统中所有活跃(已开始但未提交)事务的ID列表。读一行时——用该行的DB_TRX_ID与ReadView比较:
- 如果 trx_id < ReadView的最小活跃ID——该版本由已提交事务创建——可见。
- 如果 trx_id == 当前事务的ID——自己创建的版本——可见。
- 如果 trx_id 在活跃事务列表中——该版本由另一个活跃(未提交)事务创建——不可见——需要沿着DB_ROLL_PTR的回滚链查找上一个版本——重新比较。
- 如果 trx_id 大于所有活跃ID——该版本由未来(在当前事务之后才开始)的事务创建——不可见——同样沿回滚链找更旧的版本。
READ COMMITTED与REPEATABLE READ的区别
两种隔离级别MVCC的ReadView生成时机不同:
READ COMMITTED——每次执行SELECT语句时——生成一个新的ReadView(体现当前最新已提交数据)。所以同一事务中的两次SELECT可能获取到不同的结果(在这期间另一提交修改了数据)——这就是不可重复读的根源。
REPEATABLE READ——只在事务中第一次SELECT时生成一个ReadView——整个事务的后续SELECT都复用这个ReadView。这就保证了同一事务内多次读取同一数据得到一致的结果。这也是"InnoDB的RR可重复读"的实现原理。
版本清理(Purge)
旧版本不能永远保留。当系统中没有任何事务需要访问某个旧版本时——该版本可以被物理删除。InnoDB的后台Purge线程定期扫描undo log——删除那些早于最旧活跃事务ID的版本——回收空间。
复习检查
MVCC解决了什么问题——解决了读-写冲突——读操作不阻塞写——写操作不阻塞读——大大提升了数据库的并发性能——特别是读多写少的场景。
REPEATABLE READ下——同一事务内多次SELECT同一行的结果——因为第一次SELECT建立的ReadView在整个事务中复用——所以多次读取同一行的结果始终一致。
READ COMMITTED下——如果两次SELECT之间另一个事务提交了修改——第二次读取会看到提交后的新值——因为重新生成了ReadView——意味着不可重复读的发生。
MVCC的行数据隐藏列——DB_TRX_ID记录最后修改该行的事务ID——DB_ROLL_PTR指向回滚段的上一版本——读取时通过ReadView配合这两个列值的数据判断可见性。
版本清理线程在什么条件下能够清除一个旧版本——当该旧版本对当前所有活跃事务都不可见(即活跃事务ID列表中的ID都大于此版本的trx_id)时——可以被安全删除。
MVCC版本链的具体工作示例
以一条数据的更新为例——逐层追踪MVCC版本链和ReadView的可见性判断:
初始: 行R的DB_TRX_ID=100, DB_ROLL_PTR=NULL (版本1,由事务100创建)
事务T1(trx_id=200): UPDATE R SET val=1;
在undo log中创建版本2(old_val → new_val=1)
行R现在: DB_TRX_ID=200, DB_ROLL_PTR→version1
事务T2(trx_id=300): UPDATE R SET val=2;
在undo log中创建版本3(old_val=new_val=2)
行R现在: DB_TRX_ID=300, DB_ROLL_PTR→version2→version1
事务T3(trx_id=400, REPEATABLE READ级别)执行SELECT:
T3的ReadView记录活跃事务ID列表=[200,300](T1,T2未提交)
读取行R: DB_TRX_ID=300 → 在活跃列表中(300未提交)→不可见
沿DB_ROLL_PTR指向version2: DB_TRX_ID=200→在活跃列表中(200未提交)→不可见
沿DB_ROLL_PTR指向version1: DB_TRX_ID=100→小于ReadView最小活跃ID200→可见
T3读到的是最初的值(事务100最后提交的版本)
事务T1提交(T1 trx_id=200已提交)——但T3的ReadView不变(REPEATABLE READ下复用)
T3再次SELECT:
行R: DB_TRX_ID=300 → 还在活跃列表中→不可见(300仍未提交)
version2: DB_TRX_ID=200 → 虽已提交但200仍在ReadView活跃列表→不可见
version1: trx_id=100 → 被标记可见
T3读到的还是初始值(事务100的版本)这个示例说明——REPEATABLE READ下T3始终看到事务開始时刻的数据库快照——直到T3结束前——其他事务的提交对T3不可见。如果T3在READ COMMITTED级别——则第一次SELECT后T1提交时——下一次SELECT将重新生成ReadView——200会从活跃列表中移除——所以第二次SELECT将看到T1的修改。
MVCC在PostgreSQL中的实现差异
PostgreSQL的MVCC实现与InnoDB不同——每一行数据不是通过undo log保存旧版本——而是通过在数据页中同时保留多个版本(每个版本在页中占一行)——通过xmin(插入该版本的transaction ID)和xmax(删除/更新该版本的transaction ID)字段来判断可见性。这种实现方式下——旧版本只在表的堆文件中——不需要额外的undo日志空间。但缺点是被更新后的旧版本行在表中成为"死元组"(dead tuple)——
需要VACUUM进程来回收——并因此导致数据膨胀(table bloating)。
PostgreSQL通过自动VACUUM和手动VACUUM FULL来控制表膨胀的程度和频率。
Update操作在MVCC下的版本链变化
一条UPDATE在MVCC中逻辑上等于DELETE+INSERT——将当前行标记为删除——插入新行。InnoDB在undo log中记录这一操作——使旧版本可通过回滚指针访问。这种设计使得事务回滚时——只需将当前版本的DB_TRX_ID和ROLL_PTR恢复到更新前的状态即可——而不需要在数据页中物理地删除数据。
MVCC在数据库生态中的重要性
MVCC是关系型数据库在高并发下保持高性能最关键的架构决策之一。没有MVCC的数据库(如MyISAM)在执行读操作时必须加表锁或行锁——导致并发写阻塞读——并发性能显著下降。理解MVCC的基本原理——有助于在数据库开发调优中正确选择隔离级别、识别长事务导致undo膨胀的问题来源、并分析高并发场景下读写冲突的行为。
复习检查(续)
MVCC版本链的可见性判断——当前行DB_TRX_ID在ReadView活跃列表中不可见——沿回滚指针查找上一个版本——直到找到DB_TRX_ID在ReadView范围之外(小于最小活跃ID)的版本为止。
InnoDB的Purge线程清理旧版本的时机——当没有活跃事务需要引用该旧版本时(即该版本的trx_id已小于ReadView的最小活跃ID)——物理删除undo日志中的该版本。
PostgreSQL通过xmin/xmax实现MVCC——xmin记录插入该行的事务ID——xmax记录删除/更新该行的事务ID——通过这两个字段的比较确定行的可见性——旧版本以"死元组"形式留在数据页中——需要VACUUM回收。
长事务对MVCC系统的影响——长事务(长时间不提交)会阻止Purge线程回收其启动之前的旧版本——可能导致undo log持续增长——InnoDB的系统表空间占用超过预期——在极端情况下产生"undo表空间满"错误。
ReadView的活跃事务列表包含了什么——在ReadView生成时刻——所有已经开始但尚未提交的事务的ID列表——不包括ReadView生成之后才开始的新事务。
MVCC在REPEATABLE READ下的幻读防御
标准SQL的REPEATABLE READ不能防止幻读——MVCC快照对已存在的行有效——但不能阻止新行的插入。如果事务A执行SELECT * FROM orders WHERE price>100——返回2行。事务B插入了一个price=150并提交。事务A再次执行同样的SELECT——因为MVCC快照对新插入行没有版本信息(它们在事务A开始后才提交)——事务A的SELECT会看到这个新行——导致幻读。MySQL InnoDB通过在REPEATABLE READ下启用Next-Key Lock来解决这个问题——不仅锁住已有的索引行——还锁住键值之间的间隙——阻止其他事务在锁定范围内插入新行。
然而——纯MVCC系统(如PostgreSQL的REPEATABLE READ)在可重复读级别是不封锁间隙的——所以幻读确实可能发生。PostgreSQL在SERIALIZABLE级别通过SSI(可串行化快照隔离)机制——使用冲突检测来识别幻读和写偏差——如果检测到提交时出现了不能被串行化调度的冲突——其中一个事务被强制回滚。
写偏差(Write Skew)与MVCC
写偏差是快照隔离(Snapshot Isolation, REPEATABLE READ的底层实现)下的一个特定异常——不同于脏读、不可重复读和幻读——它发生在两个并发事务读取同一组数据、基于各自的快照做出独立的决策、分别更新不同部分——但两个更新合起来会违反数据约束的场景。
医生值班表: 同一时间至少有一名医生值班
T1: SELECT COUNT(*) FROM doctors WHERE on_call=TRUE → 2(在岗)
T2: SELECT COUNT(*) FROM doctors WHERE on_call=TRUE → 2(在岗)
T1: UPDATE doctors SET on_call=FALSE WHERE name='Alice' → 提交
T2: UPDATE doctors SET on_call=FALSE WHERE name='Bob' → 提交
结果: 两名医生都不在岗——违反"至少一人"约束两个事务都基于对方同时在岗的旧快照做了"我可以请假"的决策——提交时各自只修改了不同的行——MVCC不会检测到冲突——导致约束被违反。这被称为写偏差(Write Skew)——也是为什么真正的SERIALIZABLE隔离级别需要在可重复度之外做额外的冲突检测(如PostgreSQL的SSI、InnoDB的Next-Key Lock)的原因之一。
版本链导致的性能隐患与最佳实践
长事务(长时间运行而不提交)会阻止InnoDB的Purge线程删除其启动之前的旧版本——导致undo日志空间膨胀——在极度压力下可能占满系统表空间。监控并避免长事务是MySQL运维中的常见任务——通过SHOW PROCESSLIST和information_schema.innodb_trx表查看超过一定时间未结束的事务。
复习检查(续二)
InnoDB的Next-Key Lock如何配合MVCC在REPEATABLE READ级别防止幻读——MVCC只能保证已有行的快照一致性——Next-Key Lock通过锁定索引间隙阻止新行插入——两者结合防幻读。
写偏差(Write Skew)与脏读/不可重复读的区别——写偏差不是读取到不一致的数据——而是基于一致的快照做了独立的决策——各自更新不同部分——合起来违反约束——需要SERIALIZABLE隔离级别来检测。
长事务导致undo log膨胀的机制——Purge线程只能清理早于当前最旧活跃事务ID的旧版本——如果有一个长时间运行的事务未提交——这个事务开始之前的所有旧版本都不能被清理——undo log持续增长。
PostgreSQL通过死元组(deadtuple)管理MVCC——被更新/删除的行作为死元组留在数据页中——VACUUM进程清理——但vacuum不够频繁会导致表膨胀(bloating)——需要定期监视和手工或自动维护VACUUM。
监控InnoDB中当前活跃事务的命令——
SELECT * FROM information_schema.innodb_trx\G——查看trx_started字段——发现超过60秒未结束的事务——需要分析原因并考虑是否终止。
MVCC在不同隔离级别下的性能取舍
在MVCC系统中——隔离级别主要影响ReadView的生成时机——而不影响底层的版本链机制本身。REPEATABLE READ的开销比READ COMMITTED稍高——因为需要在事务中持续持有ReadView——判断版本可见性时多一层检查。READ COMMITTED生成ReadView的频率高(每条语句)——但这里开销其实并不大(就是生成活跃事务ID列表的快照)。总体上——MVCC对性能的消耗主要来自版本链维护和旧版本清理——而不是隔离级别的选择——因此从READ COMMITTED升高到REPEATABLE READ对OLTP性能的影响通常可以忽略——真正影响性能的是锁竞争而非MVCC的开销。
MVCC在分布式数据库中的挑战
在分布式数据库中(如Google Spanner、CockroachDB、TiDB)——MVCC的实现比单机复杂得多——因为事务可能涉及多个节点——需要一个全局的一致性时间戳来划分哪个版本是"已提交"的。Spanner使用TrueTime(原子的+GPS时钟同步)保证跨数据中心的时间精度——CockroachDB用HLC(Hybrid Logical Clock)进行无中心化的时钟管理。分布式MVCC的可见性判断不仅依赖于本地的活跃事务列表——还要协调跨节点的时间戳边界——实现复杂度远高于单机MVCC。
MVCC的实践应用场景
MVCC不仅仅存在于数据库引擎中——在分布式版本控制系统(如Git)和对象存储系统(如MinIO)中也涉及类似"多版本"概念。数据库的MVCC为事务提供了读一致性而不阻塞写——这是OLTP场景下高并发的核心支持。理解MVCC给我们在高并发写入场景下提供了一种保证读一致性好方法的思路框架——即不是用"锁死"来解决并发——而是给数据加一个版本号去自动识别、判定"读的旧"和"写的新"之间的关系。
复习检查(续三)
InnoDB监控长时间未提交事务的命令——
SELECT * FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(),trx_started))>60——找出超过60秒未结束的事务——分析其原因。从READ COMMITTED升级到REPEATABLE READ对性能的影响——MVCC底层机制相同——REPEATABLE READ只是在事务开始时生成一次ReadView并复用——比READ COMMITTED多一次ReadView的复用——开销差异极小——对OLTP性能影响可以忽略。
分布式数据库中MVCC的挑战——需要一个全局一致性时间戳来划定事务边界——Spanner用TrueTime(原子钟+GPS)——CockroachDB用HLC(Hybrid Logical Clock)——将时间可信分配给所有参与的独立节点。
Git版本控制系统中的"多版本"与数据库MVCC的类比——Git的每次提交创建一个新的树对象——旧的版本不删除——通过HEAD指针在不同版本之间切换——这和MVCC的"保留旧版本、创建新版本、通过ReadView选择可见版本"的设计思想一致。
写偏差在MVCC快照隔离下的特殊性与整体数据库理论中的位置——它属于快照隔离下不同于等级别异常(脏读、不可重复读、幻读)的一种额外异常——它的存在意味着快照隔离不是真正的"可串行化"——需要SERIALIZABLE级别额外检测来防止。
MVCC在数据库并发控制框架中的地位
MVCC是当代关系型数据库并发控制的基础性机制——锁机制保证了写-写互斥——MVCC保证了读-写不冲突。二者结合——使高并发OLTP系统(每秒数千至数万的事务)在大量读操作的场景下能保持读快速响应、写不长期阻塞的应用体验。MVCC也和数据库的可恢复性、回滚机制紧密关联——回滚段(undo log)既服务于回滚——也服务于MVCC的版本可见性判断。因此MVCC不是一个独立特性——它贯穿了事务、并发控制和恢复三大子系统。
MVCC在工程上的常见权衡
在工程上选择使用MVCC或其他并发控制方案——往往是"读优先"还是"写优先"的业务在做取舍。MVCC在"读取很多且需要即时一致快照"的场景下优势明显(典型就是Web应用和OLTP)。但如果系统对写入延迟极其敏感——可能选择简单的读写锁机制(让读取等待写入更快写入——由采用读锁的路径决定完全不同的访问模式)。总体而言——对于OLTP业务——MVCC是当前成熟数据库的主流且经过了极大规模的实践验证的默认选择。