一篇文章搞定MySQL的事务底层MVCC与锁机制

1 MySQL的事务

1.1 事务是什么?

MySQL中的事务是由存储引擎实现的,并且只有InnoDB存储引擎才支持事务。
事务必须具有以下特性:

原子性(Atomicity):事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位。

一致性(Consistency):事务开始前和结束后,数据库的完整性约束没有被破坏 。比如A向B转账,不可能A扣了钱,B却没收到。

隔离性(Isolation):同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账。

持久性(Durability):事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。
 

1.2 MySQL事务隔离级别与并发关系

事务的隔离级别

脏读

不可重复读

幻读

READ UNCOMMITTED

READ COMMITTED

REPEATABLE READ

SERIALIZABLE

脏读:事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据

不可重复读:事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致。

幻读:系统管理员A将数据库中所有学生的成绩从具体分数改为ABCDE等级,但是系统管理员B就在这个时候插入了一条具体分数的记录,当系统管理员A改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读。

不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增。

2 如何解决事务中的不可重复读?

2.1 InnoDB 底层采用了MVCC模式保障可重复读

1. 什么是MVCC?

MVCC 是 MySQL InnoDB 实现功能,主要目的是为了提高数据库并发性能,有更好的方式去处理读-写冲突,即使有读写冲突时,也能做到不加锁,非阻塞并发读。

2. 什么是当前读和快照读?

当前读:顾名思义就是读取当前最新的数据,并且对读取的数据加锁,阻止其他事务同时修改相同的记录,避免出现安全问题。下面这些场景会使用当前读: 
update、delete、insert、select … lock in share mode、select … for update

快照读:InnoDB使用多版本控制在某个时间点向查询提供数据库的快照。InnoDB在隔离级别读已提交可重复读上使用,快照读不会在访问的表上加任何锁,其他会话可以同时修改表上的数据。

读已提交可重复读的区别

读已提交级别下,每次select都会生成一个快照(readview)。
可重复读级别下,开启事务之后第一个select才会生成快照readview(而不是事务一开始就生成快照),后续所有的查询都会使用同一个readview。

3. 什么是 Read View?

Read View 中四个比较重要的概念:
m_ids:当前系统中活跃的事务id列表
min_trx_id:当前系统中活跃的事务中最小的事务id,也就是m_ids中最小的值;
max_trx_id:系统中应该分配给下一个事务的id值
creator_trx_id:表示生成该readview的当前事务id

基于当前活跃事务列表构成ReadView,当某个事务创建ReadView时,会将当前活跃的事务也加入其中。

在这里顺便介绍一下UndoLog

Undo Log是InnoDB十分重要的组成部分,它的作用横贯InnoDB中两个最主要的部分,并发控制(Concurrency Control)和故障恢复(Crash Recovery),InnoDB中Undo Log的实现亦日志亦数据。

4. RR 隔离级别是如何解决不可重复读的?
假定有表 t1只有一条记录如下

IDA
11

此时同时开启4个事务,事务ID分别为11、12、13、14,并且按照从上至下时间顺序操作,下表为展示InnoDB内部Readview及Undolog的详情

(解释)

上表中的第三行仅仅是为了能够生成,因为必须要有更新语句才会开启事务ID

第一步:事务14 更新a = 30 并提交事务

第二步:事务13 查询 a 的值为 30 

第三步:事务11 更新 a 的值为 40 并提交事务

第四步:事务13查询 a 的值为 30

第五步:事务12 更新 a 的值为 50 并提交事务

第六步: 事务13 查询 a 的值仍然为 30  (为什么是30呢?下面我们会解释)

上述表中的操作对应的UndoLog的版本链如下

(解释)

事务开始之前就有一条记录为 a 为 1的记录

第一步将 a 的值更新为30,此时记录一条UndoLog --> 1 | 30 | trx_id: 14 | roll_pointer 指向上一条 UndoLog

第三步将 a 的值更新为30,此时记录一条UndoLog --> 1 | 40 | trx_id: 11 | roll_pointer 指向上一条 UndoLog

第五步将 a 的值更新为30,此时记录一条UndoLog --> 1 | 50 | trx_id: 12 | roll_pointer 指向上一条 UndoLog

数据的可见性需要满足以下规则,顺着UndoLog版本链,依次遍历比对直到满足可见规则,则返回此UndoLog的记录

可见规则如下:

  1. 如果 trx_id 等于当前事务ID,说明该版本是当前事务自身操作的,数据可见。
  2. 如果trx_id小于min_id,说明该版本是已提交事务生成的,数据可见。
  3. 如果trx_id大于max_id,说明该版本是将来启动的事务生成的,数据不可见。
  4. 如果min_id<=trx_id<=max_id,就包括两种情况:
    •  trx_id在m_ids数组中:表示这个版本是未提交事务生成的,数据不可见,本事务可见。
    • trx_id不在m_ids数组中:表示这个版本是已提交事务生成的,数据可见。

此时我们结合UndoLog版本链和ReadView值来解释为什么第六步查询 a 的值为 30?

解释:

前面我们讲到,RR隔离级别,开启事务之后第一个select会生成 ReadView,后续所有的查询都会使用该ReadView

事务13 在第二步的select语句已经生成了ReadView : [11,12,13] 14  (解释:中括号为m_ids 的值,表示当前活跃的事务,14 为 max_id 值表示当前ReadView生成时最大的事务ID 

第六步:我们通过对比规则,看看查询到的值是什么?

1 最近一条UndoLog 的 trx_id 为 12  -->  min_id(11) <= 12 <=max_id (14) ,继续判断 12 在m_ids 数组  [11,12,13]  中,故此记录值50 不可见,继续查看下条UndoLog

2 下一条UndoLog 的 trx_id 为 11  -->  min_id(11) <= 11 <=max_id (14) ,继续判断 11 在m_ids 数组  [11,12,13]  中,故此记录值 40 不可见,继续查看下条UndoLog

3 下一条UndoLog 的 trx_id 为 14  -->  min_id(11) <= 14 <=max_id (14) ,继续判断 14 不在m_ids 数组  [11,12,13]  中,故此记录值 30 可见 。

补充:删除数据的处理方式
删除可以认为是update的特殊情况。假如要删除一行记录,会将版本链上最新一条记录复制一份,将行格式头信息中(record header)里面的(deleted flag)标志位置为true,表示当前记录已被删除。若顺着版本链访问到这条记录,(deleted flag)标志位为true,表示记录已删除,不返回数据。

至于第二步和第四步,阁下可以自己使用这个规则去推理一下,另外有兴趣的同学可以操作一下,下面提供同款数据和操作步骤

CREATE TABLE `t1`(
	`id` INT(11) UNSIGNED AUTO_INCREMENT,
	`a` int(11) NOT NULL default 0,
   PRIMARY KEY ( `id` )
)ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT INTO `t1` (`id`, `a`) VALUES (1, 1);

CREATE TABLE `t2`(
	`id` INT(11) UNSIGNED AUTO_INCREMENT,
	`a` int(11) NOT NULL default 0,
   PRIMARY KEY ( `id` )
)ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT INTO `t2` (`id`, `a`) VALUES (1, 1);
INSERT INTO `t2` (`id`, `a`) VALUES (2, 2);
INSERT INTO `t2` (`id`, `a`) VALUES (3, 3);
INSERT INTO `t2` (`id`, `a`) VALUES (4, 4);

3 RR隔离级别会出现幻读吗?

假定有表 t1记录如下,同时开启3个事务,并且按照从上至下操作时序,展示InnoDB内部readview及undolog详情

(解释)

update t1 set a = 30; 属于当前读,会更新把ID = 2 的记录也更新掉,那么会在ID为2 的UndoLog中加一条记录 2 30 12 roll_pointer ,此时最后一步查询时,通过规则判断

最近一条UndoLog 的 trx_id 为 13  等于 当前操作的事务ID 13 , 故此记录值 ID = 2 ,A = 30 可见

那么这样就导致了幻读产生了,如果我们业务需求中不希望产生幻读,如何解决?请继续往下看,让我娓娓道来!

4 MySQL锁介绍

4.1 共享锁和排它锁

InnoDB实现标准的行级锁定,其中有两种类型的锁, 共享(S)锁和排他(X)锁。
共享 ( S) 锁允许持有该锁的事务读取一行 。
独占 ( X) 锁允许持有该锁的事务更新或删除一行 。

select * from t where id = XX lock in share mode;    # S 锁
select * from t where id = XX for update;    # X 锁

4.2 表级锁和行级锁 

表级锁
MySQL锁中粒度最大的一种锁,表示当前的操作对整张表加锁,资源开销比行锁少,不会出现死锁的情况,但是发生锁冲突的概率很大。被大部分的mysql引擎支持,MyISAM和InnoDB都支持表级锁,但是InnoDB默认的是行级锁。
Mysql的表级别锁分为两类:元数据锁(Metadata Lock,MDL)、表锁。

行级锁 
MySQL中锁定粒度最细的一种锁,表示只针对当前操作的行进行加锁。行级锁能大大减少数据库操作的冲突。其加锁粒度最小,但加锁的开销也最大。有可能会出现死锁的情况,出现死锁的解决办法就是必须有一方事务回滚或者同时回滚。另外,行级锁按照使用方式分为共享锁和排他锁。

lock tables t write;   # 表级锁
select * from t where id = XX for update;   # 行级锁 

4.3 意向锁

InnoDB支持多粒度锁定,允许行锁和表锁共存,有两种类型的意向锁:
意向共享锁( IS) 表示事务打算在表中的各个行上设置共享锁 。
意向排他锁 (IX)表示事务打算对表中的各个行设置排他锁。
什么场景下会使用到意向锁?
select * from t where id = 1 for update;  
InnoDB除了在id=1 的这条记录上增加了行级X锁之前,还对该表添加了一个意向排它锁。

为什么要加意向锁?
主要是解决表锁与行锁之间的互斥性,如果没有意向锁,在表锁的之前,要判断当前表是否存在行锁,需要逐行去扫描表,性能也就会变得非常差。

4.4 表级锁类型的兼容性

4.5 间隙锁

间隙锁是在索引记录之间的间隙上的锁,或在第一条索引记录之前或最后一条索引记录之后的间隙上的锁。
例如:SELECT * FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE;
阻止其他事务将值15插入 column t.c1,无论该列中是否已经存在任何此类值,因为该范围内所有现有值之间的间隙都已锁定。

间隙锁InnoDB是“纯粹的抑制性”,这意味着它们的唯一目的是防止其他事务插入到间隙中。间隙锁可以共存,一个事务采用的间隙锁不会阻止另一个事务在同一间隙上采用间隙锁。共享和独占间隙锁之间没有区别。它们彼此不冲突,并且执行相同的功能。

4.6 Next-Key锁

InnoDB有三种行锁的算法:
1 Record Lock:单个行记录上的锁。
2 Gap Lock:间隙锁,锁定一个范围,但不包括记录本身。
3 Next-Key Lock:1+2,锁定一个范围,并且锁定记录本身。对于行的查询,都是采用该方法,主要目的是解决幻读的问题。

例如: 有t1表如下图所示
执行 select * from t where id = 10 for update;
该SQL语句锁定的范围是(5,10],下个键值范围是(10,15],所以插入5~15之间的值的时候都会被锁定,要求等待。即:插入6,7,8,9,10,11,12,13,14,15 会被锁住。插入非这个范围内的值都正常。

4.7 看几个栗子

栗子 1 :

事务1:select * from  t1 where id > 8 and id < 18  for update;

事务2:INSERT INTO `t1` VALUE  (7,7,7,7, '7');

事务2 能否正常插入?

答案是不能插入,因为间隙锁表示的是区间值,在上述例子中所包含的区间是(-无穷,1]、(1,5]、(5,10]、(10,15]、(15,+无穷)

id > 8 and id < 18,所包含的间隙锁区间有 (5,10]、(10,15]、(15,+无穷) ,要插入的值7 刚好在这个区间里面,所以不能插入

演示如下:

栗子 2 :

事务1:select * from  t1 where id = 8  for update;

事务2:INSERT INTO `t1` VALUE  (7,7,7,7, '7');

事务2 能否正常插入?

答案是不能,当一个记录查询值为空,则记录锁扩散成间隙锁,事务1加隙锁区间为 (5,10],而id = 7 是这个区间里的值。

演示如下:

InnoDB for update 为什么连等值查询都要加间隙锁?

如果不加间隙锁的话,意味着其他事务可以插入一个值到这个间隙当中,而这个插入的值有可能就是之前查询语句中所满足条件的记录,若不加锁则可能导致幻读。

看到这里,我们是否想起了什么?

没错,第三节,如果我们业务需求中不希望产生幻读,只需要在查询时加上for update即可,即:select * from t1 for update;

栗子 3 :

事务1:select * from  t1 where id = 8  for update;

事务2:  select * from  t1  where id = 8  for update; 

(事务2能否加锁成功?)

事务1:INSERT INTO `t1` VALUE  (8,8,8,8, '8');

事务2:   INSERT INTO `t1` VALUE  (8,8,8,8, '8');

(事务2能否插入成功?)

看操作,事务2能加锁成功,但是不能插入成功,原因是间隙锁是兼容的,但是插入时需要获取插入意向锁,而插入意向锁是不兼容的

4.8 插入意向锁

插入意向锁是一种间隙锁形式的意向锁,在执行 INSERT 操作之前设置。
当执行插入操作时,总会检查当前插入操作的下一条记录(已存在的主索引节点)上是否存在锁对象,判断是否锁住了 gap,如果锁住了,则判定和插入意向锁冲突,当前插入操作就需要等待,也就是配合间隙锁或者Next-Key

防止幻读操作
假设有值为 5 和 10 的索引记录。分别尝试插入值 7 和 8 的单独事务,在获得插入行的排他锁之前,每个使用插入意向锁锁定 5 和 10 之间的间隙,但不会相互阻塞,因为行是不冲突的。

事务1:INSERT INTO `t1` VALUE  (8,8,8,8, '8');

事务2:INSERT INTO `t1` VALUE  (7,7,7,7, '7');

事务2 是可以插入成功的

5 加锁过程

现有表t1如下图所示,其中id为主键,b字段有一个普通索引,现在开启事务后执行 select * from t1 where b = 10; 加锁过程是如何的?

首先对 b = 10 的二级索引进行加锁,再对相应的聚簇索引记录 ID= 10 的行记录进行加锁

再看个栗子:

现有表T1如下图所示,其中id为主键,a, b字段有各有一个索引分别为idx_a、idx_b,现按顺序执行以下语句,会发生什么?

 

(解释)

结果是事务2 发生了死锁,但是在事务2看来,他仅仅执行了一个查询操作,就出现了死锁了,很纳闷!

现在我们分析一下事务2为什么会出现死锁

1、事务1 在执行 select * from t1 where a = 10 for update; 时对索引 idx_a 值为10 的记录加锁成功,同时也对聚簇索引值为10 的记录加锁成功

2、事务2 在执行 select * from t1 where b = 10 for update; 时对索引 idx_b 值为10 的记录加锁成功,但是对聚簇索引值为10 的记录进行加锁不成功,原因是聚簇索引值为10 的记录此时已经被事务1 获取,事务2的线程只能处于等待事务1释放

3、事务1执行更新操作,更新某个字段时先需要获取改字段的索引锁,然而idx_b 值为10 的记录锁已经被事务2获取,只能等待事务2释放 ,此时已经形成了循环等待的死锁,InnoDB会选择一个成本较小的事务回滚掉,也就出现了截图上面的信息

看到这里,坚持不易,给自己鼓励一下!!!

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值