事务隔离级别所要解决的问题(脏读、不可重复读、幻读),本质上都发生在多个并发事务同时(或交错)访问 *相同数据范围*(如同一张表、同一行、或满足相同查询条件的记录集)时。

一、四种标准隔离级别概览

隔离级别 脏读 不可重复读 幻读 并发性能 默认数据库
READ UNCOMMITTED ✅ 允许 ✅ 允许 ✅ 允许 ⭐⭐⭐⭐⭐ 几乎无
READ COMMITTED ❌ 禁止 ✅ 允许 ✅ 允许 ⭐⭐⭐⭐ Oracle, PostgreSQL, SQL Server
REPEATABLE READ ❌ 禁止 ❌ 禁止 ⚠️ 标准允许,MySQL InnoDB 实际禁止 ⭐⭐⭐ MySQL
SERIALIZABLE ❌ 禁止 ❌ 禁止 ❌ 禁止 少数关键系统

💡 脏读:读到未提交的数据
不可重复读:同一事务内多次读同一行,结果不同(被 UPDATE)
幻读:同一事务内多次执行相同范围查询,结果集行数不同(被 INSERT/DELETE)


二、各隔离级别详解 + 示例


1. READ UNCOMMITTED(读未提交)

✅ 特点

  • 可读取其他事务 未提交 的数据(脏读)
  • 不加读锁,性能最高,一致性最差

⚠️ 风险场景

1// 会话 A
2@Transactional(isolation = Isolation.READ_UNCOMMITTED)
3public void read() {
4    Account a = repo.findById(1L); // 读到 balance = 999
5    // 但会话 B 还没 COMMIT!甚至可能 ROLLBACK!
6}
1-- 会话 B
2START TRANSACTION;
3UPDATE accounts SET balance = 999 WHERE id = 1;
4-- 未 COMMIT → 会话 A 已读到 999(脏数据!)
5ROLLBACK; -- 真实余额仍是 100

📌 适用场景

  • 日志分析、监控指标等容忍错误的只读场景
  • 绝不用于金融、订单、账户等业务!

2. READ COMMITTED(读已提交)✅ 最常用

✅ 特点

  • 只能读已提交数据(防脏读)
  • 每次 SELECT 都读最新已提交值 → 同一事务内多次读可能不同(不可重复读)
  • 如果其他事务正在修改但 尚未提交,读操作会 跳过未提交的更改,返回上一个已提交的值 (对比读未提交: 可能直接返回其他事务尚未提交的修改值*(即“脏数据”),而不是上一个已提交的值。 )

🧪 示例(不可重复读)

1@Transactional(isolation = Isolation.READ_COMMITTED)
2public void check() {
3    Account a1 = repo.findById(1L); // balance = 100
4
5    // 此时会话 B: UPDATE balance = 200; COMMIT;
6
7    Account a2 = repo.findById(1L); // balance = 200 ❗️变了
8}

⚠️ 风险

  • “读-改-写”模式危险:

    1if (account.getBalance() >= 100) {
    2    account.setBalance(account.getBalance() - 100); // 基于旧快照,可能透支!
    3    repo.save(account);
    4}

    ✅ 安全做法

1-- 原子更新(推荐!)
2UPDATE accounts 
3SET balance = balance - 100 
4WHERE id = 1 AND balance >= 100;
5-- 检查 affected rows == 1

📌 适用场景

  • 大多数 Web 应用、API 服务、电商下单等

3. REPEATABLE READ(可重复读)✅ MySQL 默认

✅ 特点(MySQL InnoDB)

  • 普通 SELECT 基于事务开始时的 MVCC 快照
  • 同一事务内多次读同一行,结果一致(防不可重复读)
  • 通过 Next-Key Lock 防止幻读(比 SQL 标准更强)

🧪 示例(快照读一致)

1@Transactional(isolation = Isolation.REPEATABLE_READ)
2public void observe() {
3    Account a1 = repo.findById(1L); // 100
4
5    // 会话 B: UPDATE balance = 200; COMMIT;
7    Account a2 = repo.findById(1L); // 仍为 100 ✅
     // “当前读”
2    SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 返回 200// 或在本会话内 update balance = 200
     Account a2 = repo.findById(1L); //为 200 ✅
8}

⚠️ 注意:当前读(Current Read)会看到新值!

以下操作属于 当前读,会读取最新已提交的数据,并加锁:

  • SELECT ... FOR UPDATE
  • SELECT ... LOCK IN SHARE MODE
  • UPDATE
  • DELETE
  • INSERT(间接影响)

📌 适用场景

  • 报表生成、账务对账、金融核对等需读一致性的场景

4. SERIALIZABLE(串行化)

✅ 特点

  • 完全消除并发异常,效果等价于串行执行
  • 代价:性能极低,易死锁

🔒 实现方式(因数据库而异)

数据库 实现方式
MySQL InnoDB 所有 SELECT 自动转为 SELECT ... LOCK IN SHARE MODE(加共享锁)
PostgreSQL 使用 SSI(Serializable Snapshot Isolation),不加读锁,提交时检测冲突

🧪 MySQL 示例(自动加锁)

1 @Transactional(isolation = Isolation.SERIALIZABLE)
2 public void read() {
3    repo.findById(1L); // 实际执行:SELECT ... LOCK IN SHARE MODE
4}

→ 其他事务无法同时 UPDATE id=1,会被阻塞。

📌 适用场景

  • 银行核心账务、法律审计等绝对一致性要求 + 低并发场景

三、关键补充:锁的粒度问题

accountRepo.findById(1L) 会锁表吗?

场景 是否加锁? 锁什么?
普通 SELECT(默认) ❌ 不加锁
FOR UPDATE / LOCK IN SHARE MODE ✅ 加锁 id=1 这一行(前提是 id 是索引/主键)
SERIALIZABLE(MySQL) ✅ 自动加共享锁 id=1 这一行
WHERE 条件无索引 + FOR UPDATE ⚠️ 可能锁多行或整表 因全表扫描

结论:只要 id 是主键(InnoDB 聚簇索引),只会锁一行,不会锁表


四、最佳实践总结

场景 推荐隔离级别 建议
普通 Web API、CRUD READ COMMITTED ✅ 最平衡
金融对账、报表 REPEATABLE READ ✅ 保证读一致性
极高一致性 + 低并发 SERIALIZABLE ⚠️ 谨慎使用
监控/日志(容忍错误) READ UNCOMMITTED 🚫 避免用于业务逻辑
防并发修改 显式加锁 or 原子 SQL UPDATE ... WHERE version = ?FOR UPDATE

Spring Boot 设置方式

1@Service
2public class AccountService {
3
4    // 读已提交(推荐大多数场景)
5    @Transactional(isolation = Isolation.READ_COMMITTED)
6    public void transfer() { /* ... */ }
7
8    // 可重复读(MySQL 默认,适合对账)
9    @Transactional(isolation = Isolation.REPEATABLE_READ)
10    public void generateReport() { /* ... */ }
11
12    // 串行化(慎用!)
13    @Transactional(isolation = Isolation.SERIALIZABLE)
14    public void criticalAdjust() { /* ... */ }
15}