MySQL事务、锁、MVCC

526 阅读13分钟

耳闻能详

事务: ACID

锁:乐观锁、悲观锁、表锁、行级锁、间隙锁、记录锁、自增锁、意向锁。。。

MVCC:多版本并发控制版本,是MySQL数据库用来提高读写效率的,它就相当于java的JUC并发中的cas (cas可参考 Java 并发之CAS概念以及Atomic**类

先谈MVCC

基础知识

当前读

  1. 概念:读取到的数据是最新版本,总是读取到最新版本的数据,并且当前读返回的数据都会加上锁,保证其他事务不会并发的修改这条记录。
  2. 场景:insert、update、delete、select ... lock in share modelselect .....for update

其中,lock in share modelfor update是加锁的操作。

快照读

  1. 概念:读取到的数据是历史版本的记录,不用加锁
  2. 场景:select ...

Innodb事务的隔离级别

  1. 读未提交:只要修改了数据,不管有没提交,其他事物都可以马上看到数据
  2. 读已提交:已提交读也可以成为读已提交,已提交读可以解决脏读的问题,它的级别比读未提交要高,所以,将隔离级别设置为读已提交后,其他事物修改了数据必须提交后,当前事务才可以看到
  3. 可重复读:同一个事务下,每次查询的结果必须是一致的
  4. 串行化:一个事务执行完,其他事务才会执行,按顺序执行。

其中,可重复读是Innodb的默认隔离级别。

关于隔离级别相关的知识点,可以参考这篇博客:图解mysql事务的四个隔离级别

举个案例引出MVCC

现在有两个事务,事务内容如下:

image.png

问:A能否读取到B修改后提交的数据?

答:在RC(读已提交)的隔离级别下:能 在RR(可重复读)的隔离级别下,不能。 (这个也很有意思,注意一下,继续往下面阅读,你可能会对这发出疑问)

原因:这就涉及到了MVCC和它的可见性算法

何为MVCC

包括了三部分:

1. 隐藏字段

在数据库表中,除了用户自定义的数据字段,还存在三个用户不可见的字段

  • DB_TRX_ID创建或者最后一次修改该记录的事务id
  • DB_ROW_ID隐藏主键,在你创建的表中没有设置主键的时候生效
  • DB_ROLL_PTR回滚指针

2. undolog 回滚日志

主要体现在事务的原子性上,当事务失败时,就是根据undolog来进行事务回滚的。

  • 不同的事务同一条记录进行修改的时候,该记录的undolog会形成一个链表,当不同的事务进行修改时,将最新的记录插入到链表头链表尾存放最早的数据记录

3. readview 读视图

事务在进行快照读的时候产生的读视图 (注意这句话,很有意思的)

readview也存放三个字段:

  • trx_list: 系统活跃的事务id
  • up_limit_id: 列表中最小的事务id
  • low_limit_id: 系统尚未分配的下一个事务id

何为可见性算法

可见性算法是在你要进行读取数据的时候所需要用到的Innodb规定的规则,规则如下:

  1. 首先比较DB_TRX_ID < up_limit_id,如果小于,则当前事务能看到DB_TRX_ID所在的记录,如果大于等于进入下一个判断
  2. 接下来判断DB_TRX_ID >= low_limit_id,如果大于等于则代表DB_TRX_ID所在的记录在Read View生成后才出现的,那么对于当前事务肯定不可见,如果小于,则进入下一步判断
  3. 判断DB_TRX_ID是否在活跃事务中,如果在,则代表在Read View生成时刻,这个事务还是活跃状态,还没有commit,修改的数据,当前事务也是看不到如果不在,则说明这个事务在Read View生成之前就已经开始commit,那么修改的结果是能够看见的

MVCC+可见性算法实现事务读取数据问题

image.png

问:事务2的快照读能否读到事务4修改后的数据

答: RC和RR隔离级别下都可以

是不是很疑惑,RR情况下不是读不到的嘛?

那我们就来根据可见性算法分析一下,我们先列出我们需要的字段以及他们对应的值

image.png

再通过可见性算法进行分析,你会发现,确实是可以读取到修改的数据。

这就很奇怪了,这和我们生活中实际遇到的情况是不一样的,也就是上面我让你注意的 在RC和RR两种隔离级别下能否读取数据的那个回答

不必疑惑,这其实和readview的创建有关系

RC:每次进行快照读都会创建新的readview
RR:除了第一次进行快照读会创建新的readview,第二次及第二次以后的快照读都不会创建新的readview,
而是使用第一次创建的那个readview(前提:整个事务里面只出现了快照读)

所以说,在RR隔离级别下,不能读取到修改后的数据的情况就是下面这种情况。

image.png

分析如下:

  • 在事务2开启后,进行的快照读中创建readview视图,事务2再次访问事务4修改的数据所使用的readview就是一开始创建的readview。
  • 最小的事务id:1
  • 系统尚未分配的下一个事务id:5
  • 最后一次修改记录的事务id:4

image.png

再根据可见性算法,很容易知道:在RR隔离级别下,读取不到修改的数据。(是不是明白了,跟发现新大陆一样),这就是MySQL的MVCC了~~

也就是说,如果在RR隔离级别下,如果在进行一次事务之前已经有了一次快照读,而新事务提交的数据再次进行快照读下看不到

注意点:

  • 在RR隔离级别下,如果事务中只有快照读,那么第二次及第二次以后的快照读都会使用第一次的readview
  • 在RR隔离级别下,如果事务中除了快照读,还出现了当前读,那么readview会重新创建 -- 这也就是幻读的产生
  • RR没有解决幻读问题,而是通过间隙锁的方式来解决幻读问题。

再谈一下事务的ACID

老生常谈的事务四大特性:原子性一致性隔离性持久性

通过上面对MVCC原理的讲解,你就会发现:事务的原子性是通过undolog来实现的、隔离性是根据MVCC来实现的,而你还需要知道的是,事务的持久性是通过redolog来实现的,而原子性+隔离性+持久性共同实现了事务的一致性。

事务的持久性

上面说过事务的持久性是通过redolog实现的,那具体是怎么实现的呢?这就涉及到了redolog的二阶段提交

redolog的二阶段提交

redolog的二阶段提交又涉及到一个机制:WAL(write ahead log)预写日志机制 (先写日志,再写数据)

我们都知道数据的读写有两种方式:随机读写和顺序读写,随机的读写效率要低于顺序读写,而在我们的现实操作中大部分情况下是随机读写,所以WAL出现了,它先将数据通过顺序读写的方式写到日志文件中,然后再将数据写入到对应的磁盘文件中,这种方式是比直接进行随机读写效率要快的,同时这也保证了数据的持久性因为我们先将数据写入到日志中,只要日志保存好了,那么不管数据有没有写入到磁盘中,数据都不会丢失。

如果你在网上搜redolog的二阶段提交相关的资料,你会发现,他们都提到了binlog。又是一个新的名词,不要慌,它其实就是一个二进制格式的日志文件,它记录了你对数据库进行的操作,是mysql自带的。

那他又有什么用呢?不要着急,直接给你上图理解(以更新数据为例子)

image.png

图解:

  1. 执行器先从引擎中找到数据,如果在内存中直接返回,如果不在内存中,查询后返回
  2. 执行器拿到数据之后会先修改数据,然后调用引擎接口重新写入数据
  3. 引擎将数据更新到内存,同时写数据到redolog中此时处于prepare阶段,并通知执行器执行完成,随时可以操作
  4. 执行器生成这个操作的binlog
  5. 执行器调用引擎的事务提交接口,引擎把刚刚写完的redo改成commit状态,更新完成

所谓的二阶段提交就是 数据第一次写入到redolog,redolog处于prepare阶段,然后再将数据操作写入到binlog中,写入后再将prepare状态改为commit状态。

  • 第一阶段:数据写入到redolog,redolog处于prepare阶段
  • 第二阶段:数据写入binlog,redolog的prepare阶段改为commit阶段。

那为什么需要二阶段提交呢? -- 保证数据的持久性

还是以上图为例:

image.png

思考一下:

  • 当你的操作执行到 ① 处的时候,断电了,数据会怎么样?
  • 当你的操作执行到 ② 处的时候,断电了,数据会怎么样?

首先你要知道的一个点是:断电了,数据库就要进行数据恢复,而数据库的数据恢复是通过redolog和binlog来实现的。数据库会比较redolog和binlog的数据,通过比较结果选择如何进行数据恢复。

知道了这点就好办了。

对于第一种情况来说,redolog中有数据记录,binlog中没有数据记录,而且redolog中的状态是prepare状态,不是最终的commit状态,所以说这时候数据库会选择把redolog的prepare状态丢失掉,并进行数据回滚。

对于第二种情况来说,redolog中和binlog中都有数据记录,进行数据库恢复时,先去看binlog,binlog将redolog的状态从prepare改为了commit,且这时候的数据记录是一样的,所以说这时候数据库会将数据提交更新,而不是将数据丢弃。

注意:两阶段提交要保证redolog和binlog都写成功才能保证数据的恢复正常。 如果只有其中一个日志写入成功了就会出现下面这两种情况:

  • 先写redo log后写binlog:假设在redo log写完,binlog还没有写完的时候,MySQL进程异常重启。由于我们前面说过的,redo log写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行c的值是1。但是由于binlog没写完就crash了,这时候binlog里面就没有记录这个语句。因此,之后备份日志的时候,存起来的binlog里面就没有这条语句。然后你会发现,如果需要用这个binlog来恢复临时库的话,由于这个语句的binlog丢失,这个临时库就会少了这一次更新,恢复出来的这一行c的值就是0,与原库的值不同。
  • 先写binlog后写redo log:如果在binlog写完之后crash,由于redo log还没写,崩溃恢复以后这个事务无效,所以这一行c的值是0。但是binlog里面已经记录了“把c从0改成1"这个日志。所以,在之后用binlog来恢复的时候就多了一个事务出来,恢复出来的这一行c的值就是1,与原库的值不同。

最后再说一下数据库中的锁吧

锁的类型

  1. 共享锁:也叫读锁,S锁;当一个事务加上读锁,其他事务只能对该事务加读锁,不能加写锁,直到读锁被释放
    作用:支持并发的读取数据,读取数据的时候不支持修改,避免出现重复读的问题
  2. 排它锁:也叫写锁,X锁;当一个事务加上写锁,其他事务不能对该事务加其他任何锁
    作用:在数据修改的时候,不允许其他人同时修改,也不允许其他人读取,避免了出现脏数据和脏读的问题。
  3. 行锁:锁住表中的一行或多行的数据,其他事务只能访问到没加锁的数据,加锁的数据访问不到
    特点:粒度大,加锁简单,容易冲突;
  4. 表锁:锁住整张表的数据,其他事务必须等到当前加锁的事务释放锁才能对表进行访问
    特点:粒度小,加锁比表锁麻烦,不容易冲突,相比表锁支持的并发要高
  5. 记录锁:行锁的一种,锁住的是某一行的数据
    特点:加了记录锁之后数据可以避免数据在查询的时候被修改的重复读问题,也避免了在修改的事务未提交前被其他事务读取的脏读问题
  6. 间隙锁:是属于行锁的一种, 间隙锁是在事务加锁后其锁住的是表记录的某一个区间,当表的相邻ID之间出现空隙则会形成一个区间,遵循左开右闭原则
    特点:范围查询并且查询未命中记录,查询条件必须命中索引、间隙锁只会出现在RR隔离级别的事务中。
  7. 意向锁:当一个事务带着表锁去访问一个被加了行锁的资源,那么,此时,这个行锁就会升级为意向锁,将表锁住

重点讲一下上面提到的,在RR级别下解决幻读所使用到的锁--间隙锁

间隙锁加锁的逻辑:

  1. 加锁时以Next-Key为基本单位
  2. 查找过程中扫描过的范围才加锁
  3. 唯一索引等值查询,没有间隙锁,只加行锁
  4. 索引等值查询最右一个扫描到的不满足条件值不加行锁
  5. 索引覆盖且只加S锁时,不锁主键索引

Next-Key单位的定义:扫描到的区间+后一个等值形成(a,b]

光听概念肯定很难理解,直接上例子方便理解:

image.png

其中,id是主键,c是辅助索引

  • 情况一:等值查询间隙锁

image.png

  • 情况二:非唯一索引等值锁

image.png

  • 情况三:主键索引范围锁

image.png

  • 情况四:非唯一索引范围锁

image.png

  • 情况五:非索引字段查询

image.png

注意:在当前读的情况下,不要查询没有索引的项目(对应为情况五,加了大粒度的锁,不好~~)