Files
2026-05-24 11:42:38 +08:00

9.3 KiB
Raw Permalink Blame History

tags, create time
tags create time
MySQL
隔离级别
READ COMMITTED
REPEATABLE READ
SERIALIZABLE
2026-05-16 00:00

隔离级别与可见性

概述

SQL 标准定义了四种事务隔离级别,它们控制着并发事务之间的可见性程度。理解每个级别的语义和可能产生的问题,是正确设计高并发系统的前提。

四种隔离级别

flowchart LR
    RU["Read Uncommitted<br/>读未提交"] --> RC["Read Committed<br/>读已提交"]
    RC --> RR["Repeatable Read<br/>可重复读"]
    RR --> S["Serializable<br/>串行化"]
    
    RU -->|"无保护"| W1["脏读 / 不可重复读 / 幻影读"]
    RC -->|"防脏读"| W2["不可重复读 / 幻影读"]
    RR -->|"防不可重复读"| W3["防幻影读"]
    S -->|"完全串行"| W4["一切正常"]
    
    style RU fill:#EE5A24,color:#fff
    style RC fill:#FF9F43,color:#000
    style RR fill:#00D866,color:#fff
    style S fill:#00B6BC,color:#fff

速查表

隔离级别 脏读 不可重复读 幻影读 InnoDB 默认
Read Uncommitted ❌ 可能出现 ❌ 可能出现 ❌ 可能出现 ❌ 不用
Read Committed (RC) ✅ 防止 ❌ 可能出现 ❌ 可能出现 Java 项目常用
Repeatable Read (RR) ✅ 防止 ✅ 防止 ✅ 防止* MySQL 默认
Serializable ✅ 防止 ✅ 防止 ✅ 防止 极端场景

* RR 下 InnoDB 通过 Next-Key Lock 解决了大部分幻影读问题,但不是所有场景都能完全避免。

三大并发问题详解

1. 脏读(Dirty Read)

[!QUESTION] 最危险的问题:读到「还没来得及后悔」的数据 脏读的本质是事务 B 直接看到了事务 A 未提交的中间状态。一旦 A 回滚,B 拿到的数据就永远失去了意义。

-- 隔离级别: Read Uncommitted

BEGIN;  -- 事务 A
UPDATE accounts SET balance = 1000 WHERE user_id = 1;
-- 此时 balance=1000,但事务 A 尚未提交

-- 事务 B 在此刻读取
BEGIN;  -- 事务 B
SELECT balance FROM accounts WHERE user_id = 1;
-- 读到 balance=1000 ← 脏数据!

-- 事务 A 回滚
ROLLBACK;  -- balance 恢复到原来的 500

-- 事务 B 读到的 1000 永远消失了

2. 不可重复读(Non-Repeatable Read)

[!QUESTION] 同一个事务里,为什么前后读到的不一样? 不可重复读关注的是 UPDATE/DELETE 操作的影响:事务 A 在同一事务内两次读取同一行,结果被事务 B 的修改给「污染」了。

-- 隔离级别: Read Committed

BEGIN;  -- 事务 A
SELECT balance FROM accounts WHERE user_id = 1;  -- 读到 500

-- 事务 B 在此期间修改并提交
BEGIN;  -- 事务 B
UPDATE accounts SET balance = 1000 WHERE user_id = 1;
COMMIT;

-- 事务 A 再次读取(在同一个事务内)
SELECT balance FROM accounts WHERE user_id = 1;  
-- 读到 1000 ← 同一次事务里读到了不同值!

[!TIP] 不可重复读 ≠ 幻影读 不可重复读针对的是 同一行数据 被修改后读到的变化;幻影读针对的是 行数范围 的变化——多出了几行或少了几行。这是两个不同的维度。

3. 幻影读(Phantom Read)

[!QUESTION] 「幽灵行」从哪来? 幻影读发生在基于范围的查询中。事务 A 根据某个条件筛选数据,事务 B 恰好插入了符合该条件的新行,导致 A 再次查询时"无中生有"地多出了几行。

-- 隔离级别: Read Committed / Repeatable Read

BEGIN;  -- 事务 A
SELECT COUNT(*) FROM orders WHERE amount > 100;  
-- 结果: 10

-- 事务 B 插入新订单
BEGIN;  -- 事务 B
INSERT INTO orders (amount) VALUES (200);
COMMIT;

-- 事务 A 再次查询
SELECT COUNT(*) FROM orders WHERE amount > 100;
-- RC: 读到 11 ← 出现了「幻影行」
-- RR(InnoDB): 仍读到 10 ← 幻影像被挡住了

[!QUESTION] 为什么 InnoDB 能在 RR 下挡住幻影? RC 每次 SELECT 都创建新的 Read View,所以能看到后来提交的新行。而 RR 下第一个 SELECT 就创建了 Read View,整个事务期间都用这个视图——后来的 INSERT 在 Read View 中不可见。再加上 Next-Key Lock 锁住插入间隙,INSERT 也会被阻塞。详见 hhs/MySQL/06-事务与并发控制/25-MVCC 原理。

各隔离级别下的 Read View 行为

sequenceDiagram
    participant A as Transaction A
    participant RC_DB as RC 模式
    participant RR_DB as RR 模式
    participant B as Transaction B

    A->>RC_DB: BEGIN;
    A->>RC_DB: SELECT * FROM t
    Note over A,RC_DB: Read View #1
    A->>RC_DB: SELECT * FROM t
    Note over A,RC_DB: Read View #2 (new each time)

    B->>RC_DB: BEGIN;
    B->>RC_DB: INSERT INTO t VALUES (...)
    B->>RC_DB: COMMIT;

    A->>RC_DB: SELECT * FROM t
    Note over A,RC_DB: Read View #3
    Note over RC_DB: Can see new rows committed by B!

    A->>RR_DB: BEGIN;
    A->>RR_DB: SELECT * FROM t
    Note over A,RR_DB: Read View #1
    A->>RR_DB: SELECT * FROM t
    Note over A,RR_DB: Reuses Read View #1

    B->>RR_DB: BEGIN;
    B->>RR_DB: INSERT INTO t VALUES (...)
    B->>RR_DB: COMMIT;

    A->>RR_DB: SELECT * FROM t
    Note over RR_DB: Cannot see B's rows - RV#1 was created before B committed

实际生产中的选择

// Go 连接 MySQL 时,通过 DSN 参数指定隔离级别
dsn := "user:pass@tcp(localhost:3306)/db?charset=utf8mb4&parseTime=True&loc=Local&transaction_isolation=READ-COMMITTED"
db, err := sql.Open("mysql", dsn)
// transaction_isolation 设置的是该连接所有事务的默认隔离级别
// 也可以在执行 BeginTx() 时单独指定 txOptions := &sql.TxOptions{Isolation: sql.LevelReadCommitted}

RC vs RR 在 InnoDB 中的核心差异

维度 RC(读已提交) RR(可重复读)
Read View 创建时机 每次 SELECT 前都创建全新的 Read View 第一次 SELECT 时创建一次,整个事务复用
UNDO Log 读取路径 同一行可能存在多个版本,需要从最新往前追溯第一个符合当前 Read View 的版本 只需沿着 undo log 找到第一个符合事务启动时 Read View 的版本即可
锁定策略 普通的行级共享锁 / 排他锁 除了行锁外,还有 Next-Key Lock(Record Lock + Gap Lock)用于范围查询
并发性能 较高——锁范围小,等待时间短 较低——Gap Lock 会阻塞其他事务的 INSERT
一致性保障 只保证读到最近提交的值 保证整个事务看到一致的数据快照

[!TIP] 为什么 Java 项目偏爱 RC? JVM 本身就有多线程并发模型作为"天然隔离层"。Spring 等框架的 @Transactional 在大多数场景下只需要 RC 级别的保护,再加上应用层的悲观锁(SELECT FOR UPDATE)或乐观锁(version 字段),就足以覆盖大部分业务需求。RC 让 InnoDB 少做很多工作。

Next-Key Lock 如何挡幻影

InnoDB 在 RR 模式下对 范围查询 使用 Next-Key Lock(记录锁 + 间隙锁的组合):

-- 假设 orders.amount 上有索引

BEGIN;  -- 事务 A(RR 模式)
-- 查找 amount > 100 的记录
SELECT * FROM orders WHERE amount > 100 FOR UPDATE;
-- InnoDB 会对以下区间加 Next-Key Lock:
-- (-∞, 100] —— 锁住这个范围内的记录以及插入间隙
-- (100, +∞)   —— 同样加间隙锁,阻止符合条件的插入

这意味着在事务 A 的范围内,任何其他事务尝试插入 amount = 50 或 amount = 200 的行时,都会被阻塞直到事务 A 提交。这就是 RR 下幻影读被解决的核心机制。

Next-Key Lock 的例外情况

Next-Key Lock 并非万能,有几种场景 RR 仍无法阻止幻影读:

[!IMPORTANT] RC 模式下无论怎么加锁,幻影读都无法避免 RC 的语义就是「每次查询都能看到最新提交的行」,所以不存在 Gap Lock。如果你选了 RC 级别,InnoDB 连间隙锁都不加,任何 INSERT 都不会被挡住。

[!NOTE] 唯一索引(UNIQUE KEY)的特殊行为 当查询条件使用了唯一索引时,InnoDB 会退化到 Record Lock(只做 record lock,不做 gap lock)。因为唯一索引保证了不会有重复值,理论上不存在插入间隙的问题。但这只在等值查询且走唯一索引时成立。

推荐场景速查

场景 推荐隔离级别 理由
金融/支付 Serializable 或 RR + FOR UPDATE 强一致性优先
电商下单 RR 库存扣减、订单创建需要完整一致性
内容 CMS RC 允许最终一致,性能更好
高并发读 RC 减少 RR 下的锁竞争
Java/Spring 生态 RC Spring 默认就是 RC

隔离级别选择的注意事项

[!WARNING] MySQL 默认的 RR 不是免费的午餐 虽然 RR 提供了最强的隔离性,但也带来了更严格的锁定策略和更高的冲突概率。如果你用的是 Java/Ruby/Python 等语言,这些框架通常以 RC 级别与 MySQL 通信。混用 RC + RR 时需要特别注意事务语义是否与设计预期一致。

关联笔记