文章转自:https://www.cnblogs.com/guanbin-529/p/14221889.html
目录
一、事务四要素:ACID
对于事务,我之前的理解是很粗糙的,不就是为了保证操作的原子性么?一般订单系统或者财务系统跟钱打交道的都会用到对吧?事务可以保证所有的操作一次性提交,要么全部成功,要么全部失败,譬如 A 转账给 B 100 元,先从 A 的账户里扣除 100 元,再在 B 的账户上加上 100 元,这两个操作必须同时成功,如果 A 的钱减了之后程序异常了,事务会将 A 的钱回滚回去,就是这么简单啊。
确实如此,原子性可以说是事务四要素中最直白的一个,也是最容易理解的一个。但是我们往往忽略了事务的另外三个要素:一致性、隔离性和持久性。这四个要素合起来就是 ACID 。
- 原子性(Atomicity):要么全部完成,要么全部不完成;
- 一致性(Consistency):一个事务单元需要提交之后才会被其他事务可见;
- 隔离性(Isolation):并发事务之间不会互相影响,设立了不同程度的隔离级别,通过适度的破坏一致性,得以提高性能;
- 持久性(Durability):事务提交后即持久化到磁盘不会丢失。
关于 一致性 的概念比较模糊,网上也存在着几种不同的解释,譬如有的人理解是:事务开始前和结束后数据都是满足约束规则的,如外键约束,我这里使用的是这里的说法,对错在其次,个人感觉比较容易理解吧,如果有意见欢迎讨论。
在大学学习数据库理论的时候,其实就已经接触过 ACID 了,记得考试时还考过一个选择题,“下面哪个不是数据库事务的四要素?”,只是当时对事务并没有什么感性的认识,只是照本宣科而已。现在回过头来看看 ACID 这四个要素,仔细想一想,数据库是如何实现这四要素的?发现还真不是那么简单。
但是对于我们了解死锁来说,我们还是应该把注意力放在 “I” 上面,因为不同的隔离级别,可能对应的加锁过程也不一样,而正是由于引入了各种各样的隔离级别,才让锁问题变得格外复杂。解决和分析死锁问题,首先得知道当前数据库的隔离级别。那么隔离级别是个什么东西呢?其实从上面也看的出来,隔离性和一致性是有冲突的,有时候为了提高性能,会适度的破坏一致性,可能会出现一个事务还没提交,它的修改就被其他事务看见了,这其实就是传说中的 脏读,它属于最低级的隔离级别:读未提交 。
二、事务并发存在的问题
在细讲事务的隔离级别之前,让我们先来看一下下面这张表,以及几个对这个表的操作,瞧瞧在多事务并发处理数据的时候可能会发生哪些意想不到的情况。
表很简单,就三个字段,主键 id、姓名 name 和余额 balance,其中 name 为二级索引,如下:
2.1 脏读(dirty read)
假设有两个事务,一个在进行转账操作,将 A 账户的 100 元转到 B 账户,同时有另一个事务在对 A 和 B 的账户余额进行求和统计,如下图所示:
2.2 不可重复读(unrepeatable read)
第二种场景叫 不可重复读,如下图所示,事务 2 第一次获取 A 的账户余额为 1000 元,这个时候事务 1 对 A 的账户余额进行操作减去了 100 元,事务 2 再去查询 A 的账户余额发现变成了 900 元,这样就出现了同一个事务对同一条记录读取两遍,两次读出来的结果竟然不一样。
不可重复读和脏读的区别在于,脏读是读取了另一个事务未提交的修改,而不可重复读是读取了另一个事务提交之后的修改,本质上都是其他事务的修改影响了本事务的读取。那么不可重复读有什么问题呢?假设上面的事务 2 存在着两个子例程,第一个例程是读取所有账户的余额计算总和,可以得到所有人的余额总和为 3000,第二个例程是再次读取所有账户的余额计算平均值,3 个人总和 3000 按理应该是平均每人 1000 才对,却计算出了 2900/3 = 966,这就导致了数据不一致。
2.3 幻读(phantom read)
第一次听到 幻读 这个名字觉得很酷,但是这个很酷的名字却是数据库中最难理解的一个名词之一。网上关于幻读和可重复读的讨论特别多,因为幻读和可重复读很类似,有时候真的傻傻分不清楚。对于幻读,最简单的解释就是:同样的条件,第一次和第二次读出来的记录数不一样。幻读和不可重复读的区别在于,后者是两次读取同一条记录,得到不一样的结果;而前者是两次读取同一个范围内的记录,得到不一样的记录数(这种说法其实只是便于理解,但并不准确,因为可能存在另一个事务先插入一条记录然后再删除一条记录的情况,这个时候两次查询得到的记录数也是一样的,但这也是幻读,所以严格点的说法应该是:两次读取得到的结果集不一样)。很显然,不可重复读是因为其他事务进行了 UPDATE 操作,幻读是因为其他事务进行了 INSERT 或者 DELETE 操作。同样的,下面举一个幻读的例子:
事务 2 的两次查询,第一次查出 2 条记录,第二次却查出 3 条记录,多出来的这条记录,正如 phantom(幽灵,幻影,错觉) 的意思,就像幽灵一样。
2.4 丢失更新(lost update)
上面说的三种情况,都是一个事务写,一个事务读,由于一个事务的写导致另一个事务读到了不该读的数据;那么如果两个事务都是写,又会发生什么呢?
假设两个事务同时对 A 的余额进行修改,他们都查出 A 的当前余额为 1000,然后事务 2 修改 A 的余额,将 A 的余额加 100 变成 1100 并提交,这个时候 A 的余额应该是 1100,但是这个时候事务 1 并不知道 A 的余额已经变动,而是继续在 1000 的基础上进行减 100 的操作并提交事务,就这样事务 2 的提交被覆盖掉了,事务 1 提交之后 A 的余额变成了 900 元。这就是说事务 1 的提交覆盖了事务 2 的提交,事务 2 的 UPDATE 操作完全丢失了,整个过程如下图所示:
这就是经典的 丢失更新 问题,由于最后一步是提交操作,所以又叫做 提交覆盖,有时候又叫 Read-Modify-Write 问题。一个典型的场景是并发对某个变量进行自增或自减,譬如商品表的库存字段,每次下单之后库存值需要减 1,大概的流程如下:
- SELECT name, stock FROM product WHERE id = 100;
- 判断 stock 值是否足够,如果足够,则下单:if (stock > n) process order;
- 更新 stock 值,减去下单的商品数量:new_stock = stock - n;
- UPDATE product SET stock = new_stock WHERE id = 100;
如果两个线程同时下单,很可能就会出现下面这样的情况:
- 线程 A 获取库存值为 10;
- 线程 B 获取库存值为 10;
- 线程 A 需要买 5 个商品,校验通过,并下单;
- 线程 B 需要买 5 个商品,校验通过,并下单;
- 线程 A 下单完成,更新库存值为 10 - 5 = 5;
- 线程 B 下单完成,更新库存值为 10 - 5 = 5;
两个线程下单结束后,商品的库存还剩 5 个,而实际上 10 个商品都已经卖光了。和提交覆盖相对的,还有另一个 丢失更新 问题,叫做 回滚覆盖,如下图所示:
操作和提交覆盖情景基本上一样,只是最后一步事务 1 的提交变成了回滚,这样 A 的余额恢复成原始值 1000,事务 2 的 UPDATE 操作也完全没有生效,想一想这真的是非常可怕,一个事务的回滚操作竟然影响了另一个正常提交的事务。回滚覆盖问题可以说是程序 bug 了,因此几乎所有的数据库都不允许回滚覆盖。
有时候我们把回滚覆盖称之为 第一类丢失更新 问题,提交覆盖称为 第二类丢失更新 问题
三、隔离级别
上面说了这么多并发场景下数据操作可能遇到的问题,那么要如何解决呢?我们能想到的最简单的方法就是对操作的数据进行加锁,写的时候不允许其他事务读,读的时候不允许其他事务写,这样是不是就完美解决了?确实如此。这其实就是四大隔离级别里的 序列化,在序列化隔离级别下,可以保证事务的安全执行,数据库的一致性得以保障,但是它大大降低了事务的并发能力,性能最低。
为了调和事务的安全性和性能之间的冲突,适当的降低隔离级别,可以有效的提高数据库的并发性能。于是便有了四种不同的隔离级别:
- 读未提交(Read Uncommitted):可以读取未提交的记录,会出现脏读,幻读,不可重复读,所有并发问题都可能遇到;
- 读已提交(Read Committed):事务中只能看到已提交的修改,不会出现脏读现象,但是会出现幻读,不可重复读;(大多数数据库的默认隔离级别都是 RC,但是 MySQL InnoDb 默认是 RR)
- 可重复读(Repeatable Read):MySQL InnoDb 默认的隔离级别,解决了不可重复读问题,但是任然存在幻读问题;(MySQL 的实现有差异,后面介绍)
- 序列化(Serializable):最高隔离级别,啥并发问题都没有。
针对这四种隔离级别,应该根据具体的业务来取舍,如果某个系统的业务里根本就不会出现重复读的场景,完全可以将数据库的隔离级别设置为 RC,这样可以最大程度的提高数据库的并发性。不同的隔离级别和可能发生的并发现象如下表:
其中,在 可重复读(RR) 隔离级别下,是否可能出现第二类丢失更新问题(提交覆盖)比较有争议,有的人认为提交覆盖问题其实是不可重复读问题的一种特殊形式,所以在 RR 隔离级别解决了不可重复读,自然也不可能出现提交覆盖,比如 这里 和 这里,但是也有人认为 RR 下会出现提交覆盖问题,比如 Mysql Repeatable-Read级别第二类丢失更新。我自己在 MySQL 的 RR 隔离级别下做了个类似的实验,发现确实会存在提交覆盖,目前我对这个问题也比较迷茫,如果有哪位同学知道,还望不吝赐教。
------------------- 2017-10-01 补充 -----------------------
通过查阅多方资料,终于对这个问题有了一个初步的认识。网上对此有很多不一致的说法,其实也是因为 MySQL 的实现和 ANSI-SQL 标准之间的差异,在标准的传统实现中,RR 隔离级别是使用持续的 X 锁和持续的 S 锁来实现的(参看下面的 “隔离级别的实现” 一节),由于是持续的 S 锁,所以避免了其他事务有写操作,也就不存在提交覆盖问题。但是 MySQL 在 RR 隔离级别下,普通的 SELECT 语句只是快照读,没有任何的加锁,和标准的 RR 是不一样的。如果要让 MySQL 在 RR 隔离级别下不发生提交覆盖,可以使用 SELECT ... LOCK IN SHARE MODE 或者 SELECT ... FOR UPDATE 。
4.2 MySQL 的隔离级别
虽然数据库的四种隔离级别通过 LBCC 技术都可以实现,但是它最大的问题是它只实现了并发的读读,对于并发的读写还是冲突的,写时不能读,读时不能写,当读写操作都很频繁时,数据库的并发性将大大降低,针对这种场景,MVCC 技术应运而生。MVCC 的全称叫做 Multi-Version Concurrent Control(多版本并发控制),InnoDb 会为每一行记录增加几个隐含的“辅助字段”,(实际上是 3 个字段:一个隐式的 ID 字段,一个事务 ID,还有一个回滚指针),事务在写一条记录时会将其拷贝一份生成这条记录的一个原始拷贝,写操作同样还是会对原记录加锁,但是读操作会读取未加锁的新记录,这就保证了读写并行。要注意的是,生成的新版本其实就是 undo log,它也是实现事务回滚的关键技术。关于 InnoDb 的 MVCC 机制的实现原理推荐看下这篇文章:MySQL数据库事务各隔离级别加锁情况--read committed && MVCC。
InnoDb 通过 MVCC 实现了读写并行,但是在不同的隔离级别下,读的方式也是有所区别的。首先要特别指出的是,在 read uncommit 隔离级别下,每次都是读取最新版本的数据行,所以不能用 MVCC 的多版本,而 serializable 隔离级别每次读取操作都会为记录加上读锁,也和 MVCC 不兼容,所以只有 RC 和 RR 这两个隔离级别才有 MVCC。下面我们通过两个例子,来看看这两个隔离级别下 MVCC 有什么区别。
还是使用上面的 account 表,首先将 MySQL 的事务隔离级别设置为 RR,然后开启一个事务,执行下面的 SQL:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
开启第二个事务,如下:
1 2 3 4 5 6 7 8 9 10 |
|
可以看到事务 2 查询出来的还是 A 原始的信息,这个时候事务 1 使用 commit 提交:
1 2 |
|
并在事务 2 中再查询一次,发现查询出来的结果不变,还是原始值:
1 2 3 4 5 6 7 |
|
同样的,我们将数据库的隔离级别设置为 RC,重新做一次这个实验,第一步都是一样的,只是第二步当事务 1 commit 之后,事务 2 再查一次发现查询出来的是最新提交的记录了:
1 2 3 4 5 6 7 |
|
通过这个实验可以知道,尽管 RR 和 RC 隔离级别都实现了 MVCC 来满足读写并行,但是读的实现方式是不一样的:RC 总是读取记录的最新版本,如果该记录被锁住,则读取该记录最新的一次快照,而 RR 是读取该记录事务开始时的那个版本。虽然这两种读取方式不一样,但是它们读取的都是快照数据,并不会被写操作阻塞,所以这种读操作称为 快照读(Snapshot Read),有时候也叫做 非阻塞读(Nonlocking Read),RR 隔离级别下的叫做 一致性非阻塞读(Consistent Nonlocking Read)。
除了 快照读 ,MySQL 还提供了另一种读取方式:当前读(Current Read),有时候又叫做 加锁读(Locking Read) 或者 阻塞读(Blocking Read),这种读操作读的不再是数据的快照版本,而是数据的最新版本,并会对数据加锁,根据加锁的不同,又分成两类:
- SELECT ... LOCK IN SHARE MODE:加 S 锁
- SELECT ... FOR UPDATE:加 X 锁
- INSERT / UPDATE / DELETE:加 X 锁
当前读在 RR 和 RC 两种隔离级别下的实现也是不一样的:RC 只加记录锁,RR 除了加记录锁,还会加间隙锁,用于解决幻读问题,关于记录锁和间隙锁的概念后面再讲,有兴趣的同学可以和上面一样,通过两个例子来感受一下。
首先在 RC 隔离级别下,开启一个事务,执行下面的 SQL 查询所有 id > 3 的记录,使用当前读而不是快照读:
1 2 3 4 5 6 7 8 9 10 |
|
同时,开启另一个事务,向 account 表中新增一条记录,然后修改 id = 4 的记录:
1 2 3 4 5 6 7 |
|
可以看到事务 2 在事务 1 当前读之后,仍然可以新增记录,但是在执行 update 操作的时候被阻塞,这说明了事务 1 在执行当前读的时候在 id = 4 这条记录上加了锁,但是并没有对 id > 3 这个范围加锁。然后我们切换到事务 1 中,再次查询:
1 2 |
|
这个时候事务 1 直接死锁了,原因其实很简单,事务 2 在 insert 新纪录的时候(id = 5)会在新纪录上加锁,所以事务 1 再次执行当前读,想获取 id > 3 的记录,就需要在 id = 4 和 id = 5 这两条记录上加锁,但是 id = 5 这条记录已经被事务 2 锁住了,于是事务 1 被事务 2 阻塞,同时事务 2 还在等待 事务 1 释放 id = 4 上的锁,于是便产生了死锁。
接下来把隔离级别设置成 RR,再重复事务 1 刚刚的操作:
1 2 3 4 5 6 7 8 9 10 |
|
同时,事务 2 向 account 表中新增一条记录:
1 2 3 4 |
|
我们发现,这个时候事务 2 就被阻塞了,很显然事务 1 在执行 select ... lock in share mode 的时候,不仅在 id = 4 这条记录上加了锁,而且在 id > 3 这个范围上也加了锁。
关于 MySQL 不同的隔离级别,读操作的差异总结起来如下图所示(其中,读未提交和可序列化都和 MVCC 不兼容,可以暂且认为它们都属于当前读):
4.3 查看和设置 MySQL 的隔离级别
可以通过查看 MySQL 中的系统变量 tx_isolation
的值来确定当前 MySQL 正在使用什么隔离级别。使用下面的 SQL 查询当前数据库的隔离级别:
1 2 3 4 5 6 |
|
这里要注意的是在 MySQL 中变量一般分为两类:用户变量 和 系统变量,用户变量的变量名格式为 @variable
,而系统变量的格式为 @@variable
,tx_isolation
是系统变量,所以变量名为 @@tx_isolation
。其中,系统变量又可以分为 全局变量 和 会话变量,默认情况下使用 select @@variable
查询出来的是会话变量的值,也可以写作 select @@session.variable
或者 select @@local.variable
,如果要查询全局变量的值,则使用 select @@global.variable
。
所以通常使用下面的 SQL 查询数据库的隔离级别:
1 2 3 4 5 6 |
|
另外可以使用 SET TRANSACTION
命令修改 MySQL 的隔离级别,譬如下面的 SQL 修改隔离级别为 RC:
1 |
|
同样其他的隔离级别可以使用下面的 SQL 进行修改:
- set transaction isolation level read uncommitted;
- set transaction isolation level read committed;
- set transaction isolation level repeatable read;
- set transaction isolation level serializable;
这里也有一个要注意的地方,默认的 SET TRANSACTION
命令 只对当前会话的下一个事务有效,当下个事务结束之后,下下个事务又会恢复到当前会话的隔离级别。我在这个地方困惑了好久,因为使用 SET TRANSACTION
命令设置完隔离级别之后,通过 select @@tx_isolation
查询当前会话的隔离级别是不会变化的,但是实际上下一个事务的隔离级别确实已经修改了(而且似乎没有什么好的方法可以查询当前事务的隔离级别,如果有谁知道,欢迎指点一二 ^_^)。
------------------- 2017-10-14 补充 -----------------------
找到了一种可以查看事务隔离级别的方法,那就是查 information_schema.INNODB_TRX
表。这种方式有一个不方便的地方在于,事务里必须要先执行一条 SQL 语句,才会在 INNODB_TRX 表中有记录,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
|
如果要修改当前会话的隔离级别,而不是仅仅下一个事务的隔离级别(这在做实验时挺有用的),需要使用下面命令:
1 |
|
另外,也可以使用修改系统变量的方法来修改隔离级别:
1 |
|
如果要修改全局变量的话,则使用命令 set global transaction isolation level ...
或者 set @@global.tx_isolation = ...
,方法与上面类似,不再赘述。不过要注意的是,这个命令不会改变当前会话的隔离级别,而是对新的会话有影响,所以要关闭当前会话,重新开启一个会话才能看到效果。
题外话:事务 与 autocommit
网上有很多文章中在开启事务之前,还会提到要先执行一下
set autocommit = 0;
其实我感觉没有必要。autocommit 的默认值为 1,指的是 MySQL 默认情况下会自动提交执行的每一条 SQL 语句,每执行一条 SQL 都会新建一个独立的事务,这其实叫做 隐式事务(implicit transaction);而我们经常用的 begin/start transaction 这种方式新建的事务叫做 显式事务(explicit transaction),并使用 commit/rollback 来结束事务。我们在 MySQL Client 里测试的时候,这种交互方式其实就是典型的隐式事务,不过大多数时候我们都是用 begin/commit 来测试,用 begin 开启一个事务的时候,autocommit 就相当于设置成 0 了,完全没必要手工再设置这个值。不过有意思的是,我们也可以使用 set autocommit = 0/set autocommit = 1 来替代 begin/commit,效果是一样的。
参考
- 数据库的脏读、幻读、不可重复读
- 事务的隔离级别以及Mysql事务的使用
- 事务并发的可能问题与其解决方案
- Innodb 中 RR 隔离级别能否防止幻读?
- Innodb中的事务隔离级别和锁的关系
- MySQL总结
- MySQL的并发控制与加锁分析
- MySQL加锁分析
- 数据库并发不一致分析
- MySQL的InnoDB的幻读问题
- mysql、innodb和加锁分析
- mysql的write skew问题
- 浅谈数据库隔离级别
- 数据库并发的五个问题以及四级封锁协议与事务隔离的四个级别
- MySQL 乐观锁与悲观锁