--- tags: [MySQL, 隔离级别, READ COMMITTED, REPEATABLE READ, SERIALIZABLE] create time: 2026-05-16 00:00 --- # 隔离级别与可见性 ## 概述 SQL 标准定义了四种事务隔离级别,它们控制着并发事务之间的可见性程度。理解每个级别的语义和可能产生的问题,是正确设计高并发系统的前提。 ## 四种隔离级别 ```mermaid flowchart LR RU["Read Uncommitted
读未提交"] --> RC["Read Committed
读已提交"] RC --> RR["Repeatable Read
可重复读"] RR --> S["Serializable
串行化"] 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 拿到的数据就永远失去了意义。 ```sql -- 隔离级别: 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 的修改给「污染」了。 ```sql -- 隔离级别: 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 再次查询时"无中生有"地多出了几行。 ```sql -- 隔离级别: 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/25-MVCC 原理]]。 ## 各隔离级别下的 Read View 行为 ```mermaid 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; -- Read View #1 A->>RC_DB: SELECT * FROM t; -- 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; -- Read View #3 Note over RC_DB: Can see new rows committed by B! A->>RR_DB: BEGIN; A->>RR_DB: SELECT * FROM t; -- Read View #1 A->>RR_DB: SELECT * FROM t; -- 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 // 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(记录锁 + 间隙锁的组合): ```sql -- 假设 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 时需要特别注意事务语义是否与设计预期一致。 ## 关联笔记 - [[hhs/MySQL/23-ACID 与原子性实现]] — ACID 的基础概念和 Redo/Undo Log - [[hhs/MySQL/25-MVCC 原理]] — Read View 的详细构造算法 - [[hhs/MySQL/26-锁机制总览]] — 隔离级别与锁类型的对应关系