MySQL 的事务隔离级别

我们先来回忆下什么是事务。

考虑一种场景。银行转账,甲转 100 给乙,分为两步:

  • 甲扣除 100
  • 乙增加 100

如果甲的钱扣了,乙的钱没有增加,那么数据就会不一致,这时候就要用到事务。因为事务的一个特性,就是原子性:要么不做,要么全做

一、事务的基本要素(ACID)

  1. 原子性(Atomicity):事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位。
  2. 一致性(Consistency):事务开始前和结束后,数据库的完整性约束没有被破坏 。比如A向B转账,不可能A扣了钱,B却没收到。
  3. 隔离性(Isolation):同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账。
  4. 持久性(Durability):事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。

二、事务的隔离级别

我们回到转账的例子。

甲扣除 100 后,如果事务 A 没有提交,那么另外一个事务 B 来查询甲的余额,那应该是多少呢?这时候就和事务隔离级别有关了。

事务隔离级别有以下 4 种:

  1. 读未提交(read-uncommitted)
  2. 不可重复读(read-committed)
  3. 可重复读(repeatable-read)
  4. 串行化(serializable)

Mysql 的默认隔离级别为可重复读(repeatable-read)。

2.1 读未提交(read-uncommitted)

我们来看转账的例子,假设 A 一开始的余额为 500。表名为 account,表数据为:

id name amount
1 500
2 0

设置隔离级别为读未提交。

1
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
事务A 事务B
start transaction; start transaction;
select amount from account where id = 1; (amount = 500)
update account set amount = amount -100 where id = 1;
select amount from account where id = 1; (amount = 400)
select amount from account where id = 1; (amount = 400)
roll back;
commit;

可以看到,事务 B 读取到了事务 A 未提交的数据。实际上事务 A 进行了回滚,amount 仍未 500,但是事务 B 读到的为 400 ,出现了脏读。因此,实际中,一般不会采用这种隔离级别。

2.2 不可重复读(read-committed)

设置隔离级别为不可重复读。

1
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
事务A 事务B
start transaction; start transaction;
select amount from account where id = 1; (amount = 500)
update account set amount = amount -100 where id = 1;
select amount from account where id = 1; (amount = 400)
select amount from account where id = 1; (amount = 500)
commit;
select amount from account where id = 1; (amount = 400)
commit;

可以看到,在事务 A 提交前的改动,事务 B 是读取不到的。只有 A 事务提交后,B 才能读取到事务 A 的改动。

我们看到,在事务 B 中,先后两次读取,amount 的值是不一样的,这就是不可重复读

不可重复读会有一个问题,假如事务 B 的逻辑是分两次读取 amount,分别插入到两个表中,那么就会出现数据不一致。

2.3 可重复读(repeatable-read)

设置隔离级别为可重复读。

1
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
事务A 事务B
start transaction; start transaction;
select amount from account where id = 1; (amount = 500)
update account set amount = amount -100 where id = 1;
select amount from account where id = 1; (amount = 400)
select amount from account where id = 1; (amount = 500)
commit;
select amount from account where id = 1; (amount = 500)
commit;

可以看到,不论事务 A 是否提交,事务 B 读到的 amount 值都是不变的。这就是可重复读

除了上面提到的脏读、不可重复读,还有一种情况是幻读:在事务中,前后两次查询,记录数量是不一样的

由于 mysql 的 RR(可重复读)一并解决了幻读的问题,所以我们直接看上述场景,在 mysql 中的表现:

事务A 事务B
start transaction; start transaction;
select count(*) from account; (count = 2)
insert into account values(3,’丙’,0);
commit;
select count(*) from account; (count = 2)
commit;

可见,在事务 A 提交前后,事务 B 查询的结果数量是一直的,并没有出现幻读的情况。

2.4 串行化(serializable)

1
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;

事务隔离级别为串行化时,读写数据都会锁住整张表。因此事务 A 执行的时候,事务 B 会阻塞,不存在并发问题。

三、事务的并发问题

刚刚已经谈到了常见的问题,我们再来总结下:

  1. 脏读:事务 B 读取了事务 A 更新的数据,然后 A 回滚操作,那么 B 读取到的数据是脏数据。
  2. 不可重复读:事务 B 多次读取同一数据,事务 A 在事务 B 多次读取的过程中,对数据作了更新并提交,导致事务 B 多次读取同一数据时,结果不一致。
  3. 幻读:事务 B 多次统计数据量,事务 A 在事务 B 多次统计的过程中,对数据作了新增或删除操作,导致事务 B 多次统计数据时,结果不一致。

小结:不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表。

四、快照读、当前读

我们先考虑下这样一个问题(基于 MySQL 的默认隔离级别可重复读 RR )。

假设甲同时转账给乙和丙,那么对于甲来说,就会产生两个事务,事务 A 及事务 B 。这两个事务分别查询余额,并进行扣款操作:

事务A 事务B
start transaction; start transaction;
select amount from account where id = 1; (amount = 500)
select amount from account where id = 1; (amount = 500)
update account set amount = amount -100 where id = 1;
select amount from account where id = 1; (amount = 400)
commit;
select amount from account where id = 1; (amount = 500)
update account set amount = amount -100 where id = 1;
select amount from account where id = 1; (amount = 400?)
commit;

根据前面可重复读的特性,事务 B 读到的 amount 应该是 500 ,那么减去 100 后,还会是 400 吗?如果是 400 ,那么不是会造成数据不一致(实际转出了两笔 100 ,应该还剩 300 )。

结果是否定的,事务 B 最后读到的 amount 应该是 300 。

在事务中,执行普通 select 查询之后,会创建快照,后面再执行相同的 select 语句时,查询的其实是前面生成的快照。这也就是为什么会有可重复读。

而如果执行:

1
2
3
4
5
select * from table where ? lock in share mode; 
select * from table where ? for update;
insert into table values (…);
update table set ? where ?;
delete from table where ?;

会执行当前读,获取最新数据。

回到前面的问题,如果事务 B 执行 amount - 100 操作,会触发当前读,读取事务 A 提交后的数据,也就是 400,在此基础上执行 -100 操作,最终 amount 变成 300。

实际上的流程应该如下:

事务A 事务B
start transaction; start transaction;
select amount from account where id = 1; (amount = 500)
select amount from account where id = 1; (amount = 500)
update account set amount = amount -100 where id = 1;
select amount from account where id = 1; (amount = 400)
commit;
select amount from account where id = 1; (amount = 500,快照读)
update account set amount = amount -100 where id = 1;(触发当前读,amount 为 400 -100)
select amount from account where id = 1; (amount = 300)
commit;

还有一种情况,假如事务 A 和事务 B 并发修改数据但还未提交,那么会怎么样呢?

事务A 事务B
start transaction; start transaction;
select amount from account where id = 1; (amount = 500)
select amount from account where id = 1; (amount = 500)
update account set amount = amount -100 where id = 1;
update account set amount = amount -100 where id = 1;
select amount from account where id = 1; (amount = 400?)
select amount from account where id = 1; (amount = 400?)
commit;
commit;

由于事务 A 和事务 B 都没有提交,不能触发当前读,那么 amount 最终会是多少呢?会是 400 吗?

答案是 300,了解数据库锁机制的话,就不会有这种困惑了。

事务 A 提交前,会一直持有排他锁(具体是行锁还是表锁,要看查询条件有没有走索引),此时事务 B 更新是会阻塞的。也就是说,只有事务 A 提交,或回滚之后,事务 B 才能获得排它锁,从而进行更新奖品的操作。

五、总结

Mysql 的默认隔离级别为可重复读 RR(repeatable-read)。

隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大。因此在开发中需要根据实际情况来进行取舍。

MySQL 的存储引擎是 InnoDB ,通过 MVCC(多版本并发控制) 来实现快照读和当前读,有兴趣的可以参考https://blog.csdn.net/whoamiyang/article/details/51901888