文章目录
一、事务的概念
事务(transaction)就是一组 DML 语句,这些语句在逻辑上存在相关性,这一组 DML 语句要么全部成功,要么全部失败,是一个整体。MySQL 提供一种机制,保证我们达到这样的效果。事务还规定不同的客户端看到的数据是不相同的。
事务就是要做的或所做的事情,主要用于处理操作量大、复杂度高的数据。假设一种场景:转账(给一方减去指定的金额,给另一方加上对应的金额)。像这样,将多条 SQL 语句打包就构成了一个事务。
一个 MySQL 数据库,可不止你一个事务在运行,同一时刻,甚至有大量的请求被包装成事务在向 MySQL 服务器发起事务处理请求。如果不对这些事务加以控制,就很容易出现问题,比如大家都访问同样的表数据,或者存在执行到一半出错或者不想再执行的情况。
因此,一个完整的事务,绝对不是一个简单的 SQL 集合,还需要满足以下四个属性:
- 原子性:一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。如果事务在执行过程中发生错误,就会被回滚(rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
- 一致性:在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。
- 隔离性:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read Uncommitted)、读提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)。
- 持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
上面的四个属性,可以简称为 ACID 。
- 原子性(Atomicity,或称不可分割性)。
- 一致性(Consistency)。
- 隔离性(Isolation,又称独立性)。
- 持久性(Durability)。
为什么会出现事务?
事务被 MySQL 的编写者设计出来,是为了当应用程序访问数据库的时候,事务能够简化我们的编程模型,不需要我们去考虑各种各样的潜在错误和并发问题。因此事务本质上是为了应用层服务的,而不是伴随着数据库系统天生就有的。
二、事务的版本支持
在 MySQL 中只有使用了 InnoDB 数据库引擎的数据库或表才支持事务,而 MyISAM 不支持。
查看数据库引擎。
三、事务的提交方式
事务的提交方式常见的有两种:自动提交和手动提交。
查看事务的提交方式。
show variables like 'autocommit';
说明:autocommit == ON 为自动提交,autocommit == OFF 为手动提交。
可以设置是否打开自动提交。
set autocommit=1;
:打开自动提交,即设置成自动提交。
set autocommit=0;
:关闭自动提交,即设置成手动提交。
四、事务的相关演示
为了便于演示,我们将 MySQL 的全局隔离级别设置成读未提交。
我们发现隔离级别没有改变,这是因为把全局隔离级别设置完之后需要重启终端才能生效。
创建一张测试表(简单银行用户表):
- 正常演示:(自动提交 ON)
启动两个终端,左终端开始一个事务,右终端查看表中的信息。
左终端开始一个事务,插入若干条记录,并设置若干个保存点。
左终端回滚到保存点 s2,右终端查看表信息。
左终端全部回滚,右终端查看表信息。
左终端插入两条记录并提交,右终端查看表信息。
- 非正常演示:验证事务的原子性。(自动提交 ON / OFF)
启动两个终端,左终端开始一个事务,右终端查看表中的信息。
左终端插入几条记录后,右终端查看表信息。让左终端直接崩溃(Ctrl + \)。
客户端启动事务后,若没有 commit,且因为一些异常而退出了,则 MySQL 不会提交,自动回滚到最开始。所以右终端看不到事务开始后插入的记录了。
一旦手动启动(begin;
或 start transaction;
)一个事务,就需要手动提交(commit;
),和 MySQL 中事务是否会自动提交无关!
- 非正常演示:验证事务的持久性。(自动提交 ON)
启动两个终端,左终端开始一个事务,右终端查看表中的信息。
左终端启动一个事务后插入了记录,commit 了,然后崩溃。
一旦事务 commit 了,就已经被提交,不能再回滚了。
- 非正常演示:验证单条 SQL 都会被 MySQL 包装成事务,以事务的方式提交的。(自动提交 OFF / ON)
启动两个终端,左终端操作,右终端查看表中的信息。
先把自动提交关掉。
左终端插入一条记录,右终端查看表信息。
使左终端崩溃,右终端查看表信息,发现回滚了。
把自动提交打开。
左终端插入一条记录,右终端查看表信息。
使左终端崩溃,右终端查看表信息,发现插入的记录还在,说明已经提交了。
所有的单条 SQL,本质在 MySQL 中,各自都会被以事务的方式进行提交!
即一条 SQL 就是一个事务;四五条 SQL 就是四五个事务。
实际上,我们一直都在使用单 SQL 事务,只不过 autocommit 默认是 ON,会被自动提交,不需要手动开始和提交一个事务。
即自动提交是给 MySQL 中的单条 SQL 设置的(默认行为)。
命令行操作事务:
① start transaction;
或 begin;
。
② 正常操作。
③ rollback to savepoint;
或 rollback;
。
④ commit;
。
结论:
- 只要输入
begin;
或者start transaction;
,事务便必须要通过commit;
手动提交才会持久化,与是否设置 autocommit 无关。 - 手动启动一个事务,事务可以手动回滚,若操作异常,MySQL 会自动回滚。
- 对于 InnoDB,每一条 SQL 都会默认被封装成事务,自动提交(autocommit 默认为 ON)。
事务操作注意事项:
- 如果没有设置保存点,也可以回滚,只不过只能回滚到事务的开始。
- 如果一个事务被提交了(commit),则不可以回滚(rollback)。
- 可以选择回退到哪个保存点。
- InnoDB 支持事务,MyISAM 不支持事务。
五、事务的隔离级别
MySQL 服务可能会同时被多个客户端进程(线程)访问,访问的方式以事务方式进行。
所有事务都要有一个执行过程,那么在多个事务各自执行多个 SQL 的时候,有可能会出现互相影响的情况。比如:多个事务同时访问同一张表,甚至同一行记录。
数据库中:
- 为了保证事务执行过程中尽量不受干扰,于是就有了隔离性的概念。
- 为了允许事务受不同程度的干扰,于是就有了隔离级别的概念。
隔离级别:
- 读未提交(Read Uncommitted):在该隔离级别,所有的事务都可以看到其他事务没有提交的执行结果(实际生产中不可能使用这种隔离级别)。其实就相当于没有任何隔离性,也会有很多并发问题,比如脏读、不可重复读、幻读等。
- 读提交(Read Committed):该隔离级别是大多数数据库默认的隔离级别(不是 MySQL 默认的)。它满足了隔离的简单定义:一个事务只能看到其他的已经提交的事务所做的改变。但是存在不可重复读和幻读的问题。
- 可重复读(Repeatable Read):这是 MySQL 默认的隔离级别,它确保同一个事务在执行过程中,多次读取操作数据时会看到同样的数据行。但是会有幻读问题(MySQL 不存在,其他数据库可能存在)。
- 串行化(Serializable):这是事务的最高隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决了幻读的问题。它在每个读的数据行上面加上共享锁,但是可能会导致超时和锁竞争(这种隔离级别太极端,实际生产基本不使用)。
隔离级别基本都是通过锁实现的,不同的隔离级别对锁的使用是不同的。常见的有表锁、行锁、读锁、写锁、间隙锁(GAP)、Next-Key 锁(GAP+行锁)等。
1.查看与设置隔离级别
- 查看隔离级别:
查看全局隔离级别。
select @@global.tx_isolation;
查看会话隔离级别。
select @@session.tx_isolation;
查看会话隔离级别。
select @@tx_isolation;
- 设置隔离级别:
设置全局隔离级别。
set global transaction isolation level 隔离级别;
设置会话隔离级别。
set session transaction isolation level 隔离级别;
设置当前会话隔离级别,不会影响其它会话。
全局隔离级别只会影响后续的新会话,不会影响当前会话。
2.读未提交(Read Uncommitted)
几乎没有加锁,虽然效率高,但是问题太多,严重不建议采用。
两个终端,将隔离级别都设置为读未提交。
左右终端都各自开始一个事务,左终端事务对数据进行了写操作但是没有 commit,右终端事务读到了左终端事务未 commit 的数据。
若一个事务在执行中,读到了另一个执行中的事务的未 commit 的数据,这种现象叫做脏读(dirty read)。
3.读提交(Read Committed)
两个终端,将隔离级别都设置为读提交。
左右终端都各自开始一个事务,左终端事务对数据进行了写操作但是没有 commit,右终端事务没有读到左终端事务未 commit 的数据。
当左终端事务对数据 commit 了,右终端事务才能读到左终端事务的数据。但是对于右终端而言,它还在事务执行中,在不同的时间读,却读取到了不同的数据。
一个事务在执行中,在不同的时间读到了不同的数据,这种现象叫做不可重复读。
4.可重复读(Repeatable Read)
两个终端,将隔离级别都设置为可重复读。
左右终端都各自开始一个事务,左终端事务对数据进行了写操作但是没有 commit,右终端事务没有读到左终端事务未 commit 的数据。左终端事务对数据 commit 了,右终端事务没有读到左终端事务已 commit 的数据。
当右终端事务对数据 commit 了,右终端才能读到左终端事务已 commit 的数据。
一个事务在执行中,在不同的时间读到相同的数据,这种现象叫做可重复读。
- 在一般的数据库,一个事务在可重复读情况时,无法屏蔽其他事务 insert 的数据(因为隔离性的实现是通过对数据加锁完成的,而新插入的数据原本并不存在,所以一般的加锁无法屏蔽此类问题),因此这就会造成虽然大部分内容是可重复读的,但是 insert 的数据在可重复读情况下被读取出来,导致出现幻读。
- 一个事务在执行中,多次读相同范围的数据读到了新的数据,如同出现了幻觉,这种现象叫做幻读。
很明显,MySQL 在 RR 级别的时候,是解决了幻读问题的,通过 Next-Key 锁(GAP+行锁)。
在 MySQL 中,一个事务在 RR 级别的情况下,会屏蔽其他事务新插入的数据,即 MySQL 在 RR 级别的时候,解决了幻读问题。
在 RR 级别下,多个事务的 update、多个事务的 insert、多个事务的 delete,是会加锁的。但是 select 查询和这些操作是不冲突的。这就是通过读写锁(锁有行锁或者表锁)+ MVCC 完成隔离性。
5.串行化
对所有的操作全部加锁,进行串行化,不会有问题。但是由于是串行化,效率很低,几乎完全不会被采用。
两个终端,将隔离级别都设置为串行化。
左右终端都各自开始一个事务,左右终端事务都对数据进行了读操作,可以并发执行,不会被阻塞,即读取不会串行化。
但当左终端事务要对表进行写操作时,左终端事务就会被阻塞。
当右终端事务对数据 commit 了,左终端事务刚才的写操作才能完成。
左右终端都各自开始一个事务。
但当左终端事务要对表进行写操作时,左终端事务就会被阻塞。
当右终端事务在插入数据时,报错了。与此同时,左终端事务刚才的写操作完成。
(右终端事务也要对数据进行写操作,也就是左右终端事务都在申请锁,会造成死锁,所以为了破环死锁,让右终端事务结束)
多个事务同时进行读操作时加的是共享锁,因此可以并发执行读操作,但一旦需要进行写操作,就会串行化。
6.总结
- 其中隔离级别越严格,安全性越高,但数据库的并发性能也就越低,往往需要在两者之间找一个平衡点。
- 不可重复读的重点是修改和删除:在同样的条件下,已经读取过的数据,再次读取,发现值不一样了。
幻读的重点在于新增:在同样的条件下,第一次读和第二次读的记录数不一样。幻读本质上是不可重复读的一种特殊情况。 - MySQL 默认的隔离级别是可重复读(RR级别),一般情况下不要修改。
- 事务也有长短事务这样的概念。事务间互相影响,指的是事务在并发执行的时候(即都没有 commit 的时候),影响会比较大。
上表中只写出了各种隔离级别下在进行读操作时是否需要加锁。
但是,无论是哪种隔离级别,只要进行写操作,就一定需要加锁。
- 隔离性:让并发执行的各个事务,看到不同的数据修改(增删改)。
- 隔离级别:允许一个事务可以看到数据修改的不同程度。
为何要存在隔离级别?
不仅仅是为了考虑安全问题,而是在安全和效率之间找一个平衡点。这个平衡点不是由数据库决定的,而是由上层业务场景决定的。
六、一致性
事务执行的结果,必须使数据库从一个一致性状态,变到另一个一致性状态。当数据库只包含事务成功提交的结果时,数据库处于一致性状态。
在技术上,数据库通过 AID 来保证 C:
- 若事务尚未完成而被迫中断,且该未完成的事务对数据库所做的修改已被写入数据库,此时为了保证数据库处于一致性状态,就需要事务自动回滚到最开始的状态,就好像这个事务从来没有执行过一样,即一致性需要原子性来保证。
- 事务提交后,对数据的修改必须是永久的,即便系统故障也不能丢失,即一致性需要持久性来保证。
- 多个事务同时访问同一个数据时,必须保证这多个事务在并发执行时,不会因为交叉执行而导致数据的不一致,即一致性需要隔离性来保证。
一致性和用户的业务逻辑强相关,一般数据库提供技术支持,但是一致性还是要用户业务逻辑做支撑,即一致性需要上层用户编写出正确的业务逻辑来保证。
换言之,一致性是由用户和数据库共同决定的。
七、数据库并发的场景
数据库并发的场景无非如下三种:
- 读-读:不存在任何问题,也不需要并发控制。
- 读-写:有线程安全问题,可能会存在事务隔离性问题,可能遇到脏读、幻读、不可重复读。
- 写-写:有线程安全问题,可能会存在更新丢失问题。
八、读-写下的 MVCC
多版本并发控制(MVCC,Multi-Version Concurrency Control)是一种用来解决读-写冲突的无锁并发控制。
理解 MVCC 需要知道三个知识:3 个隐藏字段、undo 日志、Read View 。
为事务分配单向增长的事务 ID,为每个修改保存一个版本,版本与事务 ID 关联,读操作只读该事务开始前的数据库的快照。
MVCC 保证在并发读写数据库时,可以做到读操作不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能,同时还可以解决脏读、幻读、不可重复读等事务隔离性问题,但不能解决更新丢失问题。
1.记录中的 3 个隐藏列字段
- DB_ROW_ID:6 字节,隐含的自增 ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以 DB_ROW_ID 生成一个聚簇索引。
- DB_TRX_ID:6 字节,最近修改/插入的事务 ID,记录创建这条记录/最后一次修改该记录的事务 ID 。
- DB_ROLL_PTR:7 字节,回滚指针,指向这条记录的上一个版本。
补充:实际还有一个删除 flag 隐藏字段,即记录被删除并不代表真的删除,而是删除 flag 变了。
2. undo 日志
undo log,在事务中承担回滚的日志。
可以简单理解成,就是 MySQL 中的一段内存缓冲区,用来保存日志数据。会在合适的时候,将相关数据刷新到磁盘当中。
数据记录的历史版本就是存储在 undo log 中。
3.模拟 MVCC
创建一张测试表。
这张表的字段如下:
注:假设创建该记录的事务 ID 和隐式主键分别为 9 和 1 。第一条记录没有历史版本,所以我们设置其回滚指针为 null 。
现在有一个事务 ID 为 10 的事务,对 student 表中的记录进行 update:将 name 由张三改成李四。
- 事务 10,因为要进行写操作,所以要先给该记录加行锁。
- 修改前,先将该行记录拷贝到 undo log 中,所以 undo log 中就有了一行副本数据。(原理就是写时拷贝)
- 修改原始记录中的 name 由张三改成李四,并且修改原始记录的隐藏字段 DB_TRX_ID 为当前事务 10 的 ID,往原始记录的回滚指针 DB_ROLL_PTR 写入 undo log 中副本数据的地址,从而使原始记录指向副本记录(即表示上一个版本就是它)。
- 事务 10 提交,释放锁。
此时最新的记录,就是 name 为李四的那条记录。
现在又有一个事务 11,对 student 表中的记录进行 update:将 age 由 28 改成 38 。
- 事务 11,因为要进行写操作,所以要先给该记录(最新记录)加行锁。
- 修改前,先将该行记录拷贝到 undo log 中(采用头插方式插入到 undo log 中),所以 undo log 中就又有了一行副本数据。
- 修改原始记录中的 age 由 28 改成 38 。并且修改原始记录的隐藏字段 DB_TRX_ID 为当前事务 11 的 ID,往原始记录的回滚指针 DB_ROLL_PTR 写入 undo log 中副本数据的地址,从而使原始记录指向副本记录(即表示上一个版本就是它)。
- 事务 11 提交,释放锁。
此时最新的记录,就是 age 为 38 的那条记录。
这样,我们就有了一个基于链表记录的版本链。undo log 中的一个一个历史版本,我们可以称之为一个一个快照。
所谓的回滚,无非就是用历史数据覆盖当前数据。所谓的创建保存点,可以理解成是给历史版本做标记,从而在回滚时可以直接用标记了的历史数据覆盖当前数据。
关于 insert 和 delete 操作:
- delete(删除记录):先将该行记录拷贝一份到 undo log 中,然后将该原始记录的删除 flag 隐藏字段设置为 1 。这样回滚后就相当于删除的数据又恢复了。
- insert(插入记录):由于新插入的记录是没有历史版本的,但是一般为了能够进行回滚操作,也会将该新插入的记录拷贝一份到 undo log 中,该行副本数据的删除 flag 隐藏字段被设置为 1,这样回滚后就相当于新插入的数据没有插入了。
换言之,增删改操作都是可以形成版本链的。
select 读取,既有可能是读取最新版本,也有可能是读取历史版本。
- 当前读:读取最新记录。增删改,都叫做当前读。
- 快照读:读取历史版本。
注:select 可以当前读,在 select 语句后加上比如 lock in share mode(共享锁)、for update 。
多个事务同时在进行增删改的时候,因为都要读取最新版本,所以都是当前读,因此都需要加锁,这就是串行化。
多个事务同时在进行 select 查询的时候,既有可能是当前读,也有可能是快照读。如果是当前读(读取最新版本),那么也需要加锁。但如果是快照读(读取历史版本)的话,是不需要加锁的(因为历史版本不会被修改),也就是可以并发执行。换言之,提高了效率,这就是 MVCC 的意义所在。
select 是当前读还是快照读,取决于当前事务的隔离级别!
隔离级别,本质就是根据级别的不同,让事务看到哪一个快照。
那为什么要有隔离级别呢?
事务都是原子的。无论如何,事务总有先有后。当一个事务启动时,事务 ID 已经确定,此时对于该事务而言,数据的状态也是确定的。
事务从 begin -> CRUD -> commit,是有一个阶段的,即事务有执行前、执行中、执行后的阶段。但不管怎么启动多个事务,总是有先有后的。
那么多个事务在执行中,CRUD 操作是会交织在一起的。那么,为了保证事务的先后顺序,就应该让不同的事务看到它该看到的内容,这就是隔离性与隔离级别要解决的问题。
关于 undo log 中的版本链:
在 undo log 中形成的版本链不仅仅是为了能够进行回滚操作,其他事务在执行过程中也有可能读取版本链中的某个版本,即快照读。
因此,只有当某条记录的最新版本已经完成修改并被事务提交,并且此时没有其他事务读取该记录的历史版本,这时该记录在 undo log 中的版本链才可以被清除。
对于 insert 插入的记录来说,因为是新插入的,所以不会有其他事务访问它的历史版本,因此当该记录被事务提交后,就可以将该记录在 undo log 中的版本链清除了。
4. Read View
对于读未提交而言,由于事务可以读到其他未提交事务修改过的记录,所以直接读取最新版本就行,而且也不用加锁。
对于串行化而言,事务使用加锁的方式来访问记录,所以也是直接读取最新版本就行。
对于读提交和可重复读而言,如何保证不同的事务能够看到不同的版本呢?即如何实现隔离级别呢?
事务在进行快照读的时候会生成读视图(Read View),在该事务进行快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID 。
Read View 在 MySQL 源码中就是一个类,本质是用来进行可见性判断,决定事务的可见性的。即当某个事务进行快照读时,会创建一个 Read View,根据 Read View 来判断当前事务能够看到记录的哪个版本。
下面是 Read View 的结构(经过简化):
class ReadView {
// 省略...
private:
/** 高水位:大于等于这个ID的事务均不可见*/
trx_id_t m_low_limit_id
/** 低水位:小于这个ID的事务均可见 */
trx_id_t m_up_limit_id;
/** 创建该 Read View 的事务ID*/
trx_id_t m_creator_trx_id;
/** 创建视图时的活跃事务ID列表*/
ids_t m_ids;
/** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,
* 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
trx_id_t m_low_limit_no;
/** 标记视图是否被关闭*/
bool m_closed;
// 省略...
};
/*
部分成员变量的说明:
m_ids; // 一张列表,用来维护Read View生成时刻,系统正活跃的事务ID(不包括m_creator_trx_id)
m_up_limit_id; // 记录m_ids列表中事务ID最小的ID(没有写错)
m_low_limit_id; // ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1(没有写错)
m_creator_trx_id; // 创建该ReadView的事务ID
*/
Read View 对记录版本链中的一个版本进行可见性判断,对应的关键函数的源码如下:
注:将来传给函数
changes_visible
的参数 id,就是版本链中的版本的 DB_TRX_ID 。
/** Check whether the changes by id are visible.
@param[in] id transaction id to check against the view
@param[in] name table name
@return whether the view sees the modifications of id. */
bool changes_visible(trx_id_t id, const table_name_t& name) const
MY_ATTRIBUTE((warn_unused_result))
{
ut_ad(id > 0);
// DB_TRX_ID < m_up_limit_id(已提交)或 DB_TRX_ID是创建该Read View的事务ID,则可见
if (id < m_up_limit_id || id == m_creator_trx_id) {
return(true);
}
check_trx_id_sanity(id, name);
// DB_TRX_ID >= m_low_limit_id(生成Read View时还没启动的其他事务),则不可见
if (id >= m_low_limit_id) {
return(false);
}
// m_up_limit_id <= DB_TRX_ID < m_low_limit_id,且活跃事务ID列表为空,则可见
else if (m_ids.empty()) {
return(true);
}
const ids_t::value_type* p = m_ids.data();
// m_up_limit_id <= DB_TRX_ID < m_low_limit_id,若在活跃事务ID列表中则不可见,若不在则可见
return (!std::binary_search(p, p + m_ids.size(), id));
}
将上述源码图示如下:
Read View 第一次形成的时间点,是在事务第一次进行 select 快照读的时候。
对一条记录,遍历它的版本链(从最新版本开始),对每个版本进行可行性判断,直到找到一个当前事务可见的版本为止。
整体流程演示:
假设当前有一条记录:
事务操作:
事务 1 [id=1] | 事务 2 [id=2] | 事务 3 [id=3] | 事务 4 [id=4] |
---|---|---|---|
事务开始 | 事务开始 | 事务开始 | 事务开始 |
修改且已提交 | |||
进行中 | 快照读 | 进行中 | |
事务 4 修改了 name 由张三变成李四。
当事务 2 对该行记录执行快照读时,数据库为该事务 2 生成一个 Read View 读视图。
此时的版本链是:
事务 2 对该行记录执行快照读时,对版本进行可见性判断的过程:
只有事务 4 修改过该行记录,并在事务 2 执行快照读之前就提交了事务。
事务 2 在快照读该行记录的时候,就会调用 Read View 里的函数changes_visible
对该记录版本链中的每个版本进行可见性判断(从最新版本开始),直到找到一个当前事务 2 可见的版本为止(即拿版本中的 DB_TRX_ID 去跟 m_up_limit_id、m_low_limit_id、m_creator_trx_id 和 m_ids 进行比较,判断当前事务 2 能否看到该版本,若不能则继续拿下一个版本比较,直到找到一个版本为止)。
由于事务 2 找到了它的可见版本,所以不再继续往后找。
九、RR 与 RC 的本质区别
select ... lock in share mode;
表示以加共享锁的方式进行读取,是当前读。
在 RR 级别下事务首次进行快照读所处的时间点:
演示一:事务 B 在事务 A 对数据进行写操作之前,快照读过一次数据。
事务 A 操作 | 事务 A 描述 | 事务 B 描述 | 事务 B 操作 |
---|---|---|---|
begin | 开启事务 | 开启事务 | begin |
select * from account | 快照读查询 | 快照读查询 | select * from account |
update 操作 | 更新数据 | ||
commit | 提交事务 | ||
select 快照读,没有读到最新数据 | select * from account | ||
select 当前读,读到最新数据 | select * from account lock in share mode |
两个终端,将隔离级别都设置为可重复读。
左右终端都各自开始一个事务,右终端事务在左终端事务对数据进行写操作之前查看了数据,然后左终端事务对数据进行了写操作。右终端事务再次查看数据,没有看到任何修改。
左终端事务对数据 commit 了,右终端事务仍然看不到任何修改。
当右终端事务进行当前读(读最新版本)时,才能读到最新数据。
当右终端事务再次进行快照读时,仍然看不到最新数据。
演示二:事务 B 在事务 A 对数据进行写操作之前,没有快照读过一次数据。
事务 A 操作 | 事务 A 描述 | 事务 B 描述 | 事务 B 操作 |
---|---|---|---|
begin | 开启事务 | 开启事务 | begin |
select * from account | 快照读查询 | ||
update 操作 | 更新数据 | ||
commit | 提交事务 | ||
select 快照读,读到最新数据 | select * from account | ||
select 当前读,读到最新数据 | select * from account lock in share mode |
两个终端,将隔离级别都设置为可重复读。
左右终端都各自开始一个事务,左终端事务对数据进行了写操作,然后对数据 commit 。右终端事务查看数据,可以看到修改。右终端事务确实读的是最新数据。因为在不同的时间都读到相同的数据,所以不存在不可重复读的问题。
演示一与演示二的唯一区别,仅仅是事务 B 在事务 A 对数据进行写操作之前,是否快照读过一次数据。
结论:事务中快照读的结果,是非常依赖于该事务首次出现快照读的地方,即某个事务中首次出现快照读的地方,决定该事务后续快照读的结果。
正是因为 Read View 的更新与否,从而造成了 RR 和 RC 级别下快照读的结果的不同。
RR 与 RC 的本质区别:
- 在 RR 级别下,事务在第一次进行快照读时会创建一个 Read View,将当前系统活跃的其他事务记录下来,此后再进行快照读时,依旧使用这个 Read View 进行可见性判断。因此,在第一次快照读之后其他事务所作的修改对该事务而言是不可见的。即在 RR 级别下,在事务第一次进行快照读生成 Read View 时,Read View 会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的,而早于 Read View 创建的其他事务所做的修改均是可见的。即在 RR 级别下,Read View 在第一次形成后不再更新,事务的可见性不再变化。
- 而在 RC 级别下,事务在每次快照读时都会重新创建一个 Read View,然后再根据当前的这个 Read View 进行可见性判断。因此当前事务可以看到其他事务提交的最新数据。
总结:
- 在 RR 级别下,事务的第一个快照读才会创建 Read View,之后的快照读获取的都是同一个 Read View,所以是可重复读的。
- 在 RC 级别下,事务的每个快照读都会获取最新的 Read View,所以才会有不可重复读问题。