MySQL
当数据库管理系统在写入或者更新数据的过程中,为了保证数据是正确可靠的,需要满足四个特性:原子性、一致性、隔离性和持久性,简称 ACID !

  • Atomicity(原子性):一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,能被恢复(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
  • Consistency(一致性):在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入之前和写入之后的数据必须完全符合预期设定的结果。
  • Isolation(隔离性):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。
  • Durability(持久性):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

例如以银行转账为例,从原账户扣除金额,以及向目标账户添加金额,这两个阶段的操作,被视为一个完整的逻辑过程,不可拆分,简单的说,要么全部成功,要么全部失败!这个过程被称为一个事务,具有 ACID 四个特点!
当下主流的数据库,都支持多个事务并发执行,当一个事务在写入数据,另一个事务也要读这条数据,会出现哪些问题?当一个事务在写入数据,另一个事务也要写入这条数据,又会发生什么哪些问题?
当多个事务并发处理同一条数据时,如果事务隔离性不合理,就会产生:脏读、不可重复读和幻读!
在事务的四个特性里面,其中隔离性总共分为四种级别:由低到高依次为 Read uncommitted 、Read committed 、Repeatable read 、Serializable ,这四个级别可以逐个解决脏读 、不可重复读 、幻读等这几类问题。

在 MySQL 中事务的隔离级别有以下 4 种:

  1. 读未提交(READ UNCOMMITTED)
  2. 读已提交(READ COMMITTED)
  3. 可重复读(REPEATABLE READ)
  4. 序列化(SERIALIZABLE)

MySQL 默认的事务隔离级别是可重复读(REPEATABLE READ),这 4 种隔离级别的说明如下。

1、READ UNCOMMITTED

读未提交,也叫未提交读,该隔离级别的事务可以看到其他事务中未提交的数据。该隔离级别因为可以读取到其他事务中未提交的数据,而未提交的数据可能会发生回滚,因此把该级别读取到的数据称之为脏数据,把这个问题称之为脏读。

2、READ COMMITTED

读已提交,也叫提交读,该隔离级别的事务能读取到已经提交事务的数据,因此它不会有脏读问题。但由于在事务的执行中可以读取到其他事务提交的结果,所以在不同时间的相同 SQL 查询中,可能会得到不同的结果,这种现象叫做不可重复读。

3、REPEATABLE READ

可重复读,是 MySQL 的默认事务隔离级别,它能确保同一事务多次查询的结果一致。但也会有新的问题,比如此级别的事务正在执行时,另一个事务成功的插入了某条数据,但因为它每次查询的结果都是一样的,所以会导致查询不到这条数据,自己重复插入时又失败(因为唯一约束的原因)。明明在事务中查询不到这条信息,但自己就是插入不进去,这就叫幻读 (Phantom Read)。

4、SERIALIZABLE

序列化,事务最高隔离级别,它会强制事务排序,使之不会发生冲突,从而解决了脏读、不可重复读和幻读问题,但因为执行效率低,所以真正使用的场景并不多。
简单总结一下,MySQL 的 4 种事务隔离级别对应脏读、不可重复读和幻读的关系如下:

事务隔离级别 脏读 不可重复读 幻读
读未提交(READ UNCOMMITTED)
读已提交(READ COMMITTED) ×
可重复读(REPEATABLE READ) × ×
串行化(SERIALIZABLE) × × ×

只看以上概念会比较抽象,接下来,一步步通过执行的结果来理解这几种隔离级别的区别。

前置知识

1、事务相关的常用命令

  1. # 查看 MySQL 版本
  2. select version();
  3. # 开启事务
  4. start transaction;
  5. # 提交事务
  6. commit;
  7. # 回滚事务
  8. rollback;

2、MySQL 8 之前查询事务的隔离级别

查看全局 MySQL 事务隔离级别和当前会话的事务隔离级别的 SQL 如下:

  1. select @@global.tx_isolation,@@tx_isolation;

以上 SQL 执行结果如下图所示:脏读、幻读和不可重复读 - 图1

3、MySQL 8 之后查询事务的隔离级别

  1. select @@global.transaction_isolation,@@transaction_isolation;

4、查看连接的客户端详情

每个 MySQL 命令行窗口就是一个 MySQL 客户端,每个客户端都可以单独设置(不同的)事务隔离级别,这也是演示 MySQL 并发事务的基础。以下是查询客户端连接的 SQL 命令:

  1. show processlist;

以上 SQL 执行结果如下:脏读、幻读和不可重复读 - 图2

5、查询连接客户端的数量

可以使用以下 SQL 命令,查询连当前接 MySQL 服务器的客户端数量:

  1. show status like 'Threads%';

以上 SQL 执行结果如下:脏读、幻读和不可重复读 - 图3

6、设置客户端的事务隔离级别

通过以下 SQL 可以设置当前客户端的事务隔离级别:

  1. set session transaction isolation level 事务隔离级别;

事务隔离级别的值有 4 个:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。

7、新建数据库和测试数据

创建测试数据库和表信息,执行 SQL 如下:

  1. -- 创建数据库
  2. drop database if exists testdb;
  3. create database testdb;
  4. use testdb;
  5. -- 创建表
  6. create table userinfo(
  7. id int primary key auto_increment,
  8. name varchar(250) not null,
  9. balance decimal(10,2) not null default 0
  10. );
  11. -- 插入测试数据
  12. insert into userinfo(id,name,balance) values(1,'Java',100),(2,'MySQL',200);

创建的表结构和数据如下:脏读、幻读和不可重复读 - 图4

8、名称约定

接下来会使用两个窗口(两个客户端)来演示事务不同隔离级别中脏读、不可重复读和幻读的问题。其中左边的黑底绿字的客户端下文将使用“窗口 1”来指代,而右边的蓝底白字的客户端下文将用“窗口 2”来指代,如下图所示:脏读、幻读和不可重复读 - 图5

脏读

所谓的脏读,指的是读到了其他事务未提交的数据,未提交意味着这些数据可能会保存到数据库,也可能会回滚,不保存到数据库。当这个数据发生了回滚,就意味着这个数据不存在,这就是脏读!
脏读、幻读和不可重复读 - 图6
脏读最大的问题就是可能会读到不存在的数据。比如在上图中,事务 B 的更新数据被事务 A 读取,但是事务 B 回滚了,更新数据全部还原。也就是说事务 A 刚刚读到的数据并没有存在于数据库中。
从结果上看,事务 A 读出了一条不存在的数据,这个问题比较很严重!
当数据库的事务隔离级别为读未提交,就会发生脏读现象!
一个事务读到另外一个事务还没有提交的数据,称之为脏读。脏读演示的执行流程如下:

执行步骤 客户端1(窗口1) 客户端2(窗口2) 说明
第 1 步

| set session transaction isolation level read uncommitted;
start transaction;
select * from userinfo; | 设置事务隔离级别为读未提交;
开启事务;
查询用户列表,其中 Java 用户的余额为 100 元。 | | 第 2 步 | start transaction;
update userinfo set balance=balance+50 where name=’Java’; |

| 开启事务;
给 Java 用户的账户加 50 元; | | 第 3 步 |

| select * from userinfo; | 查询用户列表,其中 Java 用户的余额变成了 150 元。 |

脏读演示步骤1

设置窗口 2 的事务隔离级别为读未提交,设置命令如下:

  1. set session transaction isolation level read uncommitted;

PS:事务隔离级别读未提交存在脏读的问题。
然后使用命令来检查当前连接窗口的事务隔离界别,如下图所示:脏读、幻读和不可重复读 - 图7开启事务并查询用户列表信息,如下图所示:脏读、幻读和不可重复读 - 图8

脏读演示步骤2

在窗口 1 中开启一个事务,并给 Java 账户加 50 元,但不提交事务,执行的 SQL 如下:脏读、幻读和不可重复读 - 图9

脏读演示步骤3

在窗口 2 中再次查询用户列表,执行结果如下:脏读、幻读和不可重复读 - 图10从上述结果可以看出,在窗口 2 中读取到了窗口 1 中事务未提交的数据,这就是脏读。

不可重复读

不可重复读,指的是在一个事务内,最开始读到的数据和事务结束前的任意时刻读到的同一批数据出现不一致的情况。
脏读、幻读和不可重复读 - 图11
比如上图,事务 A 两次读取同一数据,第一次读取结果为 1,当事务 B 修改了数据并提交,此时的事务 A 第二次读取结果为 2,两次读取结果不一致!
不可重复读是指一个事务先后执行同一条 SQL,但两次读取到的数据不同,就是不可重复读。不可重复读演示的执行流程如下:

执行步骤 客户端1(窗口1) 客户端2(窗口2) 说明
第 1 步

| set session transaction isolation level read committed;
start transaction;
select * from userinfo; | 设置事务隔离级别为读已提交;
开启事务;
查询用户列表,其中 Java 用户的余额是 100 元。 | | 第 2 步 | start transaction;update userinfo set balance=balance+20 where name=’Java’;commit; |

| 开启事务;
给 Java 用户的余额加 20 元;提交事务。 | | 第 3 步 |

| select * from userinfo; | 查询用户列表,其中 Java 用户的余额变成了 120 元。 |

窗口 2 同一个事务中的两次查询,得到了不同的结果这就是不可重复读,具体执行步骤如下。

不可重复读演示步骤1

设置窗口 2 的事务隔离级别为读已提交,设置命令如下:

  1. set session transaction isolation level read committed;

PS:读已提交可以解决脏读的问题,但存在不可重复读的问题。
使用命令来检查当前连接窗口的事务隔离界别,如下图所示:脏读、幻读和不可重复读 - 图12在窗口 2 中开启事务,并查询用户表,执行结果如下:脏读、幻读和不可重复读 - 图13此时查询的列表中,Java 用户的余额为 100 元。

不可重复读演示步骤2

在窗口 1 中开启事务,并给 Java 用户添加 20 元,但不提交事务,再观察窗口 2 中有没有脏读的问题,具体执行结果如下图所示:脏读、幻读和不可重复读 - 图14从上述结果可以看出,当把窗口的事务隔离级别设置为读已提交,已经不存在脏读问题了。接下来在窗口 1 中提交事务,执行结果如下图所示:脏读、幻读和不可重复读 - 图15

不可重复读演示步骤3

切换到窗口 2 中再次查询用户列表,执行结果如下:脏读、幻读和不可重复读 - 图16从上述结果可以看出,此时 Java 用户的余额已经变成 120 元了。在同一个事务中,先后查询的两次结果不一致就是不可重复读。

不可重复读和脏读的区别

脏读可以读到其他事务中未提交的数据,而不可重复读是读取到了其他事务已经提交的数据,但前后两次读取的结果不同。

幻读

幻读和不可重复读,有点类似,但是表达的侧重点不一样。
例如事务 A 对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。此时,突然事务 B 插入了一条数据并提交了,当事务 A 提交了修改数据操作之后,再次读取全部数据,结果发现还有一条数据未更新,给人感觉好像产生了幻觉一样。这就是幻读!
脏读、幻读和不可重复读 - 图17
当有别的事务,在插入或者删除同一条数据的时候,就容易产生幻读的现象!
当数据库的事务隔离级别为读未提交、读提交、可重复读时,就会发生幻读现象!
幻读名如其文,它就像发生了某种幻觉一样,在一个事务中明明没有查到主键为 X 的数据,但主键为 X 的数据就是插入不进去,就像某种幻觉一样。幻读演示的执行流程如下:

执行步骤 客户端1(窗口1) 客户端2(窗口2) 说明
第 1 步

| set session transaction isolation level repeatable read;
start transaction;
select * from userinfo where id=3; | 设置事务隔离级别为可重复读;
开启事务;
查询用户编号为 3 的数据,查询结果为空。 | | 第 2 步 | start transaction;
insert into userinfo(id,name,balance) values(3,’Spring’,100);
commit; |

| 开启事务;
添加用户,用户编号为 3;
提交事务。 | | 第 3 步 |

| insert into userinfo(id,name,balance) values(3,’Spring’,100); | 窗口 2 添加用户编号为 3 的数据,执行失败。 | | 第 4 步 |

| select * from userinfo where id=3; | 查询用户编号为 3 的数据,查询结果为空。 |

具体执行结果如下步骤所示。

幻读演示步骤1

设置窗口 2 为可重复读,可重复有幻读的问题,查询编号为 3 的用户,具体执行 SQL 如下:

  1. set session transaction isolation level repeatable read;
  2. start transaction;
  3. select * from userinfo where id=3;

以上 SQL 执行结果如下图所示:脏读、幻读和不可重复读 - 图18从上述结果可以看出,查询的结果中 id=3 的数据为空。

幻读演示步骤2

开启窗口 1 的事务,插入用户编号为 3 的数据,然后成功提交事务,执行 SQL 如下:

  1. start transaction;
  2. insert into userinfo(id,name,balance) values(3,'Spring',100);
  3. commit;

以上 SQL 执行结果如下图所示:脏读、幻读和不可重复读 - 图19

幻读演示步骤3

在窗口 2 中插入用户编号为 3 的数据,执行 SQL 如下:

  1. insert into userinfo(id,name,balance) values(3,'Spring',100);

以上 SQL 执行结果如下图所示:脏读、幻读和不可重复读 - 图20添加用户数据失败,提示表中已经存在了编号为 3 的数据,且此字段为主键,不能添加多个。

幻读演示步骤4

在窗口 2 中,重新执行查询:

  1. select * from userinfo where id=3;

以上 SQL 执行结果如下图所示:脏读、幻读和不可重复读 - 图21在此事务中查询明明没有编号为 3 的用户,但插入的时候却却提示已经存在了,这就是幻读。

不可重复读和幻读的区别

二者描述的则重点不同,不可重复读描述的侧重点是修改操作,而幻读描述的侧重点是添加和删除操作。

如何解决

为了解决上述问题,数据库通过锁机制来解决并发访问的问题。
以 Mysql 为例,根据锁定对象不同,分为:行级锁和表级锁;根据并发事务锁定的关系上看,分为:共享锁定和独占锁定
共享锁定会防止独占锁定,但允许其他的共享锁定;而独占锁定既防止共享锁定也能防止其他独占锁定;为了更改数据,数据库在进行更改的行上施加了行级独占锁定,insertupdatedeleteselsct for update语句都会隐式采用必要的行锁定,当冲突加剧,会上升到表级锁定,此时会影响到其他表的访问操作。
直接使用锁机制管理是很复杂的,基于锁机制,数据库给用户提供了不同的事务隔离级别,只要设置了事务隔离级别,数据库就会分析事务中的 sql 语句然后自动选择合适的锁,可以依次有效的解决脏读、不可重复读和幻读问题!
脏读、幻读和不可重复读 - 图22
整体的来说,事务的隔离级别和数据库并发性是成反比的,隔离级别越高,并发性越低。

总结

这里演示了 MySQL 的 4 种事务隔离级别:读未提交(有脏读问题)、读已提交(有不可重复读的问题)、可重复读(有幻读的问题)和序列化,其中可重复读是 MySQL 默认的事务隔离级别。脏读是读到了其他事务未提交的数据,而不可重复读是读到了其他事务已经提交的数据,但前后查询的结果不同,而幻读则是明明查询不到,但就是插入不了。