索码理

V1

2022/07/19阅读:13主题:全栈蓝

MySQL MVCC你了解吗

前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站。

什么是MVCC

multiversion concurrency control(多版本并发控制)的缩写,顾名思义就是通过多版本对并发进行控制。

MVCC用来做什么

MVCC能提升数据库并发性能,你可将MVCC看成行级别锁的一种妥协,它在许多情况下避免了使用锁,同时可以提供更小的开销。根据实现的不同,它可以允许非阻塞式读,在写操作进行时只锁定必要的记录。

MySQL InnoDB 引擎中的多版本并发控制

InnoDB 是一个多版本的存储引擎。它保留有关已更改行的旧版本的信息,以支持并发和回滚等事务功能。此信息存储在系统表空间或撤消表空间(Undo Tablespaces)中称为回滚段(rollback segment)的数据结构中,InnoDB使用回滚段中的信息来执行事务回滚中所需的撤消操作,它还使用这些信息来构建行的早期版本以进行一致的读取。

在 InnoDB 多版本方案中,当你使用SQL语句删除行时,不会立即从数据库中物理删除它。InnoDB仅在丢弃为删除而写入的更新回滚日志记录时,才会物理删除相应的行及其索引记录。这种删除操作称为清除,它非常快,通常与执行删除的 SQL 语句所用的时间顺序相同。

名词解释

  • 撤消表空间(Undo Tablespaces):Undo Tablespaces包含回滚日志(undo log),这是一些记录的集合,其中包含关于如何撤消事务对聚集索引记录的最新更改的信息。

  • 快照(snapshot):一个时间点的数据集表示,同样的数据即使其他事务已经更改,也不影响当前事务读取到之前的数据,以此达到可重复读的效果。

MVCC实现

MVCC 主要由隐式字段、undo log和read view来实现的。

隐式字段

InnoDB 为存储在数据库中的每一行数据后面都会添加三个字段:

  1. 一个6字节的DB_TRX_ID字段:每次对聚集索引修改时,MySQL会分配一个transaction id(TRX_ID)给当前数据的隐藏列DB_TRX_ID。同一事务中对同一条数据多次修改只分配一个TRX_ID。此外,删除在内部被视为更新,删除时将行中的特殊位标记为已删除。
    可通过下面语句查询当前事务的事务id
SELECT TRX_ID FROM INFORMATION_SCHEMA.INNODB_TRX  WHERE TRX_MYSQL_THREAD_ID = CONNECTION_ID();
  1. 一个7字节的DB_ROLL_PTR字段:称为滚动指针。滚动指针指向写入回滚段的回滚日志(undo log)。DB_ROLL_PTR会保存上一个undo logo的内存地址,可以通过它来找到该记录修改前的信息。

  2. 一个6字节的DB_ROW_ID字段:B+树的主键ID,每次添加数据时单调递增。如果表设置有主键则DB_ROW_ID等于主键的值,可以通过SELECT _rowid FROM 表名 查看当前表的DB_ROW_ID。DB_ROW_ID不属于MVCC的实现部分

除第一次添加外每次事务操作时下次的行的隐藏字段DB_ROLL_PTR滚动指针都会保存上次undo log的内存地址,每次需要进行事务回滚时会根据DB_ROLL_PTR地址找到上次的数据然后进行回滚。
可以把DB_ROLL_PTR以外的数据看成链表的数据部分,DB_TRX_ID字段是当前数据的版本,DB_ROLL_PTR看成链表的指针部分,第一条数据相当于是链表的头部,这些版本串联成一个链表,所以DB_TRX_ID和DB_TRX_ID就组成了undo log的版本链。

undo log

回滚日志(undo log):每次进行事务修改时,MySQL InnoDB引擎都会把之前的数据放到undo log中,以便下次进行事务回滚时恢复数据,undo log相当于对之前数据的备份。回滚日志在回滚段(rollback segment)中被分为插入型回滚日志(insert undo log)和更新型回滚日志(update undo log)。

  • 插入型回滚日志(insert undo log)只有在事务需要回滚的时候才需要,事务一旦提交插入型回滚日志就会被删除。
  • 更新型回滚日志也用于一致性读取,但只有在没有为其分配快照的事务后才能丢弃它们,在一致性读取中可能需要更新型回滚日志中的信息来构建早期版本的数据库行。

read view

InnoDB 的 MVCC 机制使用的内部快照。read view由四部分组成:

  • m_ids:生成 ReadView 时当前系统中活跃的读写事务的事务id 列表
  • m_low_limit_id:在生成 ReadView 时当前系统中活跃的读写事务中最小的事务id,也就是 m_ids 中的最小值
  • m_up_limit_id:生成 ReadView 时系统中应该分配给下一个事务的id 值
  • m_creator_trx_id:生成该 ReadView 的事务的事务 id

MySQL 5.7 ReadView部分源码:

class ReadView {
private:
 /** The read should not see any transaction with trx id >= this
 value. In other words, this is the "high water mark". */

 trx_id_t m_low_limit_id;

 /** The read should see all trx ids which are strictly
 smaller (<) than this value.  In other words, this is the
 low water mark". */

 trx_id_t m_up_limit_id;

 /** trx id of creating transaction, set to TRX_ID_MAX for free
 views. */

 trx_id_t m_creator_trx_id;

 /** Set of RW transactions that was active when this snapshot
 was taken */

 ids_t  m_ids;

 /** The view does not need to see the undo logs for transactions
 whose transaction number is strictly smaller (<) than this value:
 they can be removed in purge if not needed by other views */

 trx_id_t m_low_limit_no;

 /** AC-NL-RO transaction view that has been "closed". */
 bool  m_closed;

 typedef UT_LIST_NODE_T(ReadView) node_t;

 /** List of read views in trx_sys */
 byte  pad1[64 - sizeof(node_t)];
 node_t  m_view_list;
  };

根据ReadView,在读取记录的时候,可以根据下面的规则判断记录是否可见:

  1. 如果记录的 DB_TRX_ID = m_creator_trx_id,表示是当前事务创建的ReadView,所以是可见的
  2. 如果记录的DB_TRX_ID < m_low_limit_id,表示这条记录已经提交,所以是可见的
  3. 如果记录的DB_TRX_ID >= m_up_limit_id,表示这条记录事务在当前事务开启后才开启的,所以是不可见的
  4. 如果记录的 m_low_limit_id <= DB_TRX_ID < m_up_limit_id,分两种情况:
    4.1 如果DB_TRX_ID在m_ids活跃的事务id列表中,表示该事务还没有提交,所以不可见。
    4.2 如果DB_TRX_ID在m_ids活跃的事务id列表中,表示该事务已经提交,所以是可见的。

如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。

注意:begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个修改操作InnoDB表的语句,事务才真正启动,才会向mysql申请事务id,mysql内部是严格按照事务的启动顺序来分配事务id的。

Read View在READ COMMITTED和REPEATABLE READ隔离级别中的区别

Read View在READ COMMITTED和REPEATABLE READ隔离级别的区别主要就是Read View生成的时机的不同。

  • READ COMMITTED隔离级别下每次查询都会生成一个Read View。
  • REPEATABLE READ隔离级别下只有在第一次查询的时候会生成一个Read View。

生成Read View时机的不同就是REPEATABLE READ隔离级别可重复读的原因。READ COMMITTED隔离级别下每次查询都会生成一个Read View,每次生成Read View就相当于每次都new了一个ReadView对象,每次ReadView对象里面的那些数据信息也都是可能不一样,比如m_ids、m_up_limit_id、m_low_limit_id等都是可能不一样的。
而REPEATABLE READ隔离级别下只有在第一次查询的时候会生成一个Read View,这就意味着生成的ReadView对象数据是不变的,根据版本链查询的数据也是不变的,这就造就了在REPEATABLE READ隔离级别下的可重复读。

以下面这张图为例:

假设有两个事务,DB_TRX_ID分别为13和12,m_ids列表为[12,13],m_up_limit_id=14,m_low_limit_id=12。两个事务未提交之前,查询id=1的记录都是DB_TRX_ID为11的这条记录。如果此时DB_TRX_ID=12的这条记录提交了,然后在DB_TRX_ID=13的事务中再进行一次查询:

在READ COMMITTED隔离级别下,由于每次查询都生成一个Read View,在DB_TRX_ID=12的事务提交之后,此时再查询的时候,m_ids列表为[13],m_up_limit_id=14,m_low_limit_id=13,根据可见性规则DB_TRX_ID=12的事务是可见的,就是说在通过DB_TRX_ID=13的事务进行查询时,由于DB_TRX_ID=13还未提交,在活跃事务列表m_ids中,所以顺着版本链查找就查到了DB_TRX_ID=12的这条记录了。

REPEATABLE READ隔离级别下,由于只有在第一次查询的时候会生成一个Read View,在DB_TRX_ID=12的事务提交之后,再进行查询的时候会复用之前的Read View,ReadView对象数据还是m_ids=[12,13],m_up_limit_id=14,m_low_limit_id=12,再次通过DB_TRX_ID=13进行查询时,查到的还是DB_TRX_ID=11的这条记录,就达到了可重复读的目的。

MVCC 幻读的解决

幻读是一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据。在REPEATABLE READ 隔离级别下InnoDB引擎中的MVCC可以很大程度地避免幻读现象,但是不能完全禁止幻读。

有个特殊的情况下还是会出现幻读:
一张account空表,表结构如下: 表结构

首先,开启两个事务T1和T2,

  • T1开启事务,并查询id=1记录
T1
T1

可以看到T1查询结果为空

T1查询结果
T1查询结果
  • T2开启事务,暂时不做任何操作
T2
T2

然后,T2插入一条amount=1000的记录并提交,只执行选择部分 可以看到account表中有一条数据。 表数据

最后,在事务T1中修改id=1记录,将amount修改为1001,然后再进行一次查询

查询结果
查询结果

这个时候发现T1竟然查到了之前没有的记录,这就出现了幻读。

ReadView并不能阻止T1执行UPDATE或者DELETE语句来改动这个新插入的记录(由于T2已经提交,因此改动该记录并不会造成阻塞),但是这样一来,这条新记录的trx_id隐藏列的值就变成了T1的事务id。之后T1再使用普通的SELECT语句去查询这条记录时就可以看到这条记录了,也就可以把这条记录返回给客户端。因为这个特殊现象的存在,我们也可以认为MVCC并不能完全禁止幻读。

总结

MVCC机制就是在READ COMMITTD和REPEATABLE READ这个两个隔离级别下通过Read View机制和版本链对比机制,使得不同的事务会根据数据版本链对比规则读取同一条数据在版本链上的不同版本数据,这样子可以使不同事务的读-写、 写-读操作并发执行,从而提升系统性能。

推荐阅读:
温故而知新-MySQL隔离级别

能力一般,水平有限,如有错误,请多指出。
如果文章对你有用就点个关注给个赞呗
更多文章可以关注一下我的微信公众号suncodernote

分类:

后端

标签:

数据库

作者介绍

索码理
V1