MySQL 事务 数据库锁 悲观锁 乐观锁
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 账户,这个过程涉及两个操作:
- 从 A 账户扣 100 元
- 给 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
约束失败等 - 事务和锁相关错误: 在事务操作中遇到死锁或锁等待等问题
- 两个或多个事务在等待对方持有的资源而形成循环依赖,导致系统检测到死锁。
- 事务等待获取资源的时间超出了设定的超时时间,可能需要检查当前事务的锁竞争情况
事务不是导致错误的原因,而是处理错误的机制:
情况 | 数据库是否报错? | 事务是否回滚? |
---|---|---|
主键冲突 | ✅ 报错 | ✅ 回滚(事务开启时) |
外键约束失败 | ✅ 报错 | ✅ 回滚(事务开启时) |
唯一约束失败 | ✅ 报错 | ✅ 回滚(事务开启时) |
业务逻辑错误(如负余额) | ✅ 报错 | ✅ 回滚(事务开启时) |
- 没开启事务时, 每条 SQL 都是独立提交的, 报错不会回滚之前的操作
- 事务不会主动报错,报错的原因是 SQL 语句违反数据库的完整性约束
- 事务的作用是确保错误发生时,数据不会进入不一致的状态, 事务只是处理数据库错误的工具, 并不是开启事务就万事大吉了
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, 这也是属于数据不一致,
这种很简单, 把更新两张表的语句放到同一个事务里就行了, 因为可以利用事务的回滚保证, 若有一个操作失败, 则同时都失败,