@Transactional 的隔离级别
定义与作用
isolation 是 @Transactional 的核心属性之一,用于定义当前事务与其他并发事务之间的隔离程度。数据库事务的并发执行可能引发三种典型的数据一致性问题:脏读(Dirty Read)、不可重复读(Non-repeatable Read)和幻读(Phantom Read)。隔离级别通过控制事务对数据的可见性,在数据一致性与并发性能之间做出权衡。
Spring 的 Isolation 枚举将 JDBC 标准隔离级别映射为声明式配置,最终由底层数据库实现。不同数据库的默认隔离级别不同:MySQL InnoDB 默认为 REPEATABLE_READ,Oracle 和 SQL Server 默认为 READ_COMMITTED。
适用位置与常用属性
isolation 与 propagation 并列配置在 @Transactional 注解中:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void transferMoney(Long from, Long to, BigDecimal amount) { ... }
五种隔离级别对比表
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 底层数据库默认 | 说明 |
|---|---|---|---|---|---|
| DEFAULT | - | - | - | 依数据库而定 | 使用底层数据源的默认隔离级别 |
| READ_UNCOMMITTED | ✅ 允许 | ✅ 允许 | ✅ 允许 | 无 | 最低隔离,性能最好,一致性最差 |
| READ_COMMITTED | ❌ 禁止 | ✅ 允许 | ✅ 允许 | Oracle / SQL Server | 读取已提交数据,防止脏读 |
| REPEATABLE_READ | ❌ 禁止 | ❌ 禁止 | ✅ 允许 | MySQL InnoDB | 同一事务多次读取结果一致 |
| SERIALIZABLE | ❌ 禁止 | ❌ 禁止 | ❌ 禁止 | 无 | 最高隔离,完全串行化,性能最差 |
Spring 的 DEFAULT 不等于数据库的默认级别:
Isolation.DEFAULT表示 Spring 不指定隔离级别,完全交由底层Connection的默认设置。在大多数连接池中,这等同于数据库的默认隔离级别。
核心原理:三种并发问题与隔离级别的防御机制
脏读、不可重复读、幻读的定义
隔离级别的实现机制
MySQL InnoDB 的具体实现:
- READ_UNCOMMITTED:直接读取最新版本,不加锁,不检查事务状态;
- READ_COMMITTED:每次 SELECT 生成新的
ReadView,只能看到已提交版本; - REPEATABLE_READ:事务首次 SELECT 时生成
ReadView,后续复用,保证快照一致性; - SERIALIZABLE:所有普通 SELECT 隐式转换为
SELECT ... LOCK IN SHARE MODE,加共享锁,与写操作互斥。
隔离级别影响对比图
MySQL 的特殊性:InnoDB 的
REPEATABLE_READ通过 Gap Lock(间隙锁) 和 Next-Key Lock 在一定程度上防止了幻读,因此 MySQL 的 RR 比标准 SQL 定义的 RR 更强。但在严格的 SQL 标准意义上,RR 仍允许幻读。
完整示例:飞翔科技转账系统的隔离级别验证
场景简述
飞翔科技公司的内部财务系统需要实现员工转账功能。大翔(CEO)要求白歌(架构师)验证不同隔离级别下的并发行为。小崔(后端开发)编写测试代码,模拟两个事务并发操作同一账户。
操作前:数据库表结构与初始数据
-- 员工账户表
CREATE TABLE employee_account (
emp_id INT PRIMARY KEY,
emp_name VARCHAR(50),
balance DECIMAL(10,2) NOT NULL
);
-- 初始数据
INSERT INTO employee_account (emp_id, emp_name, balance) VALUES
(1001, '大翔', 10000.00),
(1002, '白歌', 8000.00);
Service 层:不同隔离级别的配置
package com.feixiang.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class TransferService {
@Autowired
private JdbcTemplate jdbcTemplate;
// 转账方法:使用 READ_COMMITTED(防止脏读,允许不可重复读)
@Transactional(isolation = Isolation.READ_COMMITTED)
public void transfer(int fromId, int toId, double amount) {
// 1. 查询转出方余额
Double fromBalance = jdbcTemplate.queryForObject(
"SELECT balance FROM employee_account WHERE emp_id = ?",
Double.class, fromId
);
System.out.println("[转账前查询] 转出方余额:" + fromBalance);
if (fromBalance < amount) {
throw new IllegalStateException("余额不足");
}
// 2. 扣减转出方余额
jdbcTemplate.update(
"UPDATE employee_account SET balance = balance - ? WHERE emp_id = ?",
amount, fromId
);
// 3. 模拟耗时,期间其他事务可能修改数据
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 4. 再次查询转出方余额(验证不可重复读)
Double fromBalanceAgain = jdbcTemplate.queryForObject(
"SELECT balance FROM employee_account WHERE emp_id = ?",
Double.class, fromId
);
System.out.println("[转账后查询] 转出方余额:" + fromBalanceAgain);
// 5. 增加转入方余额
jdbcTemplate.update(
"UPDATE employee_account SET balance = balance + ? WHERE emp_id = ?",
amount, toId
);
System.out.println("[转账完成] " + fromId + " → " + toId + " 金额:" + amount);
}
// 查询方法:使用 REPEATABLE_READ(保证快照一致性)
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public void checkBalanceConsistency(int empId) {
// 第一次查询
Double balance1 = jdbcTemplate.queryForObject(
"SELECT balance FROM employee_account WHERE emp_id = ?",
Double.class, empId
);
System.out.println("[RR 第一次查询] 余额:" + balance1);
// 模拟等待其他事务修改
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 第二次查询
Double balance2 = jdbcTemplate.queryForObject(
"SELECT balance FROM employee_account WHERE emp_id = ?",
Double.class, empId
);
System.out.println("[RR 第二次查询] 余额:" + balance2);
if (balance1.equals(balance2)) {
System.out.println("[RR 验证通过] 两次读取结果一致");
} else {
System.out.println("[RR 验证失败] 两次读取结果不一致!");
}
}
// 统计方法:演示幻读(使用 READ_COMMITTED)
@Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true)
public int countHighBalanceEmployees(double threshold) {
// 第一次统计
int count1 = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM employee_account WHERE balance > ?",
Integer.class, threshold
);
System.out.println("[第一次统计] 余额 > " + threshold + " 的员工数:" + count1);
// 模拟等待
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 第二次统计
int count2 = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM employee_account WHERE balance > ?",
Integer.class, threshold
);
System.out.println("[第二次统计] 余额 > " + threshold + " 的员工数:" + count2);
return count2;
}
}
并发测试代码
package com.feixiang;
import com.feixiang.service.TransferService;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import java.util.concurrent.CountDownLatch;
public class IsolationTest {
public static void main(String[] args) throws InterruptedException {
AnnotationConfigApplicationContext ctx =
new AnnotationConfigApplicationContext("com.feixiang");
TransferService service = ctx.getBean(TransferService.class);
// 场景一:READ_COMMITTED 下的不可重复读验证
System.out.println("=== 场景一:READ_COMMITTED 不可重复读验证 ===");
CountDownLatch latch1 = new CountDownLatch(2);
// 事务 A:转账(READ_COMMITTED)
new Thread(() -> {
try {
service.transfer(1001, 1002, 2000.00);
} catch (Exception e) {
System.err.println("事务 A 异常:" + e.getMessage());
} finally {
latch1.countDown();
}
}, "Tx-A-Transfer").start();
// 事务 B:并发修改转出方余额
new Thread(() -> {
try {
Thread.sleep(50); // 让事务 A 先执行第一次查询
// 通过另一个 Service 方法模拟并发修改
service.simulateExternalDeduction(1001, 1000.00);
} catch (Exception e) {
System.err.println("事务 B 异常:" + e.getMessage());
} finally {
latch1.countDown();
}
}, "Tx-B-Deduction").start();
latch1.await();
// 场景二:REPEATABLE_READ 下的快照一致性验证
System.out.println("\n=== 场景二:REPEATABLE_READ 快照一致性验证 ===");
CountDownLatch latch2 = new CountDownLatch(2);
new Thread(() -> {
try {
service.checkBalanceConsistency(1001);
} catch (Exception e) {
System.err.println("事务 C 异常:" + e.getMessage());
} finally {
latch2.countDown();
}
}, "Tx-C-RR-Check").start();
new Thread(() -> {
try {
Thread.sleep(50);
service.simulateExternalDeduction(1001, 500.00);
} catch (Exception e) {
System.err.println("事务 D 异常:" + e.getMessage());
} finally {
latch2.countDown();
}
}, "Tx-D-Deduction").start();
latch2.await();
// 场景三:幻读验证
System.out.println("\n=== 场景三:READ_COMMITTED 幻读验证 ===");
CountDownLatch latch3 = new CountDownLatch(2);
new Thread(() -> {
try {
service.countHighBalanceEmployees(5000.00);
} catch (Exception e) {
System.err.println("事务 E 异常:" + e.getMessage());
} finally {
latch3.countDown();
}
}, "Tx-E-Count").start();
new Thread(() -> {
try {
Thread.sleep(50);
service.insertNewEmployee(1003, "小崔", 9000.00);
} catch (Exception e) {
System.err.println("事务 F 异常:" + e.getMessage());
} finally {
latch3.countDown();
}
}, "Tx-F-Insert").start();
latch3.await();
ctx.close();
}
}
辅助方法(用于模拟外部并发操作)
// 在 TransferService 中添加
@Transactional
public void simulateExternalDeduction(int empId, double amount) {
jdbcTemplate.update(
"UPDATE employee_account SET balance = balance - ? WHERE emp_id = ?",
amount, empId
);
System.out.println("[外部扣款] 员工" + empId + " 被扣款 " + amount);
}
@Transactional
public void insertNewEmployee(int empId, String name, double balance) {
jdbcTemplate.update(
"INSERT INTO employee_account (emp_id, emp_name, balance) VALUES (?, ?, ?)",
empId, name, balance
);
System.out.println("[插入新员工] " + name + ",余额:" + balance);
}
操作后:控制台输出与数据变化
场景一(READ_COMMITTED 不可重复读)典型输出:
=== 场景一:READ_COMMITTED 不可重复读验证 ===
[转账前查询] 转出方余额:10000.0
[外部扣款] 员工1001 被扣款 1000.0
[转账后查询] 转出方余额:7000.0
[转账完成] 1001 → 1002 金额:2000.0
关键观察:事务 A 第一次查询余额为 10000.0,在事务 A 执行期间,事务 B 扣款 1000.0 并提交。事务 A 第二次查询余额变为 7000.0(因为转账扣了 2000,外部扣款扣了 1000)。两次查询结果不一致,不可重复读现象发生。
场景二(REPEATABLE_READ 快照一致性)典型输出:
=== 场景二:REPEATABLE_READ 快照一致性验证 ===
[RR 第一次查询] 余额:7000.0
[外部扣款] 员工1001 被扣款 500.0
[RR 第二次查询] 余额:7000.0
[RR 验证通过] 两次读取结果一致
关键观察:事务 C 在 REPEATABLE_READ 下,两次查询结果均为 7000.0。尽管事务 D 在此期间修改了数据并提交,但事务 C 的 ReadView 在第一次查询时生成并复用,保证了快照一致性。
场景三(READ_COMMITTED 幻读)典型输出:
=== 场景三:READ_COMMITTED 幻读验证 ===
[第一次统计] 余额 > 5000.0 的员工数:2
[插入新员工] 小崔,余额:9000.0
[第二次统计] 余额 > 5000.0 的员工数:3
关键观察:事务 E 第一次统计结果为 2 人,事务 F 插入新员工(余额 9000)并提交后,事务 E 第二次统计结果为 3 人。同一事务内两次统计结果不同,幻读现象发生。
最终数据库状态:
| emp_id | emp_name | balance |
|---|---|---|
| 1001 | 大翔 | 6500.00 |
| 1002 | 白歌 | 10000.00 |
| 1003 | 小崔 | 9000.00 |
易错场景与面试考点
反例一:在 MySQL 下使用 READ_UNCOMMITTED 读取未提交数据
@Service
public class RiskyService {
@Autowired
private JdbcTemplate jdbcTemplate;
// 错误:为了性能使用 READ_UNCOMMITTED
@Transactional(isolation = Isolation.READ_UNCOMMITTED, readOnly = true)
public double queryBalance(int empId) {
return jdbcTemplate.queryForObject(
"SELECT balance FROM employee_account WHERE emp_id = ?",
Double.class, empId
);
}
}
问题分析:READ_UNCOMMITTED 允许读取其他事务未提交的数据(脏读)。在转账场景中,如果事务 A 修改了余额但未提交,事务 B 读取到修改后的值并基于此做决策(如发放优惠券),随后事务 A 回滚,事务 B 的决策就是基于错误数据的。除非在极端性能敏感且允许数据短暂不一致的场景(如实时统计近似值),否则严禁在业务逻辑中使用 READ_UNCOMMITTED。
反例二:误用 SERIALIZABLE 导致系统吞吐量暴跌
@Service
public class ReportService {
@Autowired
private JdbcTemplate jdbcTemplate;
// 错误:报表查询使用 SERIALIZABLE
@Transactional(isolation = Isolation.SERIALIZABLE, readOnly = true)
public List<Map<String, Object>> generateDailyReport() {
// 复杂的聚合查询,扫描大量数据
return jdbcTemplate.queryForList(
"SELECT * FROM employee_account WHERE ..."
);
}
}
问题分析:SERIALIZABLE 通过加共享锁实现完全串行化,报表查询扫描大量数据时会锁定整个表或大量行,导致所有写操作(转账、扣款)阻塞。在并发较高的生产环境中,这会导致死锁和超时风暴。报表类只读查询应使用 READ_COMMITTED 或 REPEATABLE_READ,并通过快照读(MySQL 的 MVCC)避免加锁。
反例三:依赖 Spring 隔离级别解决应用层并发问题
@Service
public class InventoryService {
@Autowired
private JdbcTemplate jdbcTemplate;
// 错误:认为 REPEATABLE_READ 可以防止超卖
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void deductStock(int productId, int quantity) {
Integer stock = jdbcTemplate.queryForObject(
"SELECT stock FROM inventory WHERE product_id = ?",
Integer.class, productId
);
if (stock >= quantity) {
jdbcTemplate.update(
"UPDATE inventory SET stock = stock - ? WHERE product_id = ?",
quantity, productId
);
}
}
}
问题分析:REPEATABLE_READ 保证的是同一事务内多次读取同一行的结果一致,但上述代码是"先读后写",两次操作之间没有原子性保护。两个事务并发执行时,可能都读到 stock = 10,然后各自扣减 1,最终 stock = 8 而非预期的 9。防止超卖需要乐观锁(版本号)或悲观锁(SELECT FOR UPDATE),而非仅靠隔离级别。
正确写法:
@Transactional
public void deductStock(int productId, int quantity) {
// 使用乐观锁:UPDATE 时检查版本号
int updated = jdbcTemplate.update(
"UPDATE inventory SET stock = stock - ?, version = version + 1 " +
"WHERE product_id = ? AND stock >= ? AND version = ?",
quantity, productId, quantity, expectedVersion
);
if (updated == 0) {
throw new ConcurrentModificationException("库存不足或数据已被修改");
}
}
面试高频考点
Q1:脏读、不可重复读、幻读的区别是什么?
脏读:读取了其他事务未提交的数据,随后该事务回滚,导致读取到的数据无效。不可重复读:同一事务内两次读取同一行数据,结果不同(被其他事务修改并提交)。幻读:同一事务内两次执行相同条件的范围查询,结果集的行数不同(被其他事务插入或删除符合条件的行)。
Q2:MySQL 的 REPEATABLE_READ 为什么能一定程度上防止幻读?
标准 SQL 的
REPEATABLE_READ只保证同一行数据多次读取一致,不保证范围查询结果集不变。但 MySQL InnoDB 在REPEATABLE_READ下使用 Gap Lock(间隙锁) 和 Next-Key Lock,不仅锁定查询到的行,还锁定行之间的间隙,阻止其他事务在间隙中插入新行,从而在一定程度上防止了幻读。但这不是 SQL 标准的要求,其他数据库(如 PostgreSQL)的 RR 可能表现不同。
Q3:Spring 的 @Transactional(isolation = ...) 一定会生效吗?
不一定。Spring 只是将隔离级别设置传递给底层 JDBC
Connection,最终由数据库实现。如果数据库不支持该隔离级别(如某些嵌入式数据库),或当前事务已存在且传播行为为REQUIRED(加入现有事务),则新设置的隔离级别不会生效——因为加入现有事务意味着共享同一个Connection和事务上下文,隔离级别在事务开始时已经确定。
Q4:生产环境中如何选择隔离级别?
绝大多数 OLTP 业务使用
READ_COMMITTED(Oracle/SQL Server 默认)或REPEATABLE_READ(MySQL 默认)。READ_UNCOMMITTED仅用于对一致性要求极低的统计场景。SERIALIZABLE仅在极端一致性场景(如金融对账)中使用,且通常通过业务锁替代。选择原则是:在满足业务一致性的前提下,使用最低的隔离级别以获得最佳并发性能。