mvcc

MySQL InnoDB存储引擎,实现的是基于多版本的并发控制协议——MVCC (Multi-Version Concurrency Control) (注:与MVCC相对的,是基于锁的并发控制,Lock-Based Concurrency Control)。MVCC最大的好处,相信也是耳熟能详:读不加锁,读写不冲突。在读多写少的OLTP应用中,读写不冲突是非常重要的,极大的增加了系统的并发性能,这也是为什么现阶段,几乎所有的RDBMS,都支持了MVCC。



在Mysql中MVCC是在Innodb存储引擎中得到支持的,Innodb为每行记录都实现了三个隐藏字段:



6字节的事务ID(DB_TRX_ID )
7字节的回滚指针(DB_ROLL_PTR)
隐藏的ID
6字节的事物ID用来标识该行所述的事务,7字节的回滚指针需要了解下Innodb的事务模型。




  1. Innodb的事务相关概念
    为了支持事务,Innbodb引入了下面几个概念:
    redo log
    redo log就是保存执行的SQL语句到一个指定的Log文件,当Mysql执行recovery时重新执行redo log记录的SQL操作即可。当客户端执行每条SQL(更新语句)时,redo log会被首先写入log buffer;当客户端执行COMMIT命令时,log buffer中的内容会被视情况刷新到磁盘。redo log在磁盘上作为一个独立的文件存在,即Innodb的log文件。
    undo log
    与redo log相反,undo log是为回滚而用,具体内容就是copy事务前的数据库内容(行)到undo buffer,在适合的时间把undo buffer中的内容刷新到磁盘。undo buffer与redo buffer一样,也是环形缓冲,但当缓冲满的时候,undo buffer中的内容会也会被刷新到磁盘;与redo log不同的是,磁盘上不存在单独的undo log文件,所有的undo log均存放在主ibd数据文件中(表空间),即使客户端设置了每表一个数据文件也是如此。
    rollback segment
    回滚段这个概念来自Oracle的事物模型,在Innodb中,undo log被划分为多个段,具体某行的undo log就保存在某个段中,称为回滚段。可以认为undo log和回滚段是同一意思。

    Innodb提供了基于行的锁,如果行的数量非常大,则在高并发下锁的数量也可能会比较大,据Innodb文档说,Innodb对锁进行了空间有效优化,即使并发量高也不会导致内存耗尽。
    对行的锁有分两种:排他锁、共享锁。共享锁针对对,排他锁针对写,完全等同读写锁的概念。如果某个事务在更新某行(排他锁),则其他事物无论是读还是写本行都必须等待;如果某个事物读某行(共享锁),则其他读的事物无需等待,而写事物则需等待。通过共享锁,保证了多读之间的无等待性,但是锁的应用又依赖Mysql的事务隔离级别。
    隔离级别
    隔离级别用来限制事务直接的交互程度,目前有几个工业标准:
    - READ_UNCOMMITTED:脏读
    - READ_COMMITTED:读提交
    - REPEATABLE_READ:重复读
    - SERIALIZABLE:串行化
    Innodb对四种类型都支持,脏读和串行化应用场景不多,读提交、重复读用的比较广泛,后面会介绍其实现方式。

  2. 行的更新过程
    下面演示下事务对某行记录的更新过程:

  3. 初始数据行



F1~F6是某行列的名字,1~6是其对应的数据。后面三个隐含字段分别对应该行的事务号和回滚指针,假如这条数据是刚INSERT的,可以认为ID为1,其他两个字段为空。
2.事务1更改该行的各字段的值



当事务1更改该行的值时,会进行如下操作:
用排他锁锁定该行
记录redo log
把该行修改前的值Copy到undo log,即上图中下面的行
修改当前行的值,填写事务编号,使回滚指针指向undo log中的修改前的行
3.事务2修改该行的值



与事务1相同,此时undo log,中有有两行记录,并且通过回滚指针连在一起。
因此,如果undo log一直不删除,则会通过当前记录的回滚指针回溯到该行创建时的初始内容,所幸的时在Innodb中存在purge线程,它会查询那些比现在最老的活动事务还早的undo log,并删除它们,从而保证undo log文件不至于无限增长。



  1. 事务提交
    当事务正常提交时Innbod只需要更改事务状态为COMMIT即可,不需做其他额外的工作,而Rollback则稍微复杂点,需要根据当前回滚指针从undo log中找出事务修改前的版本,并恢复。如果事务影响的行非常多,回滚则可能会变的效率不高,根据经验值没事务行数在1000~10000之间,Innodb效率还是非常高的。很显然,Innodb是一个COMMIT效率比Rollback高的存储引擎。据说,Postgress的实现恰好与此相反。

  2. Insert Undo log
    上述过程确切地说是描述了UPDATE的事务过程,其实undo log分insert和update undo log,因为insert时,原始的数据并不存在,所以回滚时把insert undo log丢弃即可,而update undo log则必须遵守上述过程。

  3. 事务级别
    众所周知地是更新(update、insert、delete)是一个事务过程,在Innodb中,查询也是一个事务,只读事务。当读写事务并发访问同一行数据时,能读到什么样的内容则依赖事务级别:
    READ_UNCOMMITTED
    读未提交时,读事务直接读取主记录,无论更新事务是否完成
    READ_COMMITTED
    读提交时,读事务每次都读取undo log中最近的版本,因此两次对同一字段的读可能读到不同的数据(幻读),但能保证每次都读到最新的数据。
    REPEATABLE_READ
    每次都读取指定的版本,这样保证不会产生幻读,但可能读不到最新的数据
    SERIALIZABLE
    锁表,读写相互阻塞,使用较少
    读事务一般有SELECT语句触发,在Innodb中保证其非阻塞,但带FOR UPDATE的SELECT除外,带FOR UPDATE的SELECT会对行加排他锁,等待更新事务完成后读取其最新内容。就整个Innodb的设计目标来说,就是提供高效的、非阻塞的查询操作。

  4. MVCC
    上述更新前建立undo log,根据各种策略读取时非阻塞就是MVCC,undo log中的行就是MVCC中的多版本,这个可能与我们所理解的MVCC有较大的出入,一般我们认为MVCC有下面几个特点:
    每行数据都存在一个版本,每次数据更新时都更新该版本
    修改时Copy出当前版本随意修改,个事务之间无干扰
    保存时比较版本号,如果成功(commit),则覆盖原记录;失败则放弃copy(rollback)
    就是每行都有版本号,保存时根据版本号决定是否成功,听起来含有乐观锁的味道。。。,而Innodb的实现方式是:
    事务以排他锁的形式修改原始数据
    把修改前的数据存放于undo log,通过回滚指针与主数据关联
    修改成功(commit)啥都不做,失败则恢复undo log中的数据(rollback)
    二者最本质的区别是,当修改数据时是否要排他锁定,如果锁定了还算不算是MVCC? 



Innodb的实现真算不上MVCC,因为并没有实现核心的多版本共存,undo log中的内容只是串行化的结果,记录了多个事务的过程,不属于多版本共存。但理想的MVCC是难以实现的,当事务仅修改一行记录使用理想的MVCC模式是没有问题的,可以通过比较版本号进行回滚;但当事务影响到多行数据时,理想的MVCC据无能为力了。



比如,如果Transaciton1执行理想的MVCC,修改Row1成功,而修改Row2失败,此时需要回滚Row1,但因为Row1没有被锁定,其数据可能又被Transaction2所修改,如果此时回滚Row1的内容,则会破坏Transaction2的修改结果,导致Transaction2违反ACID。



理想MVCC难以实现的根本原因在于企图通过乐观锁代替二段提交。修改两行数据,但为了保证其一致性,与修改两个分布式系统中的数据并无区别,而二提交是目前这种场景保证一致性的唯一手段。二段提交的本质是锁定,乐观锁的本质是消除锁定,二者矛盾,故理想的MVCC难以真正在实际中被应用,Innodb只是借了MVCC这个名字,提供了读的非阻塞而已。

对于delete操作,innodb是通过先将要删除的那一行标记为删除,而不是马上清除这一行,因为innodb实现了MVCC,这些undo段用来实现MVCC多版本机制。锁不阻塞读,读也不阻塞写,这样大大提高了并发性。那么在一致性读的时候,怎么才能找到和事务开始的那个版本呢?



 主键索引,每个行都有一个事务ID和一个undo ID,这个undo ID指向了这行的先前版本的位置。
 非主键索引(辅助索引secondary index),通过先找主键索引再找到undo段。而对于update操作,则是先标记删除,然后insert一个新的行,接下来如果有一致性读,那么查找old version的行的原理和delete操作是一样的
  



  innoDB的行记录格式中有6字节事务ID的和7字节的回滚指针,通过为每一行记录添加这两个额外的隐藏值来实现MVCC,这两个值一个记录这行数据何时被创建,另外一个记录这行数据何时过期(或者被删除)。但是InnoDB并不存储这些事件发生时的实际时间,相反它只存储这些事件发生时的系统版本号。这是一个随着事务的创建而不断增长的数字。每个事务在事务开始时会记录它自己的系统版本号。每个查询必须去检查每行数据的版本号与事务的版本号是否相同。让我们来看看当隔离级别是REPEATABLE READ时这种策略是如何应用到特定的操作的。




  • SELECT:
    当隔离级别是REPEATABLE READ时select操作,InnoDB必须每行数据来保证它符合两个条件:
    1、InnoDB必须找到一个行的版本,它至少要和事务的版本一样老(也即它的版本号不大于事务的版本号)。这保证了不管是事务开始之前,或者事务创建时,或者修改了这行数据的时候,这行数据是存在的。
    2、这行数据的删除版本必须是未定义的或者比事务版本要大。这可以保证在事务开始之前这行数据没有被删除。
    符合这两个条件的行可能会被当作查询结果而返回。

  • INSERT:
    InnoDB为这个新行记录当前的系统版本号。

  • DELETE:
    InnoDB将当前的系统版本号设置为这一行的删除ID。

  • UPDATE:
    InnoDB会写一个这行数据的新拷贝,这个拷贝的版本为当前的系统版本号。它同时也会将这个版本号写到旧行的删除版本里。
    这种额外的记录所带来的结果就是对于大多数查询来说根本就不需要获得一个锁。他们只是简单地以最快的速度来读取数据,确保只选择符合条件的行。这个方案的缺点在于存储引擎必须为每一行存储更多的数据,
    做更多的检查工作,处理更多的善后操作。
    MVCC只工作在REPEATABLE READ和READ COMMITED隔离级别下。READ UNCOMMITED不是MVCC兼容的,因为查询不能找到适合他们事务版本的行版本;它们每次都只能读到最新的版本。
    SERIABLABLE也不与MVCC兼容,因为读操作会锁定他们返回的每一行数据。
    并发控制技术:
    LBCC:Lock-Based Concurrency Control,基于锁的并发控制。
    MVCC:Multi-Version Concurrency Control,基于多版本的并发控制协议。纯粹基于锁的并发机制并发量低,MVCC是在基于锁的并发控制上的改进,主要是在读操作上提高了并发量。
    在MVCC并发控制中,读操作可以分成两类:
    1)快照读 (snapshot read):读取的是记录的可见版本 (有可能是历史版本),不用加锁(共享读锁s锁也不加,所以不会阻塞其他事务的写)。
    2)当前读 (current read):读取的是记录的最新版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。



InnoDB MVCC提供了两个关键功能,
一:写不阻塞读 。 二:读一致性。一下主要介绍一下InnoDB实现读一致性需要达到的效果大家容易理解,要实现Repeatable Read 事务隔离级别,就是InnoDB实现到什么程度了,我感觉开发人员比较容易糊涂,我也是。



附:写不阻塞读,个人感觉只要是MVCC的,肯定就是写不阻塞读了,MVCC给我们程序员提供的最好东西也就是写不阻塞读,不过Mysql InnoDB 在某些情况下写会阻塞读,下文会写到。 


InnoDB engine 有一个全局的 Transaction ID (事务ID,下文用trx_id 表示,即当前版本) 使用show engine innodb status 可以看到。



写在前面:以下描述过于繁杂,我表述也成问题,如果把 事务ID 当版本ID 来读就更好理解。



每开启一个事务的时候 trx_id 就会自增保证每个事务都有自己的trx_id,事务内多步操作不会每个操作都增长(废话) 。当 autocommit = 1 的时候 每个select 都会让trx_id 涨一,这时 trx_id 的增长当会受查询缓存影响,如查询两次没有涨那肯定就是查询缓存起作用了,连续两个select 查询结果一样,后一个是读得查询缓存。



我得理解:每个条数据都记录了一个事务id,就是该数据最后被一次修改的事务ID。



再稍微提下一undo log,redo log。网上一找一堆,我把自己认为合理的说一下,这个undo log和读一致性有关,有必要提一下:
来个找到的定义,写得比较好就直接贴出来:
redo log:重做日志,就是每次mysql在执行写入数据前先把要写的信息保存在重写日志中,但出现断电,奔溃,重启等等导致数据不能正常写入期望数据时,服务器可以通过redo_log中的信息重新写入数据。
undo log:撤销日志,与redo log恰恰相反,当一些更改在执行一半时,发生意外,而无法完成,则可以根据撤消日志恢复到更改之前的壮态。
以一个update 操作为例子:
首先记录undo-log,把本次修改的字段原始值记录下来(包括旧版本的事务id,即修改前的事务id,读一致性里面这个比较重要)
然后在本条记录上进行修改(具体看参考文献)(映像oracle 是复制一条新的记录,标记为update操作的版本号,可能这就是oracle的MVCC那么纯粹,可以flashbak 查询而mysql InnoDB 不行的原因,oracle查询带着版本号就行,我猜mysql 就得一层层的查redo-log,性能上面肯定就不行,这个功能也就做不了了)
修改后写redo-log(包括有新版本事务id)
接着就commit;



undo redo log 说了就说 关于InnoDB如何实现MVCC 读一致性的:



每个select 都会产生一个 read view
在事务开启的时候创建一个记录之前已经是活跃的事务(还没有提交的事务)trx_id 列表,这个就是 read view,在事务结束前是不会变的,代表着当前的版本!



设其中最早的事务id为trx_id_low,最迟的事务id为trx_id_up
本操作所在的事务id(版本号)w为 trx_id_cur



首先:这个trx_id_cur 肯定大于trx_id_up ,版本号是唯一的,递增的。



查询的时候,查到当行的记录的trx_id 为 trx_id_row 。



首先这个trx_id_row 可能比trx_id_cur大,那就比trx_id_up 也大了,场景:
读的时候其他线程又新起了一个事务,插入了一条数据还commit了。版本号就比当前操作大了,这种数据肯定不应该可见,不然读到后面版本提交的数据那何来MVCC。
以上就是很多博客直接说的
条件1 trx_id_row > trx_id_up(不是很好理解),但是算法就是这样的(由于不可见到条件5去查undo log)



条件2 如果trx_id_row < trx_id_low 由于trx_id_low < trx_id_up < trx_id_cur 所以说明该行数据在本次事务开始已经提交了,所以可见,直接返回到结果集。



条件3 如果 trx_id_low <= trx_id_row <= trx_id_up
就应该遍历对当前 read view 的 list
如果包含trx_id_row,说明当前事物开始的时候,这个事物还没有提交,现在提交了肯定对与当前事物来说不可见,毕竟当前事物开始的时候它还没有被提交(到条件5去查undo log)。
如果不包含就没有问题了,因为trx_id_row < trx_id_up < trx_id_cur , 比当前版本小,又是提交了的,就是可见的了(这个可以理解吧),直接加入返回结果集了。



条件5 对于所有不可见的 trx_id_row 就通过 该行主键(应该是oracle ROW_ID 那种)去查undo_log ,找到undo log 中最新一个版本,把它的trx_id 赋值给当前的trx_id_row 再跑一次判断,直到满足条件就返回。为什么需要循环跑?就是条件1的极端情况,本事务启动后,先后有两个事务修改过当前行的数据,最新一条redo log 的事务id 都 > trx_id_cur > trx_id_up 肯定需要再循环判断,找到具体需要的版本 ,这时一个类似链式的过程(当然具体数据结构我不知道,再次验证innoDB 不能带版本查询的原因了)



我觉得有点需要提(由于这个在源码剖析的方法外面,别人没有提):trx_id_row = trx_id_cur 的时候是本事务内的操作,对本事务肯定可见了。


Category storage