Spring 事务隔离级别详解
事务隔离级别是数据库并发控制的基石,也是面试中的高频考点。本文从 ACID 理论出发,深入剖析四种隔离级别、脏读/不可重复读/幻读的成因、MySQL 默认 RR 的设计取舍、MVCC 机制以及 Spring 中的配置方式,帮助你彻底掌握这一核心知识点。
一、事务四大特性 ACID 回顾
数据库事务(Transaction)是若干数据库操作的原子单元,一个正确实现的事务必须同时满足以下四个特性,简称 ACID:
1.1 原子性(Atomicity)
原子性要求事务中的所有操作要么全部成功,要么全部失败回滚,不存在中间状态。换句话说,事务是最小执行单位,不可再分。例如,转账操作中扣款和收款必须同时成功或同时失败,不能出现”钱扣了但没到账”的情况。
在 MySQL 中,原子性主要通过 Undo Log 来实现。当事务执行过程中,数据被修改之前,MySQL 会先将旧值写入 Undo Log。如果事务需要回滚,就从 Undo Log 中读取数据并恢复,实现”一键还原”的效果。
1.2 一致性(Consistency)
一致性是指事务执行前后,数据库从一个一致状态转换到另一个一致状态。一致性是 ACID 中最难理解的概念,它本质上要求业务规则在事务执行前后不被破坏。比如转账前后两个账户的总余额必须保持不变,这就是业务一致性约束。
一致性需要通过应用层的合理设计以及原子性、隔离性的配合来实现,数据库本身并不感知业务逻辑。
1.3 隔离性(Isolation)
隔离性指不同事务之间相互隔离,各自执行不受干扰。隔离性通过数据库的锁机制和并发控制协议来实现。隔离程度越低并发性能越高,但数据一致性风险也越大;隔离程度越高数据越安全,但并发性能越差。这是一对天然矛盾。
隔离级别正是用来定义”隔离程度”的刻度尺,后文将重点展开。
1.4 持久性(Durability)
持久性要求事务一旦提交,其对数据库的修改就是永久性的,即使系统崩溃也不会丢失。在 MySQL 中,持久性主要依赖 Redo Log 和 Double Write Buffer 来保障。数据页的修改先写入 Redo Log(顺序 IO),然后在合适的时机刷写到磁盘。即使数据库宕机,也可以通过 Redo Log 恢复已提交的事务。
二、四种事务隔离级别详解
ANSI SQL-92 标准定义了四种事务隔离级别,从低到高依次为:读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)。每种隔离级别都明确指定了禁止出现哪些并发问题。
2.1 读未提交(Read Uncommitted)
读未提交是隔离级别最低的模式,一个事务可以看到另一个事务尚未提交的修改。这种模式几乎不提供事务隔离,并发性能最好,但问题也最多。
存在的问题: 脏读(Dirty Read)。即一个事务读取到了另一个事务未提交的数据,如果后者最终回滚,前者读取的数据就变成了”脏数据”。
适用场景: 极少使用,一般仅用于对数据一致性要求极低且追求极致性能的场景,例如日志采集等。
MySQL 行为: MySQL 的 autocommit 默认为开启状态,每个 SQL 语句自动构成一个事务。在这种设置下,如果使用 READ UNCOMMITTED,一个 SELECT 语句可以看到其他事务中尚未 commit 的数据。
2.2 读已提交(Read Committed)
读已提交要求一个事务只能看到其他事务已提交的修改。这是大多数主流数据库(Oracle、SQL Server、PostgreSQL)的默认隔离级别。相比读未提交,它解决了脏读问题。
存在的问题: 不可重复读(Non-Repeatable Read)。即在同一事务内,两次相同的 SELECT 可能读到不同的数据,因为另一个事务在此期间提交了修改并更新了数据行。
MySQL 行为: 在 MySQL 中,使用读已提交模式时,每次 SELECT 都会生成一个新的快照(Snapshot),快照基于已提交的最新数据。这个特性使得同一个事务中多次读取同一行数据可能得到不同结果。
2.3 可重复读(Repeatable Read)
可重复读是 MySQL InnoDB 引擎的默认隔离级别。它要求在同一事务中,多次执行相同的 SELECT 语句必须返回相同的结果,即使其他事务在此期间提交了新的修改。RR 解决了不可重复读问题。
存在的问题: 理论上仍可能出现幻读(Phantom Read),即在同一事务中,两次执行范围查询可能返回不同数量的记录,因为其他事务在此期间插入了新的记录。不过 InnoDB 通过 MVCC 和 Next-Key Lock 在很大程度上抑制了幻读,在标准 RR 级别下基本不会出现。
MySQL 行为: MySQL 的 RR 通过 MVCC 实现快照读,每次事务开启时创建一致性快照(Read View),后续所有普通 SELECT 都基于该快照读取数据,从而保证可重复性。
2.4 串行化(Serializable)
串行化是最高级别的隔离,完全禁止并发,所有事务必须顺序执行。它通过强制事务串行执行来彻底消除所有并发异常(脏读、不可重复读、幻读)。
存在的问题: 并发性能极差,高并发场景下会造成严重的性能瓶颈和线程阻塞。当事务竞争激烈时,系统吞吐量会急剧下降。
MySQL 行为: 在 MySQL 中,如果隔离级别设为 SERIALIZABLE,所有普通 SELECT 都会被转换为 SELECT ... LOCK IN SHARE MODE,即加共享锁;而范围查询会加 Next-Key Lock,阻止其他事务在范围内插入或更新数据。
三、各隔离级别解决的问题一览
理解隔离级别的核心在于掌握它们分别解决了哪些并发异常。下面的表格汇总了脏读、不可重复读和幻读在四种隔离级别下的表现:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | ✅ 可能发生 | ✅ 可能发生 | ✅ 可能发生 |
| 读已提交 | ❌ 不可能 | ✅ 可能发生 | ✅ 可能发生 |
| 可重复读 | ❌ 不可能 | ❌ 不可能 | ❌ 基本不可能* |
| 串行化 | ❌ 不可能 | ❌ 不可能 | ❌ 不可能 |
*注:MySQL InnoDB 在 RR 级别下通过 Next-Key Lock 机制基本消除了幻读。
3.1 脏读(Dirty Read)
脏读是指一个事务读取了另一个事务尚未提交的数据。如果该事务最终回滚,读取的数据就无效了。例如,事务 A 读取了事务 B 修改但未提交的数据,随后事务 B 回滚,事务 A 基于错误数据做了进一步的处理,就会导致数据不一致。
脏读是所有并发异常中最严重的一种,因为它读取的数据本身就不被认可。
3.2 不可重复读(Non-Repeatable Read)
不可重复读指在同一事务中,两次相同的读取操作得到了不同结果。常见场景:事务 T1 读取某行数据,事务 T2 在此期间修改并提交了同一行数据,事务 T1 再次读取该行时看到了不同的值。
不可重复读本质上是一个行级别的并发冲突,适用于那些更关注”单行数据一致性”的场景。
3.3 幻读(Phantom Read)
幻读指在同一事务中,两次执行相同的范围查询(如 SELECT COUNT(*) WHERE status = 1)得到了不同数量的记录。这是因为另一个事务在此期间插入了或删除了符合条件的记录。
幻读是范围级别的并发问题,与不可重复读的关键区别在于:不可重复读针对的是已有数据的修改,而幻读针对的是新数据的插入或删除。
四、MySQL 默认 RR(可重复读)的原因
MySQL(InnoDB 引擎)选择 RR 作为默认隔离级别,并非偶然,而是经过深思熟虑的设计决策,主要有以下几方面原因:
4.1 历史兼容性因素
MySQL 早期版本中,RR 是唯一一种能通过 Gap Lock 抑制幻读的隔离级别。在 MySQL 5.0 之前,RR 级别下的 Gap Lock 行为是唯一可靠的防幻读手段。为了给用户提供最”安全”的默认行为,MySQL 将 RR 设为了默认级别。
4.2 主从复制一致性
在 MySQL 的异步复制或半同步复制模式下,如果使用 RC 级别,主库和从库的数据可能因为并发事务的时间差异而产生不一致。RR 的快照机制使得事务在开始时就确定了数据的”一致视图”,有利于保障主从数据的一致性。
4.3 业务开发的便利性
对于大多数业务系统而言,RR 提供了一个”安全的默认”:同一事务内多次查询结果一致,开发人员无需担心事务执行过程中数据被其他事务修改导致逻辑错误。这种特性让业务代码的编写和调试更加简单。
4.4 InnoDB 的 MVCC 优化
InnoDB 在 RR 级别下实现了一套高效的 MVCC 机制,通过 Read View 和 Undo Log 链,不需要为所有读操作加锁就能实现可重复读。这使得 RR 在保证数据一致性的同时,仍然具有较好的并发性能。相比之下,串行化级别虽然更安全,但性能代价过大。
五、隔离级别与锁的关系
隔离级别和锁是数据库并发控制的两个维度。隔离级别定义了”能看到什么”,而锁机制决定了”如何实现看不到”。二者相互配合,共同保障事务的隔离性。
5.1 读未提交与读已提交:当前读加锁
在 RC 级别下,每次 SELECT 都读取最新已提交的数据,但为了防止读取过程中数据被其他事务修改,通常需要对读取的行加共享锁(S Lock),读取完成后立即释放。这种策略称为”读已提交,当前读”。
5.2 可重复读:快照读与当前读
RR 级别下,普通 SELECT(快照读)不涉及任何锁,只依赖 MVCC 生成一致性快照。只有执行 UPDATE/DELETE/INSERT(当前读)时才会加锁。
当前读加的是排他锁(X Lock),锁住的是索引记录及其 Gap,防止其他事务在范围内进行修改。
5.3 串行化:全部当前读
串行化级别下,所有 SELECT 都被隐式转换为当前读,需要获取共享锁或 Next-Key Lock。这种强制加锁策略保证了绝对隔离,但极大限制了并发能力。
5.4 锁的类型一览
| 锁类型 | 作用 | 兼容范围 |
|---|---|---|
| 共享锁(S Lock) | 允许其他事务读取被锁行,但禁止修改 | S ↔ S 兼容,S ↔ X 不兼容 |
| 排他锁(X Lock) | 禁止其他事务读取和修改被锁行 | 与任何锁不兼容 |
| 记录锁(Record Lock) | 锁住单条索引记录 | - |
| 间隙锁(Gap Lock) | 锁住索引记录之间的间隙,防止插入 | - |
| Next-Key Lock | 记录锁 + 间隙锁的组合 | - |
| 意向锁(Intention Lock) | 表级锁,表示事务对某行有锁意图 | 用于协调表锁和行锁 |
六、Spring @Transactional 的 isolation 属性
Spring 提供了声明式事务管理,通过 @Transactional 注解可以灵活配置事务的隔离级别。isolation 属性接受 Isolation 枚举值,共五个选项。
6.1 isolation 属性的五个取值
public enum Isolation {
DEFAULT(-1), // 使用数据库默认隔离级别
READ_UNCOMMITTED(1), // 读未提交
READ_COMMITTED(2), // 读已提交
REPEATABLE_READ(4), // 可重复读
SERIALIZABLE(8); // 串行化
}
Isolation.DEFAULT 表示使用底层数据库的默认隔离级别,这是最常用的设置。MySQL 下默认为 REPEATABLE_READ,Oracle 下默认为 READ_COMMITTED。
6.2 注解使用示例
@Service
public class OrderService {
// 使用数据库默认隔离级别
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order);
inventoryService.deduct(order.getProductId(), order.getQuantity());
}
// 显式指定读已提交,避免长事务中的快照过期问题
@Transactional(isolation = Isolation.READ_COMMITTED)
public BigDecimal getAccountBalance(Long userId) {
return accountMapper.selectBalance(userId);
}
// 显式指定串行化,用于财务对账等强一致性场景
@Transactional(isolation = Isolation.SERIALIZABLE)
public void reconcileAccounts() {
// 强一致场景,适合数据量较小但一致性要求极高的操作
}
}
6.3 常见陷阱
陷阱一:自调用导致事务失效。 在同一个类中,一个方法调用另一个标注了 @Transactional 的方法时,由于不经过 Spring 代理对象,事务不会生效。这是 Spring 事务最常见也最容易被忽视的坑。
陷阱二:异常被 catch 吞掉。 如果在 @Transactional 方法内部捕获了异常但未重新抛出,Spring 事务管理器认为事务正常执行完毕,会正常提交,但实际上业务逻辑可能已经出错。
陷阱三:非 public 方法。 Spring 事务管理器默认通过 JDK 动态代理实现,代理只能拦截 public 方法的调用。非 public 方法上的 @Transactional 完全无效。
七、快照读 vs 当前读
理解快照读和当前读是掌握 MVCC 和事务隔离机制的关键。这两种读方式的区别直接决定了事务能看到什么数据。
7.1 快照读(Snapshot Read)
快照读读取的是事务开始时创建的一致性快照中的数据,是一种”历史版本”的读取方式。它不涉及任何锁,因此不会阻塞其他事务的读写操作。
MySQL 中,普通 SELECT 语句(不带 FOR UPDATE / LOCK IN SHARE MODE)就是快照读。
快照读的核心由 Read View(读视图)实现。Read View 包含三个关键信息:
m_ids:活跃事务 ID 列表(未提交的事务)min_trx_id:最小活跃事务 IDmax_trx_id:创建 Read View 时最大的事务 ID + 1
判断某行数据对当前事务可见的规则:比较数据行中的 trx_id(事务版本号)与 Read View 中的 m_ids 和 max_trx_id。如果 trx_id 小于 min_trx_id,说明该行数据在当前事务开始前就已经提交,可见;如果 trx_id 在 m_ids 中,说明该行数据由未提交事务修改,不可见,需要沿着 Undo Log 链向上查找更早的版本。
7.2 当前读(Current Read)
当前读读取的是数据的最新版本,即当前数据库中真实存在的数据。当前读会对读取的记录加锁,以保证读取过程中数据不被其他事务修改。
MySQL 中,以下操作都属于当前读:
SELECT ... LOCK IN SHARE MODESELECT ... FOR UPDATEINSERT、UPDATE、DELETE
这些操作在读取数据的同时,会对读取的行加共享锁或排他锁,并可能触发 Gap Lock 以防止幻读。
7.3 快照读与当前读的关键区别
| 特性 | 快照读 | 当前读 |
|---|---|---|
| 读取版本 | 历史快照 | 最新版本 |
| 加锁 | 不加锁 | 加锁 |
| 是否阻塞 | 否 | 是(等待锁释放) |
| 适用语句 | 普通 SELECT | FOR UPDATE / LOCK IN SHARE / DML |
| 隔离级别影响 | RR 下可重复,RC 下每次新建快照 | 始终读取最新数据 |
7.4 实验验证
-- 事务A:开启事务
BEGIN;
SELECT * FROM orders WHERE status = 'PENDING'; -- 快照读,返回3条
-- 事务B:插入一条新记录并提交
INSERT INTO orders (status) VALUES ('PENDING');
COMMIT;
-- 事务A:再次查询
SELECT * FROM orders WHERE status = 'PENDING'; -- RR下仍返回3条,RC下返回4条
UPDATE orders SET status = 'PROCESSING' WHERE status = 'PENDING'; -- 当前读,涉及4条
八、MVCC 在 RR 下的表现
MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现高并发读写的重要机制。它通过为每一行数据维护多个版本,使得读操作和写操作互不阻塞,从根本上提升了数据库的并发能力。
8.1 MVCC 的核心思想
MVCC 的核心是:为每个读操作创建一个”快照”,让读操作看到的始终是一个一致的数据库视图,而写操作则基于最新版本进行。这种”读写分离”的策略避免了读操作被写操作阻塞,也避免了写操作被读操作阻塞。
在 InnoDB 中,每一行数据实际上都包含两个隐藏列:
DB_TRX_ID:最近一次修改该行的事务 ID(6 字节)DB_ROLL_PTR:指向 Undo Log 中旧版本的指针(7 字节)
当一行数据被更新时,InnoDB 并不会直接覆盖旧数据,而是将旧数据写入 Undo Log,形成一条版本链(Version Chain),新数据行中的 DB_ROLL_PTR 指向这条链的起点。
8.2 Read View 的创建时机
在 MySQL RR 隔离级别下,Read View 在事务第一次读取数据时创建,之后整个事务期间复用同一个 Read View。这就是为什么 RR 级别下同一个事务中多次 SELECT 结果一致的原因。
而在 RC 隔离级别下,Read View 在每次 SELECT 时重新创建,因此每次都能看到最新提交的数据,从而造成不可重复读。
8.3 Undo Log 版本链
Undo Log 是 MVCC 能够工作的关键基础设施。每当事务修改一行数据时,旧值会被复制到 Undo Log 中,并形成一条链表:
最新行数据(trx_id=100)
↓ DB_ROLL_PTR
Undo Log记录1(trx_id=80,旧值)
↓ DB_ROLL_PTR
Undo Log记录2(trx_id=50,旧值)
当一个事务需要读取某个版本的行数据时,如果当前版本对该事务不可见,就沿着 DB_ROLL_PTR 指针链向上查找,直到找到一个可见的版本或遍历完整个链。
8.4 MVCC + Next-Key Lock 彻底解决幻读
InnoDB 在 RR 级别下,不仅通过 MVCC 提供快照读,还通过 Next-Key Lock 提供当前读的保护。Next-Key Lock 是记录锁(Record Lock)和间隙锁(Gap Lock)的组合。
对于范围查询 SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE,InnoDB 会对区间 [10, 20] 以及区间末尾的下一个 Gap 加锁,防止其他事务在区间内插入新记录,从而彻底消除了幻读。
九、高频面试题
面试题一:MySQL 的默认可重复读隔离级别下,是否还存在幻读?
参考答案:
在标准 SQL 语义下,可重复读隔离级别理论上仍然存在幻读的可能——两次范围查询结果行数可能不同。但在 MySQL InnoDB 引擎下,由于 Next-Key Lock(记录锁 + 间隙锁)的存在,RR 级别下基本消除了幻读。
具体来说,当执行范围查询时,InnoDB 会锁定查询范围内的所有索引记录以及它们之间的间隙。如果其他事务试图在这个范围内插入新记录,就会被 Gap Lock 阻塞,直到当前事务结束。
不过需要注意的是,InnoDB 的幻读消除也有例外:当查询条件完全匹配某条记录(等值查询)时,InnoDB 会将 Next-Key Lock 退化为 Record Lock,此时只锁定该记录本身,间隙不受保护。如果另一个事务在此间隙中插入新记录,仍可能产生幻读。
面试题二:Spring 事务失效的常见场景有哪些?
参考答案:
Spring 声明式事务失效的场景主要包括:
1. 自调用问题。 同一个类中,一个非 @Transactional 方法调用另一个 @Transactional 方法时,由于调用发生在代理对象内部,不经过 Spring 的 AOP 拦截链,事务不会生效。解决方案:注入自身 Bean 后调用,或使用 AspectJ 模式。
2. 异常被捕获未重新抛出。 @Transactional 默认只在抛出 RuntimeException 或 Error 时才会触发回滚。如果业务代码捕获了异常并自行处理,事务会正常提交。解决:重新抛出异常,或配置 rollbackFor = Exception.class。
3. 非 public 方法。 Spring 默认使用 JDK 动态代理,只能代理 public 方法。private 方法、protected 方法上的 @Transactional 完全无效。
4. 传播行为配置错误。 例如 PROPAGATION_SUPPORTS 不会创建新事务,如果外层没有事务,则内层操作也在非事务上下文中执行。
5. 多数据源未正确配置。 使用多数据源时,如果没有正确配置 TransactionManager,可能导致事务管理不生效。
面试题三:MVCC 能否完全替代锁?
参考答案:
MVCC 并不能完全替代锁,二者是互补关系,各自在不同场景发挥作用。
MVCC 的优势场景: 读多写少的并发场景。多个读操作可以同时进行,互不阻塞,因为它们各自读取不同的快照版本。写操作也可以在后台进行,不阻塞读操作。
锁的必要场景: 需要保证”写-写”或”读-写”严格串行的场景。例如账户余额扣减,如果两个事务同时读取到相同的余额值然后各自扣减,就会出现丢失更新的问题。此时必须通过 SELECT ... FOR UPDATE 或 UPDATE 语句的当前读加排他锁来保证串行执行。
简言之,MVCC 优化了”读”的不阻塞,锁解决的是”写”和”读写冲突”的不阻塞。InnoDB 的最佳实践是:快照读用 MVCC,当前读用锁,二者协同工作。
面试题四:读已提交和可重复读在快照实现上的区别是什么?
参考答案:
两者最核心的区别在于 Read View 的创建频率。
在 读已提交(RC) 隔离级别下,Read View 在每一条 SELECT 语句执行时重新创建。这意味着每次查询都能看到已提交事务的最新修改,因此同一事务中两次相同的 SELECT 可能返回不同结果(不可重复读)。
在 可重复读(RR) 隔离级别下,Read View 在事务第一次读操作时创建,并在整个事务生命周期内复用。因此整个事务期间,所有快照读都基于同一个 Read View,保证了同一个事务中多次查询结果的一致性(可重复读)。
这个差异是由隔离级别的语义决定的:RC 追求”每次读取都反映数据库当前状态”,RR 追求”事务执行期间看到的数据视图不变”。
面试题五:事务隔离级别越高越好吗?为什么大多数系统使用读已提交?
参考答案:
事务隔离级别并非越高越好。隔离级别越高,数据一致性越强,但并发性能越差。具体选择取决于业务场景的权衡:
高隔离级别(RR、SERIALIZABLE)的代价:
- 快照读在整个事务期间不变,这可能导致事务持有 Read View 时间过长,Undo Log 版本链过长,增加清理负担。
- 串行化强制所有事务排队执行,高并发下系统吞吐量急剧下降。
- 间隙锁和 Next-Key Lock 的范围可能很大,容易造成锁争用和死锁。
为什么很多系统选择 RC:
- RC 提供了脏读保护(不会读到未提交数据),同时性能代价相对较小。
- 对于大多数业务场景,不可重复读是可接受的——同一事务中两次查询读到不同数据,反而符合业务预期(反映最新状态)。
- Oracle、PostgreSQL 等主流数据库默认使用 RC,经过了大量生产环境的验证。
- RC 下事务持有的锁时间更短,死锁概率更低,更适合短事务场景。
📚 延伸阅读:
- Spring 事务传播行为详解 — 七种传播行为的原理与选择策略
- Spring 事务失效场景与解决方案 — 十大失效场景逐一分析