MySQL事务究竟有没有被隔离
我们知道在 RR 级别下,对于一个事务来说,读到的值应该是相同的,但有没有想过为什么会这样,它是如何实现的?会不会有一些特殊的情况存在?本篇文章会详细的讲解 RR 级别下事务被隔离的原理。在阅读后应该了解如下的内容:
- 了解 MySQL 中的两种视图
- 了解 RR 级别下,如何实现的事务隔离
- 了解什么是当前读,以及当前读会造成哪些问题
明确视图的概念
在 MySQL 中,视图有两种。第一种是 View,也就是常用来查询的虚拟表,在调用时执行查询语句从而获取结果,语法如create view
第二种则是存储引擎层 InnoDB 用来实现 MVCC(Mutil-Version Concurrency Control | 多版本并发控制)时用到的一致性视图consistent read view
,用于支持 RC 和 RR 隔离级别的实现。简单来说,就是定义在事务执行期间,事务内能看到什么样的数据。
事务真正的启动时机:
在使用begin
或start transation
时,事务并没有真正开始运行,而是在执行一个对 InnoDB 表的操作时(即第一个快照读操作时),事务才真正启动。
如果想要立即开始一个事务,可以用start transaction with consistent snapshot
命令。
不期待的结果,事务没有被隔离
在之前 MySQL 事务 介绍中,知道在 RR 的级别的事务下,如果其他事务修改了数据,事务中看到的数据和启动事务时的数据是一致的,并不会受其他事务的影响。可是,有没有什么特殊的情况呢?
看下面这个例子:
创建表:
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`k` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);
按照下图开启事务:
这时对于事务 A 来说,查询 k 的值为 1, 而事务 B 的 K 为 3。不是说,在 RR 级别下的事务,不受其他事务影响吗,为什么事务 B 结果为 3 而不是所期待的 2。这就涉及到了 MVCC 中 “快照” 的工作原理。
MVCC 的实现 - 快照
什么是 “快照”?
在 RR 级别下,事务启动时会基于整库拍个“快照”,用于记录当前状态下的数据信息。可这样的话,对于库比较大的情况,事务的启动应该非常慢。可实际上的执行过程非常快,原因就在于 InnoDB 中的实现。
“快照”的实现
在 InnoDB 中,每个事务都有唯一的事务 ID,叫做transaction id
。在事务开始时,按照严格递增的顺序向 InnoDB 事务系统申请
数据库中,每行数据具有多个版本。在每次开启更新的事务时,都会生成一个新的数据版本,并把transaction id
赋值给当前数据版本的事务 ID,记为row trx_id
如下图所示,对于同一行数据连续更新了 4 次,对应 4 个版本如下。对于最新版本 V4 情况,K 为 22,是被事务 id 为 25 所更新的,进而 row trx_id 是 25
在每次更新时,都会生成一条回滚日志(undo log),上图中三个虚拟箭头(U1,U2,U3)就是 undo log。每次开启事务的视图 V1,V2,V3 物理上并不真实存在,而是通过当前事务版本和 undo log 计算出来。
了解了 row trx_id 和 transaction id,就可以进一步了解事务具体是如何进行隔离的了。
事务隔离的实现
在 RR 级别下,如果要想实现一个事务启动时,能够看到所有已经提交的事务结果,而在事务执行期间,其他事务的更新均不可见的效果。
只需要在事务启动时规定,以启动的时刻为准,如果一个数据版本在启动前生成,就可以查看。如果在启动后生成,则不能查看,通过 undo log 一直查询上一个版本数据,直到找到启动前生成的数据版本或者自己更新的数据才结束。
在具体实现上,InnoDB 为每个事务构造一个数组,用来保存事务启动瞬间,当前正在活跃(启动没有提交)的所有事务 ID。数组里 ID 最小为低水位,当前系统里面创建过的事务 ID 最大值 + 1 为高水位。这个数组和高水位,就组成了当前事务的一致性视图(read-view),如下图所示。
数据是否看见,就是通过比较数据的 row trx_id 和 一致性视图的对比而得到的。
在比较时:
1、如果 row trx_id 出现在绿色部分,表示该版本是已提交的事务或者当前的事务自己生成的,该数据可见
事务A | 事务B |
---|---|
start transaction with consistent snapshot; | |
update t set k = k+1 where id=1; | |
commit; | |
start transaction with consistent snapshot; | |
select * from t where id=1; |
事务 A 修改 k 值后提交,接着事务 B 查询 k 值。这时对于启动的事务 B 来说,k 值的 row trx_id 等于事务 A 的transaction id
。而事务 B 在 事务 A 之后申请,假设当前活跃事务只有 B。B 的transaction id
肯定大于事务 A,所以当前版本 row trx_id 一定小于低水位,进而 k 值为 A 修改后的值。
2、如果在红色部分,表示由未来的事务生成的,该数据不可见
事务A | 事务B |
---|---|
start transaction with consistent snapshot; | |
start transaction with consistent snapshot; | |
update t set k = k+1 where id=1; | |
commit; | |
select * from t where id=1; |
事务 A 开启后,查询 k 值,但未提交事务。事务 B 在事务 A 开启后修改 K 值。此时对于事务 A 来说,修改后 k 值的row trx_id
等于事务B transaction id
。假设当前的活跃的只有事务 A,则row trx_id
大于高水位的值,所以事务 B 的修改对 A 不可见。
3、如果落在黄色部分,两种情况
(1)row trx_id 在数组中,表示该版本是由未提交的事务生成的,不可见
事务A | 事务B |
---|---|
start transaction with consistent snapshot; | |
start transaction with consistent snapshot; | |
update t set k = k+1 where id=1; | |
select * from t where id=1; |
事务 A,B 先后开启,假设只有 A,B 两个活跃的事务。此时对于事务 B 来说一致性视图中的数组包含事务 A 和 B 的 transaction id
当事务 B 查询 k 值时,发现数组中包含事务 A 的 transaction id,说明是未提交的事务。所以不可见
这时,假设让事务 A 在事务 B 查询前提交:
事务A | 事务B |
---|---|
start transaction with consistent snapshot; | |
start transaction with consistent snapshot; | |
update t set k = k+1 where id=1; | |
commit; | |
select * from t where id=1; |
当对于事务 B 来说,事务 A 虽然提交了,当其提交的 row transaction_id 依然是 B 启动时的活跃事务里面的,所以更新依然不可见。
也就是说,只要在事务启动时,同时活跃的其他事务,无论是否比当前事务提交的早或晚,都是属于不可见的情况。
(2)row trx_id 不在数组中,表示该版本是由已提交的事务生成,可见
事务A (transaction id = 100) | 事务B (transaction id = 101) | 事务 C (transaction id = 102) |
---|---|---|
start transaction with consistent snapshot; | ||
update t set k = k+1 where id=1; | ||
start transaction with consistent snapshot; | ||
update t set k = k+1 where id=1; | ||
commit; | ||
start transaction with consistent snapshot; | ||
select * from t where id=1; |
假设当前只活跃 A,C 两个事务。对于事务 C 来说,一致性视图数组为[100,102]。当前 k 的 row trx_id 为 101,不在一致性数组中。说明是已经提交事务,所以数据可见。
InnoDB 就是利用了所有数据都有多个版本这个特性,实现了秒级创建快照的能力。
现在回到文章开始的三个事务,现在分析下为什么事务 A 的结果是 1。
假设如下:
- 在 A 启动时,系统只有一个活跃的事务 ID 为 99
- A,B,C 事务的版本号为 100,101,102 且当前系统中只有这四个事务
- 三个事务开始前,(1,1)对应 row trx_id 为 90
这样对于事务 A,B,C 中一致性数组和 row trx_id 如下:
右侧显示的回滚段的内容,第一次更新为事务 C,其 row trx_id 等于 102,值为 (1,2)。最新 row trx_id 为事务 B 的 101,值为 (1,3)
对于事务 A 来说,视图数组为 [99,100]。读取流程如下:
- 获取当前 row trx_id 为 101 的数据。发现比高水位大,落在红色,不可见
- 向上查找,发现 row trx_id 为 102 的,比高水位大,不可见
- 向上查找,发现 row trx_id 为 90,比低水位小,落在绿色可见
这时事务 A 无论在什么时候查询,看到的结果都一致,这被称为一致性读。
上面的判断逻辑为代码逻辑,现在翻译成便于理解的语言,对于一个事务视图来说,除了自己的更新可见外:
- 版本未提交,不可见(包含了还未提交的事务,或者开始同时活跃,但先一步提交的事务)
- 版本已提交,在视图后创建提交的,不可见
- 版本已提交,在视图创建前提交,可见
现在应该清楚,可重复读的能力就是通过一致性读实现的。可是在文章开始部分事务 B 的更新语句如果按照一致性读的情况,事务 C 在事务 B 之后提交,结果应该是(1,2)不是(1,3)。原因就在于当前读的影响。
当前读的影响
对于文章开头部分的事务 B 来说,如果在更新操作前查询一次数据,返回结果确实是 1。但由于更新操作,并不是在历史版本上更新,否则事务 C 的更新就会被覆盖。因此事务 B 的更新操作是在(1,2)的基础上操作的。
什么是当前读?
在更新操作时,都是先读后写,这个读,就是只能读当前的值(最新已经提交的值),进而称为“当前读”。
除 update 语句外,给 select 语句加锁,也是当前读。锁的类型可以是读锁(S锁,共享锁)和写锁(X锁,排他锁)。
比如想让事务 A 的查询语句获取当前读中的值:
# 共享锁 - 允许其他事务读取被锁定的行
mysql> select k from t where id=1 lock in share mode;
# 排它锁 - 不允许其他事务读取被锁定的行
mysql> select k from t where id=1 for update;
在当前读下,快照查询的过程
在事务 B 更新时,当前读拿到的值为(1,2),更新后生成的新版本数据为(1,3),当前版本的 row trx_id 101.
所以在接下里的执行的查询语句时,当前 row trx_id 为101,判断为自己更新的,所以可见。所以查询结果是(1,3)。
假设事务 C 改成如下事务 C' 这样,在事务 B 更新后,再提交。
这时虽然(1,2)已经生成了,但根据两阶段锁协议,由于事务 C’ 没有提交,没有释放写锁。这时事务 B 就会被锁住,等到其他事务释放后,再继续当前读。
可重复读的核心就是一致性读,而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。
版权声明:
作者:Joe.Ye
链接:https://www.appblog.cn/index.php/2023/04/01/have-mysql-transactions-been-quarantined/
来源:APP全栈技术分享
文章版权归作者所有,未经允许请勿转载。
共有 0 条评论