一、MySQL 事务
本文所说的 MySQL 事务都是指在 InnoDB 引擎下,MyISAM 引擎是不支持事务的。
数据库事务指的是一组数据操作,事务内的操作要么全部成功,要么全部失败。什么都不做,不一定是真的什么都没做,有可能做了一部分但是只要有一步失败,就要回滚所有操作,有点一不做二不休的意思,效果就是什么都没做。假设一个网购付款的操作,用户付款后要涉及订单状态更新、扣库存以及其他一系列动作,这就是一个事务。如果一切正常那就相安无事,一旦中间有某个环节异常,那整个事务就要回滚,总不能更新了订单状态但是不扣库存。
事务具有原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)四个特性,简称 ACID,缺一不可。本文总结下事务的隔离性。
二、概念说明
以下几个概念是事务隔离级别要实际解决的问题,所以需要搞清楚都是什么意思。
- 脏读(Dirty Read)
脏读指的是读到了其他事务未提交的数据,未提交意味着这些数据可能会回滚,也就是可能最终不会存到数据库中,也就是不存在的数据。读到了并一定最终存在的数据,这就是脏读。 - 可重复读(Repeatable Read)
可重复读指的是在一个事务内,最开始读到的数据和事务结束前的任意时刻读到的同一批数据都是一致的。通常针对数据更新(UPDATE)操作。 - 不可重复读(Non-Repeatable Read)
对比可重复读,不可重复读指的是在同一事务内,不同的时刻读到的同一批数据可能是不一样的,可能会受到其他事务的影响,比如其他事务改了这批数据并提交了。通常针对数据更新(UPDATE)操作。 - 幻读(Phantom Read)
幻读多是针对数据插入(INSERT)操作来说的。事务 A 按照一定条件进行数据读取, 期间事务 B 插入了相同搜索条件的新数据,事务 A 再次按照原先条件进行读取时,发现了事务 B 新插入的数据,称为幻读。
三、事务隔离级别
SQL 标准定义了四种隔离级别,MySQL 全部支持。这四种隔离级别分别是:
- 读未提交(READ UNCOMMITTED)
- 读提交 (READ COMMITTED)
- 可重复读 (REPEATABLE READ)
- 串行化 (SERIALIZABLE)
从上往下,隔离强度逐渐增强,性能逐渐变差。采用哪种隔离级别要根据系统需求权衡决定,其中,可重复读是 MySQL 的默认级别。
事务隔离其实就是为了解决上面提到的脏读、不可重复读、幻读这几个问题,下面展示了 4 种隔离级别对这三个问题的解决程度。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
读未提交 | 可能 | 可能 | 可能 |
读提交 | 不可能 | 可能 | 可能 |
可重复读 | 不可能 | 不可能 | 可能 |
串行化 | 不可能 | 不可能 | 不可能 |
只有串行化的隔离级别全部解决了这 3 个问题,其他的 3 个隔离级别都有缺陷。
四、如何设置隔离级别
我们可以通过以下语句查看当前数据库的隔离级别,通过下面语句可以看出我使用的 MySQL 的隔离级别是 REPEATABLE-READ,也就是可重复读,这也是 MySQL 的默认级别。
mysql> select version();
+-----------+
| version() |
+-----------+
| 5.6.17 |
+-----------+
1 row in set (0.04 sec)
# 5.7.20 之前
mysql> SELECT @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set (0.02 sec)
mysql> show variables like 'tx_isolation';
+---------------+-----------------+
| Variable_name | Value |
+---------------+-----------------+
| tx_isolation | REPEATABLE-READ |
+---------------+-----------------+
1 row in set (0.00 sec)
mysql>
# 查看事务隔离级别 5.7.20 之后
mysql> show variables like 'transaction_isolation';
mysql> SELECT @@transaction_isolation;
修改隔离级别的语句是:set [作用域] transaction isolation level [事务隔离级别]。示例:SET [SESSION | GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}。其中,作用域可以是 SESSION 或者 GLOBAL,GLOBAL 是全局的,而 SESSION 只针对当前回话窗口;事务隔离级别可以是 {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE} 这四种,不区分大小写。比如下面这个语句的意思是设置全局隔离级别为读提交级别。
mysql> set global transaction isolation level read committed;
设置完成后,只对之后新开启的 session 才起作用,对已经启动的 session 无效。如果用 shell 客户端那就要重新连接 MySQL,如果用 Navicat 那就要创建新的查询窗口。
五、MySQL 中执行事务
事务的执行过程如下,以 begin 或者 start transaction 开始,然后执行一系列操作,最后要执行 commit 操作,事务才算结束。当然,如果进行回滚操作(rollback),事务也会结束。
需要注意的是,begin 命令并不代表事务的开始,事务开始于 begin 命令之后的第一条语句执行的时候。例如下面示例中,select * from xxx 才是事务的开始。
mysql> begin;
mysql> select * from xxx;
mysql> commit; -- 或者 rollback;
另外,通过以下语句可以查询当前有多少事务正在运行。
mysql> select * from information_schema.innodb_trx;
创建如下一张简单的表来辅助分析几种隔离级别,表结构如下。
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(30) DEFAULT NULL,
`age` tinyint(4) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8
初始只有一条记录:
mysql> SELECT * FROM user;
+----+-----------------+------+
| id | name | age |
+----+-----------------+------+
| 1 | 古时的风筝 | 1 |
+----+-----------------+------+
1 row in set (0.00 sec)
5.1 读未提交
MySQL 事务隔离其实是依靠锁来实现的,加锁自然会带来性能的损失。而读未提交隔离级别是不加锁的,所以它的性能是最好的,没有加锁、解锁带来的性能开销。但有利就有弊,任何事务对数据的修改都会第一时间暴露给其他事务,即使事务还没有提交,所以它连脏读的问题都没办法解决。
下面来做个简单实验验证一下,首先设置全局隔离级别为读未提交:set global transaction isolation level read uncommitted;
设置完成后,只对之后新起的 session 才起作用,对已经启动的 session 无效,所以需要重新开启 2 个会话连接来模拟两个事务。
启动两个事务,分别为事务 A 和事务 B,在事务 A 中使用 update 语句,修改 age 的值为 10,初始是 1,在执行完 update 语句之后,在事务 B 中查询 user 表,会看到 age 的值已经是 10 了,这时候事务 A 还没有提交,而此时事务 B 有可能拿着已经修改过的 age =10 去进行其他操作了。在事务 B 进行操作的过程中,很有可能事务 A 由于某些原因,进行了事务回滚操作,那其实事务 B 得到的就是脏数据了,拿着脏数据去进行其他的计算,那结果肯定是有问题的。
顺着时间轴往下,表示两事务操作的执行顺序,重点看图中 age 字段的值。
读未提交,其实就是可以读到其他事务未提交的数据,但没有办法保证你读到的数据最终一定是提交后的数据,如果中间发生回滚,那就会出现脏数据问题,读未提交没办法解决脏数据问题,更别提可重复读和幻读了。
5.2 读提交
既然读未提交没办法解决脏数据问题,那么就有了读提交。读提交就是一个事务只能读到其他事务已经提交过的数据,也就是其他事务调用 commit 命令之后的数据。那脏数据问题就迎刃而解了。
读提交事务隔离级别是大多数流行数据库的默认事务隔离级别,比如 Oracle,但不是 MySQL 的默认隔离级别。
我们来继续做一下验证,首先把事务隔离级别改为读提交级别:set global transaction isolation level read committed;
之后需要重新打开新的 session 窗口。
同样开启事务 A 和事务 B 两个事务,在事务 A 中使用 update 语句将 id = 1 的记录的 age 字段改为 10。此时,在事务 B 中使用 select 语句进行查询,我们发现在事务 A 提交之前,事务 B 中查询到的记录 age 一直是 1,直到事务 A 提交,此时在事务 B 中 select 查询,发现 age 的值已经是 10 了。
这就出现了一个问题,在同一事务中(本例中的事务 B ),事务的不同时刻同样的查询条件,查询出来的记录内容是不一样的,事务 A 的提交影响了事务 B 的查询结果,这就是不可重复读,也就是读提交隔离级别。
每个 select 语句都有自己的一份快照,而不是一个事务一份,所以在不同的时刻,查询出来的数据可能是不一致的。
读提交解决了脏读的问题,但是无法做到可重复读,也没办法解决幻读。
5.3 可重复读
可重复是对比不可重复而言的,上面说不可重复读是指同一事务不同时刻读到的数据值可能不一致,而可重复读是指,事务不会读到其他事务对已有数据的修改,即使其他事务已提交,也就是说,事务开始时读到的已有数据是什么,在事务提交前的任意时刻,这些数据的值都是一样的。但是,对于其他事务新插入的数据是可以读到的,这也就引发了幻读问题。
同样的,需改全局隔离级别为可重复读级别:set global transaction isolation level repeatable read;
在这个隔离级别下,启动两个事务,两个事务同时开启。
首先看一下可重复读的效果,事务 A 启动后修改了数据,并且在事务 B 之前提交,事务 B 在事务开始和事务 A 提交之后两个时间节点所读取的数据相同,已经可以看出可重复读的效果。
做到了可重复读,这只是针对已有行的更改操作有效,但是对于新插入的记录,就没这么幸运了,幻读就这么产生了。我们看一下这个过程:
事务 A 开始后,执行 update 操作,将 age = 1 的记录的 name 改为“风筝2号”;
事务 B 开始后,在事务 A 执行完 update 后,执行 insert 操作,插入记录 age = 1,name = 古时的风筝,这和事务 A 修改的那条记录值相同,然后提交。
事务 B 提交后,事务 A 中执行 select,查询 age = 1 的数据,这时会发现多了一行,并且发现还有一条 name = 古时的风筝,age = 1 的记录,这其实就是事务 B 刚刚插入的,这就是幻读。
要说明的是,当你在 MySQL 中测试幻读的时候,并不会出现上图的结果,幻读并没有发生,MySQL 的可重复读隔离级别部分解决了幻读问题,这会在后面的内容说明。
5.4 串行化
串行化是 4 种事务隔离级别中隔离效果最好的,解决了脏读、不可重复读、幻读的问题,但是效果最差,它将事务的执行变为顺序执行,与其他三个隔离级别相比,它就相当于单线程,后一个事务的执行必须等待前一个事务结束。
六、MySQL 如何实现事务隔离
首先说读未提交,它是性能最好,也可以说它是最野蛮的方式,因为它压根儿就不加锁,所以根本谈不上什么隔离效果,可以理解为没有隔离。
再来说串行化,读的时候加共享锁,也就是其他事务可以并发读,但是不能写;写的时候加排它锁,其他事务不能并发写也不能并发读。
最后说读提交和可重复读。这两种隔离级别是比较复杂的,既要允许一定的并发,又要兼顾的解决问题。
6.1 实现可重复读
为了解决不可重复读,或者为了实现可重复读,MySQL 采用了 MVCC (多版本并发控制) 的方式。
我们在数据库表中看到的一行记录可能实际上有多个版本,每个版本的记录除了有数据本身外,还要有一个表示版本的字段,记为 row trx_id,而这个字段就是使其产生的事务的 id,事务 ID 记为 transaction id,它在事务开始的时候向事务系统申请,按时间先后顺序递增。
按照上面这张图理解,一行记录现在有 3 个版本,每一个版本都记录着使其产生的事务 ID,比如事务 A 的 transaction id 是 100,那么版本 1 的 row trx_id 就是 100,同理版本 2 和版本 3 的 row trx_id 分别为 200 和 300。
在上面介绍读提交和可重复读的时候都提到了一个词,叫做快照,学名叫做一致性视图,这也是可重复读和不可重复读的关键,可重复读是在事务开始的时候生成一个当前事务全局性的快照,而读提交则是每次执行语句的时候都重新生成一次快照。
对于一个快照来说,它能够读到哪些版本数据,要遵循以下规则:
- 当前事务内的更新,可以读到;
- 版本未提交,不能读到;
- 版本已提交,但是却在快照创建后提交的,不能读到;
- 版本已提交,且是在快照创建前提交的,可以读到。
利用上面的规则,再返回去套用到读提交和可重复读的那两张图上就很清晰了。还是要强调,两者主要的区别就是在快照的创建上,可重复读仅在事务开始时创建一次,而读提交每次执行语句的时候都要重新创建一次。
6.2 并发写问题
存在这样的情况,两个事务,对同一条数据做修改,最后结果应该是哪个事务的结果呢?肯定得是时间靠后的那个对不对。并且更新之前要先读数据,这里所说的读和上面说到的读不一样,更新之前的读叫做“当前读”,总是当前版本的数据,也就是多版本中最新一次提交的那版。
快照读就是读取数据的时候会根据一定规则读取事务可见版本的数据。 而当前读就是读取最新版本的数据。
什么情况下使用的是快照读(快照读,一般不会加锁)?一般的 select * from .... where ...
语句都是快照读。
什么情况下使用的是当前读(当前读,会在搜索的时候加锁)?一般的 select * from .... where ... for update; select * from .... where ... lock in share mode; update .... set .. where ... ; delete from. . where ..
语句都是当前读。
假设事务 A 执行 update 操作, update 的时候要对所修改的行加行锁,这个行锁会在提交之后才释放。而在事务 A 提交之前,事务 B 也想 update 这行数据,于是申请行锁,但是由于已经被事务 A 占有,事务 B 是申请不到的,此时,事务 B 就会一直处于等待状态,直到事务 A 提交,事务 B 才能继续执行,如果事务 A 的时间太长,那么事务 B 很有可能出现超时异常。如下图所示。
加锁的过程要分有索引和无索引两种情况,比如下面这条语句 update user set age = 11 where id = 1
,id 是这张表的主键,是有索引的情况,那么 MySQL 直接就在索引数中找到了这行数据,然后干净利落地加上行锁就可以了。
而下面这条语句 update user set age = 11 where age = 10
,表中并没有为 age 字段设置索引,所以, MySQL 无法直接定位到这行数据,那怎么办呢?当然也不是加表锁了,MySQL 会为这张表中所有行加行锁,没错,是所有行。但是呢,在加上行锁后,MySQL 会进行一遍过滤,发现不满足的行就释放锁,最终只留下符合条件的行。虽然最终只为符合条件的行加了锁,但是这一锁一释放的过程对性能也是影响极大的。所以,如果是大表的话,建议合理设计索引,如果真的出现这种情况,那很难保证并发度。
6.3 部分解决幻读
6.3.1 无幻读示例
上面介绍可重复读的时候,那张图里标示着出现幻读的地方实际上在 MySQL 中并不会出现,MySQL 已经在可重复读隔离级别下部分解决了幻读的问题。
前面刚说了并发写问题的解决方式就是行锁,而解决幻读用的也是锁,叫做间隙锁,MySQL 把行锁和间隙锁合并在一起,部分解决了并发写和幻读的问题,这个锁叫做 Next-Key 锁。
假设现在表中有两条记录,并且 age 字段已经添加了索引,两条记录 age 的值分别为 10 和 30。
mysql> select * from user;
+----+-----------------+------+
| id | name | age |
+----+-----------------+------+
| 1 | 古时的风筝 | 10 |
| 2 | 风筝2号 | 30 |
+----+-----------------+------+
2 rows in set (0.00 sec)
此时,在数据库中会为索引维护一套 B+ 树,用来快速定位记录。B+ 索引树是有序的,所以会把这张表的索引分割成几个区间。
如图所示,分成了3 个区间,(负无穷,10]、(10,30]、(30,正无穷],在这3个区间是可以加间隙锁的。 之后,我用下面的两个事务演示一下加锁过程。
在事务 A 提交之前,事务 B 的插入操作只能等待,这就是间隙锁起的作用。当事务 A 执行 update user set name = '风筝2号’ where age = 10;
的时候,由于条件 where age = 10,age 字段是索引列,数据库不仅在 age = 10 的行上添加了行锁,而且在这条记录的两边,也就是 (负无穷,10]、(10,30] 这两个区间加了间隙锁,从而导致事务 B 插入操作无法完成,只能等待事务 A 提交。不仅插入 age = 10 的记录需要等待事务 A 提交,age < 10、10 < age < 30 的记录也无法完成,而大于等于30的记录则不受影响,这种效果看上去解决了幻读问题。
这是有索引的情况,如果 age 不是索引列,那么数据库会为整个表加上间隙锁。所以,如果是没有索引的话,不管 age 是否大于等于30,都要等待事务A提交才可以成功插入。
6.3.2 有幻读示例
假设上表中初始数据与 6.3.1 小节的数据相同,看如下示例。
时间 | Session1 | Session2 |
BEGIN; | BEGIN; | |
T1 | SELECT * FROM USER WHERE age = 10; 一条结果(1, 古时的风筝,10) | |
T2 | INSERT INTO USER VALUES (NULL, “古时的风筝”, 10); | |
COMMIT; | ||
T3 | SELECT * FROM USER WHERE age = 10; 一条结果(1, 古时的风筝,10) | |
T4 | UPDATE USER SET NAME = “风筝2号” WHERE age = 10; 影响的数据:2行 | |
T5 | SELECT * FROM USER WHERE age = 10; 两条结果:(1, 风筝2号,10)、(14, 风筝2号,10) | |
COMMIT; |
来分析下情形:
- T1时刻:读取年龄为 10 的数据, Session1 拿到了 1 条记录。
- T2时刻:另一个进程 Session2 插入了一条新的记录,年龄也为 10,并提交了事务。
- T3时刻:Session1 再次读取年龄为 10 的数据,发现还是 1 条数据,貌似 Session2 新插入的数据并未影响到 Session1 的事务读取。
对于 T1 — T3 时刻的情形,从结果来看,在可重复读隔离级别下似乎解决了幻读的问题。
- T4时刻:Session1 修改年龄为 10 的数据,发现影响行数为 2 条。 为什么 T3 时候只能查到 1 条数据,但现在修改数据时却修改了 2 条呢?
- T5时刻:Session1 再次读取年龄为 10 的数据,发现结果变成了 2 条,我们知道新增的第二条记录就是 Session2 在 T2 时刻新增的那条数据,并在 T4 时刻被修改 name 字段。
T4,T5 的结果来看,Session1 读到了 Session2 新插入的数据,产生了幻读现象。
如果事务中都使用快照读,那么就不会产生幻读现象,但是快照读和当前读混用就会产生幻读。当我们在事务中每次读取都使用当前读,也就是人工把 InnoDb 变成了串行化,一定程度上降低了并发性,但是也同样避免了幻读的情况。可重复读隔离级别下,一个事务中只使用当前读,或者只使用快照读都能避免幻读。
七、总结
MySQL 的 InnoDB 引擎才支持事务,其中可重复读是默认的隔离级别。
读未提交和串行化基本上是不需要考虑的隔离级别,前者不加锁限制,后者相当于单线程执行,效率太差。
读提交解决了脏读问题,行锁解决了并发更新的问题。并且 MySQL 在可重复读级别部分解决了幻读问题,是通过行锁和间隙锁的组合 Next-Key 锁实现的。