MySQL 事务底层原理 以及 MVCC多版本并发控制
一、前言
1、什么是 事务
事务是并发控制的单位,是用户定义的一个操作序列。
2、事务的性质
分别是原子性、一致性、隔离性、持久性。
原子性(Atomicity)
原子性是指事务包含的所有操作要么全部成功,要么全部失败回滚,因此事务的操作如果成功就必须要完全应用到数据库,如果操作失败则不能对数据库有任何影响。
一致性(Consistency)
一致性是指事务必须使数据库从一个一致性状态变换到另一个一致性状态,也就是说一个事务执行之前和执行之后都必须处于一致性状态。举例来说,假设用户A和用户B两者的钱加起来一共是1000,那么不管A和B之间如何转账、转几次账,事务结束后两个用户的钱相加起来应该还得是1000,这就是事务的一致性。
隔离性(Isolation)
隔离性是当多个用户并发访问数据库时,比如同时操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。关于事务的隔离性数据库提供了多种隔离级别,稍后会介绍到。
持久性(Durability)
持久性是指一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的,即便是在数据库系统遇到故障的情况下也不会丢失提交事务的操作。例如我们在使用JDBC操作数据库时,在提交事务方法后,提示用户事务操作完成,当我们程序执行完成直到看到提示后,就可以认定事务已经正确提交,即使这时候数据库出现了问题,也必须要将我们的事务完全执行完成。否则的话就会造成我们虽然看到提示事务处理完毕,但是数据库因为故障而没有执行事务的重大错误。这是不允许的。
二、事务的并发问题
1、为什么会产生问题
MySQL 是支持事务并发执行的,会带来的问题就是:「并发安全性问题」。在数据库事务中并发问题是这样子的:A 事务来写某条记录的数据,B 事务也在写该条记录的数据。那如果啥也不做,势必会造成数据的错乱,MySQL 在设计之初就考虑到了这个问题。
那么 MySQL 到底是如何解决这样的问题的呢?其实是使用了 MVCC 多版本控制机制
、事务隔离机制
、锁机制
等办法来解决事务并发问题。那:在数据库中如果并发事务不做控制和处理,会有什么样的危害呢?
2、脏数据
脏数据的具体概念有以下四种,分别是:脏写、脏读、不可重复读、幻读。主要说 后面三种。
2.1 脏读
脏读:事务A读取到了事务B中未提交的数据,当事务B回滚后,事务A读取到的数据都是脏数据。
假设有两个事务 A、B。事务 A 先开启了,将 id 为 1 的记录中的 name 改成了 A,但是还没有提交。此时事务 B 开启了。事务 B 查询到当前 name 的值为 A,然后就会按照 A 逻辑去执行处理。结果事务 A 回滚了事务,事务 B 再次查询的时候发现记录值不是 A。这就是脏读。事务 B 读取到的 name 值是事务 A 修改但是没有提交的记录。
2.2 不可重复读
不可重复读:事务A多次读取同一数据,但每次读取的值不同。这是因为事务B多次对数据进行更新和提交。
假设有三个事务 A、B、C ,事务 A 先开启了,但是还没有执行任何的操作,事务 B 开启了,事务 B 将 id 为 1 的记录的 name 改为 B 并提交了事务,此时事务 A 开始活动了,查询到的这条记录的 name 值为 B,还是还未执行任何操作。此时事务 C 开启了,事务 C 将 id 为 1 的记录的 name 改为 C 并提交了事务。此时事务 A 又开始活动了,结果查询到的 id 为 1 的 name 值又变成了 C。这就是不可重复读。
2.3 幻读
幻读:前后读取到的记录的数量不一样,即事务A在操作过程中,表数据多次新增或删除,并且新增或删除的数据影响到事务A的操作结果。
幻读和不可重复读有点类似,不可重复读强调的是数据的值不一样,重点是修改,而幻读强调的是记录的数量不一样,重点是新增或删除。就好像是看花眼产生重影一样。
假设有两个事务 A、B。事务 A 先开启了,并执行了这样的 SQL:select * from user
,假设现在结果是 5 条,此时事务 B 开启了,并往 user 表中插入了一条记录,并提交了事务,此时事务 A 又执行了 select * from user
结果发现是 6 条记录。懵逼了。还以为自己饿昏了眼花了。这就是所谓的幻读。
事务隔离机制会依赖
MVCC(多版本控制机制技术) 去实现,因此,先介绍 MVCC。
三、MVCC(Multi-Version Concurrency Control,即多版本并发控制)
MVCC 是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问。
3.1、依赖两个隐藏列:事务ID,回滚指针
MVCC是通过在每行记录后面保存两个隐藏的列来实现的。一个保存了行的事务ID(DB_TRX_ID),一个保存了行的回滚指针(DB_ROLL_PT)。每开始一个新的事务,都会自动递增产 生一个新的事务id。事务开始时刻的会把事务id放到当前事务影响的行事务id中,当查询时需要用当前事务id和每行记录的事务id进行比较。
事务 ID
:就是每个事务的唯一标识回滚指针
:该事务之前的记录的引用(指针)。换句话说就是相对现在时间节点的老数据
3.2、依赖一个日志:undo log(回滚日志)
undo log是一种用于撤销回退的日志,在事务没提交之前,MySQL会先记录更新前的数据到 undo log日志文件里面,当事务回滚时或者数据库崩溃时,可以利用 undo log来进行回退。
假设现在事务 A 开启了事务,将值改为 A
事务 A 还在活跃中,这个时候事务 B 开启了,将值改为 B
此时事务 A 和事务 B 都还在活跃中,这个时候事务 C 开启了,并将值改为 C
看到这里是不是稍微有一点感觉了。这上面的图有一个专有名词:MVCC 版本控制链。同时这里又涉及到一个新的名词:ReadView。就是每个事务在开启的时候都会创建一个 ReadView 视图。
3.3、依赖一个视图:ReadView
在每个事务开启的时候都会创建一个 ReadView 视图,作用就是用来记录每个事务中的操作的一些 Undo Log 记录。
涉及到几个字段。分别是:m_ids、min_trx_id、max_trx_id、creator_trx_id
。他们的具体含义如下:
- m_ids:用于记录活跃中的事务的 ID;
- min_trx_id:当前活跃的事务中的最小的事务 ID;
- max_trx_id:下一个即将要生成的事务 ID。注意这里并不是指的最大的事务 ID,这个事务一定是当前的 m_ids 中不存在的。(事务 ID 的生成是递增的);
- creator_trx_id:当前活跃事务的 ID;
解释 :
假设有一个记录现在是这样存放的
看到这里大家应该知道的是,这条记录一定是原来的某一个事务修改后的结果。也就是说这是一条原本的已经存在的记录。
现在假设有 A、B、C 三个事务,他们分别先后开启,假设他们的事务 ID 依次为:4、5、6。
先来看事务 A,此时的m_ids 为:[4、5、6],min_trx_id 为:4,max_trx_id 为 7(下图第三行为max_trx_id),creator_trx_id 为 4。
事务 A 首先执行了一次查询操作,他此时是这么执行的:
首先他会顺着 MVCC 的版本控制链往下找。找啥?找该条记录的以前操作它的事务 ID,他发现找到的这个 undolog 日志的对应的事务 ID 为 3,比自己的 4 要小,所以可以肯定这条记录不是自己修改的,而又因为 m_ids 中的事务 ID 为 4、5、6,3 是比他们都要小的,所以可推断出查找到的这条记录是在本次事务开启之前就已经存在的。所以事务 A 查询到的值为 C。
此时事务 B 同样开始查询这条记录了。以此类推事务 B 此时的执行流程大概是这样子的,首先事务 B 会以同样的方式查询数据(PS:这些操作都是在内存中的)同样查询到的结果是 C,经过上面的的对于事务 A 的分析,相信这里已经不是问题了。但是假如现在事务 B 将该值改成了 B,也就是下面的这张图的样子。
此时事务 A 又开始活跃了,还是执行查询操作,这个时候结果该是多少呢?
首先事务 A 发现同样会顺着该条记录的 MVCC 版本控制链往下找,发现事务 ID 为 5 ,比 m_ids 中的最小的事务 ID 4 要大,那么可以且是存在于该集合中的,此时就可以断定事务 ID 为 5 的事务是正在进行中的事务,所以事务 A 是不会取该条 undo log 的值的。
然后继续往下找,找到了事务 ID 为 3 的 undo log 记录,对比后发现 3 不在 m_ids 中,且比 m_ids 中的最小的事务 ID 都要小。下面的判断就和刚开始的查询判断一样了。
假设此时事务 A 将该条记录的值改成 A ,然后事务 A 再查询这条记录,那么请问这个时候事务 A 查询是怎么样子的(这一步非常重要)?现在这些事务以及数据在我们的脑袋中应该是这样子的:
那么事务 A 到底是怎么查询的?查出来的结果到底是 A 还是 B?先来看下这张图,然后根据图一步一步来分析
事务 A 开始查询,返现此时的 undo log 日志针对于该条记录的 undo log 链(MVCC 版本链的另一种叫法)的第一条记录的事务 ID 为 4 ,一对比发现不就是自己修改的值吗?那么查询的结果就是 A。
那么此时如果是事务 B 来执行查询呢?结果你能否分析一下?那就是首先 B 发现最新的事务 ID 为 4 ,且在 m_ids 中,可以断定这是一条正在执行中的事务,且不和自己的一样,所以是不会取该值的。
然后继续顺着 undo log 日志链往下找,找到了事务 ID 为 5 的记录,发现和自己的一样,那这个不就是需要查找的结果吗?也就是说 事务 B 查找到的结果是 B。
小结:
-
ReadView 其实使用版本链机制
-
他里面的核心属性为:
-
m_ids: 一个列表, 存储当前系统活跃的事务 id (重点)
-
min_trx_id: 当前 m_ids 活动事务中的最小的事务 ID
-
max_trx_id: 下一个即将被分配出来的事务 ID
-
creator_trx_id: 当前的事务的 ID
-
ReadView 记录的是:每个事务中的 Undo log 日志
四、事务的隔离级别
4.1、四种隔离级别
数据库事务的隔离级别有4个,由低到高依次为Read uncommitted(读未提交)、Read committed(读提交)、Repeatable read(可重复读取)、Serializable(可串行化),这四个级别可以逐个解决脏读、不可重复读、幻象读这几类问题。
4.2、隔离级别如何实现的?
未提交读(RU:read-uncommitted):在RU级别中,事务读到的所有数据都是最新的数据,可能是事务提交后的数据,也可能是事务执行中的数据(可能会被回滚)。
当隔离级别为RU时:
-
所有的读不加锁,读到的数据都是最新的数据,性能最好。
-
所有的写加行级锁,写完释放。
提交读(RC:read-committed):使用MVCC技术,在每一行加入隐藏的字段(DB_TRX_ID:修改该行的最后一个事务的id,DB_ROLL_PTR:指向当前行的undo log日志,DB_ROW_ID:行标识,DELETE_BIT:删除标志),它实现了不加锁的读操作。
当隔离级别为RC时:
- 写操作:加行级锁。事务开始后,会在UNDO日志中写入修改记录,数据行中的隐藏列DATA_POLL_PTR存储指向该行的UNDO记录的指针。
- 读操作:不加锁。在读取时,如果该行被其它事务锁定,则顺着隐藏列DATA_POLL_PTR指针,找到上一个有效的历史记录(有效的记录:该记录对当前事务可见,且DELETE_BIT=0)。
可重复读(RR:repeatable-read):使用MVCC技术来实现不加锁的读操作。
当隔离级别为RR时:
- 写操作:加行级锁。事务开始后,会在UNDO日志中写入修改记录,数据行中的隐藏列DATA_POLL_PTR存储指向该行的UNDO记录的指针。
- 读操作:不加锁。在读取时,如果该行被其它事务锁定,则顺着隐藏列DATA_POLL_PTR指针,找到上一个有效的历史记录(有效的记录:该记录对当前事务可见,且DELETE_BIT=0)。
从上面可以知道:实际上RC和RR级别的操作基本相同,而不同之处在于:行记录对于当前事务的可见性(可见性:即哪个版本的行记录对这个事务是可见的)。RC级别对数据的可见性是该数据的最新记录,RR基本对数据的可见性是事务开始时,该数据的记录。
1) 行记录的可见性(read_view)的实现
在innodb中,创建一个事务的时候,会将当前系统中的活跃事务列表创建一个副本(read_view),里面存储着的都是在当前事务开始时,还没commit的事务,这些事务里的值对当前事务不可见。read_view中有两个关键值 up_limit_id(当前未提交事务的最小版本号-1,在up_limit_id之前的事务都已经提交,在up_limit_id之后的事务可能提交,可能还没提交) 和 low_limit_id(当前系统尚未分配的下一个事务id,也就是目前已出现过的事务id的最大值+1。注意:low_limit_id不是最大的活跃事务的id。)
注意:当前事务和正在commit的事务是不在read_view中的。
2)无论是RC级别还是RR级别,其判断行记录的可见性的逻辑是一样的。
当该事务要读取undo中的行记录时,会将行记录的版本号(DB_TRX_ID)与read_view进行比较:
- 如果DB_TRX_ID小于up_limit_id,表示该行记录在当前事务开始前就已经提交了,并且DELETE_BIT=0,则该行记录对当前事务是可见的。
- 如果DB_TRX_ID大于low_limit_id,表示该行记录在所在的事务在本次事务创建后才启动的,所以该行记录的当前值不可见。
- 如果up_limit_id< = DB_TRX_ID <= low_limit_id,判断DB_TRX_ID是否在活跃事务链中,如果在就是不可见,如果不在就是可见的。
- 如果上面判断都是不可见的,则读取undo中该行记录的上一条行记录,继续进行判断。
而对于RC级别的语句级快照和RR级别的事务级快照的之间的区别,其实是由read_view生成的时机来实现的。RC级别在执行语句时,会先关闭原来的read_view,重新生成新的read_view。而RR级别的read_view则只在事务开始时创建的。所以RU级别每次获取到的都是最新的数据,而RR级别获取到的是事务开始时的数据。
3)值得注意的是: 在上面的可见性判断中,虽然逻辑是一样的,但是实际意义上是有区别的:
在第二步中,对于RC级别来说,low_limit_id是执行语句时已出现的最大事务id+1,可以认为在执行语句时,是不存在事务会比low_limit_id要大,所以大于low_limit_id的事务都是不可见的。而对于RR级别来说,low_limit_id是当前事务开始时已出现的最大事务+1(也可以认为是当前事务的id+1,因为在创建当前事务时,当前事务的id最大),大于low_limit_id的事务表示是在该事务开始后创建的,所以对RR级别是不可见。
在第三步中,对于RC级别来说,只要DB_TRX_ID不在活跃链表中,则无论DB_TRX_ID是否大于事务id,RC都是可见的。而对于RR级别来说,因为low_limit_id就是当前事务id+1,可以认为小于low_limit_id的事务都是在当前事务创建前出现的,所以也只需要简单判断DB_TRX_ID是否在活跃链表中。
串行化(serializable):读写都会加锁。
4.3、实例分析:RC(Read Commit)和 RR(Repeatable read)
1). Read commit
Read Commit 是事务隔离级别的其中一种,含义是:读取已经提交的记录。举个例子来说,假设有事务 A 和事务 B 都在活动中,事务 B 提交的记录是能够被事务 A 读取到的。
具体我们开始一步一步来分析。首先需要大家知道的是在 RC 隔离级别下,一个事务的每次查询操作,数据库都会为其创建一个新的 ReadView,这就是 RC 的核心思想。
假设有事务 A 和事务 B ,事务 ID 分别为 10 和 11,事务 A 还没开始活跃,事务 B 就将某条记录的值改为 B(假设原来的值为 X),但是还未提交,现在你可以想象一下下面这张图:
此时事务 A 开始活跃了,他首先执行了一次查询操作。按照上面的核心思想,此时数据库会重新创建一个 ReadView 里面的几个属性的值分别为:
-
m_ids:[10,11]
-
min_trx_id:10
-
max_trx_id: 12
-
creator_trx_id:10
接着就是就是和上面说过的一样的查询过程了,首先 A 查询到的最近的一个事务 ID 为 11,发现在 m_ids 中,但是又和自己的事务 ID 不相等,所以就会顺着 undo log 链继续查找,然后找到了事务 ID 为 3 的记录,发现不在 m_ids 中且,比最小的事务 ID 10 还要小,所以可以断定出事务 ID 为 3 的这个记录是原本就存在的记录,所以查询到的结果就是 X。
接着事务 B 又开始活跃了,事务 B 直接提交了事务,然后事务 A 又发起了一起查询操作。现在这个时候就是 RC 的核心了:这个时候数据库会再次为事务 A 创建一个新的 ReadView 里面的四个属性分别为:
-
m_ids:[10]
-
min_trx_id:10
-
max_trx_id: 12
-
creator_trx_id:10
然后 A 按照正常的流程去查询,首先查询到的是事务 ID 为 11 的记录,结果发现不在 m_ids 中,那这个时候就可以断定的是:这个是最近已经提交的记录,所以是能够查询到 B 这个值的,也就是说这次查询得到的结果就是 B 。
总之,就是 实现了 RC隔离级别下,读取 另一个事务 已经提交的数据。
2). Repeatable read
Repeatable read 是 MySQL 默认的隔离级别。
RR 的核心思想是:ReadView 创建以后直到事务提交,都不会再次重新生成。
查询过程和前面的一模一样。
接着事务 B 又开始活跃了,直接提交了事务,然后事务 A 又发起了一次查询。这个时候奇迹就出现了。因为我们刚刚说了:ReadView 创建以后直到事务提交,都不会再次重新生成。因为事务 A 在创建 ReadView 的时候 m_ids 是 10 和 11,所以现在查询的时候里面仍然是这个值,现在的查询是这样子的:事务 A 首先查询到的事务 ID 为 11 ,结果发现在 m_ids 中,也就不会取该值,会继续查找,当查找到事务 ID 为 3 的时候,发现不在 m_ids 中,所以查询到的就是 X。
总之,就是 实现了 RR隔离级别下,读取 另一个事务 最刚开始创建的数据。
五、MySQL中的锁
Mysql两种常用的引擎:MyISAM和InnoDB,前者只支持表锁;后者支持表锁和行锁,默认行锁。
5.1、锁分类
Mysql为了解决事务并发,数据安全问题,使用了锁机制。
按照锁的粒度可以把锁分为表级锁和行级锁:
1) 表级锁
Mysql中粒度最大的一种锁,会锁住当前操作的整张表,并发性能低,但表锁的实现简单,耗费资源少,加锁快,不会出现死锁。
2) 行级锁
Mysql中粒度最小的一种锁,只会锁住当前操作的数据行。行锁极大地提高了Mysql的并发性能,但行锁的开销较大,速度较慢,会出现死锁。
按照锁的性质可以把锁分为共享锁和排它锁:
1) 共享锁(S锁)
其他事务可以读取被共享锁锁住的数据行,不能修改该数据行,并且也只能对该数据行加共享锁,而不能加排它锁。
2) 排它锁(X锁)
当一个事务对数据行加上排他锁,那么该事务可以读取和修改该数据行,而其他事务不允许对该数据行加任何锁。
5.2、表锁
Mysql中表锁除了共享锁和排他锁之外,还存在着两种锁:意向共享锁(IS),意向排他锁(IX)。
意向锁的作用是表明该事务想对该表加一个共享/排他锁,但并没有真正把锁加上去。比如,当事务想对一个被排他锁锁住的表加上共享锁/排他锁时,必须先在该表上添加一个意向共享锁/意向排他锁,直到锁住表的排他锁被释放。
事务在给一个数据行加共享锁前必须先取得该表的IS锁,在加排它锁前必须先取得该锁的IX锁。并且意向共享锁可以同时并存多个,但是意向排他锁同时只能有一个存在。
5.3、行锁
InnoDB行锁是通过给索引上的索引项加锁来实现的,这意味着:只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁。
Innodb中的行级锁有以下几种:
1) Record Lock: 对索引项加锁,锁定符合条件的行。其他事务不能修改和删除加锁项;
2) Gap Lock: 对符合条件范围的“间隙”加锁,锁定记录的范围,不包含索引项本身。其他事务不能在锁范围内插入数据。“间隙(GAP)”是指 键值在条件范围内但并不存在的记录。
3) Next-key Lock: 锁定索引项本身和间隙。Record Lock和Gap Lock的结合,Next-key Lock就是我们所说的间隙锁,可解决幻读问题。
举例来说,假如emp表中只有101条记录,其empid的值分别是 1,2,...,100,101,下面的SQL:
Select * from emp where empid > 100 for update;
是一个范围条件的检索,InnoDB不仅会对符合条件的empid值为101的记录加锁,也会对empid大于101(这些记录并不存在)的“间隙”加锁。
在使用范围条件检索并锁定记录时,InnoDB这种加锁机制会阻塞符合条件范围内键值的并发插入。
5.4、注意
1) InnoDB 存储引擎在 REPEATABLE-READ(可重读) 事务隔离级别下使用的是Next-Key Lock 锁,解决的幻读的问题,因此InnoDB 引擎的默认的隔离级别 REPEATABLE-READ(可重读) 已经达到了SQL标准的 SERIALIZABLE(可串行化) 隔离级别,并且事务不需要串行化。
2) 当查询的索引含有唯一属性时,将next-key lock降级为record key。
3) Innodb中行级锁是加在索引上,所以只有使用索引时,才会加行锁,否则只会加表锁。但这并不意味着,只要使用了索引就会加行级锁,如果MySQL认为全表扫描效率更高,比如对一些很小的表或者索引范围包括大部分表数据,它就不会使用索引,这种情况下InnoDB将使用表锁,而不是行锁。
5.5、死锁
不同于MyISAM总是一次性获得所需的全部锁,InnoDB的锁是逐步获得的,当两个事务都需要获得对方持有的锁,导致双方都在等待,这就产生了死锁。 发生死锁后,InnoDB一般都可以检测到,并使一个事务释放锁回退,另一个则可以获取锁完成事务,我们可以采取以上方式避免死锁:
1) 通过表级锁来减少死锁产生的概率;
2) 多个程序尽量约定以相同的顺序访问表;
3 )同一个事务尽可能做到一次锁定所需要的所有资源。
参考:
腾讯面试:MySQL事务与MVCC如何实现的隔离级别?_敖丙-CSDN博客_mvcc面试
Mysql的四个隔离级别是如何实现的_QEcode的博客-CSDN博客_mysql的隔离级别是怎么实现的