MySQL之MVVC原理
什么是MVVC
MVVC (Multi-Version Concurrency Control) (注:与MVCC相对的,是基于锁的并发控制,Lock-Based Concurrency Control)是一种基于多版本的并发控制协议,只有在InnoDB引擎下存在。MVCC是为了实现事务的隔离性,通过版本号,避免同一数据在不同事务间的竞争,你可以把它当成基于多版本号的一种乐观锁。当然,这种乐观锁只在事务级别提交读和可重复读有效。MVCC最大的好处,相信也是耳熟能详:读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能。
不仅是MySQL,包括Oracle,PostgreSQL等其他数据库系统也都实现了MVCC,但各自的实现机制不尽相同,因为MVCC没有一个统一的实现标准。
可以认为MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只锁定必要的行。
MVCC的实现方式有多种,典型的有乐观(optimistic)并发控制 和 悲观(pessimistic)并发控制。
MVCC只在 READ COMMITTED 和 REPEATABLE READ 两个隔离级别下工作。其他两个隔离级别和MVCC不兼容,因为 READ UNCOMMITTED 总是读取最新的数据行,而不是符合当前事务版本的数据行。而 SERIALIZABLE 则会对所有读取的表都加锁。
MVVC的实现机制
InnoDB在每行数据都增加三个隐藏字段,一个唯一行号,一个记录创建的版本号,一个记录回滚的版本号。
在多版本并发控制中,为了保证数据操作在多线程过程中,保证事务隔离的机制,降低锁竞争的压力,保证较高的并发量。在每开启一个事务时,会生成一个事务的版本号,被操作的数据会生成一条新的数据行(临时),但是在提交前对其他事务是不可见的,对于数据的更新(包括增删改)操作成功,会将这个版本号更新到数据的行中,事务提交成功,将新的版本号更新到此数据行中,这样保证了每个事务操作的数据,都是互不影响的,也不存在锁的问题。
undo-log
undo log是为回滚而用,具体内容就是copy事务前的数据库内容(行)到undo buffer,在适合的时间把undo buffer中的内容刷新到磁盘。undo buffer与redo buffer一样,也是环形缓冲,但当缓冲满的时候,undo buffer中的内容会也会被刷新到磁盘;与redo log不同的是,磁盘上不存在单独的undo log文件,所有的undo log均存放在主ibd数据文件中(表空间),即使客户端设置了每表一个数据文件也是如此。
InnoDB存储引擎在数据库每行数据的后面添加了三个字段
- 6字节的事务ID(DB_TRX_ID)字段:用来标识最近一次对本行记录做修改(insert|update)的事务的标识符,即最后一次修改(insert|update)本行记录的事务id。至于delete操作,在innodb看来也不过是一次update操作,更新行中的一个特殊位将行表示为deleted,并非真正删除。
- 7字节的回滚指针(DB_ROLL_PTR)字段:指写入回滚段(rollback segment)的 undo log record (撤销日志记录记录)。如果一行记录被更新, 则 undo log record 包含 ‘重建该行记录被更新之前内容’ 所必须的信息。
- 6字节的DB_ROW_ID字段:包含一个随着新行插入而单调递增的行ID,当由innodb自动产生聚集索引时,聚集索引会包括这个行ID的值,否则这个行ID不会出现在任何索引中。
结合聚簇索引的相关知识点,如果表中没有主键或合适的唯一索引,也就是无法生成聚簇索引的时候,InnoDB会帮我们自动生成聚集索引,但聚簇索引会使用DB_ROW_ID的值来作为主键;如果有主键或者合适的唯一索引,那么聚簇索引中也就不会包含 DB_ROW_ID了 。
Read View和快照Snapshot
事务快照是用来存储数据库的事务运行情况。一个事务快照的创建过程可以概括为:
- 查看当前所有的未提交并活跃的事务,存储在数组中
- 选取未提交并活跃的事务中最小的XID,记录在快照的xmin中
- 选取所有已提交事务中最大的XID,加1后记录在xmax中
Read View (主要是用来做可见性判断的):创建一个新事务时,copy一份当前系统中的活跃事务列表。意思是,当前不应该被本事务看到的其他事务id列表。
对于Read View快照的生成时机,也非常关键,正是因为生成时机的不同,造成了RC,RR两种隔离级别的不同可见性;
- 在innodb中(默认repeatable read级别),事务在begin/start transaction之后的第一条select读操作后,会创建一个快照(Read View),将当前系统中活跃的其他事务记录记录起来
- 在innodb中(默认repeatable committed级别),事务中每条select语句都会创建一个快照(Read View)
RC是语句级多版本(事务的多条只读语句,创建不同的ReadView,代价更高),RR是事务级多版本(一个ReadView);
read committed 总是读最新一份快照数据,而repeatable read 读事务开始时的行数据版本。
read Commited隔离级别判断算法在每次语句执行的过程中,都关闭read_view, 重新创建当前的一份新的read_view。
read view中事务id T_min~T_max,当前事务T1。
...执行sql,创建一份最新的read_view;
...T1<t_min,说明t1事务比较早,该行对当前事务t1可见。 ...t1=""> T_max,说明T1比较晚,该行对当前事务不可见,根据DB_ROLL_PTR找到上一个判断再次判断。
...T_min <= T1 <= T_max,如果read_view中有该事务,则不可见,找上一个版本。如果不在则可见(在read commited下)。
repeatable read各级离别下判断算法:创建事务trx结构的时候,就生成了当前的global read view。
...trx_id_1< trx_id_min那么表明该行记录所在的事务已经在本次新事务创建之前就提交了,所以该行记录的当前值是可见的。
...trx_id_1>trx_id_max的话,那么表明该行记录所在的事务在本次新事务创建之后才开启,所以该行记录的当前值不可见。通过DB_ROLL_PTR找到上一版数据判断
...trx_id_min<=trx_id_<=trx_id_max, 那么表明该行记录所在事务在本次新事务创建的时候处于活动状态,从trx_id_min到trx_id_max进行遍历,如果trx_id_1等于他们之中的某个事务id的话,那么不可见。通过DB_ROLL_PTR找到上一版数据判断`
实验1:
session A |
session B |
mysql> set tx_isolation='repeatable-read'; |
mysql> set tx_isolation='repeatable-read'; |
|
mysql> select * from t1; Empty set (0.01 sec) |
mysql> start transaction; |
|
|
mysql> insert into t1(c1,c2) values(1,1); |
mysql> select * from t1; +----+------+ | c1 | c2 | +----+------+ | 1 | 1 | +----+------+ 1 row in set (0.00 sec) |
|
实验2:
mysql> set tx_isolation='repeatable-read'; |
mysql> set tx_isolation='repeatable-read'; |
|
mysql> select * from t1; Empty set (0.01 sec) |
mysql> start transaction with consistent snapshot; |
|
|
mysql> insert into t1(c1,c2) values(1,1); |
mysql> select * from t1; Empty set (0.00 sec) |
|
上面两个实验很好的说明了 start transaction 和 start tansaction with consistent snapshot的区别。第一个实验说明,start transaction执行之后,事务并没有开始,所以insert发生在session A的事务开始之前,所以可以读到session B插入的值。第二个实验说明,start transaction with consistent snapshot已经开始了事务,所以insert语句发生在事务开始之后,所以读不到insert的数据。
所以事务开始时间点,分为两种情况:
- START TRANSACTION 时,是第一条语句的执行时间点,就是事务开始的时间点,第一条select语句建立一致性读的snapshot;
- START TRANSACTION WITH consistent snapshot 时,则是立即建立本事务的一致性读snapshot,当然也开始事务了;
参考:http://www.cnblogs.com/digdeep/p/4947694.html
一致性读肯定是读取在某个时间点已经提交了的数据,有个特例:本事务中修改的数据,即使未提交的数据也可以在本事务的后面部分读取到。
MVVC下的CRUD
SELECT:
当隔离级别是REPEATABLE READ时select操作,InnoDB必须每行数据来保证它符合两个条件:
- InnoDB必须找到一个行的版本,它至少要和事务的版本一样老(也即它的版本号不大于事务的版本号)。这保证了不管是事务开始之前,或者事务创建时,或者修改了这行数据的时候,这行数据是存在的。
- 这行数据的删除版本必须是未定义的或者比事务版本要大。这可以保证在事务开始之前这行数据没有被删除。
符合这两个条件的行可能会被当作查询结果而返回。
INSERT:InnoDB为这个新行记录当前的系统版本号。
DELETE:InnoDB将当前的系统版本号设置为这一行的删除ID。
UPDATE:InnoDB会写一个这行数据的新拷贝,这个拷贝的版本为当前的系统版本号。它同时也会将这个版本号写到旧行的删除版本里。
这种额外的记录所带来的结果就是对于大多数查询来说根本就不需要获得一个锁。只是简单地以最快的速度来读取数据,确保只选择符合条件的行。这个方案的缺点在于存储引擎必须为每一行存储更多的数据,做更多的检查工作,处理更多的善后操作。
MVCC只工作在REPEATABLE READ和READ COMMITED隔离级别下。READ UNCOMMITED不是MVCC兼容的,因为查询不能找到适合他们事务版本的行版本;它们每次都只能读到最新的版本。SERIABLABLE也不与MVCC兼容,因为读操作会锁定他们返回的每一行数据。
当前读和快照读
MySQL的InnoDB存储引擎默认事务隔离级别是RR(可重复读), 是通过 “行排他锁+MVCC” 一起实现的,不仅可以保证可重复读,还可以部分防止幻读,而非完全防止;
为什么是部分防止幻读,而不是完全防止?
效果: 在如果事务B在事务A执行中,insert了一条数据并提交,事务A再次查询,虽然读取的是undo中的旧版本数据(防止了部分幻读),但是事务A中执行update或者delete都是可以成功的。(参考:MySQL 读提交和重复读隔离级别实验 实验三)
因为在innodb中的操作可以分为当前读(current read)和快照读(snapshot read):
- 快照读:读取的是快照版本,也就是历史版本
简单的select操作(当然不包括 select … lock in share mode, select … for update)
- 当前读:读取的是最新版本
UPDATE、DELETE、INSERT、SELECT … LOCK IN SHARE MODE、SELECT … FOR UPDATE是当前读。
在RR级别下,快照读是通过MVVC(多版本控制)和undo log来实现的,当前读是通过加record lock(记录锁)和gap lock(间隙锁)来实现的。
参考:https://www.jianshu.com/p/adb15359d924