1 事务的基本概念
1.1 事务
用户定义的一个数据库操作序列,这些操作要么全部都做,要么全不做,是一个不可分割的工作单位。事务是恢复和并发控制的基本单位。
1.2 事务的4个特性
- 原子性(Atomicity):事务是数据库的逻辑工作单位,事务中包括的诸操作要么都做,要么都不做。
- 一致性(Consistency ):事务的执行结果必须是数据库从一个一致性状态到另一个一致性状态。
- 隔离性(Isolation):一个事务的执行不能被其他事务干扰。
- 持续性(Permanence):指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来的操作或者故障不应该对其执行结果有任何影响。
保证事务ACID特性是事务管理的重要任务 。事务ACID特性遭到破坏的因素有:
- 多个事务并行运行时,不同事务的操作交叉执行。
- 事务在运行过程中被强行停止。
2 标准SQL事务隔离级别
2.1 事务并发问题
在高等教育出版社的《数据库系统概论》中认为并发操作带来的数据不一致性包括丢失修改、不可重复读和读“脏”数据。
-
丢失修改(Lost Update):两个事务读入同一数据并修改,后一事务提交的结果破坏了前一事务提交的结果,导致前一事务的修改被丢失。
-
不可重复读(Non-repetable Read):一个事务读取数据后,另一个事务执行更新操作,使前一个事务无法再现前一次读取的结果。具体来讲包括三种情况:
(1)一个事务读取某一数据后,另一个事务对其进行了修改,当前一事务再次读取数据时,得到与前一次不同的值。
(2)一个事务按一定条件从数据库中读取了某些数据记录后,另一个事务删除了其中部分记录,当前一个事务再次按照相同条件读取数据时,发现某些记录神秘地消失了。
(3)一个事务按一定条件从数据库中读取了某些数据记录后,另一个事务插入了一些记录,当前一个事务再次按相同条件读取数据时,发现多了一条记录。
后面两种不可重复读有时也被成为幻影(phantom row)现象。
-
读“脏”数据(Dirty Read)
读“脏”数据是指一个事务修改某一数据并将其写回磁盘,另一个事务读取同一个数据后,前一个事务由于某些原因被撤销,这时被前一事务修改过的数据恢复原值,后一事务读到的数据就与数据库汇总的数据不一样。
一般认为,按照不同操作类型导致的数据不一致分为脏读、不可重复读 、幻读。简单来说:
- 因为查询操作没有约束,导致读取了未提交数据,这种影响称为“脏读”;
- 因为更新操作没有约束,在同一事务内,不同的时刻读到的同一批数据可能是不一样的(期间被别的事务更新),这种影响称为“不可重复读”;
- 因为插入和删除操作没有约束,在同一事务内在操作过程中进行两次查询,第二次查询的结果包含第一次查询中未出现的数据或者少了第一次查询中出现的数据(期间被别的事务插入或者删除的数据),这种影响称为“幻读”。
2.2 事务隔离级别
2.2.1 标准SQL事务隔离级别实现原理
解决并发事务问题的最常见方式就是悲观并发控制了(也就是数据库中的锁),封锁是实现并发控制的一个非常重要的技术。标准SQL定义了四种事务隔离级别,它们的实现是依赖锁的。不同的隔离级别及其实现方式如下表所示:
事务隔离级别 | 实现方式 |
---|---|
读未提交 | 事务对当前被读取的数据不加锁; 事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级共享锁,直到事务结束才释放。 |
读已提交 | 事务对当前被读取的数据加行级共享锁(当读到时才加锁),一旦读完该行,立即释放该行级共享锁; 事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁,直到事务结束才释放。 |
可重复读 | 事务在读取某数据的瞬间(就是开始读取的瞬间),必须先对其加行级共享锁,直到事务结束才释放; 事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁,直到事务结束才释放。 |
序列化读 | 事务在读取数据时,必须先对其加表级共享锁 ,直到事务结束才释放; 事务在更新数据时,必须先对其加表级排他锁 ,直到事务结束才释放。 |
注意:基本的锁类型有两种:排它锁(exclusive locks,Xlock)和共享锁(share locks,Slock)
排它锁又称为写锁。该锁保证该事务释放数据对象上的锁之前,其他事务不能再读取和修改数据对象。
共享锁又称为读锁。该锁保证该事务释放数据对象上的锁之前,其他事务可以读取数据对象,但是不允许任何修改。
2.2.2 并发操作带来的数据不一致性
隔离级别 | 并发操作带来的数据不一致性 |
---|---|
read uncommitted(RU) 读未提交 | 脏读、不可重复读 、幻读 |
read committed(RC) 读已提交 | 不可重复读、幻读 |
repeatable read(RR) 可重复读 | 幻读(MVCC、next-key-lock解决幻读) |
serializable(S) 序列化 |
2.3 设置MySQL隔离级别
SQL 标准定义的四种隔离级别,MySQL 全都支持。MySQL的事务实现逻辑是位于引擎层的,并且不是所有的引擎都支持事务的,后文引用的例子都是以InnoDB引擎为基准。
2.3.1 查询当前会话的隔离级别
# 在新版本中(5.7.20+),两种查询方式均可兼容查询
select @@transaction_isolation;
select @@tx_isolation;
此时返回的是:REPEATABLE-READ
2.3.2 查询系统全局的隔离级别
# 在新版本中(5.7.20+),两种查询方式均可兼容查询
select @@global.transaction_isolation;
select @@global.tx_isolation;
此时返回的是:READ-COMMITTED
由此可见系统全局设置的隔离级别和数据库设置的隔离级别可以是不同的。
2.3.3 设置会话的隔离级别
# 设置【读未提交】
set session transaction isolation level read uncommitted;
# 设置【读已提交】
set session transaction isolation level read committed;
3 不同隔离级别与并发事务演示
3.1 读未提交
MySQL演示:
设transaction_test
为存放不同人的名字name
和年龄age
的表。
事务A | 事务B |
---|---|
begin | |
select * from transaction_test where name = 'Tom' 查询结果:Tom的年龄为20 | begin |
update transaction_test set age=27 where name='Tom' 修改Tom的年龄为27 | |
select * from transaction_test where name = 'Tom' 重新查询结果:Tom的年龄为27 | |
commit 或者rollback | |
select * from transaction_test where name = 'Tom' 重新查询结果为 1. commit:Tom的年龄为27 2. rollback:Tom的年龄为20 | |
commit |
3.2 读已提交
MySQL演示:
事务A | 事务B |
---|---|
begin | |
select * from transaction_test where name = 'Tom' 查询结果:Tom的年龄为20 | begin |
update transaction_test set age=27 where name='Tom' 修改Tom的年龄为27 | |
select * from transaction_test where name = 'Tom' 重新查询结果:Tom的年龄为20 | select * from transaction_test where name = 'Tom' 查询结果:Tom的年龄为27 |
commit | |
select * from transaction_test where name = 'Tom' 重新查询结果:Tom的年龄为27 | |
commit |
3.3 可重复读
MySQL演示:
事务A | 事务B |
---|---|
begin | |
select * from transaction_test where name = 'Tom' 查询结果:Tom的年龄为20 | begin |
update transaction_test set age=27 where name='Tom' 修改Tom的年龄为27 | |
select * from transaction_test where name = 'Tom' 重新查询结果:Tom的年龄为20 | select * from transaction_test where name = 'Tom' 查询结果:Tom的年龄为27 |
commit | |
select * from transaction_test where name = 'Tom' 重新查询结果:Tom的年龄为20 | |
commit | |
select * from transaction_test where name = 'Tom' 重新查询结果:Tom的年龄为20 |
3.4 序列化读
序列化读的隔离级别严格按照先后顺序执行数据变更操作,读写加锁。演示略。
4 MySQL 实现事务隔离的原理
并发控制的方法包括封锁技术、时间戳方法、乐观控制法和多版本控制法等。
在只使用锁来实现隔离级别的控制的时候,需要频繁的加锁解锁,而且很容易发生读写的冲突。例如在RC级别下,事务A更新了数据行1,事务B则在事务A提交前读取数据行1都要等待事务A提交并释放锁,如下表所示:
事务A | 事务B |
---|---|
Xlock data | |
Read(data) | |
Write(data) | |
Slock data | |
等待 | |
Commit | 等待 |
Unlock data | 等待 |
Slock A | |
Read(A) | |
Commit | |
Unlock A |
为了不加锁解决读写冲突的问题,MySQL引入了多版本并发控制(Multi-Version Concurrency Control, MVCC)机制。
4.1 多版本并发控制
版本(version)是指数据库中数据对象的一个快照,记录了数据对象某个时刻的状态。多版本并发控制是指在数据库汇总通过维护数据对象的多个版本信息来实现高效并发控制的一种策略。
在多版本机制中,每个数据对象Q的写操作都创建一个新版本,这样一个数据对象就有一个版本序列Q1,Q2, … ,Qm 与之相关联。每一个版本Qk拥有版本的值,创建Qk的事务的时间戳和成功读取Qk的事务的最大时间戳。
事务A | 事务B |
---|---|
Read(Q) | |
Write(Q) | |
创建新版本Q’ | |
Read(Q) | |
Commit | |
Commit |
多版本协议
用TS(T)表示事务T的时间戳,TS(Ti)<TS(Tj)表示事务Ti在事务Tj之前开始执行。
多版本协议描述如下:
假设版本Qk具有小于或等于TS(T)的最大时间戳。
━若事务T发出读操作,则返回版本Qk的内容。
━若事务T发出写操作,则:
╰━当TS(T) < R-timestamp(Qk)时,回滚T;
╰━当TS(T) = W-timestamp(Qk)时,覆盖Qk的内容。
否则,创建Q的新版本。
若一个数据对象的两个版本Qk和Ql,其 W-timestamp 都系统中最老的事务的时间戳。那么这两个版本中较旧的那个版本将不再被用到,因而可以从系统中删除。
多版本并发控制利用物理存储上的多版本来维护数据的一致性。这就意味着当检索数据库时,每个事务都看到一个数据的一段时间前的快照,而不管正在处理的数据当前的状态。多版本并发控制和封锁体制相比,主要的好处是消除的数据库中数据对象读和写操作的冲突,有效地提高了系统的性能。
4.2 MySQL快照读和当前读
4.2.1 快照读
快照读即 snapshot read,它的官方叫法是Consistent Nonlocking Reads,不加锁的非阻塞读,即一致性非锁定读。
它是InnoDB 通过 MVCC(多版本控制)将数据库在过去某个时刻的快照应用在查询上,使得这次查询只能看到别的事务生成快照前提交的数据,而不能看到别的事务生成快照后提交的数据或者未提交的数据。简而言之,读取的记录是快照版本,也就是历史版本。
快照读的问题在于:在同一个事务中,能够读取到之前提交的数据。表现为
- 如果在同一个事务中,先更新了表中的一些行,然后进行查询,读到了更新后的数据,应该是读到未更新的数据。即:产生了 不可重复读 的问题。
- 如果在同一个事务中,先更新了表(drop table / alter table),然后进行查询,会发现表的状态已经不是快照时的状态了。
一致读取是InnoDB处理READ COMMITTED
和REPEATABLE READ
隔离级别的 SELECT 语句的默认模式。一致读取不会在它访问的表上设置任何锁,因此其他会话可以自由地在对表执行一致读取的同时修改这些表。读不加锁,读写不冲突,这个对于 MySQL 并发访问提升很大。
在 read-committed
隔离级别下,事务中的快照读,总是以最新的快照为基准进行查询的。
在 repeatable-read
隔离级别下,快照读是以事务开始时的快照为基准进行查询的,如果想以最新的快照为基准进行查询,可以先把事务提交完再进行查询。
在 repeatable-read
隔离级别下,别的事务在你生成快照后进行的删除、更新、新增,快照读是看不到的。
注意:在事务中,为查询创建的快照,并不适用于 DML
语句。
4.2.2 当前读
当前读即 current read,它的官方叫法是 Locking Reads,加锁的阻塞读,即锁定读。
它读取记录的最新版本。读取时还要保证其他并发事务不能修改当前记录,并且会对读取的记录进行加锁。像UPDATE、DELETE、INSERT、SELECT … LOCK IN SHARE MODE、SELECT … FOR UPDATE这些操作都是一种当前读。
4.2.3 MySQL快照读和当前读演示
假设现在的隔离级别是读已提交。
事务A | 事务B |
---|---|
begin | |
select * from transaction_test where name = 'Tom' 查询结果:Tom的年龄为20 | begin |
update transaction_test set age=27 where name='Tom' 修改Tom的年龄为27 | |
select * from transaction_test where name = 'Tom' 重新查询结果:Tom的年龄为20 | |
select * from transaction_test where name = 'Tom' for update 重新查询结果:(阻塞) | |
(阻塞) | |
(阻塞) | commit |
查询结果:Tom的年龄为27 | |
select * from transaction_test where name = 'Tom' 重新查询结果:Tom的年龄为27 | |
commit |
4.3 MySQL日志机制
MySQL日志机制是MySQL实现数据库事务功能除了锁技术和MVCC的另一项重要技术。
4.3.1 bin log
bin log,即归档日志。
bin log是MySQL层面的,以二进制记录的日志,用于数据库基于时间点的还原。它会记录DDL、DML以及数据库表的操作语句等,同时操作的消耗时间也会记录进去。事务提交之后会记录bin log,定时刷盘。如果操作没有记录到bin log里面,那么一系列提交和未提交的事务都将数据丢失。它提供了整体业务层面的保障。
4.3.2 redo log
redo log,即重做日志。
redo log是InnoDB存储引擎层面的日志,记录的是数据修改之后的值,不管事务是否提交都会记录下来。重做日志对数据的正确提交提供保障。对数据的操作,比如更新或者插入操作,经常会伴随数据的持久化,如果直接进行刷盘,对应IO层是一个资源浪费,或者说性能阻塞,所以说,InnoDB通过先写日志,再写磁盘,将读写持久化进行分离,可以高速写入日志再慢慢写入磁盘 ,通过这个机制保证数据IO高效。如果写入磁盘失败或者延迟,可以通过redo log重新快速刷入磁盘中。
4.3.3 undo log
undo log,即回滚日志。
undo log也是InnoDB存储引擎层面的日志,它保存事务发生之前的数据版本,可以用于回滚,同时可以提供MVCC下的读,也即非锁定读。
4.4 隐式字段
4.4.1 DB_TRX_ID
-
DB_TRX_ID,事务ID
- 6byte
- 记录创建这条记录时,最后一次修改该记录的事务ID
4.4.2 DB_ROLL_PTR
-
DB_ROLL_PTR,回滚指针
- 7byte
- 指向这条记录的上一个版本(存储于回滚段里面)
4.4.3 DB_ROW_ID
-
DB_ROW_ID,隐藏主键,隐含的自增ID
- 6byte
- 如果数据表中没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引
4.4.4 其他字段
比如 创建版本号、删除版本号等字段
4.6 读视图
read view,读视图,决定了数据是否可以可被其他事务读取的关键因素。
-
读视图的组成
- List:生成read-view时候活跃的事务ID集合
- up limit id:list中最小的事务id
- low limit id:下一个要分配的事务id
演示1:
事务1 | 事务2 | 事务3 | 事务4 |
---|---|---|---|
begin | begin | begin | begin |
… | … | … | update… |
commit | |||
select… | |||
【read-View】 List:1,2,3 Up ID: 1 Low ID:5 |
演示2:
事务1 | 事务2 | 事务3 | 事务4 |
---|---|---|---|
begin | begin | ||
… | select… | ||
begin | begin | ||
update… | |||
commit | |||
select… | |||
TRX_ID=1 最后一次修改该记录的事务ID | |||
【read-view】 List:1,2 Up ID:1 Low ID:3 |
-
结论
- 1 可重复读:在首次 快照读生成读视图
- 2 读已提交:在每次快照读时生成读视图
4.7 数据可见性算法
4.8 InnoDB的事务具体实现
仅仅MVCC能否解决幻读问题?实际上MVCC并不能解决幻读问题,因为为查询创建的快照,并不适用于 DML
语句。以下做个例子:
假设 student 表为空表
事务A | 事务B |
---|---|
begin | |
select count(*) from student # 没有加锁 查询结果:0 | begin |
插入了一条id=1的数据 | |
commit | |
select count(*) from student # 读快照 重新查询结果:0 | |
更新id=1的数据 # update是当前读 更新结果:更新成功,并生成一个更新的快照 | |
select count(*) from student # 读快照查询结果:1。是id=1的一条记录。因为MVCC可以查到当前事务生成的快照 | |
commit |
可以看到前后查出来的数据行不一致,发生了幻读。所以说只有MVCC是不能解决幻读问题的,解决幻读问题靠的是间隙锁。如下:
事务A | 事务B |
---|---|
begin | |
select * from student in share mode # 加上共享锁 查询结果:空 | begin |
想插入了一条id=1的数据,由于有间隙锁,等待 | |
select * from student # 读快照 重新查询结果:空 | |
更新id=1的数据 # update是当前读 更新结果:由于不存在数据,不进行更新 | |
select * from student # 读快照查询结果:空 | |
commit | |
插入了一条id=1的数据 | |
commit |
参阅官方文档了解MySQL的锁定读和一致性非锁定读以及隐式锁定和显式锁定等相关概念之后,来看下InnoDB的事务具体是怎么实现的(下面的读都指的是非主动加锁的select):
事务隔离级别 | 实现方式 |
---|---|
未提交读(RU) | 事务对当前被读取的数据不加锁,都是当前读; 事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级共享锁,直到事务结束才释放。 |
提交读(RC) | 事务对当前被读取的数据不加锁,且是快照读; 事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁(Record),直到事务结束才释放。 通过快照,在这个级别MySQL就解决了不可重复读的问题 |
可重复读(RR) | 事务对当前被读取的数据不加锁,且是快照读; 事务在更新某数据的瞬间(就是发生更新的瞬间),必须先对其加行级排他锁(Record,GAP,Next-Key),直到事务结束才释放。 通过间隙锁,在这个级别MySQL就解决了幻读的问题 |
序列化读(S) | 事务在读取数据时,必须先对其加表级共享锁 ,直到事务结束才释放,都是当前读; 事务在更新数据时,必须先对其加表级排他锁 ,直到事务结束才释放。 |
可以看到,InnoDB通过MVCC很好的解决了读写冲突的问题,而且提前一个级别就解决了标准级别下会出现的幻读和不可重复读问题,大大提升了数据库的并发能力。
5. 经验与扩展
5.1 选择隔离级别
-
上松下严:上游的业务传播属性要适配下游的隔离级别!
-
了解Spring事务传播属性:当 Spring开启了事务并设置了传播机制,那么会覆盖 MySQL已有的事务隔离级别。如果MySQL不支持该隔离级别,Spring的事务也也就不会生效。
-
可重复读(RR)、读已提交(RU)是常态。(有可能搭配锁)
5.2 改进的多版本并发控制
多版本协议可以进一步改进。区分事务的类型为只读事务和更新事务。对于只读事务,发生冲突的可能性很小,可以采用多版本时间戳。对于更新事务,采用教保守的两阶段封锁(2PL)协议。这样的混合协会称为MV2PV。具体做法如下:
除了传统的读锁(共享锁)和写锁(排他锁)外,引进一个新的封锁了类型,称为验证锁(certify lock, Clock)。封锁的相容矩阵如下表所示:
R-Lock | W-Lock | C-Lock | |
---|---|---|---|
R-Lock | Y | Y | Y |
W-Lock | Y | N | N |
C-Lock | N | N | N |
注:Y表示相容请求,N表示不相容请求
在这里,系统最多只要维护数据对象的两个版本。多个读操作可以和一个写操作并发执行。这种情况是传统的2PL锁不允许的,提高了读写事务之间的并发度。
目前的很多商用数据库系统,例如Oracle,Kingbase ES都是采用MV2PL协议的。
具体请查阅相关资料:数据库系统概述-高等教育出版社
6 小结
在本文中介绍了事务的基本概念和隔离级别,以及MySQL对事务的实现原理,包括MySQL Logs、隐式字段、快照读和当前读、读视图以及数据可见性算法等组成MVCC基本结构的内容。
参考资料
[1] https://www.imooc.com/learn/1309 普贤. 探秘 MySQL 多版本并发控制原理.慕课网.
[2] https://cloud.tencent.com/developer/article/1708499 X先生. 深入理解MySQL中事务隔离级别的实现原理. 腾讯云+社区. 2020-10-29.
[3] https://segmentfault.com/a/1190000023332101 X先生. 一文读懂数据库中的乐观锁和悲观锁和MVCC. 思否. 2020-07-22.
[4] https://dev.mysql.com/doc/refman/8.0/en/ MySQL 8.0 Reference Manual
[5] https://www.docs4dev.com/docs/zh/mysql/5.7/reference/ MySQL5.7中文文档
[6] https://www.thegeeksearch.com/understanding-innodb-locking-in-mysql-database Understanding InnoDB locking in MySQL database
[7] 王珊 萨师煊. 数据库系统概述(第5版). 高等教育出版社. 2015.