前言

我们在上一章节中介绍过数据库的 带你了解数据库中事务的 ACID 特性 相关用法。本章节主要介绍数据库中一个非常重要的知识点:事务的隔离级别

本章将重点探讨以下问题:

  • 事务的隔离级别有哪些?
  • 如果并发事务没有进行隔离,会出现什么问题?
注:以下示例均采用 MySQL 数据库。

在多个事务并发操作数据库时,如果没有有效的隔离机制,就会引发种种数据一致性问题。大体上可分为以下几类。

一、并发事务引发的问题

在并发事务没有进行隔离的情况下,主要会发生以下三种问题。

1. 脏读(Dirty Read)

脏读 指一个事务读取了另外一个事务未提交的数据。

具体案例见后文介绍。

2. 不可重复读(Non-repeatable Read)

不可重复读 指在一个事务内读取表中的某一行数据,多次读取结果不同。

不可重复读与脏读的区别:脏读是读取了前一事务未提交的脏数据;不可重复读是重新读取了前一事务已提交的数据。

具体案例见后文介绍。

3. 幻读(Phantom Read)

幻读(又称虚读)指在一个事务内读取到了别的事务插入的数据,导致前后读取不一致。

具体案例见后文介绍。

二、事务隔离级别概念

2.1 隔离级别分类

事务的隔离级别主要分为以下四种:

  • Read Uncommitted(读未提交)
  • Read Committed(读已提交)
  • Repeatable Read(可重复读)
  • Serializable(串行化)

2.2 各级别特性详解

Read Uncommitted

读未提交:隔离级别最低。在这种隔离级别下,会引发脏读、不可重复读和幻读。

Read Committed

读已提交:读到的都是别人提交后的值。这种隔离级别下,会引发不可重复读和幻读,但避免了脏读。

Repeatable Read

可重复读:这种隔离级别下,会引发幻读,但避免了脏读和不可重复读。

Serializable

串行化:最严格的隔离级别。在 Serializable 隔离级别下,所有事务按照次序依次执行。脏读、不可重复读、幻读都不会出现。

隔离级别

三、MySQL 事务隔离级别操作

3.1 查看事务隔离级别

查看当前会话的事务隔离级别:

SHOW VARIABLES LIKE 'tx_isolation';

查看全局的事务隔离级别:

SHOW GLOBAL VARIABLES LIKE 'tx_isolation';

使用系统变量查询:

SELECT @@global.tx_isolation; 
SELECT @@session.tx_isolation; 
SELECT @@tx_isolation;

3.2 设置 MySQL 的事务隔离级别

语法格式

SET [GLOBAL | SESSION] TRANSACTION ISOLATION LEVEL
  {
       REPEATABLE READ
     | READ COMMITTED
     | READ UNCOMMITTED
     | SERIALIZABLE
   };
  • GLOBAL:设置全局的事务隔离级别。
  • SESSION:设置当前 Session 的事务隔离级别。如果语句没有指定 GLOBALSESSION,默认值为 SESSION

使用系统变量设置

SET GLOBAL tx_isolation='REPEATABLE-READ';
SET SESSION tx_isolation='SERIALIZABLE';

四、案例分析

下面通过实际操作演示并发控制语句,具体命令可参考上面的 操作 介绍。

演示表结构(product 表):

productIdproductNameproductPriceproductCount
1xiaomi1999100

带着上面的数据,我们来看一下事务在没有隔离性的情况下会引发哪些问题。以下操作同时打开两个窗口模拟 2 个用户并发访问数据库。

4.1 事务隔离级别设置为 Read Uncommitted

查询事务隔离级别:

SELECT @@tx_isolation;

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

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
注意:需要同时修改两个窗口的事务隔离级别。

以下以两位用户抢小米手机为例:

时间轴事务 A事务 B
T1start transaction;
T2select p.productName, p.productCount from product p where p.productId=1; (productCount = 100)
T3 start transaction;
T4 select p.productName, p.productCount from product p where p.productId=1; (productCount = 100)
T5 update product set productCount = 99 where productId = 1;
T6select p.productName, p.productCount from product p where p.productId=1; (productCount = 99)
T7 ROLLBACK;
T8select p.productName, p.productCount from product p where p.productId=1; (productCount = 100)

流程解析:

  1. T1:A 用户开启事务。
  2. T2:A 用户查询当前小米手机剩余数量,显示为 100。
  3. T3:B 用户开启事务。
  4. T4:B 用户查询当前小米手机剩余数量,显示为 100。
  5. T5:B 用户购买了一台小米手机,执行 update 操作。此时只修改数据并未提交事务。
  6. T6:A 用户刷新页面查询,此时数量显示为 99(读取了 B 未提交的数据)。
  7. T7:B 用户购买失败,回滚事务。
  8. T8:A 用户再次查询,此时数量显示恢复为 100。

小结:
事务 A 读取了未提交的数据,事务 B 的回滚导致了事务 A 的数据不一致,导致了事务 A 的 脏读

4.2 事务隔离级别设置为 Read Committed

查询事务隔离级别:

SELECT @@tx_isolation;

设置隔离级别为提交读:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
注意:需要同时修改两个窗口的事务隔离级别。
时间轴事务 A事务 B
T1start transaction;
T2select p.productName, p.productCount from product p where p.productId=1; (productCount = 100)
T3 start transaction;
T4 select p.productName, p.productCount from product p where p.productId=1; (productCount = 100)
T5 update product set productCount = 99 where productId = 1;
T6 commit;
T7select p.productName, p.productCount from product p where p.productId=1; (productCount = 100)
T8select p.productName, p.productCount from product p where p.productId=1; (productCount = 99)

小结:
可以看到避免了 脏读 现象(T7 时刻 A 读取的仍是 100,因为 B 尚未提交)。但是却出现了 不可重复读 问题:事务 A 还没有结束,再次读取时(T8),productCount 从 100 变成了 99。

4.3 事务隔离级别设置为 Repeatable Read(MySQL 默认级别)

查询事务隔离级别:

SELECT @@tx_isolation;

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

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
注意:需要同时修改两个窗口的事务隔离级别。
时间轴事务 A事务 B
T1start transaction;
T2select p.productName, p.productCount from product p where p.productId=1; (productCount = 100)
T3 start transaction;
T4 select p.productName, p.productCount from product p where p.productId=1; (productCount = 100)
T5 update product set productCount = 99 where productId = 1;
T6 commit;
T7select p.productName, p.productCount from product p where p.productId=1; (productCount = 100)
T8select p.productName, p.productCount from product p where p.productId=1; (productCount = 100)

小结:
可以看到 可重复读 隔离级别避免了 脏读不可重复读 的问题。事务 A 查询到的小米数量始终等于 100,即使事务 B 修改并提交了数量为 99,事务 A 读取到的值还是 100。

注:理论上该级别仍可能出现 幻读 现象。例如当事务 A 去减 1 等于 99 时,逻辑上可能产生错误(此时应该是 99-1=98 才对)。接下来我们再提高一个事务隔离级别。

4.4 事务隔离级别设置为 Serializable

查询事务隔离级别:

SELECT @@tx_isolation;

设置隔离级别为串行化:

SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
时间轴事务 A事务 B
T1start transaction;
T2select p.productName, p.productCount from product p where p.productId=1; (productCount = 100)
T3 start transaction;
T4 update product set productCount = 99 where productId = 1; (等待中..)

小结:
Serializable 隔离级别中,我们可以看到事务 B 去做修改动作时卡住了,不能向下执行。这是因为给事务 A 的 select 操作上了锁,所以事务 B 去修改值的话,就会被阻塞。只有当事务 A 操作执行完毕,才会执行事务 B 的操作。这样就避免了上述三个问题。

五、总结与建议

问题本质

回到问题的本身,其实我们并不需要将事务隔离级别提到这么高(如 Serializable),因为这会严重影响并发性能。

问题的本质是:当我们读完数据后,希望在这期间别人不能修改它。因为别人读到了 count,就会修改 count 的值并写进去。所以我们在 select 操作的时候,加上 for update。这时候就会把这行操作给锁掉。那么另外一个人也进行相同的操作,也表示 select 出来的 count 需要进行 update,需要锁住。

select p.productName, p.productCount from product p where p.productId=1 for update;

通过悲观锁机制(FOR UPDATE),可以在较低的隔离级别下实现数据的一致性控制,避免过度依赖高隔离级别带来的性能损耗。


说明:本文示例基于 MySQL 5.7 及以下版本。在 MySQL 8.0 中,系统变量 tx_isolation 已被废弃,建议使用 transaction_isolation 代替。