前言
多版本并发控制(MVCC)是 MySQL InnoDB 存储引擎实现高性能事务的核心机制。它通过创建数据快照,使得读写操作可以无锁并发,极大地提升了数据库的并发性能。本文将深入探讨 MVCC 的工作原理、实现细节以及它与事务隔离级别的紧密关系。
一、 MVCC 要解决什么问题?
在高并发场景下,数据库事务处理主要面临三种操作组合:
读-读:无需任何控制,不会产生问题。
写-写:必须通过加锁(行锁、表锁等)实现串行化,保证数据一致性。
读-写:如果采用加锁的方式,读操作会阻塞写操作,写操作也会阻塞读操作,严重影响并发性能。
MVCC 的终极目标就是优雅地解决 读-写冲突,实现无锁的非阻塞并发读。
二、 MVCC 的实现基石
MVCC 的实现依赖于两个核心部分:数据的版本链 和 事务的读视图 (Read View)。
数据的版本链 (Undo Log) InnoDB 的每行记录中都包含两个重要的隐藏字段:
DB_TRX_ID (6字节):记录最后一次插入或更新该行数据的事务 ID。
DB_ROLL_PTR (7字节):回滚指针,指向该行数据的前一个版本(存储在 Undo Log 中)。 每次对记录进行更新时,都会将旧值写入 Undo Log,然后
DB_ROLL_PTR
会形成一个指向旧版本记录的链表,即版本链。链首是最新的记录,链尾是最旧的记录。
事务的读视图 (Read View) Read View 是事务在执行快照读(普通 SELECT 语句)时产生的读视图,它决定了当前事务能看到哪个版本的数据。 它主要包含以下关键属性:
m_ids:创建 Read View 时,系统中所有活跃(尚未提交)事务的事务 ID 集合。
min_trx_id:m_ids 集合中的最小值。
max_trx_id:创建 Read View 时,系统尚未分配的下一个事务 ID(并非
m_ids
中的最大值)。creator_trx_id:创建该 Read View 的事务的事务 ID。
三、 可见性算法
有了版本链和 Read View,就可以根据以下规则判断某个版本的记录是否对当前事务可见:
如果数据版本的
DB_TRX_ID
小于min_trx_id
,说明该版本在 Read View 创建前已提交,可见。如果数据版本的
DB_TRX_ID
大于等于max_trx_id
,说明该版本是由在 Read View 创建之后才启动的事务生成的,不可见。需要沿着版本链继续查找旧版本。如果数据版本的
DB_TRX_ID
在[min_trx_id, max_trx_id)
区间内:若
DB_TRX_ID
在m_ids
集合中,说明创建 Read View 时该事务仍活跃,其修改不可见。若
DB_TRX_ID
不在m_ids
集合中,说明创建 Read View 时该事务已提交,其修改可见。
如果当前记录对自己的事务做了修改(
DB_TRX_ID == creator_trx_id
),那么该版本总是可见的。
四、 MVCC 与隔离级别
MVCC 的行为因事务隔离级别而异,核心区别在于 Read View 的生成时机:
READ COMMITTED (读已提交):每次执行快照读时都会生成一个新的 Read View。这会导致每次读都能看到其他事务已提交的最新修改,从而产生“不可重复读”现象。
REPEATABLE READ (可重复读):只在第一次执行快照读时生成一个 Read View,后续所有读操作都复用这个视图。这就保证了在整个事务过程中,看到的数据内容是一致的,实现了可重复读。
五、说人话(个人理解)
对于Mysql的MVCC他解决的问题是在事务并发情况下,对于读+写操作的无锁解决方案。
在事务并发场景下会出现读+读、读+写、写+写这三种组合,其中对于读+读是无需干预的,能够保证并发场景下数据的一致性以及隔离性。对于写+写操作就需要按照一定次序串行执行了,对于该问题就需要锁来实现,如果不加锁的话是无法保证数据的一致性以及隔离性的。这个问题不在MVCC讨论的范畴中,主要解决方式与Mysql的锁相关。此处不再赘述。对于读+写操作在解决方案上可以通过锁来保证数据的一致性但是加锁就会导致锁的竞争问题进而影响整体Mysql的并发度问题导致命令执行效率下降。此时MVCC机制就实现了无锁的情况下舍弃数据实时性为代价提高事务并发效率。因为在一定场景下可以接受数据出现一定程度的不一致问题,因此可以牺牲此部分来追求并发度的提升。
对于MVCC机制他的实现基础,即立足点是针对数据表中的数据进行不同版本的控制,针对数据不同版本判断各个事务对于数据的可见性。即事务对应的版本对应所查询的数据的版本是否合理。进而引出了快照读这个操作。上述两个关键点:”事务的版本”和“数据的版本”是MVCC机制实现的基础。
其中数据的版本通过每行数据中几个隐藏字段进行标识:trx_id字段标识了最后一次修改/增加改行数据的事务id。roll_pointer字段指向该行数据上个版本的数据信息。在事务执行过程中会将这些信息记录到undo log日志中,通过该日志可以实现事务回滚以及MVCC。
事务的版本控制通过建立readview来控制。readview包含信息为:m_ids记录了当前readview创建时刻所有活跃的事务(已创建但是未提交的事务)。min_trx_id记录创建readview时最小的活跃事务id。max_trx_id记录创建readview时最大的活跃事务id。create_trx_id记录创建readview时分配给当前事务的id,该字段为全局自增字段。通过trx_id和roll_pointor字段可以对数据建立一个版本链。然后每个事务的readview为该事务创建时刻对当前数据库事务处理情况的一个快照,通过对比当前事务需要查询的数据版本链信息可以得出当前事务可见的数据版本。
这个具体的规则为:当数据trx_id小于事务readview的min_trx_id时,证明此时数据行最后一次修改的事务是在当前事务创建之前就已经提交了,所以是合理的,因此是对于该事务而言是可见的。当trx_id大于事务readview的max_trx_id时,证明此时数据行最后一次修改的事务是在事务创建之后又新建的事务,此时在时间线上该数据行的版本是先于当前事务的,所以该数据行对于事务而言是不可见的,需要通过roll_pointor字段查询旧版本的数据行。如果trx_id位于readview的min_trx_id和max_trx_id之间时,证明此数据行最后一次修改的事务是与当前事务是位于同一时间线的,证明是同一版本,但是修改该数据行的事务是否提交这个还需要判断,因此进一步判断该trx_id是否存在于m_ids列表中,如果存在,证明这两个事务都是活跃的,此时不能保证该数据行是否修改完成,所以是不可见的。而如果不存在m_ids列表中时,证明修改该数据行的事务已经提交,此时是可见的。
而对应MVCC的控制也有级别之分。其中就是可重复读级别和读已提交级别。 对于两者的区别是两者的readview创建时机不同。其中读已提交级别他的readview创建是在每一次快照读时都会重新创建,更新其中的m_ids等字段,此时他的并发程度更高,因为该级别每一次快照读都会放大数据行的版本可见区间。但是会在数据一致性上带来不可重复读的问题,也就是一个事务两次读取同一数据不一致的问题。因为他在每次快照读时都会重建readview,如果第一次和第二次查询期间有其他事务提交对任务的修改,此时就会出现不可重复读的问题。而可重复读的readview创建时机是在事务第一次创建时进行创建,后续不会新建。此时对于可重复读隔离级别而言他的快照读操作就定格在了事务创建的那一刻了,此时就算有其他事务在第一次查询和第二次查询期间他都只会查询到第一次的数据行,因为第二次的数据行对于该隔离级别而言是不可见的。但是也会有一定问题就是幻读问题。
更多资料:0voice · GitHub