MySQL中的事务,锁和 MVCC 是怎么个事?

事务

我们平时在开发中是离不开 MySQL 的事务的,那什么是事务?事务就是逻辑上的一组操作,要么一荣俱荣,要么一损俱损,谁都别活。 事务的目的就是一个,保证数据的一致性。

事务我们常说有四个特性,ACID,那这些的含义是什么?

  1. A(Atomicity) 原子性: 事务是最小的执行单位,不允许分割,要么全部完成,要么完全失去作用。
  2. C(Consistency)一致性: 执行事务前后,数据保持一致,我给你 100 ,那么我就减少 100 ,你增加 100,咱俩的钱的总量是不变的。
  3. I(Isolation)隔离性: 每个事务之间的隔离的,互不影响。
  4. D(Durability)持久性: 一个事务提交之后,对数据库的改变是持久的。

而原子性,隔离性,持久性都是手段,究极目的都是为了一致性。

因为存在并发的事务,提升了性能,也会出现一些问题。而这些问题也是通过很多手段进行解决,我们细细道来~

脏度

一个事务读取到了另一个事务还没有提交的数据,这就是脏读。举个栗子,比如,A 事务和B 事务同时进行,A 事务将数据 a 从10改成了 20,B 事务读取了 a 的值是 20,当 A 回滚了的话,B 事务还是用 20 进行的后续处理,这就是脏读,也是比较严重的问题。

丢失修改

还是举个栗子,AB 两个事务同时进行,A 事务读取 a 的值为 20,然后进行-1 得到 19,B 事务读取 a 的值也是 20 然后进行-1 也是 19,两个存储库之后发现结果为 19, 丢失了率先提交的事务的数据。

不可重复读

当一个事务对一个数据进行多次查询的时候,因为别的事务进行了修改并提交,那么可能会导致,这个事务中多次读取到的数据是不一致的,这就叫不可重复读。

幻行读

和不可重复读的场景类似,区别就是,读取几行数据的时候,比如范围查询,age<50 ,读到了 5 行数据,然后其他事务也插入了 age 小于 50 的数据,那么这个事务再次执行这个查询的时候会发现数据多了,这就是幻行读。

为了解决这些问题,MySQL 也弄出了很多的方案,首当其冲的就是隔离级别

SQL 的隔离级别

  1. 读未提交: 最低的隔离级别,可以读取未提交的数据,脏读,不可重复读,幻行读都可能会发生。
  2. 读已提交: 允许读取已经提交的数据,避免了脏度的出现,但是不可重复读和幻行读仍然可能出现。
  3. 可重复度: 对同一个字段的多次的读取结果是一致的,除非自己修改的,避免了脏读和不可重复读,但是仍然存在幻行读的情况。
  4. 可串行化: 最高的隔离级别,都特么别玩了,事务给我一个一个走,三种问题都避免了,效率直接拉低到谷底。

InnoDB 存储引擎默认的隔离级别是可重复度,也就是 RR ,为什么呢?这就和 MySQL 的机制有关系了,MySQL 我们很常见的特性就是集群部署,读写分离,主从复制,而主从复制是依赖于 binlog 的,我在之前的文章讲解 binlog 的时候说过,binlog 记录数据是是以逻辑SQL 的形式的,有三种模式,分别是 statementrow和mixed ,具体的大家翻我前面的文章,statement 记录的是 SQL 的原文。好了举个栗子。在 RC 的隔离级别下

这样的执行过程之后因为事务 ** ** 是先提交的,那么 binlog 里面就先记录了 insert ,然后再记录 delete ,那么同步从库的时候就全给删了,这属于幻行读的一部分,所以 MySQL 默认隔离级别是 RR , 那么总不能允许幻行读的存在吧,所以就依赖两种机制来避免,就是锁+MVCC 机制。我们先说锁

MySQL 同时支持表级锁和行级锁,表级锁就是锁整张表,加锁快,不会出现死锁,但是并发的场景下效率是非常低的。

共享锁和排他锁

落实到数据库层面都是加的这两种锁,其实也就是读锁和写锁。而且都是在 SQL 上有所体现的。

共享锁可以通过 select 。。。。 lock in share mode(Mysql5.7 或 8)/select 。。。for share 来显示指定。

排他锁可以通过select 。。。。for update 来显示指定。

意向锁

意向锁其实就是一种标志,我们上面说的锁都是对于表内数据的锁,而数据是有很多行了,如何判定表中的数据有没有锁呢?我们总不能遍历所有的数据然后找吧,所以就增加了意向锁,分为意向共享锁(IS),和意向独占锁(IX),也就是当我们想对数据增加锁的时候都会对表增加意向意向锁。

行级锁

行级锁不是单纯的锁一行,而是一个概念,是以行为单位的锁,在 MySQL 中,数据是以索引树的形式组织的,锁的相关都和索引相关,关于索引的问题,我会单独抽出一篇文章进行讲解。当deleteupdate 的时候的 where 条件没有命中索引或者索引不生效的情况下,会加表锁。

行级锁主要就是三种,记录锁(record lock ),间隙锁(gap lock ),和临键锁(next-key lock ),临键锁就是记录锁和间隙锁的组合,在 RR 的隔离级别下,默认的行级锁都是临键锁,但是啊,如果你操作的索引是唯一索引或者是主键,那么加的就是记录锁了。所以关键点还是锁一下这个临键锁,我会非常详细的推演整个锁的过程,看看是怎么敲定锁的范围的。

MySQL版本,8.0.34 ,表数据示例

1、主键等值查询,并且数据存在的情况下

sql 复制代码
begin;

select * from test where aid = 5 for update;

select * from performance_schema.data_locks 查看锁的状态

此时第一行加的是表锁,加的一个IX,就是意向独占锁,第二行是加的行级锁,类型是独占锁,锁的数据是主键为5的数据。

同理使用for share 就是加的IS 和S。X,REC_NOT_GAP: 行独占锁,锁定1行

所以当查询使用主键,并且数据存在的情况下,使用的是记录锁。

2、主键等值查询,但是数据不存在的情况

sql 复制代码
begin;

select * from test where aid = 7 for update;

select * from performance_schema.data_locks 查看锁的状态 

此时第一行加的是表锁,加的一个IX ,就是意向独占锁,第二行是加的行级锁,类型是独占锁+间隙锁,数据是10,因为10是不等于查询的7 的

所以是开区间,然后得到的区间就是 (5,10) ,5没有显示是因为5是第一条数据了,其实也就是起点;

开启另一个事务执行:

insert into test values(4,4,4,4); 成功!

insert into test values(8,8,8,8);失败

X,GAP: 独占锁,开区间

所以当查询使用主键,但是数据不存在的情况下,使用间隙锁,锁定范围就是小于查询数据最近和大于查询数据最近的范围,左开右开区间。

3、使用主键范围查询两个值都能查到的情况

sql 复制代码
begin; 

select * from test where aid >=5 and aid <=10 for update; 

commit;

第一行5的数据是行独占锁,所以就是左边5 闭合区间;

第二行10 的数据是X,所以就是Next-key lock 闭区间,最后得到的结果就是 [5,10]

开启新事务

执行insert into test values(8,8,8,8);执行失败;

执行insert into test values(4,4,4,4);执行成功!

所以使用主键范围查询两个值都能查到的情况,左闭右闭。下面就不演示都能查到的情况了。

4、使用主键范围,左边能查到,右边查不到的情况

sql 复制代码
begin; 

select * from test where aid >5 and aid <7 for update; 

commit;

没有5的行独占,所以区间是**(5,10)**

开启新事务

执行insert into test values(4,4,4,4);成功!

执行insert into test values(8,8,8,8);失败!

执行update test set a = 11 where aid =10;成功!

所以使用主键范围左边能查到右边查不到的情况,会往下找到最近的数据,然后开区间。

5、使用主键范围,左右都不能查到

sql 复制代码
begin; 

select * from test where aid>6 and aid<13 for update; 

commit;

区间就是 (5,15)

开启新事务:

insert into test values(4,4,4,4); 成功!

insert into test values(8,8,8,8);失败!

update test set a = 11 where aid =15;成功!

update test set a = 11 where aid =10;失败

所以使用主键范围,左右都查不到的情况下,那么就左边最近的数据作为左开区间,右边最近的数据作为右开区间。

主键和其他索引生效的效果差不多,差的就是锁的数据量的问题,毕竟索引是按照排序规则构建的,所以范围就是有序的一种查找。从上面我们就知道右边当右边的值能查到的时候。那个值就是边界,这个其实是修复过的bug ,之所以称之为Next-key lock 就是因为要锁住下一个key,早以前的一些版本中,它会从这个值之后往下找第一个不等于它的作为边界,还是看这个例子

sql 复制代码
begin;

select * from test where aid >=5 and aid <=10 for update;

commit;

所以上面的结果的区间就变成**[5,15]** ,把20给锁了,这个是我们不希望看到了。同时对于加锁规则,比如

sql 复制代码
begin; 

select * from test where aid>6 and aid<13 for update;

commit;

这个例子在8.0.17 版本下会锁住右边15的数据,还是闭合区间,在8.0.18版本修复了。

我用的是8.0.34 ,这些问题都没有出现,应该是在8.0.25版本之后就没有这两种问题了,现在所看到的结果更像是我们期待的结果。

同理如果左边不能查到,右边能查到,那么就左边最近的数据作为左开区间,右边数据就是右边界,开闭取决于等于不等于。

小总结: 所以加锁能一定程度上解决读数据的一些问题,但是加锁就代表着有额外的开销,那么就会影响性能,在 RC 的隔离级别下,是不会使用间隙锁和临键锁的,所以就会产生不可重复读和幻行读的情况,但是我们很多业务情况下都是可以不去考虑这些问题的,比如你要更新年龄小于 15 的数据,那么即便是幻读了,但是也是年龄小于 15 的数据,对结果也是没有影响的,相反的情况下,如果额外增加锁去控制,反倒会增加开销,甚至是增加死锁的概率。所以很多大厂在即便是默认隔离级别是 RR 的情况下,也会选择 RC 的原因,遇到这种极端情况,可以用其他的手段去解决。关于死锁的问题,放到讲解索引的地方说。

当前读与快照读

快照读就是读取快照生成的那一刻的数据,比如我们不加锁的select 语句都是快照读,当前读就是加锁的 select,或者增删改的语句。其实快照读是很关键的,在 RC 的级别下,每次读取都会生成一个快照,总是读取行的最新版本,这也就是不可重复读的产生原因。而在RR 的级别下,快照在事务中第一次 select 的时候生成,然后只有本事务对数据进行更改才会更新快照。 这两个很重要,先记住。而如果在 RR 下使用了当前读,那么就要增加next-key lock来控制幻读的产生。

MVCC详解

当我们理清了锁的概念以及隔离级别之后,我们就可以进入 MVCC 了,MVCC 也就是多版本并发控制,这是一种对于并发数据一致性控制的一个手段,并不是某些引擎独有的,只是实现细节不同,下面我将会对 Innodb 引擎在 RCRR 隔离级别的场景下,来讲一下它的实现过程。

我上面说了,读取和写入都是依赖于数据快照的,不可能直接修改数据,都是修改完然后再放过去。而快照基于版本,多次更新就是多个版本,对于读操作,那么就是我要读取到不晚于我这个事务开始时间的最新版本。

对于写操作也是类似,我要写数据,那么我一定对我获得的这个数据快照升一个版本,然后依赖于这个新版本对并发修改进行控制。很有CAS 那个味道。

一个事务成功提交,那么它提交的这个数据版本,就是数据库最新的版本,对其他事务可见。

一个事务回滚,那么所做的修改全部撤销,版本失效,对其他事务不可见。

所以数据库事务的版本将会频繁更新,所以 MVCC 也会定期更新,删除不再需要的旧版本数据,释放空间。

MVCC 的基础概念和核心属性

MVCC 的实现依赖于下面这三个:

  1. 隐藏字段: InnoDB 为每行记录增加了三个隐藏字段

    DB_TRX_ID: 代表着最后一次插入或者更新该行的事务 id ,对于 delete 操作,在内部视为更新操作,会在记录头的 delete_flag 设置为已删除。

    DB_ROLL_PTR: 回滚指针,指向该行的 redolog,如果没被更新过,那么就是空。

    DB_ROW_ID: 如果没有设置主键且没有唯一索引的情况下,会使用这个id 来生成聚簇索引。

  2. ReadView: 这个也是算法的核心,依赖于这里面的数据进行判定数据是否可见,结构如下

java 复制代码
class ReadView {
  /* ... */
private:
  // 下一个将被分配的事务 id,所以大于等于这个 id 的事务的版本是不可见的
  trx_id_t m_low_limit_id;  
  // 活跃事务列表,也就是事务开启的时候,还没有提交事务的 id 列表,不包括当前事务和已经提交的事务
  ids_t m_ids; 
  // 活跃事务列表中最小的事务id,如果活跃事务为空,那么这个值就等于上面那个即将被分配事务的 id
  //小于这个id的事务都是可见的
  trx_id_t m_up_limit_id;      
  //创建该 Read View 的事务ID
  trx_id_t m_creator_trx_id;
  //事务 Number, 小于该 Number 的 Undo Logs 均可以被清除
  trx_id_t m_low_limit_no;
  //标记 Read View 是否 close
  m_closed;
}
  1. undo log : 这个之前将日志相关的知识点的时候说过了,主要就是用它进行回滚,它有个很关键的点就是它一旦事务提交就会将 log 标记成可回收状态,然后使用清理线程清理,它分为两类,一种是insert undo log ,一种是 update undo log ,对于 insert 来说新增进入的数据只能在当前事务中可见,回滚那么就删除就行了,对于 update 来说,可能是需要更新多次的,所以就需要形成一个链表,链表的尾部就是读取到的最原始数据,链表的头部就是更新的最新的数据(理解成头插法)。undolog不是事务私有的,所有的事务都会在这里记录的,所以 undolog 也记录了事务的 id 和指向上一次修改的 undolog 指针,从而形成链表,这个很关键。

数据可见性算法

上面说的都是这个算法所需要依赖的数据,在 Innodb 存储引擎中,在开启一个事务之后,在执行 select 之前都会创建一个 Read View,也就相当于打了一个快照,然后比较快照数据来决定数据是否可见。过程如下:

  1. 如果读取记录的DB_TRX_ID事务id 小于m_up_limit_id活跃事务的最小 id,那么表明记录已经提交了,是可见的。
  2. 如果读取记录的DB_TRX_ID事务id 大于等于m_up_limit_id活跃事务的最小id那么跳到步骤 5
  3. 因为m_up_limit_id可能等于当前的事务 id ,所以再判断一下活跃事务列表是否为 null,如果为空说明修改该行的事务已经提交了,那么就可见
  4. 如果读取记录的DB_TRX_ID事务id 大于等于m_up_limit_id活跃事务的最小id 且小于m_low_limit_id即将分配的事务 id ,那么就说明记录的DB_TRX_ID事务id 可能在活跃事务列表中,那么就需要查找一下,如果找到了,说明我创建 ReadView 之前这个记录已经被记录的DB_TRX_ID事务id 但是没有提交,或者创建 ReadView 之后这个记录被记录的DB_TRX_ID事务id修改了,这些都不可见,继续步骤5 往上找。 如果没找到,说明已经提交了,就可见。
  5. 根据记录行上面记录的 undolog 的指针取出快照记录,然后继续步骤 1 用快照记录的 DB_TRX_ID,直到找到满足的快照版本或者返回空。

看个流程图更清晰一些

所以这个 ReadView 很关键,在 RC 下每次都会生成一个新的 ReadView,所以就会造成不可重复读,前后看见的数据可能不一致,但是在 RR 的隔离级别下,只会用第一次生成的 ReadView,实现了可重复读也防止了快照下的幻读。

相关推荐
希冀12321 分钟前
【操作系统】1.2操作系统的发展与分类
后端
GoppViper1 小时前
golang学习笔记29——golang 中如何将 GitHub 最新提交的版本设置为 v1.0.0
笔记·git·后端·学习·golang·github·源代码管理
爱上语文2 小时前
Springboot的三层架构
java·开发语言·spring boot·后端·spring
serve the people2 小时前
springboot 单独新建一个文件实时写数据,当文件大于100M时按照日期时间做文件名进行归档
java·spring boot·后端
这孩子叫逆7 小时前
6. 什么是MySQL的事务?如何在Java中使用Connection接口管理事务?
数据库·mysql
罗政7 小时前
[附源码]超简洁个人博客网站搭建+SpringBoot+Vue前后端分离
vue.js·spring boot·后端
拾光师9 小时前
spring获取当前request
java·后端·spring
掘根10 小时前
【网络】高级IO——poll版本TCP服务器
网络·数据库·sql·网络协议·tcp/ip·mysql·网络安全
Java小白笔记10 小时前
关于使用Mybatis-Plus 自动填充功能失效问题
spring boot·后端·mybatis
Bear on Toilet11 小时前
初写MySQL四张表:(3/4)
数据库·mysql