0 - 前言
周末在家值班,看了一下MySQL的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这个名字,提供了读的非阻塞而已。
下面看看MySQL的MVCC是怎么实现的。
1 - Innodb的事务
MySQL的MVCC这个说法其实不准确,准确来说,应该是MySQL的Innodb引擎是如何实现MVCC的
Innodb为每行记录都实现了三个隐藏字段:
- 6字节的事务ID;
- 7字节的回滚指针;
- 隐藏的行号;
为了支持事务,Innodb引入了下面几个概念:
- 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数据文件中(表空间),即使设置了每表一个数据文件,仍然会存在每个表的ibd中。 - rollback segment
undo log被划分为多个段,具体某行的undo log就保存在某个段中,称为回滚段。可以认为undo log和回滚段是同一意思。 - 锁
Innodb提供了基于行的锁,如果行的数量非常大,则在高并发下锁的数量也可能会比较大,据Innodb文档说,Innodb对锁进行了空间有效优化,即使并发量高也不会导致内存耗尽。
对行的锁有分两种:排他锁、共享锁。共享锁针对读,排他锁针对写,完全等同读写锁的概念。如果某个事务在更新某行(排他锁),则其他事物无论是读还是写本行都必须等待;如果某个事物读某行(共享锁),则其他读的事物无需等待,而写事物则需等待。通过共享锁,保证了多读之间的无等待性,但是锁的应用又依赖Mysql的事务隔离级别。 - 隔离级别
隔离级别用来限制事务直接的交互程度,目前有几个工业标准:
- READ_UNCOMMITTED:读为提交
- READ_COMMITTED:读提交
- REPEATABLE_READ:重复读
- SERIALIZABLE:串行化
Innodb对四种类型都支持,脏读和串行化应用场景不多,读提交、重复读用的比较广泛。
2 - Innodb更新行记录
写入新记录
F1~F6是某行列的名字,1~6是其对应的数据。后面三个隐含字段分别对应该行的事务号和回滚指针,假如这条数据是刚INSERT的,可以认为ID为1,其他两个字段为空。
事务1更改该行的各字段的值
当事务1更改该行的值时,会进行如下操作:
- 用排他锁锁定该行
- 记录redo log
- 把该行修改前的值Copy到undo log,即上图中下面的行
- 修改当前行的值,填写事务编号,使回滚指针指向undo log中的修改前的行
事务2修改该行的值
与事务1相同,此时undo log,中有有两行记录,并且通过回滚指针连在一起。
因此,如果undo log一直不删除,则会通过当前记录的回滚指针回溯到该行创建时的初始内容,所幸的时在Innodb中存在purge线程,它会查询那些比现在最老的活动事务还早的undo log,并删除它们,从而保证undo log文件不至于无限增长。
当事务正常提交时Innbod只需要更改事务状态为COMMIT即可,不需做其他额外的工作,而Rollback则稍微复杂点,需要根据当前回滚指针从undo log中找出事务修改前的版本,并恢复。如果事务影响的行非常多,回滚则可能会变的效率不高,根据经验值没事务行数在1000~10000之间,Innodb效率还是非常高的。很显然,Innodb是一个COMMIT效率比Rollback高的存储引擎。
3 - Read View
上面说到行记录通过回滚指针串在一起,形成了一个链,这里叫他版本链。已提交读和可重复读的区别就在于它们生成ReadView的策略不同。
ReadView中主要就是有个列表来存储系统中当前活跃着的读写事务,也就是begin了还未提交的事务。通过这个列表来判断记录的某个版本是否对当前事务可见。假设当前列表里的事务id为[80,100]。
- 如果要访问的记录版本的事务id为50,比当前列表最小的id 80还小,那说明这个事务在之前就提交了,所以对当前活动的事务来说是可访问的。
- 如果要访问的记录版本的事务id为90,发现此事务在列表id最大值和最小值之间,那就再判断一下是否在列表内,如果在那就说明此事务还未提交,所以版本不能被访问。如果不在那说明事务已经提交,所以版本可以被访问。
- 如果要访问的记录版本的事务id为110,那比事务列表最大id 100都大,那说明这个版本是在ReadView生成之后才发生的,所以不能被访问。
这些记录都是去版本链里面找的,先找最近记录,如果最近这一条记录事务id不符合条件,不可见的话,再去找上一个版本再比较当前事务的id和这个版本事务id看能不能访问,以此类推直到返回可见的版本或者结束。
也就是说已提交读隔离级别下的事务在每次查询的开始都会生成一个独立的ReadView,而可重复读隔离级别则在第一次读的时候生成一个ReadView,之后的读都复用之前的ReadView。
4 - 结尾
这么看,InnoDB的虽然不是真正的多版本,但也不是说就无处可用,对一些一致性要求不高的场景和对单一数据的操作的场景还是可以发挥作用的,比如多个事务同时更改用户在线数,如果某个事务更新失败则重新计算后重试,直至成功。这样使用MVCC会极大地提高并发数,并消除线程锁。