为什么需要MVCC
锁本身就是用于并发控制的,那么为什么InnoDB还要引入MVCC,读写都加锁不就可以控制住并发吗?
锁确实可以,但是性能太差。如果是纯粹的锁,那么写和写、读和写、读和读之间都是互斥的。如果是读写锁,那么写和写、读和写之间依旧是互斥的。
数据库和一般的应用有一个很大的区别,就是数据库即使是读,也不能被写阻塞住。
所以数据库要有一种机制,避免读写阻塞。在理解了为什么MVCC必不可少后,现在你需要进一步了解一个和MVCC紧密关联的概念:隔离级别。
隔离级别
数据库的隔离级别是一组规则,用来控制并发访问数据库时如何分配、保护和共享资源。不同的隔离级别在不同的并发控制策略之间进行调整,从而提供了不同的读写隔离级别和安全性。隔离级别代表了一个事务是否了解别的事务以及了解程度怎么样。
MySQL的隔离级别有四个。
- 读未提交
Read Uncommitted
是指一个事务可以另外一个事务尚未提交的修改
- 读已提交
Read Committed
是指一个事务只能看到已经提交的事务的修改。如果在事务执行过程中有别的事务提交了,那么事务还是能够看到别的事务最新提交的修改。
- 可重复读
Repeatable Read
是指在这一个事务内部读同一个数据多次,读到的结果都是同一个。这意味着,即使在事务执行过程中有别的事务提交,这个事务依旧看不到别的事务提交的修改。这是MySQL默认的隔离级别。
- 串行化
Serializable
是指事务对数据的读写都是串行化的
从上到下,隔离性变强了但是性能变差了,所以一个提升MySQL性能最简单的方式,就是把隔离级别往下调,这也是我们的一个亮点方案。
和隔离级别密切相关的概念是脏读、幻读和不可重复读 这三个读异常。
- 脏读:读到了别的事务还没有提交的数据。之所以叫做脏读,就是因为未提交数据可能会被回滚掉。
- 不可重复读:在一个事务执行过程中,对同一行数据读到的结果不同
- 幻读:事务执行过程中,别的事务插入了新的数据并且提交了,然后事务在后续步骤里读到了这个新的数据。
用一个表用来描述隔离级别和三种读异常的关系:
理论上,可重复读是没有解决幻读的,但是因为MySQL因为使用了临键锁,因此它的可重复读隔离级别已经解决了幻读问题。
此外还有一个相似的概念:快照读和当前读。
快照读就是在事务开始的时候创建了一个数据的快照,在整个事务过程中都读这个快照;
当前读,则是每次都去读最新数据。
MySQL在可重复读这个隔离级别下,查询的执行效果和快照读非常接近。
版本链
为了实现MVCC,InnoDB引擎给每一行都加了两个额外的字段trx_id
和roll_ptr
trx_id
:事务ID,也叫做事务版本号。MVCC里面的V指的是这个数字,每一个事务在开始的时候就会获得一个ID,然后这个事务内操作的行的事务ID,都会被修改为这个事务的IDroll_ptr
:回滚指针,InnoDB通过roll_ptr
把每一行的历史版本串联在一起。
实际上,InnoDB引擎还隐式地插入了另外一个列row_id
,如果你没有设置任何主键,那么这个列就会被当作主键来使用。但是它其实和MVCC没太大关系,所以不需要关注。
下面用一个例子来说明MVCC是如何利用这两个列地。
假设最开始我插入了一行数据,我插入数据的这个事务的ID是100,那么这个时候数据行看起来是这样的。
假设有一个事务A拿到了ID 101,然后把x的值修改为15,那么就会变成这样。
这个时候,事务A修改后的roll_ptr
会指向初始状态的数据。假如现在再来一个事务B拿到ID 102,要把数据修改为20,那么就会变成下面这样。
这条链就是大名鼎鼎的版本链,这个版本链存储在所谓的undolog里。
问题来了:假如这个时候我有一个新的事务 C,我要读 x 的值,那么我该读取 trx_id 为几的数据呢?这就涉及到了另外一个和 MVCC 紧密相关的概念:Read View
Read View
可以理解为一种可见性规则,undolog里存放着历史版本的数据,当事务内部要读取数据的时候,Read View
就被用来控制这个事务应该读取哪个版本的数据。
Read View
最关键的字段叫做m_ids
,代表的是当前已经开始,但是还没有结束的事务ID,也叫做活跃事务ID。
Read View
只用于已提交读和可重复读两个隔离级别,它用于这两个隔离级别的不同点就在于什么时候生成Read view
- 已提交读:事务每次发起查询的时候,都会重新创建一个新的
Read view
- 可重复读:事务开始的时候,创建
Read view
一个很有意思的类比:已提交读就像你的渣男朋友,你每次见到他,他都会换一个新对象;而可重复读就是一个痴情男,你每次见到他,看到的都是他高中时候谈的对象。
Read view
与已提交读
在已提交读的隔离级别下,每一次查询都会产生一个新的Read view
。这意味着在事务执行过程中,Read view
是在不断变动的。假如说现在已经有三个事务了,状态分别是已提交、未提交、未提交。
假如说现在新开了一个事务A,分配给它的ID是4。如果这个时候A开始查询x的值,那么MySQL会创建一个新的Read view
,其中m_ids = 2,3
。事务A发现最后一个已经提交的事务trx_id=1
,对应的x是1,于是事务A读到x=1。
这个时候事务2提交了,事务A再次读取x,这个时候MySQL又会生成一个新的Read view
,m_ids=3
,因此事务A会读取到x=4
Read view
与可重复读
在可重复读的隔离级别下,数据库会在事务开始的时候生成一个Read view
,这意味着整个Read view
在事务执行过程中都是稳定不变的。
用前面的例子来说明,就是在事务A开始的时候就会创建出来一个Read view
m_ids=2,3
这个时候事务A去读x的数据,毫无疑问,读出来都是1
这个时候如果事务2提交了,然后事务A想要再去读x的值,Read view
不会发生变化,即m_ids=2,3
。所以,虽然事务2提交了,但是事务A不知道这回事,因此还是读到x=1
万一这时候有一个新事务 ID = 5 开始了,并且也提交了。那么事务 A 并不会读取这个新事务的数据,因为新事务 ID 已经大于事务 A 的 ID 了(5 > 4),事务 A 知道这是一个比它还要晚的事务,所以会忽略新的事务的修改。
Read View
总结
实际上和Read View
相关的概念还有三个
m_up_limit_id
指的是m_ids
中的最小值m_low_limit_id
指的是下一个分配的事务IDm_creator_trx_id
当前事务ID
m_up_limit_id 在左边,而 m_low_limit_id 在右边
面试准备
了解清楚公司数据库的隔离级别,如果公司设置的不是默认的隔离级别,那么要搞清楚为什么不使用默认的隔离级别。尤其是用了未提交读、串行化两个隔离级别,更加要弄清楚。
在面试过程中,面试官会出一些很难让人反应过来的问题,比如说面试官会口头构造一条版本链。
我现在有三个事务,ID 分别是 101、102、103。如果事务 101 已经提交了,但是 102、103 还没提交。这个时候,我开启了一个事务,准备读取数据,那么我读到的是哪个事务的数据?
如果这时候事务 103 提交了,但是 102 还没提交,那么会读到谁的呢?
第一个问题是事务101
第二个问题需要根据隔离级别来回答了
基本思路
有的时候在面了锁之后,将话题引到MVCC,问你为什么有了锁还需要MVCC?回答的关键词是避免读写阻塞
单纯使用锁的时候,并发性能会比较差,即使是在读写锁这种机制下,读和写依旧是互斥的。而数据库是一个性能非常关键的中间件,如果某个线程修改某条数据就让其他线程都不能读到这条数据,这种性能损耗是无法接受的。所以InnoDB引擎引入了MVCC就是为了减少读写阻塞。
大部分的时候,面试官在问MVCC的时候,都直接问你这几个问题
- 你是否了解MVCC?
- MVCC是什么
- MySQL的InnoDB引擎是怎么控制数据并发访问的?
- 当一个线程在修改数据的时候,另外一个线程还能不能读到数据
按照:基本定义、实现机制、隔离级别的逻辑顺序来回答
MVCC是MySQL InnoDB引擎用于控制数据并发访问的协议。MVCC主要是借助版本链来实现的。在InnoDB引擎里面,每一行都有两个额外的列,一个是
trx_id
,代表的是修改这一行数据的事务ID;另外一个是roll_ptr
,代表的是回滚指针。InnoDB通过回滚指针,将数据的不同版本串联起来,也就是版本链。这些串联起来的历史版本,被放到了undolog里面。当某一个事务发起查询的时候,MVCC会根据事务的隔离级别来生成不同的Read View
,从而控制事务查询最终得到的结果。
首先,回答里提到了undolog,面试官可能追问undolog、redolog或binlog的细节,这一部分可以把话题引到下一节课的内容。
其次,回答中提到了隔离级别,并提到了Read View
是和隔离级别有关的东西,面试官就会非常深入的问隔离级别的基本定义、MVCC是怎么利用Read View
来实现已提交读和可重复读的。
在回答的时候,要先解释清楚四个隔离级别和三个读异常,然后强调一下InnoDB引擎。
在MySQL的InnoDB引擎里,使用了临键锁来解决幻读的问题,所以实际上MySQL InnoDB引擎的可重复读隔离级别也没有幻读的问题。一般来说,隔离级别越高,性能越差,所以我之前在公司做的一个很重要的事情,就是推动隔离级别降低为已提交读。
这个回答的最后,就可以尝试把话题引导到下面的亮点方案中。
亮点方案
重点要描述清楚两方面的内容
- 推动公司把隔离级别从默认的可重复读降低为已提交读
- 在已提交读的基础上,万一需要利用可重复读的特性,该怎么办?
从前面的内容中你已经知道,MySQL 的默认隔离级别是可重复读,实际上互联网的很多应用都调整过这个隔离级别,降低为已提交读。那么你在面试的时候可以考虑使用这个来作为你的亮点方案。首先你要强调为什么要改。
最开始我来到公司的时候,我们的数据库隔离级别都是使用默认的隔离级别,也就是可重复读。但其实我们的业务场景很少利用可重复读的特性,比如说几乎全部事务内部对某一个数据都是只读一次的。
并且,可重复读比已提交读更加容易引起死锁的问题,比如说我们之前就出现过一个因为临键锁引发的死锁问题。而且已提交读的性能要比可重复读更好。所以综合之下,我就推动公司去调整隔离级别,将数据库的默认隔离级别降低为已提交读。
在这种情况下,面试官可能会追问你:“在调整了事务级别之后,万一需要可重复读的特性了,你怎么办?”
首先你要理解在什么样的场景下你才会需要可重复读这个隔离级别。
- 你需要在事务中发起两次同样的查询,并且你希望两次得到的结果是一样的。
- 你需要避开幻读,也就是事务开始之后,即便有别的事务插入了数据并且提交了,你也不希望读到这个新数据。
但是仔细想想,你真的存在这种场景吗?或者说,你真的没得选,以至于一定要使用可重复读这个隔离级别吗?
答案是几乎没有。大部分出现可重复读的需求都是因为代码没有写好,或者说至少可以通过改造业务来实现。比如说常见的可重复读,既然你需要读多次,那么自然可以在第一次读完之后缓存起来。
不过幻读是没有办法通过业务改造来解决的。但是在业务层面上,幻读一般不会被认为是一个问题,原因有两点:一是你分不清是不是幻读。比如说你在事务 A 里面读到了一条数据,你判断不出来它是在事务 A 开始之前就插入的,还是在事务 A 开始之后,事务 B 才插入并且提交的。
二是事务提交往往意味着业务已经结束,所以读到一个已经提交的事务的数据,不会损害业务的正确性。也就是说,如果事务A在开始之后,事务B才插入数据并且提交,那么这个时候事务A完全可以认为事务B所在的整个业务已经结束了,所以读出来也没什么问题。
回答的关键词是改造业务
正常来说是不推荐使用可重复读的,因为在我们的业务环境下想不到有什么场景非得使用可重复读这个隔离级别。
之前在推动降低隔离级别的时候,其实重构过一些业务。这一类业务就是在一个事务里面发起了两个同样的查询,比如在UPDATE之后又立刻查询,这种查询还必须走主库,不然会有主从延迟的问题。
这种业务可以通过缓存第一次查询的数据来避免第二次查询。但是这种改造一般是避不开幻读的。不过在业务上幻读一般不是问题。一方面是业务层面上区分不出来是否是幻读。另外一方面,事务提交了往往代表业务已经结束,那么发生幻读了,业务依旧是正常的。比如说事务 A 读到了事务 B 新插入的数据,但是事务 B 本身已经提交了,那么事务 A 就认为事务 B 所在的业务已经完结了,那么读到了就读到了,并不会出什么问题。
兜底的手段是:指定隔离级别
万一不能改造业务,那么还有一个方法,就是直接在创建事务的时候指定隔离级别。我前面调整的都是数据库的默认隔离级别,实际上还可以在 Session 或者事务这两个维度上指定隔离级别。