Mysql的 MVCC 和锁

203 阅读12分钟

开讲之前首先我们先准备一个表 table_atm

idnameage
1迪迦奥特曼18
2泰罗奥特曼19
3赛文奥特曼16

1 事务隔离级别

1.1 为什么要有事务隔离

1.1 什么事事务隔离

针对数据资源的并发访问,规定了各个事务之间相互影响的程度。我们称之为事务隔离

1.2 没有事务隔离的后果

如果多个事务同时访问同一数据,会发生哪些意想不到的情况?

  1. 脏读。事务读到了事务B还没提交的数据。举个实例
时间线线程A线程B
1begin transaction
2begin transaction
3update table_atm set age = 22 where id = 1
4 select age from table_atm where id = 1
5commmit
6roolback

步骤如上表,如果最线程B的事务回滚掉,相当于A读到了一条不存在的记录,这就是脏读

  1. 脏写 |时间线| 线程A | 线程B| |-| --- | --- | |1|begin transaction | | |2| | begin transaction | |3| | update table_atm set age = 22 where id = 1| |4 |update table_atm set age = 25 where id = 1| | |5 |commmit| | |6 | | roolback|

如上表,A好不容易把数据改了,结果因为B回滚,导致A的更改小时不见了

  1. 不可重复读 |时间线| 线程A | 线程B| |-| --- | --- | |1|begin transaction | | |2| select name fron table_atm where id = 1 | begin transaction | |3| | update table_atm set age = 22 where id = 1| |4 |select name fron table_atm where id = 1| commmit| |5 |commmit| |

线程 A 2次读到的数据不一样

  1. 幻读 |时间线| 线程A | 线程B| |-| --- | --- | |1|begin transaction | | |2| select name fron table_atm where id >= 1 | begin transaction | |3| | insert into table_atm (name, age) VALUES('艾斯奥特曼', 19)| |4| select name fron table_atm where id >= 1 | | A 第一次读到的3条数据,第二次读到的竟然有4条

1.3 四大隔离级别

  1. 读未提交 READ UNCOMMITTED
  2. 读已提交 READ COMMITTED
  3. 可重复读REPEATABLE READ
  4. 串行化SERIALIZABLE
隔离级别脏读不可重复读幻读
读未提交YYY
读已提交NYY
可重复读NNY
串行化NNN

2 MVCC

2.1 版本链

Innodb 行记录中游2个不可或缺的字段

  1. trx_id事务ID,任意事务对记录行进行更改后,都会把事务对应的事务ID 赋值给该字段
  2. roll_pointer 回滚指针,对记录行进行改动的时候,原始版本数据都会写到undo log中,roll_pointer该字段指向undo log中的对应的原始数据 比如我们针对table_atm进行如下更改
时间线操作
1update table_atm set age = 21 where id = 1
2update table_atm set age = 22 where id = 1
3update table_atm set age = 23 where id = 1

那么 id=1的版本链 如下图。其实就是将数据的各个版本通过链表的实行串联起来

image.png

2.2 视图

这个视图和我们常说的db视图是不一样,大家不要弄混 首先我们先隆重清楚视图中重要的四个字段

字段名说明
m_ids生成视图时 当前活跃的所有的事务集合
min_trx_id生成视图时 当前活跃的所有的事务集合中最小的那个 trx_id
max_trx_id注意哈,这个不是m_ids中最大的 事务id,而是生成视图时分配给下一个事务的 事务id
creator_trx_id表示生成视图的 事务id,可能为0(只有执行INSERT、DELETE、UPDATE这些语句时 才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0)

那我们如何根据这4个字段判断哪个版本的数据是当前视图可以访问的呢?

  1. 首先判断要访问记录的trx_id(后续称之为目标trx_id) 是否与当前视图持有的 creator_trx_id相同,如果相同说明在同一个事务,可见
  2. 目标trx_id 是否小于min_trx_id?如果是的话,表明目标记录该版本的事务在生成视图之前提交,可见
  3. 目标trx_id 是否 大于等于max_trx_id?如果是的话,表明目标记录该版本的事务在生成视图之后提交,不可见
  4. 目标trx_id大小 在min_trx_id max_trx_id之间。目标trx_idm_ids 集合中,说明生成视图时 该版本的事务还是活跃的,不可见。否则说明该版本的事务已经提交,可见

如果某个版本的数据对当前视图不可见,那就要根据版本链找到下个版本的数据,然后在进行如上的判断,直到找到可见的数据或者 遍历完整个版本链也没找合适的数据

2.4 视图创建窗口(基于不同的隔离级别)

准备工作,定义一下2个事务

  • 事务1 |时间线| 操作| |-| --- | |1|BEGIN,举例分配的 trx_id = 50| |2| update table_atm set age = 21 where id = 1 | |3| update table_atm set age = 22 where id = 1 | |4| update table_atm set age = 23 where id = 1 |

  • 事务2 |时间线| 操作| |-| --- | |1|begin 举例分配的 trx_id = 55| |2| 做一些其他操作,可以不是该数据行,主要是为了分配一个活跃的事务ID 没有提交哈|

版本链

image.png

而且此时 事务id为 50 55 的都没提交哈

  1. READ COMMIT 读取数据前都会生成视图 在上述2个事务的未提交的前提下,我们执行以下sql
时间线操作
1BEGIN
2select * from table_atm where id = 1

该select语句执行顺序如下

  • 生成视图ReadView,且此时活跃的mids[50,55],min_trx_id = 50, max_trx_id = 56,creator_trx_id = 0
  • 从版本链里找到可见的数据,如图所示trx_id = 50 的 事务是当前视图中活跃的事务 故不可见
  • trx_id = 40的事务不在m_ids范围内,且比min_trx_id小,故可见

然后我们把trx_id = 50的事务提交一下

时间线操作
1BEGIN,举例分配的 trx_id = 50
2update table_atm set age = 21 where id = 1
3update table_atm set age = 22 where id = 1
4update table_atm set age = 23 where id = 1
5commit
时间线操作
1BEGIN
2select * from table_atm where id = 1
3select * from table_atm where id = 1 我们下面称之为 select2 语句
  • 再执行select2 语句的时候又会生成一个新的视图,其中m_ids[55] ,min_trx_id = 55,max_trx_id = 56,creator_trx_id = 0
  • 从版本中挑选可见的数据,最新版本的trx_id = 55, 包含在m_ids内,不可见,继续查找上一个版本链trx_id = 50,小于此时的min_trx_id。可见

总结:READ COMMIT 是在每个查询时新建视图

  1. READ REPEATABLE 第一次读取的时候生成视图 我们同样操作上述流程
时间线操作
1BEGIN
2select * from table_atm where id = 1

该select语句执行顺序如下

  • 生成视图ReadView,且此时活跃的mids[50,55],min_trx_id = 50, max_trx_id = 56,creator_trx_id = 0
  • 从版本链里找到可见的数据,如图所示trx_id = 50 的 事务是当前视图中活跃的事务 故不可见
  • trx_id = 40的事务不在m_ids范围内,且比min_trx_id小,故可见

然后我们把trx_id = 50的事务提交一下

时间线操作
1BEGIN,举例分配的 trx_id = 50
2update table_atm set age = 21 where id = 1
3update table_atm set age = 22 where id = 1
4update table_atm set age = 23 where id = 1
5commit
时间线操作
1BEGIN
2select * from table_atm where id = 1
3select * from table_atm where id = 1 我们下面称之为 select2 语句
  • 再执行select2 语句的时候不会生成新的视图,此时活跃的mids[50,55],min_trx_id = 50, max_trx_id = 56,creator_trx_id = 0
  • 从版本链里找到可见的数据,如图所示trx_id = 50 的 事务是当前视图中活跃的事务 故不可见
  • trx_id = 40的事务不在m_ids范围内,且比min_trx_id小,故可见

总结:READ REPEATABLE 是在事务开始第一次查询时新建视图

3 锁

3.1 数据库锁的原理

mysql 中的锁其实就是内存中的一块信息。锁结构里有很多信息,比较有用的就是trx_idis_waiting 2个字段

  • trx_id 这个锁是由哪个事务生成的
  • is_waiting 当前事务是否在等待。
  1. 一个事务对记录进行加锁的简易图 image.png
  2. 两个事务对记录加锁的示意图 image.png 如图所示如果
  • trx_id=11 的事务占有锁,开始执行, trx_id=21的事务过来也想进行更改,首先先去内存判断该记录行是否有锁,如果有则进行排队,同时将is_waiting为true,表示为等待锁释放。 当 trx_id=11的事务提交后,才会通知后续排队的事务进行操作

3.2 锁的分类

Mysql 中的锁有2大类

  • 共享锁。也叫 S锁 ,事务读取一条记录的时候需要先获取到 S锁
  • 独占锁。也叫 X锁, 事务要改动一条记录的需要获取 X锁

锁的兼容性 如下表示

兼容性SX
SYN
XNN
  • S锁 可以和 S锁兼容。也只有这种兼容方式
  • S锁 X锁不兼容
  • X锁S锁 不兼容

3.2.1 select 语句产生 的锁

  1. 加S锁 SELECT ... LOCK IN SHARE MODE 语句会对记录加上S锁,允许别的事务继续获取S锁,但是不能获取 X锁。如果其他事务想获取 X锁,需要等待S锁释放掉
  2. X锁 SELECT ... FOR UPDATE 语句会对记录加X锁。这样其他事务既不能获取 S锁 ,也不能获取 X锁。其他事务想要获取锁,必须等该锁释放掉

3.2.2 update产生的锁

笼统来讲update 会产生X锁

3.2.3insert

insert 语句会产生隐式锁,保证插入的语句在提交事务前不会被其他事物读取到

3.2.4 next-key

3.2.5 gap 间隙锁

间隙锁锁的就是两个值之间的空隙

3.2.6 行锁

行锁就是针对数据表中记录行的锁,比如,事务A更新了某一行数据,同时事务B也要更新同一行,那么事务B一定要等待事务A执行完毕后才能进行操作

3.2.7 2阶段锁

沿用上面的一个例子

时间线线程A线程B
1begin transaction
2update table_atm set age = 25 where id = 1begin transaction
3update table_atm set age = 22 where id = 1
4
5commmit
6commmit

在 InnoDB 事务中,行锁是在需要的时候才加上的,要等到事务结束时才释放。这个就是两阶段锁协议 同时我们要知道,如果在事务中需要锁住多行记录,需要把影响并发的操作放到最后,尽可能晚点对其加锁

3.2.8 死锁

当并发系统中不同的线程出现资源互相依赖时,涉及到的线程都在互相等待对方释放锁,导致几个线程进入死循环

时间线线程A线程B
1begin transaction
2update table_atm set age = 25 where id = 1;begin transaction
3update table_atm set age = 25 where id = 2
4update table_atm set age = 25 where id = 2;
5update table_atm set age = 22 where id = 1
6commmit
7commmit

3.2.9 next-key lock

我们把行锁和间隙锁合成next-key lock, 它是一个前开后闭的取件

3.3 如何判断锁住哪些数据

先总结一下加锁原则

  1. 原则1:加锁的基本单位就是 next-key lock , next-key lock 是前开后闭区间
  2. 原则2:查找过程中访问到的对象才加锁
  3. 优化1:索引上的等值查询,给唯一索引加锁(包括主键索引)的时候,next-key lock 退化为 行锁
  4. 优化2:索引上的等值查询,向右遍历时最后一个值不满足等值条件的时候, next-key lock 退化为 间隙锁
  5. 一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止 下面我们就举例来说明每个原则 开讲之前首先我们先准备一个表 table_atm,同时对 age加一个普通索引
idnameage
5迪迦奥特曼5
10泰罗奥特曼10
15赛文奥特曼15
20艾斯奥特曼20

3.3.1 等值查询

线程A线程B线程C
begin transaction
update table_atm set age = 25 where id = 7
insert into table_atm values(8, '杰克奥特曼', 30)
update table_atm set age = 22 where id = 13
  • 根据原则1:线程A 要查询 id=7 的数据然后进行更新,所以它会锁住(5,10]
  • 根据优化2:等值查询,向右遍历到最后一个不满足条件时,退化为gap锁,所以 线程A的加锁区间是(5,10)

结论就是

  1. 线程A加锁区间是(5,10)
  2. 线程B因为A的加锁区间,导致线程B必须等待A释放锁才能进行插入
  3. 线程C是更新 id=10,不在被锁区间内,可以正常执行

3.3.2 非唯一索引等值锁

线程A线程B线程C
begin transaction
select id from table_atm where age = 10
update table_atm set name = 雷欧奥特曼 where = 10
insert into table_atm values(8, '杰克奥特曼', 30)

如上图

  1. 根据原则1:线程A锁住的区间是 (5,10]
  2. age是普通索引,查找到age=10这一行后,还会继续向下查找第一条不满足条件的记录行,所以继续加锁 (10,15]
  3. 根据优化2:等查询 next-key 锁 退化为 gap 锁,所以线程A 最终锁足的是(5,15)
  4. 根据原则2:只有访问到对象才加锁,线程A使用普通索引即完成了查询条件,并没有用到主键索引,所以针对 id=10 的主键索引上并么有被加锁, 结论
  • 线程A锁住 (5,15),并且对 age=10 加了读锁
  • 线程A 因为只查询主键ID,并没有主键索引加锁,所以线程B可以继续执行

tips

  • 共享锁只会锁住普通索引, S锁会同时把 主键索引上满足条件的行记录都加锁
  • 锁是加在索引上的,

3.3.3 主键索引的范围查询

线程A线程B线程C
begin transaction
select id from table_atm where id >= 10 and id < 11
insert into table_atm values(8, '杰克奥特曼', 30)
insert into table_atm values(13, '奥特曼', 35)
update table_atm set name = 雷欧奥特曼 where = 15

分析

  1. 线程A开始执行,首先 id>=10,按照原则1会锁住 区间(5,10],根据优化1,等值查询会退化成行锁,所以最终锁住的是 id=10
  2. 线程A开始执行 id<11, 按照原则1 锁住区间(10,15]

结论

  1. 最终线程A 锁住的区间是[10,15]
  2. 线程B第一条sql可以插入成功,第二条插入需要等待锁释放
  3. 线程C的更新也要等待锁释放

3.3.4 非唯一索引范围查询

线程A线程B线程C
begin transaction
select * from table_atm where age >= 10 and age < 11
insert into table_atm values(8, '杰克奥特曼', 30)
update table_atm set name = 雷欧奥特曼 where = 15

分析

  • 查询时使用普通索引,根据原则1 age>=10 会锁住(5,10],age<11 会锁住(10,15) 结论
  1. 线程A 锁住(5,15]

  2. 线程B和线程C都会block住,需要等待锁释放