目录
一、MySQL 事务的特性
- 原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成,不结束在中间某个环节。而且事务在执行过程中发生错误,会被会回滚到事务开始前的状态,就像事务从来没有执行过一样。
- 一致性(Consistency):是指事物操作前和操作后,数据满足完整性约束,数据保持一致性状态。
- 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致,因为多个事务同时使用相同数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他事务是隔离的。
- 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
InnoDB 是通过什么技术来保证事物的四个特性的呢?
- 持久性:通过 redo log (重做日志)来保证
- 原子性:通过 undo log (回滚日志)来保证
- 隔离性:通过 MVCC (多版本并发控制)或锁机制来保证
- 一致性:通过持久性 + 原子性 + 隔离性来保证
二、并行事务可能发生的问题
2.1、脏读
如果一个事务【读到】了另一个【未提交事务修改过的数据】,就意味着发生了【脏读】现象。
下面举个🌰:
事务 A 发生了回滚,那么事务 B 刚才得到的数据就是过期的数据,这种现象就被称为脏读。
2.2、不可重复读
在同一个事物内多次读取同一个数据,如果出现两次读到的数据不一样的情况,就意味着发生了【不可重复读】现象。
举个🌰:
2.3、幻读
在一个事务内多次查询某个符合查询条件的【记录数量】,如果前后两次查询到的记录数量是不一样的情况,就意味着发生了【幻读】情况。
举个🌰:
三、事务的隔离级别
3.1、隔离级别介绍
- 脏读:读到其他食物未提交的数据
- 不可重复读:前后读取的数据不一致
- 幻读:前后读取的记录数量不一致
这三种现象的严重性如:脏读 > 不可重复读 > 幻读
SQL标准提出了四种隔离机制来规避上述情况,隔离级别越高,性能越低,级别如下:
- 读未提交:一个事务还未提交,它做的变更就能被其他事务看到
- 读提交:一个事务提交之后,它做的变更才能被其他事务看到
- 可重复读:一个事务在执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,是MySQL InnoDB 引擎的默认隔离级别
- 串行化:对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突时,后访问的事务必须等前一个事务执行完毕才能继续执行
也就是说:
- 在【读未提交】隔离级别下,可能发生【脏读】,【不可重复读】,【幻读】
- 在【读提交】隔离级别下,可能发生【不可重复读】,【幻读】
- 在【可重复读】隔离级别下,可能发生【幻读】
- 在【串行化】隔离级别下,【脏读】,【不可重复读】,【幻读】都不可能发生
MySQL InnoDB 引擎的默认隔离级别虽然是【可重复读】,但是它很大程度上避免了【幻读】情况,但并不是完全解决了。
针对【快照读】(普通 select 语句),是通过 MVCC 方式解决了幻读,因为在可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事物插入了一条数据,是查询不到这条数据的,很好的避免了幻读问题。
针对【当前读】(select ....for update 等语句),是通过 next - key lock (记录锁加间隙锁)方式解决了幻读,因为在执行执行【当前读】操作时,会加上 next - key lock ,如果有其他事物在锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法插入成功,也很好的避免了幻读问题。
3.2、隔离级别实现
对于【读未提交】隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就行。
对于【串行化】隔离级别事务来说,通过加读写锁的方式来避免并行访问。
对于【读提交】和【可重复读】隔离级别的事务来说,它们是通过 Read View 来实现的。它们的区别就是创建 Read View 的时机不同,可以把Read View 理解成一个数据快照,就像相机拍照那样定格某一时刻风景。【读提交】隔离级别是在【每个语句执行前】都会重新生成一个Read View ,而【可重复读】隔离机制是【启动事务时】生成一个Read View ,然后整个事务期间都在用这个Read View 。
四、Read View 是如何在 MVCC 中工作的?
4.1、Read View 中四个字段的作用
- creator_trx_id :指的是创建该 Read View 的事物的事务 id。
- m_ids :指的是在创建 Read View 时,当前数据库中【活跃事务】的事务 id 列表,是一个列表,【活跃事务】是指,启动了但是还没提交的事务。
- min_trx_id :指的是在创建 Read View 时,当前数据库中【活跃事务】中事务 id 最小的事务,也就是 m_ids 的最小值。
- max_trx_id :这个并不是 m_ids 的最大值,而是创建 Read View 时当前数据库中应该给下一个事务的 id 值,也就是全局事务最大事务 id 值 + 1,
4.2、聚簇索引中的两个隐藏列
对于使用 InnoDB 存储引擎的数据库表,它的聚簇索引记录中都包含两个隐藏列,举个🌰:
trx_id :当一个事务对某条事务聚簇索引记录进行改动时,就会把该事务的事务 id 记录在 trx_id 隐藏列中。
roll_pointer :每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后这个隐藏列是个指针,指向每一个旧版记录,可以通过它找到修改前的记录。
在创建 Read View 之后,我们可以将 【trx_id】划分为这三种情况:
一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还存在以下情况:
- 如果记录的【 trx_id 】值小于 Read View 中的 【min_trx_id】值,表示这个版本记录是在创建 Read View 前已经提交的事务生成的,所以该版本的记录对当前事务可见。
- 如果记录的【trx_id】值大于等于 Read View 中的【max_trx_id】值,表示这个版本的记录是在创建 Read View 后才启动的事务生成的,所以该版本的记录对当前事务不可见。
- 如果记录的【trx_id】值在 Read View 的【min_trx_id】和【max_trx_id】之间,需要判断【trx_id】是否在【m_ids】列表中。如果记录的【trx_id】在【m_ids】列表中,表示生成该版本记录的活跃事务依然活跃着(还未提交的事务),所以该版本的记录对当前事务不可见。如果不在,表示生成该版本的活跃事务已经被提交,所以该版本对当前事务可见。
通过上述【版本链】来控制并发事务访问同一个记录时的行为就叫做 MVCC(多版本并发控制)
五、可重复读和读提交是如何工作的?
5.1、可重复读
可重复读隔离机制是启动事物时生成一个 Read View,然后整个事务期间都在用这个 Read View
假设事务A(事务 id 为 51)启动之后,紧接着事务B(事务 id 为 52)也启动了,那这两个事务创建的Read View 如下:
记录的字段🌰:
事务 A 和事务 B 的Read View 具体内容:
- 在事务 A 的 Read View 中,它的事务 id 是51,由于它是第一个启动的事务,所以此时活跃事物的事务 id 列表就只有 51,活跃事务的事务 id 列表中最小的事务 id 是事务 A 本身,下一个事务 id 则是52.
- 在事务 B 的 Read View 中,它的事务 id 是52,由于事务 A 是活跃的,所以此时活跃事物的事务 id 列表是 51 和 52 ,活跃的事务 id 中最小的事务 id 是事务 A,下一个事物 id 应该是 53.
在可重复读隔离级别下,事务 A 和事务 B 按顺序执行以下操作:
- 事务 B 读取汤圆的数字,读到数字为111111111
- 事务 A 将汤圆的数字修改为222222222,并没有提交事务
- 事务 B 读取汤圆的数字,读到数字还是111111111
- 事务 A 提交事务
- 事务 B 读取汤圆的数字,读到数字依旧是111111111
分析:
事务 B 在第一次读汤圆的数字记录,在找到记录后,它会先看这条记录的【trx_id】,此时发现 【trx_id】为 50,比事务 B 的Read View 中的【min_trx_id】值(51)还小,这就说明修改这条记录的事务早就在事务 B 启动之前提交过了,所以该版本的记录对事务 B 可见的,也就是事务 B 可以获取到这条记录。
接着,事务 A 通过 update 语句将这条记录修改了(还未提交事务),将汤圆的数字改为222222222,此时MySQL 会记录相应的 undo log,并以链表的形式串联起来,形成版本链如🌰:
在上图中【记录的字段】看到,由于事务 A 修改了该记录,以前的记录就变成了旧版本记录了,于是最新记录和旧版本记录通过链表的方式串起来,而且最新记录的【trx_id】是事务 A 的事务 id(trx_id = 51)
当事务 B 第二次去读该数字时,发现这条记录的【trx_id】值为 51。在事务 B 的Read View 的【min_trx_id】和【max_trx_id】之间,则需要判断【trx_id】值是否在【m_ids】范围内,判断的结果是在,那么说明这条记录时被还未提交事务修改的,这时事务 B 并不会读取这个版本的记录。而是沿着 【undo log】链条往下找旧版本的记录,直到找到【trx_id】小于事务 B 的 Read View 中的【min_trx_id】值的第一条记录,所以事务 B 能够读取到的是 【trx_id】为 50 的记录,读到的记录也就是汤圆数字是111111111的这条记录。
最后,当事务 A 提交事务后,由于隔离级别是【可重复读】,所以事务 B 再次读取记录时,还是基于启动事务时创建的 Read View 来判断当前版本的记录是否可见。所以,及时事务 A 将汤圆的数字修改为222222222并提交了事务,事务 B 第三次读取数据时,读到的记录都是汤圆数字为111111111的这条记录。
5.2、读提交是如何工作的?
读提交隔离级别是在每次读取数据时,都会生成一个新的 Read View。
也就是说,在事务执行期间多次读取同一数据,可能会出现前后两次读取数据不一致的情况,因为在次事务执行期间有另外的事务修改了此记录,并提交了事务。
直接举个🌰吧~
假设事务 A(事务 id 为 51)启动后,紧接着事务 B(事务 id 为 52)也启动了,接着按照顺序执行了以下操作:
- 事务 B 读取数据(创建 Read View),汤圆的数字为111111111
- 事务 A 修改数据(还未提交事务),将汤圆的数字从111111111修改为222222222
- 事务 B 读取数据(创建 Read View),汤圆的数字为111111111
- 事务 A 提交事务
- 事务 B 读取数据(创建 Read View),汤圆的数字为222222222
我们来分析为什么事务 B 第二次读数据时,读不到事务 A(还未提交事务)修改的数据
事务 B 在找到汤圆这条记录时,会看到这条记录的【trx_id】是 51 ,在事务 B 的 Read View 的 【min_trx_id】和【max_trx_id】之间,接下来需要判断【trx_id】值是否在【m_ids】范围内,判断结果是在,那么说明这条记录是被还未提交的事务修改的,这时事务 B 并不会读取这个版本的记录。而是沿着 【undo log】链条往下找旧版本的记录,直到找到【trx_id】小于事务 B 的 Read View 中的【min_trx_id】值的第一条记录,所以事务 B 能读取到的是【trx_id】为 50 的记录,也就是汤圆的数字为111111111
那为什么事务 A 提交之后,事务 B 就可以读到事务 A 修改的数据呢?
在事务 A 提交之后,由于隔离级别是【读提交】,所以事务 B 在每次读数据的时候,会重新创建 Read View ,此时事务 B 第三次读取数据创建的 Read View长这样:
事务 B 在找到汤圆这条记录时,会发现这条记录的 trx_id 是51,比事务 B 的 Read View 中的【min_trx_id】的值(52)还小,这就说明这条记录在创建 Read View 前提交过了,所以该版本的记录对事务 B 是可见的。
也正是因为在读提交隔离级别下,事务每次读数据时都重新创建 Read View,那么事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。
六、MySQL可重复读隔离级别,完全解决了幻读???
在上述中我们提到了MySQL InnoDB 引擎的默认隔离级别虽然是【可重复读】,但是很大程度上避免了幻读但未完全解决:
- 针对快照读(普通 select 语句)通过 MVCC 机制解决了幻读
- 针对当前读(select ... for update等语句)通过 next_key_lock(记录锁加间隙锁)解决了幻读
6.1、什么是幻读?
当同一个查询在不同的事件产生不同的结果集时,事务中就会出现所谓的幻象问题。🌰:如果 select 执行了两次,但第二次返回了第二次没有返回的行,则该行是“幻像行”。
6.2、幻读的两个🌰
此时表中有四条记录 id 号分别为 1,2,3,4 中间字段为上图字段一样。
场景①:
- 事务 A 查询 id = 5 的记录,此时表中没有该记录,查询不出来
- 然后事务 B 插入一条 id = 5 的记录,并且提交了事务
- 此时,事务 A 虽然不知道有 id = 5 这条数据,但是此时事务 A 去更新 id = 5 这条记录 (这个场景是不是很...),更新完毕后,再次查询 id = 5 的记录,事务 A 就能够看到了事务 B 插入的记录了。
在可重复读隔离级别下,事务 A 第一次执行普通的 select 语句时生成了一个 Read View ,之后事务 B 向表中插入了一条 id = 5 的记录并提交。接着事务 A 对 id = 5 这条记录进行了更新操作,这个时刻,这条新纪录的 【trx_id】隐藏列的值就变成了事务 A 的事务 id,之后事务 A 再用普通 select 语句去查询这条记录时就可以看到这条记录了,于是发生了幻读。
场景②:
T1时刻:事务 A 先执行【快照读语句】:select * from t_test where id > 100 得到了 3 条记录
T2时刻:事务 B 插入一个 id = 200 的记录并提交
T3时刻:事务 A 再执行【当前读语句】:select * from t_test where id > 100 for update 就会 得到4条记录