1. 脏读 脏写 幻读 - 事务隔离级别#

1.1. 脏读 - 事务隔离级别较低#

脏读是指一个事务读取到了另一个事务尚未提交的数据。若后者发生回滚,则前者读取的数据实际上并不存在。

  • 事务 A 修改了一条记录,但尚未提交

  • 事务 B 读取这条记录,看到 A 的修改

如果 A 后续回滚,那么 B 读到了不存在的数据,这就是脏读问题。

根本原因在于事务隔离级别较低,允许一个事务读取另一个事务未提交的修改数据,缺少对数据一致性的保证。

1.2. 脏写#

脏写指的是:事务 B 在事务 A 尚未提交的更新基础上又进行了更新并提交。如果随后事务 A 回滚,但 B 已经基于 A 的“未提交更新”写入了数据库,就会导致数据库中出现了一份基于“从未真正生效的数据”所写进去的值。

  • 如果 B 的更新逻辑要判断某个字段是否满足条件(例如库存是否 > 0 才扣减),而这个字段恰恰是由 A 更新的未提交数据

  • B 基于 A 未提交的数据做了扣减并提交,后来 A 回滚导致库存根本没增加(或者没变化),最终 B 却“凭空”把库存扣了

这些例子都说明了:脏写本质上是写操作基于了一份并不存在(或不确定会不会存在)的中间状态,进而可能对数据一致性和业务逻辑造成破坏, 这就是为什么数据库禁止脏写的原因。

在多数主流数据库(例如 MySQL InnoDB)的默认配置下,这种情况通常不会发生,因为对同一行记录的并发写操作自动会被加 x锁序列化:只有前一个写操作提交或回滚后,后一个事务才能对这行数据进行新的写。因此,脏写往往只在极其宽松/非常低的隔离级别(或人为关闭了某些并发控制)下,才可能被“模拟”或“测试”出来。

1.3. 不可重复读 和 幻读#

  • 不可重复读 发生在同一行数据被更新,导致同一查询多次执行返回不同的具体数值

  • 幻读 则发生在范围查询中,其他事务在该范围内插入或删除数据,导致结果集的记录数发生变化

1.4. 四种隔离级别#

SQL标准中设立了4种隔离级别, 用来解决上面的读一致性问题, 不同的隔离级别可以解决不同的读一致性问题:

隔离级别 (Isolation Level) 脏读 (Dirty Read) 不可重复读 (Non-repeatable Read) 幻读 (Phantom Read)
未提交读 (READ UNCOMMITTED) 可能 (Yes) 可能 (Yes) 可能 (Yes)
已提交读 (READ COMMITTED) 不可能 (No) 可能 (Yes) 可能 (Yes)
可重复读 (REPEATABLE READ) 不可能 (No) 不可能 (No) 可能 (Yes)(对 InnoDB 不可能)
串行化 (SERIALIZABLE) 不可能 (No) 不可能 (No) 不可能 (No)

脏写的问题严重, 数据库设计上通过删改操作默认加 x锁, 实现脏写在任何隔离级别都会避免

InnoDB支持四个隔离级别(和SQL标准定义的基本一致)。隔离级别越高,事务的并发度就越低。唯一的区别就在于,InnoDB可重复读(REPEATABLE READ)的级别就解决了幻读的问题。这也是InnoDB使用可重复读 作为事务默认隔离级别的原因。

2. 脏读 脏写 幻读如何被解决的#

2.1. 脏读 脏写#

上面提到, 数据库有四种隔离级别, 用来解决 脏读, 脏写, 不可重复读, 幻读等问题, 现在我们简单叙述一下各自的概念:

  • 脏读: 一个事务读取到了另一个事务尚未提交的数据, 若未提交的数据发生回滚…

    • 事务隔离级别较低, 允许一个事务读取另一个事务未提交的修改数据所导致
  • 脏写: 事务 B 在事务 A 尚未提交的更新基础上又进行了更新并提交

    • 脏写的问题严重, 数据库设计上通过删改操作默认加 x锁, 实现脏写在任何隔离级别都会避免
  • 幻读: 发生在范围查询中,其他事务在该范围内插入或删除数据,导致结果集的记录数发生变化

  • 不可重复读: 发生在同一行数据被更新, 导致同一查询多次执行返回不同的具体数值

其中脏写在隔离级别下都被消除了, 方法是任何写操作都需要先获得 x 锁, 然后就剩脏读, 幻读, 不可重复读这三个问题,

InnoDB 在第三级别 可重复读(REPEATABLE READ) 就解决了所有的问题, 前两个级别分别是 未提交读, 已提交读, 根据名字就可以看出, 未提交读就是允许修改的数据还没提交就允许其他事务读取, 这显然会造成脏读, 未提交读也是最低级别的事务隔离, 第二级别就是已提交读, 显然该级别解决了脏读问题, 但是还不够, 此时还有幻读和不可重复读的问题没有解决,

2.2. 不可重复读#

不可重复读指的是在同一个事务中,多次读取同一数据时,由于其他事务的修改并提交,导致读取结果不一致:

  • 事务 A:在一个事务中需要两次读取 id = 1 的余额

  • 事务 B:在事务 A 执行两次读取期间修改并提交数据

要解决“不可重复读”问题, 需要将事务隔离级别提升到 可重复读 或更高的 序列化 级别, 这两个级别实行默认一致性读 MVCC 来避免“不可重复读“问题:

  • 可重复读隔离级别下, 读取数据操作默认使用的是一致性读, 即通过 MVCC 机制读取数据的快照, 注意默认不是加锁读取
  • 当然也可以使用 Select ... LOCK IN SHARE MODE 为某行添加共享s锁, 直到当前事务结束, 这样也可以防止读取的时候数据被其他事务修改,

x锁和 s锁是互斥的, 也就是说 当事务 A 获得某行记录的 s锁后, 其他事务想要修改该行记录, 在尝试获取 x 锁的阶段就会阻塞

场景:

  • 事务 A:在一个事务中需要两次读取 id = 1 的余额
  • 事务 B:在事务 A 执行期间修改并提交数据
  • 隔离级别:设置为“可重复读”

执行过程:

  • 事务 A 开始

  • 执行 SELECT balance FROM account WHERE id = 1;

  • 结果:balance = 100

  • 数据库记录事务 A 的时间点(通过 MVCC)对 id = 1 的行加共享锁

  • 事务 B 开始

  • 执行 UPDATE account SET balance = 200 WHERE id = 1;

  • 1 如果使用锁:事务 B 被阻塞,直到事务 A 结束

  • 2 如果使用 MVCC:事务 B 可以提交,更新 balance = 200,但事务 A 仍读取旧版本(快照)数据

  • 事务 A 再次读取

  • 再次执行 SELECT balance FROM account WHERE id = 1;

  • 结果:balance = 100(无论事务 B 是否提交,事务 A 读取的是一致的旧版本数据)

  • 事务 A 结束

  • 事务 A 提交或回滚后,锁(如果有)释放,或快照失效

  • 后续事务读取到的可能是 balance = 200

结果:事务 A 在整个过程中两次读取的结果都是 100, 避免了不可重复读, 但现在仍存在“幻读” 问题

2.3. 幻读#

可重复读 解决了“不可重复读”, 但没有解决“幻读”问题, 幻读是指事务读取一个范围的数据时, 另一个事务插入或删除行, 导致再次读取范围时结果不一致 例如:

  • 事务 A:SELECT * FROM account WHERE balance > 50;(返回 1 行)
  • 事务 B:INSERT INTO account (id, balance) VALUES (2, 60); 并提交
  • 事务 A 再次执行相同查询,可能返回 2 行(幻读)

上面我们说的是数据库上的定义, MySQL 的 InnoDB 引擎在 可重复读 级别下, 通过 多版本并发控制 MVCC 和 Next-Key 锁, 在一定程度上解决了幻读问题

“幻读”是指一个事务在多次读取某个范围的数据时, 由于另一个事务插入或删除了数据并提交, 导致前后两次读取的结果集不一致(多了或少了行), 它与“不可重复读”的区别在于, 幻读涉及范围查询的结果集变化, 而不可重复读针对单行数据的修改

情况 1:事务 A 使用快照读(普通 SELECT)

事务 A 执行:

START TRANSACTION;
SELECT * FROM users WHERE age BETWEEN 10 AND 40; -- 快照读

结果:id=1, age=20 和 id=3, age=30

机制:MVCC 创建一致性读视图,事务 A 只看到开始时的快照,不加锁

事务 B 插入数据, 执行

START TRANSACTION;
INSERT INTO users (id, age) VALUES (2, 25);
COMMIT;

结果:插入成功,表数据变为:

id | age
1  | 20
2  | 25
3  | 30

事务 A 再次查询, 执行:

SELECT * FROM users WHERE age BETWEEN 10 AND 40; -- 快照读

结果:仍是 id=1, age=20 和 id=3, age=30

原因:MVCC 保证快照一致性,不受事务 B 影响,幻读被避免

结论:快照读通过 MVCC 避免幻读,但不加锁,事务 B 可以插入

情况 2:事务 A 从一开始使用 Next-Key 锁(当前读)

事务 A 开始并使用当前读

START TRANSACTION;
SELECT * FROM users WHERE age BETWEEN 10 AND 40 FOR UPDATE; -- 当前读

结果:id=1, age=20 和 id=3, age=30

机制:Next-Key 锁触发,锁定 age 在 10 到 40 的范围:

  • 记录锁:age=20 和 age=30
  • 间隙锁:(负无穷, 20)、(20, 30)、(30, 正无穷)

事务 B 尝试插入

INSERT INTO users (id, age) VALUES (2, 25);

结果:被阻塞,因为 age=25 落在间隙 (20, 30) 中,而该间隙已被锁定。

事务 A 再次查询

SELECT * FROM users WHERE age BETWEEN 10 AND 40 FOR UPDATE; -- 当前读

结果:仍是 id=1, age=20 和 id=3, age=30

原因:事务 B 的插入被阻止,数据未改变

事务 A 提交

  • COMMIT;
  • 事务 B 解除阻塞,插入成功

结论:事务 A 从一开始使用 Next-Key 锁,完全避免了幻读,因为事务 B 无法在事务 A 执行期间插入新记录

情况 3:事务 A 在第二次查询时使用当前读

事务 A 开始 执行

START TRANSACTION;
SELECT * FROM users WHERE age BETWEEN 10 AND 40; -- 快照读

结果:id=1, age=20 和 id=3, age=30

事务 B 插入数据执行:

INSERT INTO users (id, age) VALUES (2, 25);
COMMIT;

事务 A 再次查询(当前读)

SELECT * FROM users WHERE age BETWEEN 10 AND 40 FOR UPDATE; -- 当前读

结果:id=1, age=20、id=2, age=25 和 id=3, age=30

机制:当前读读取最新提交的数据,看到事务 B 的插入。Next-Key 锁在此触发,但因插入已完成,无法阻止已存在的数据被读取

结论:当前读会看到新数据,可能出现逻辑上的幻读(与第一次快照读不一致)

3. 事务#

3.1. 事务的概念#

事务是一组要么全部成功,要么全部失败的数据库操作,保证数据的一致性。

假设你要从 A 账户转 100 元到 B 账户,这个过程涉及两个操作:

  1. 从 A 账户扣 100 元
  2. 给 B 账户加 100 元

如果第一步执行了,但第二步失败了,就会导致 A 的钱少了,但 B 没收到钱,这导致了数据一致性问题。事务可以防止这种情况。

START TRANSACTION;
-- 从 A 账户扣 100 元
UPDATE accounts SET balance = balance - 100 WHERE name = 'A';
-- 给 B 账户加 100 元
UPDATE accounts SET balance = balance + 100 WHERE name = 'B';
COMMIT;

如果在执行过程中出现错误(比如 B 账户不存在),可以执行回滚,撤销已经执行的操作。

拓展: 在 MySQL(InnoDB 存储引擎)中,单个 DML 语句(INSERT, UPDATE, DELETE)本身就是一个事务,如果 AUTOCOMMIT 处于默认开启状态,它会自动提交,不需要手动 COMMIT

在数据修改操作(Update、Delete、Insert)中, MySQL InnoDB 会自动对受影响的行加上 x 锁, 事务提交的时候(语句执行完成), x锁会被自动释放。

后端开发: 在 Spring Data JPA 中,事务管理是由 Spring 的事务管理器(通常是 @Transactional 注解)来控制的,自动回滚行为也由 Spring 决定。Spring 默认会在遇到 运行时异常(RuntimeException 及其子类)错误(Error) 时自动回滚事务。

如果在事务方法内部捕获了异常,并没有将异常抛出到事务管理器,那么 Spring 不会感知到异常,从而事务不会自动回滚。

@Transactional
public void updateSomeData() {
    try {
        // 一些数据库操作
        // 可能抛出异常
    } catch (Exception e) {
        // 捕获异常后不重新抛出,事务将按正常流程提交
    }
}

3.2. 事务不是导致错误的原因,而是处理错误的机制#

连接类错误, 语法错误, 系统资源相关错误, 数据完整性和约束错误, 事务和锁相关错误, 前几种没什么好说的, 看一下最后两种:

  • 违反完整性约束的错误: 数据库自身的机制, 比如:主键重复、外键不存在、唯一约束冲突、CHECK 约束失败等
  • 事务和锁相关错误: 在事务操作中遇到死锁或锁等待等问题
    • 两个或多个事务在等待对方持有的资源而形成循环依赖,导致系统检测到死锁。
    • 事务等待获取资源的时间超出了设定的超时时间,可能需要检查当前事务的锁竞争情况

事务不是导致错误的原因,而是处理错误的机制:

情况 数据库是否报错? 事务是否回滚?
主键冲突 ✅ 报错 ✅ 回滚(事务开启时)
外键约束失败 ✅ 报错 ✅ 回滚(事务开启时)
唯一约束失败 ✅ 报错 ✅ 回滚(事务开启时)
业务逻辑错误(如负余额) ✅ 报错 ✅ 回滚(事务开启时)
  1. 没开启事务时, 每条 SQL 都是独立提交的, 报错不会回滚之前的操作
  2. 事务不会主动报错,报错的原因是 SQL 语句违反数据库的完整性约束
  3. 事务的作用是确保错误发生时,数据不会进入不一致的状态, 事务只是处理数据库错误的工具, 并不是开启事务就万事大吉了

3.3. 事务的特性#

原子性(Atomicity)

  • 事务是一个不可分割的最小操作单元,要么全部执行成功,要么全部回滚
  • 以转账场景为例,一个账户的余额减少,另一个账户的余额增加,这两个操作一定是同时成功或者同时失败的

一致性(Consistency)

  • 事务执行前后,数据库必须保持一致性状态,即不会违反数据库的完整性约束

隔离性(Isolation)

  • 并发事务互不影响,一个事务未提交前,其他事务无法看到其变化

持久性(Durability)

  • 事务提交后,其修改的数据会被永久保存,即使系统崩溃也不会丢失

一致性并不是事务本身的固有属性, 而更像是一种外在要求, 不难发现其他三个都是实打实的特征, 唯独一致性, 无法理解, 这里的一致性更像是一种外在要求, 而不是事务本身的固有的属性, 即是一种需要依赖业务层逻辑来实现的状态,

举个不恰当的例子, 我们说一个苹果, 苹果是圆的, 苹果可以为我们提供维生素C, 这都是苹果的特征, 可是你却说, 苹果也有生吃性, 苹果必须生吃才能最大化的提供营养价值, WTF? 生不生吃苹果取决于个人啊, 这怎么能是苹果的特性呢?

同理, 数据库是不是保持一致性, 这取决于业务逻辑啊, 比如需要结合锁来实现, 什么悲观锁, 乐观锁, 这怎么能是事务的特性呢?

4. 数据一致性问题#

4.1. 直接修改操作 - 隐式 x 锁#

比如常见的转账例子多个人给同一个账户转 100 块钱, 系统可能会有下面的命令:

UPDATE account SET balance = balance + 100 WHERE account_id = 002;

高并发状态下, A事务读取到此时账户余额为 100, B事务 也读取到账户余额为 100,

  • A 事务: 余额 = 100 + 100 = 200,
  • B 事务: 余额 = 100 + 100 = 200

最后账户余额仅为 200 而不是 300, 导致数据一致性问题, 因为 InnoDB 会在执行 UPDATE 时对目标行加上排它锁(X 锁), 从而确保这些操作是串行化的,

好像是告诉事务, 你想更新 account_id = 002 的数据? 行, 但请先获得这行数据的 X 锁, 所以不可能会有两个事务同时对同一行执行 UPDATE 操作, 也就是说使用单条 UPDATE 语句时, 不会出现多个事务都获取到相同 balance 的情况, 因为数据修改操作都是串行化的,

在数据修改操作(Update、Delete、Insert)中, 事务会先尝试获得受影响行的 X 锁, 若得到, 才能进行更新, 否则只能等待, 不要忘了单个语句, 也默认属于一个事务

4.2. 检查 + 修改操作 - 显式 x锁 (悲观锁) 或乐观锁#

这种属于多个事务中基于过时数据进行判断后再写入更新的场景, 读取数据与更新操作之间缺乏必要的锁机制保护, 导致多个事务基于同一份数据进行判断并更新, 从而引发数据不一致,

上面的例子过于简化了, 实际系统肯定不会直接给账户加钱, 还有很多考虑, 比如检查收账人是否存在, 检查转账人的余额是不是足够, 然后进行扣款,

步骤 1:同时读取余额

  • 事务 A 和事务 B 同时对转账人账户执行查询操作,读取到余额都为 100 元
  • 此时,如果没有加锁(如 FOR UPDATE),两个事务都基于相同的旧数据做判断

步骤 2:判断余额充足

  • 事务 A 判断:100 元 >= 转账金额 80 元,认为余额足够
  • 事务 B 同样判断:100 元 >= 转账金额 60 元,也认为余额足够

步骤 3:分别执行扣款

  • 事务 A 扣款 80 元,更新余额为 20 元
  • 事务 B 扣款 80 元,同样在自己的事务中基于最初读取到的 100 元执行更新,更新余额为 40 元
  • 最终两个事务都成功提交,导致余额可能是40, 也可能是20, 也可能是100 - 80 - 60 = -40, 导致余额出现问题(负数或不一致)。

解决办法1: 悲观锁

BEGIN;
SELECT balance FROM accounts WHERE id = ? FOR UPDATE; -- 加锁
-- 判断余额是否充足, 通过业务代码实现, SQL 无法实现逻辑判断
UPDATE accounts SET balance = balance - ? WHERE id = ?;
COMMIT;

这样可以确保在一个事务完成之前,其他事务必须等待,避免同时读取同一份数据进行判断。

解决办法2: 乐观锁

BEGIN;
SELECT balance, version FROM accounts WHERE id = ?;
-- 判断余额是否充足, 通过业务代码实现, SQL 无法实现逻辑判断
UPDATE accounts SET balance = balance - ?, version = version + 1
WHERE id = ? AND version = ?;
-- 判断 UPDATE 是否成功, 否则重试或抛出异常返回给客户端执行失败, 通过业务代码实现
COMMIT;

4.3. 多表操作 - 事务原子性#

除此之外还要确保转账人的账户 -100, 收款人的账户 +100, 不能出现后者操作失败了, 导致收款人账户余额没变, 转账人账户的钱却少了 100, 这也是属于数据不一致,

这种很简单, 把更新两张表的语句放到同一个事务里就行了, 因为可以利用事务的回滚保证, 若有一个操作失败, 则同时都失败,