MySQL 事务隔离机制
提到事务,大家肯定不陌生。最经典的例子就是银行转账。
比如,A 账户给 B 账户转账 100。在这种交易的过程中,有几个问题值得思考:
- 如何同时保证上述交易中,A 账户总金额减少 100,B账户总金额增加 100?
- A 账户如果同时在和 C 账户交易(T2),如何让这两笔交易互不影响?
- 如何在支持大量交易的同时,保证数据的合法性(没有钱凭空产生或消失) ?
- 如果交易完成时数据库突然崩溃,如何保证交易数据成功保存在数据库中?
要保证交易正常可靠地进行,数据库就得解决上面的四个问题,这也就是事务诞生的背景,它能解决上面的四个问题。
简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在MySQL 中,事务支持是在引擎层实现的。
我们知道,MySQL 是一个支持多引擎的系统,但并不是所有的引擎都支持事务。
比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。
下面,配合实例,我们分析 InnoDB 存储引擎在事务支持方面对于隔离性的实现。
隔离性与隔离级别
我们知道事务的四个特性:ACID(Atomicity
、Consistency
、Isolation
、Durability
,即原子性、一致性、隔离性、持久性)。
当数据库上有多个事务同时执行的时候,就可能会出现问题:
- 脏读(dirty read)
- 不可重复读(non-repeatable read)
- 幻读(phantom read)
为了解决这些问题,就有了隔离级别的概念。
在谈隔离级别之前,我们首先要知道,隔离的越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。
SQL标准的事务隔离级别包括:
- 读未提交(
read uncommitted
):一个事务还没提交时,它做的变更就能被别的事务看到; - 读提交(
read committed
):一个事务提交以后,它做的变更才会被其它事务看到; - 可重复读(
repeatable read
):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其它事务也是不可见的; - 串行化(
serializable
):对于同一行记录,「写」会加「写锁」,「读」会加「读锁」。当出现读写锁冲突的时候,后访问的事务必须等待前一个事务执行完成,才会继续执行。
在 MySQL 数据库中,默认的事务隔离级别是 RR。
四个案例看懂 MySQL 事务隔离级别
查看隔离级别
MySQL8 之前的查询命令是:
SELECT @@GLOBAL.tx_isolation, @@tx_isolation;
MySQL8 开始查询命令是:
SELECT @@GLOBAL.transaction_isolation, @@transaction_isolation;
根据上图,可以看到,默认的隔离级别为 REPEATABLE-READ
,「全局隔离级别」和「当前会话隔离级别」是相同的。
我们可以通过如下命令修改隔离级别(建议在修改时,仅修改当前 session 隔离级别即可,不用修改全局的隔离级别):
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
上面这条 SQL 表示,仅把当前 session
的数据库隔离级别设置为 READ UNCOMMITTED
,设置成功后,再次查询隔离级别,发现当前 session
的隔离级别已经变了,如图:
注意,这里只是修改了当前 session
的隔离级别,换一个 session
之后,隔离级别又会恢复到默认的隔离级别,如果使用的是 Navicat 的话,不同的查询窗口就对应了不同的 session
。
READ_UNCOMMITTED
READ UNCOMMITTED
是最低隔离级别,这种隔离级别中存在脏读、不可重复读以及幻读问题。
我们通过这个隔离级别,搞懂这三个问题到底是怎么回事。
建表语句:
CREATE TABLE account (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR (50) NOT NULL,
balance BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY idx_name (name)
) ENGINE = INNODB
预设两条数据,如下: zhangsan
和 zhaowu
两个用户,两个人的账户各有 1000 块。
下面通过模拟这两个用户之间的一个转账操作,借此分析这三个问题到底是怎么回事。
脏读
一个事务读到另外一个事务还没有提交的数据,称之为脏读。
具体操作如下:
- 首先打开两个 SQL 操作窗口,假设分别为 A 和 B,在 A 窗口中输入如下几条 SQL (输入完成后不用执行):
START TRANSACTION;
UPDATE account set balance=balance+100 where name='zhangsan';
UPDATE account set balance=balance-100 where name='zhaowu';
COMMIT;
- 在 B 窗口执行如下 SQL,修改默认的事务隔离级别为
READ UNCOMMITTED
,如下:
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
- 接下来在 B 窗口中输入如下 SQL,输入完成后,首先执行第一行开启事务(注意只需要执行一行即可):
START TRANSACTION;
SELECT * FROM ACCOUNT;
COMMIT;
- 接下来执行 A 窗口中的前两条 SQL,即开启事务,给
zhangsan
这个账户添加 100 元。 - 进入到 B 窗口,执行 B 窗口的第二条查询 SQL(
SELECT * FROM ACCOUNT;
),结果如下:
可以看到,A 窗口中的事务,虽然还未提交,但是 B 窗口中已经可以查询到数据的相关变化了。
这就是脏读问题。
不可重复读
不可重复读是指一个事务先后读取同一条记录,但两次读取的数据不同,称之为不可重复读。
具体操作步骤如下(操作之前先将两个账户的钱都恢复为1000):
- 首先打开两个查询窗口 A 和 B ,并且将 B 的数据库事务隔离级别设置为
READ UNCOMMITTED
。 - 在 B 窗口中输入如下 SQL,然后只执行前两条 SQL 开启事务并查询
zhangsan
的账户:
START TRANSACTION;
SELECT * FROM ACCOUNT WHERE NAME='zhangsan';
COMMIT;
- 在 A 窗口中执行如下 SQL,给
zhangsan
这个账户添加 100 块钱,如下:
START TRANSACTION;
update account set balance=balance+100 where name='zhangsan';
COMMIT;
- 再次回到 B 窗口,执行 B 窗口的第二条 SQL 查看
zhangsan
的账户,结果如下:
zhangsan
的账户已经发生了变化,即前后两次查看 zhangsan
账户,结果不一致,这就是不可重复读。
不可重复读和脏读的区别在于:脏读是看到了其它事务未提交的数据,而不可重复读是看到了其它事务已经提交的数据(由于当前 SQL 也是在事务中,因此有可能并不想看到其它事务已经提交的数据)。
幻读
幻读和不可重复读非常像。幻读指的,一个事务里面,后一个请求看到的比之前相同请求看到的,多了记录出来。 幻读仅专指「新插入的行」。
我来举一个简单例子。
在 A 窗口中输入如下 SQL:
START TRANSACTION;
insert into account(name,balance) values('wangliu',1000);
COMMIT;
然后在 B 窗口输入如下 SQL:
START TRANSACTION;
SELECT * from account;
delete from account where name='wangliu';
COMMIT;
执行步骤如下:
- 首先打开两个查询窗口 A 和 B ,并且将 B 的数据库事务隔离级别设置为
READ UNCOMMITTED
。 - 执行 B 窗口的前两行,开启一个事务,同时查询数据库中的数据,此时查询到的数据只有
zhangsan
和zhaowu
。 - 执行 A 窗口的前两行,向数据库中添加一个名为
wangliu
的用户,注意不用提交事务。 - 执行 B 窗口的第二行,由于脏读问题,此时可以查询到
wangliu
这个用户。 - 执行 B 窗口的第三行,去删除 name 为
wangliu
的记录,这个时候删除就会出问题,虽然在 B 窗口中可以查询到wangliu
,但是这条记录还没有提交,是因为脏读的原因才看到了,所以是没法删除的。此时就产生了幻觉,明明有个wangliu
,却无法删除。
这就是幻读。
看了上面的案例,大家应该明白了脏读、不可重复读以及幻读各自的含义了。
READ_COMMITTED
和 READ UNCOMMITTED
相比,READ COMMITTED
主要解决了脏读的问题,对于不可重复读和幻读则未解决。
将事务的隔离级别改为 READ COMMITTED
之后,重复上面关于脏读案例的测试,发现已经不存在脏读问题了;重复上面关于不可重复读案例的测试,发现不可重复读问题依然存在。
上面那个案例不适用于幻读的测试,我们换一个幻读的测试案例。
继续两个窗口 A 和 B,将 B 窗口的隔离级别改为 READ COMMITTED
,然后在 A 窗口输入如下测试 SQL:
START TRANSACTION;
insert into account(name,balance) values('wangliu',1000);
COMMIT;
在 B 窗口输入如下测试 SQL:
START TRANSACTION;
SELECT * from account;
insert into account(name,balance) values('wangliu',1000);
COMMIT;
执行步骤如下:
- 首先执行 B 窗口的前两行 SQL,开启事务并查询数据,此时查到的只有 zhangsan 和 zhaowu 两个用户。
- 执行 A 窗口的前两行 SQL,插入一条记录,但是并不提交事务。
- 执行 B 窗口的第二行 SQL,由于现在已经没有了脏读问题,所以此时查不到 A 窗口中添加的数据。
- 执行 B 窗口的第三行 SQL,由于 name 字段唯一,因此这里会无法插入。此时就产生幻觉了,明明没有 wangliu 这个用户,却无法插入 wangliu。
REPEATABLE_READ
和 READ COMMITTED
相比,REPEATABLE READ
进一步解决了不可重复读的问题,但是幻读则未解决。
REPEATABLE READ
中关于幻读的测试和上一小节基本一致,不同的是第二步中执行完插入 SQL 后记得提交事务。
由于 REPEATABLE READ
已经解决了不可重复读,因此第二步即使提交了事务,第三步也查不到已经提交的数据,第四步继续插入就会出错。
SERIALIZABLE
SERIALIZABLE
提供了事务之间最大限度的隔离,在这种隔离级别中,事务一个接一个顺序的执行,不会发生脏读、不可重复读以及幻读问题,最安全。
如果设置当前事务隔离级别为 SERIALIZABLE
,那么此时开启其它事务时,就会发生阻塞,必须等当前事务提交了,其它事务才能开启成功,因此前面的脏读、不可重复读以及幻读问题这里都不会发生。
事务隔离的实现
在 InnoDB 中事务隔离性是由「锁」来实现的。
首先说 READ UNCOMMITTED
,它是性能最好,也可以说它是最野蛮的方式,但是它压根儿就不加锁,所以根本谈不上什么隔离效果,可以理解为没有隔离。
再来说 SERIALIZABLE
。读的时候加共享锁,也就是其它事务可以并发读,但是不能写。写的时候加排它锁,其它事务不能并发写也不能并发读。
最后说 READ COMMITTED
和 REPEATABLE READ
。这两种隔离级别是比较复杂的,既要允许一定的并发,又想要兼顾的解决问题。
为了实现可重复读,MySQL 采用了 MVCC (多版本并发控制) 的方式。
有关锁和MVCC的分析,我们在后面文章进行详细说明。
这里我们只需要知道,MVCC 只在 READ COMMITTED
和 REPEATABLE READ
这两个隔离级别下工作。
最后,「幻读」InnoDB 通过引入间隙锁的方式解决。
总结
本文我们分析了并发访问引发的三个问题:
- 脏读:脏读指的是读到了其它事务未提交的数据,未提交意味着这些数据可能会回滚,也就是可能最终不会存到数据库中,也就是不存在的数据。读到了不一定最终存在的数据,这就是脏读。
- 不可重复读:它对比可重复读,不可重复读指的是在同一事务内,不同的时刻读到的同一批数据可能是不一样的,可能会受到其它事务的影响,比如其它事务改了这批数据并提交了。通常针对数据更新(UPDATE)操作。
- 幻读:幻读则是针对数据插入(INSERT)操作来说的。
针对这三个问题,SQL 标准定义了四种隔离级别:
- 读未提交(READ UNCOMMITTED)
- 读提交 (READ COMMITTED)
- 可重复读 (REPEATABLE READ)
- 串行化 (SERIALIZABLE)
从上往下,隔离强度逐渐增强,性能逐渐变差。采用哪种隔离级别要根据系统需求权衡决定,其中,可重复读是 MySQL 的默认级别。
事务隔离其实就是为了解决上面提到的脏读、不可重复读、幻读这几个问题,下面展示了 4 种隔离级别对这三个问题的解决程度。
最后我们简单介绍了下事务隔离的实现。
事务隔离性主要是由「锁」来实现的,为了解决可重复读,MySQL 采用了 MVCC (多版本并发控制) 的方式。
好了,有关事务隔离机制的就先介绍到这了,我们下篇见。