MySQL MVCC学习和总结

46 阅读24分钟

学习视频:

www.bilibili.com/video/BV1t5…

MySQL的事务隔离级别

MySQL 的事务隔离级别定义了事务在并发执行时,数据可见性和可能遇到的并发问题的规则。理解这些级别及其相关问题对于设计高并发、数据一致性要求高的系统至关重要。


一、MySQL 支持的四种事务隔离级别

按隔离强度从低到高排序:

  1. READ UNCOMMITTED (读未提交)

    1. 核心规则:事务可以读取其他事务未提交的修改(“脏读”)。
    2. 问题:脏读、不可重复读、幻读。
    3. 场景:几乎不使用,数据一致性风险极高。
  2. READ COMMITTED (读已提交)

    1. 核心规则:事务只能读取其他事务已提交的数据。
    2. 问题:不可重复读、幻读。
    3. 场景:Oracle 默认级别,适合多数 OLTP 系统(需业务容忍不可重复读)。
  3. REPEATABLE READ (可重复读)

    1. 核心规则:事务内多次读取同一数据时,结果始终一致(基于事务启动时的快照)。
    2. 问题:可能发生幻读(但 InnoDB 通过 MVCC + 间隙锁 解决了幻读)。
    3. 场景:MySQL InnoDB 的默认级别,适合需要强一致读的场景(如账户余额查询)。
  4. SERIALIZABLE (可串行化)

    1. 核心规则:所有事务串行执行(通过锁强制隔离)。
    2. 问题:性能极低(高锁竞争)。
    3. 场景:严格要求数据一致性的金融交易(如股票结算)。

二、不同隔离级别下的并发问题

问题现象描述发生隔离级别
脏读 (Dirty Read)事务 A 读取了事务 B 未提交的修改,若 B 回滚,A 读到的数据无效。READ UNCOMMITTED
不可重复读 (Non-Repeatable Read)事务 A 多次读取同一数据,期间事务 B 提交了更新,导致 A 两次读取结果不一致。READ UNCOMMITTED, READ COMMITTED
幻读 (Phantom Read)事务 A 按条件查询数据,期间事务 B 提交了新增/删除,导致 A 再次查询结果集变化。READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ(未完全解决)

⚠️ 注意:

  • InnoDB 在 REPEATABLE READ 下通过 MVCC + 间隙锁 (Gap Lock) 避免了幻读。
  • 但若事务内先快照读后写操作,仍可能因未加锁导致数据覆盖(本质是写冲突,非幻读)。

三、MySQL 如何实现隔离级别?

核心机制:MVCC(多版本并发控制) + 锁
  1. MVCC(快照读)

    1. 为每个事务生成 Read View,基于版本链读取历史快照。
    2. RR 级别:事务内第一条 SELECT 生成 Read View,后续复用。
    3. RC 级别:每次 SELECT 生成新 Read View。
  2. 锁机制(当前读)

    1. 记录锁 (Record Lock):锁定单行记录。
    2. 间隙锁 (Gap Lock):锁定索引记录间的间隙(防止其他事务插入新行 → 解决幻读)。
    3. 临键锁 (Next-Key Lock):记录锁 + 间隙锁(InnoDB 默认行锁)。
    4. 显式锁:SELECT ... FOR UPDATE(排他锁),SELECT ... LOCK IN SHARE MODE(共享锁)。

四、不同隔离级别的行为对比(InnoDB 引擎)

操作READ UNCOMMITTEDREAD COMMITTEDREPEATABLE READ (默认)SERIALIZABLE
普通 SELECT可读未提交数据(脏读)读最新已提交数据读事务开始时的快照加共享锁,读最新数据
SELECT ... FOR UPDATE读最新数据 + 排他锁读最新数据 + 排他锁读最新数据 + 排他锁读最新数据 + 排他锁
UPDATE/DELETE基于最新数据 + 排他锁基于最新数据 + 排他锁基于最新数据 + 排他锁 + 间隙锁基于最新数据 + 排他锁
是否解决脏读
是否解决不可重复读
是否解决幻读✅(InnoDB 特有)

五、经典问题案例与解决方案

场景:账户余额更新(RR 级别下)
sql复制
-- 事务 ABEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 快照读:返回 1000-- 事务 B 此时执行:UPDATE accounts SET balance = 800 WHERE id = 1; COMMIT;UPDATE accounts SET balance = 1000 + 100 WHERE id = 1; -- 覆盖为 1100!COMMIT;

问题:事务 B 的更新被覆盖(丢失更新)。

原因:事务 A 的写操作基于快照读的旧值,但更新了最新物理数据。

解决方案:

sql复制
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 当前读 + 加锁
场景:范围查询导致幻读(RC 级别下)
sql复制
-- 事务 ABEGIN;
SELECT * FROM orders WHERE amount > 1000; -- 返回 2 条记录-- 事务 B 插入:INSERT INTO orders (amount) VALUES (1500); COMMIT;SELECT * FROM orders WHERE amount > 1000; -- 返回 3 条记录(幻读)

解决方案:

  • 升级到 RR 隔离级别(InnoDB 通过间隙锁阻止插入)。
  • 显式加锁:SELECT ... FOR UPDATE

六、如何选择隔离级别?

场景推荐隔离级别原因
高并发读 + 容忍不可重复读READ COMMITTED避免长事务锁竞争,提升并发性能
金融交易、账户余额操作REPEATABLE READ + 显式锁保证可重复读,显式锁避免丢失更新
数据仓库分析报表READ COMMITTED每次查询看到最新提交数据
强一致性需求(如库存扣减)REPEATABLE READ + 乐观锁避免超卖,兼顾性能

七、关键命令

sql复制
-- 查看当前隔离级别SELECT @@transaction_isolation; 

-- 设置会话级隔离级别SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 设置全局级隔离级别(需重启生效)SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

总结

隔离级别脏读不可重复读幻读InnoDB 实现要点
READ UNCOMMITTED无优化,直接读最新物理数据
READ COMMITTED每次快照读生成新 Read View
REPEATABLE READ❌(实际解决)MVCC + 间隙锁防止幻读
SERIALIZABLE所有操作加锁,串行执行

最佳实践:

  1. 默认使用 REPEATABLE READ(MySQL 优化最佳)。

  2. 写操作依赖读时,必须加锁(SELECT ... FOR UPDATE)。

  3. 高频读写场景可降级到 READ COMMITTED,但需业务容忍不可重复读。

  4. 避免长事务,减少锁竞争和快照存储开销。

UndoLog

快照读和当前读

在数据库的 MVCC(多版本并发控制) 机制中,当前读(Current Read) 和 快照读(Snapshot Read) 是两种核心的数据读取方式,它们的区别直接关系到数据一致性、并发性能和事务隔离级别。以下是详细对比:


  1. 核心区别总结
特性快照读 (Snapshot Read)当前读 (Current Read)
读取的数据版本历史版本(事务开始时的数据快照)最新已提交版本(可能被其他事务修改过)
是否加锁不加锁(无阻塞)加锁(共享锁或排他锁,可能阻塞)
数据一致性基于事务开始时的数据视图(隔离级别决定)基于数据库最新状态(强一致性)
触发场景普通 SELECT 语句SELECT ... FOR UPDATE、UPDATE、DELETE 等
隔离级别支持所有支持 MVCC 的隔离级别(RC、RR 等)所有隔离级别(本质是绕过 MVCC 的锁机制)

  1. 工作原理详解
(1) 快照读 (Snapshot Read)
  • 核心逻辑:读取事务启动时(或语句执行时)已提交的数据版本,无视其他并发事务的修改。

  • 实现机制:

    • 事务首次读操作时生成 Read View(记录活跃事务 ID、最小事务 ID 等)。
    • 根据 Read View 的可见性规则,从版本链中查找符合条件的历史版本。
  • 示例:

sql复制
-- 事务 A (RR 隔离级别)BEGIN;
SELECT * FROM users; -- 生成 Read View,读取快照数据-- 事务 B 在此处更新并提交数据SELECT * FROM users; -- 仍读取同一快照(无视事务 B 的提交)COMMIT;
  • 典型场景:报表查询、只读业务逻辑(不修改数据)。
(2) 当前读 (Current Read)
  • 核心逻辑:读取数据库当前最新已提交版本,并对数据加锁防止其他事务修改。

  • 实现机制:

    • 直接访问数据行的最新物理记录(跳过 MVCC 版本链)。

    • 根据操作类型加锁:

      • SELECT ... FOR UPDATE → 加 排他锁 (X Lock)。
      • SELECT ... FOR SHARE → 加 共享锁 (S Lock)。
      • UPDATE/DELETE → 先加 X 锁再修改。
  • 示例:

sql复制
-- 事务 ABEGIN;
SELECT * FROM users WHERE id=1 FOR UPDATE; -- 加 X 锁,读取最新数据-- 其他事务尝试修改此行会被阻塞UPDATE users SET name='Bob' WHERE id=1;    -- 基于最新数据修改COMMIT;
  • 典型场景:余额更新、库存扣减(需确保操作基于最新数据)。

  1. 关键差异场景分析
场景:并发更新同一行数据
  • 表结构:
sql复制
CREATE TABLE accounts (
  id INT PRIMARY KEY,
  balance DECIMAL(10, 2)
);
INSERT INTO accounts VALUES (1, 1000);
  • 事务 A (RR 隔离级别):
sql复制
BEGIN;
-- 快照读:读取 balance=1000 (历史版本)SELECT balance FROM accounts WHERE id=1; 
-- 基于快照读结果计算:新余额 = 1000 + 100 = 1100
  • 事务 B (并发提交):
sql复制
BEGIN;
UPDATE accounts SET balance = balance - 200 WHERE id=1; -- 修改为 800COMMIT; -- 在事务 A 的 UPDATE 前提交
  • 事务 A 继续:
sql复制
-- 当前读:读取最新 balance=800UPDATE accounts SET balance = 1100 WHERE id=1; 
COMMIT;

结果:

  • 若使用 快照读 → 事务 A 的 SELECT 看到旧值 1000,但 UPDATE 基于最新值 800 覆盖为 1100 → 事务 B 的更新丢失!
  • 若使用 当前读(SELECT ... FOR UPDATE)→ 事务 A 在首次读时加锁,事务 B 被阻塞 → 避免丢失更新。

  1. 两种读取方式在 SQL 中的体现
SQL 语句读取类型说明
SELECT * FROM table快照读默认行为(RR 级别下复用同一快照)
SELECT ... FOR UPDATE当前读加排他锁,读取最新数据
SELECT ... FOR SHARE当前读加共享锁,读取最新数据
UPDATE ...当前读更新前先执行当前读获取最新值
DELETE ...当前读删除前先执行当前读获取最新值
INSERT ...不涉及读插入新数据无需读旧版本

  1. 如何选择?
  • 用快照读:

    • 业务逻辑只读(如数据展示、分析)。
    • 接受非最新数据(如历史报表)。
    • 追求高并发查询性能(无锁)。
  • 用当前读:

    • 写操作依赖读的结果(如账户扣款)。
    • 要求强一致性(如库存校验)。
    • 需要显式锁定数据防止并发修改。

⚠️ 重要警告:在可重复读(RR)隔离级别下,若写操作依赖于前面的快照读,必须使用 SELECT ... FOR UPDATE 切换为当前读,否则会导致数据覆盖(如上述余额更新场景)。


总结

维度快照读当前读
数据版本历史快照最新已提交版本
锁机制无锁加锁(S/X Lock)
一致性视图事务开始时的一致性视图数据库最新状态
性能高(无锁不阻塞)低(加锁可能阻塞)
应用场景只读操作、非实时分析读写依赖、强一致性操作(如金融交易)
典型语句普通 SELECTSELECT ... FOR UPDATE、UPDATE、DELETE

简单记忆:

  • 快照读 = “时光机读历史书”(无锁,读旧数据)
  • 当前读 = “抢到最新报纸并锁住报亭”(加锁,读新数据)

Read View 生成时机

在数据库的 MVCC(多版本并发控制) 语境中,“视图”通常指的是 Read View(读视图)。它的生成规则取决于数据库的实现和事务的隔离级别,并非所有事务在启动时都会立即生成 Read View。以下是关键细节:


1. Read View 是什么?

  • 它是事务在 执行读操作时 创建的一个快照,记录了当前时刻数据库的可见性信息:

    • 当前活跃事务列表(未提交的事务 ID)
    • 最小活跃事务 ID(min_active_tx_id
    • 下一个待分配事务 ID(next_tx_id
    • 创建该 Read View 的事务自身 ID。
  • 作用:根据 MVCC 的可见性规则,判断数据行的哪个版本对当前事务可见。

如何判断哪一个版本是可用的?

最后一个根据隔离事务级别判断:

  • 读已提交生效:读维度的,每select一次生成一个视图;
  • 可重复读不生效:视图生成为事务维度;

2. Read View 的生成时机

✅ 情况 1:事务第一次执行读操作时生成(常见)
  • 典型场景: 在 REPEATABLE READ(可重复读) 隔离级别下(如 MySQL InnoDB、PostgreSQL)。

  • 行为:

    • 事务启动时(BEGIN)不会立即生成 Read View。
    • 当事务执行 第一条 SELECT 语句 时,才创建 Read View。
    • 此后,该事务的所有读操作都复用同一个 Read View,确保看到一致的快照(避免不可重复读和幻读)。
  • 示例:

sql复制
BEGIN;                      -- 事务启动,未生成 Read ViewSELECT * FROM users;        -- 生成 Read View(快照1)UPDATE users SET ...;       -- 写操作不依赖 Read ViewSELECT * FROM users;        -- 复用之前的 Read View(快照1)COMMIT;
✅ 情况 2:每次读操作都生成新 Read View
  • 典型场景: 在 READ COMMITTED(读已提交) 隔离级别下。

  • 行为:

    • 每次执行 SELECT 语句时,都会重新生成一个新的 Read View。
    • 因此每次读操作都能看到最新已提交的数据(可能读到其他事务新提交的更改)。
  • 示例:

sql复制
BEGIN;
SELECT * FROM users;        -- 生成 Read View(看到已提交数据快照1)-- 其他事务提交了更新SELECT * FROM users;        -- 生成新的 Read View(看到已提交数据快照2)COMMIT;
❌ 情况 3:不生成 Read View
  • 写操作(如 UPDATE/DELETE): 直接操作最新数据版本,依赖行锁解决写冲突,不依赖 Read View。
  • SERIALIZABLE(可串行化)隔离级别: 可能退化为锁机制而非 MVCC(如 InnoDB 中使用 Next-Key Locks)。

3. 不同数据库的差异

数据库隔离级别Read View 生成规则
MySQL InnoDBREPEATABLE READ第一条 SELECT 时生成,后续复用同一视图
READ COMMITTED每次 SELECT 时生成新视图
PostgreSQLREPEATABLE READ第一条 SELECT 时生成(事务快照)
READ COMMITTED每条语句执行时生成新快照

4. 为什么这样设计?

  • 性能优化: 避免每个事务启动时都创建视图(尤其对只写事务无用)。

  • 隔离级别需求:

    • 可重复读 (RR): 需要固定视图保证一致性。
    • 读已提交 (RC): 需要动态视图看到最新提交。

总结

  • Read View 不是事务启动时生成,而是事务第一次读操作时生成(RR 级别)或每次读操作时生成(RC 级别)。
  • 写操作(UPDATE/DELETE)不依赖 Read View,而是直接操作最新数据并创建新版本。
  • 设计目的是平衡 数据一致性、隔离性 和 并发性能。

💡 关键点:MVCC 的“视图”是动态的,其生成由隔离级别和操作类型共同决定,而非简单与事务绑定。

疑问

1、在可重复读级别下,事务内现有一个读操作,然后有一个写操作,写操作依赖于前面的读,但是因为读是快照读,可能读不到已经提交的数据,是否会导致逻辑问题

MVCC 在可重复读(RR)隔离级别下最核心的陷阱:读操作看到的是快照,但写操作基于最新数据。如果写操作依赖于快照读的结果,而该结果可能未反映最新已提交的数据,确实会导致严重的逻辑错误和数据不一致。

问题分析(以 MySQL InnoDB 的 RR 级别为例)

  1. 事务启动:事务 A 开始 (BEGIN)。

  2. 快照读 (SELECT):

    1. 事务 A 执行第一个 SELECT 语句。
    2. 此时生成 Read View(快照)。
    3. SELECT 基于此快照读取数据,只能看到在它开始之前已提交的数据版本。即使事务 B 在事务 A 开始后提交了更新,事务 A 的这个 SELECT 也看不到。
  3. 依赖于读的写操作 (UPDATE/DELETE):

    1. 事务 A 基于上面 SELECT 的结果(快照数据)进行业务逻辑计算,然后执行 UPDATEDELETE

    2. 关键点来了:写操作 (UPDATE, DELETE, INSERT ... SELECT) 不使用之前 SELECT 的 Read View!

    3. 写操作必须读取数据库中的最新版本数据(包括其他事务已提交的最新数据)来执行修改。

    4. 写操作在修改前会尝试获取目标行的排他锁 (X Lock)。如果锁被其他事务持有,则阻塞等待。

    5. 写操作检查数据的最新版本是否被修改:

      • 当 InnoDB 执行 UPDATEDELETE 时,它首先会找到要修改行的最新物理版本。

      • 它会检查这个最新版本的数据:

        • 该版本是否对当前事务可见(根据事务 ID 和 Read View 规则)?
        • 该版本是否已被其他事务修改(即,该版本的事务 ID 是否大于当前事务的 ID,或者该版本已被标记为删除且删除事务已提交)?
  4. 潜在冲突与错误:

    1. 场景 1:数据未被其他事务修改

      • 如果最新版本对当前事务可见(即,它还是事务 A 快照里看到的那个版本),那么写操作基于这个最新版本进行修改(创建新版本)。
      • 结果: 逻辑正确(侥幸),但依赖于没有并发修改。
    2. 场景 2:数据已被其他事务修改(丢失更新 / 覆盖问题)

      • 假设事务 B 在事务 A 的 SELECT 之后、UPDATE 之前提交了对同一行数据的修改。

      • 事务 A 的 UPDATE 读取到的是事务 B 提交后的最新版本。

      • InnoDB 发现这个最新版本的事务 ID 大于事务 A 的 ID(或已被事务 B 删除),意味着该行在事务 A 的快照读之后已被修改。

      • 此时,事务 A 的 UPDATE 会基于这个它“看不见”(在快照读里)但物理上已存在的最新数据进行修改!

      • 后果:

        • 丢失更新 (Lost Update): 事务 B 的修改被事务 A 基于旧快照数据计算出的新值直接覆盖了。事务 B 的更新效果丢失。
        • 逻辑错误: 事务 A 的写操作基于的是过时的快照信息(SELECT 的结果),但作用的对象却是最新的、可能已被改变的数据。这会导致业务逻辑完全错误。
        • 破坏一致性: 最终数据库状态不符合业务预期,因为事务 A 的写操作没有在它“看到”的数据基础上进行修改。

示例说明

假设表 accounts 有字段 id, name, balance

  1. 事务 A (RR)
sql复制
BEGIN; -- 事务 A 开始 (TXID=100)SELECT balance INTO @old_bal FROM accounts WHERE id = 1; -- 生成 Read View。假设读到 balance=1000 (快照读)-- 业务逻辑:计算新余额 = @old_bal + 100 = 1100-- 在事务 A 执行 UPDATE 之前...
  1. 事务 B (RC 或 RR)
sql复制
BEGIN;
UPDATE accounts SET balance = balance - 200 WHERE id = 1; -- 成功提交,余额变为 800 (最新物理版本)COMMIT; -- 在事务 A 的 UPDATE 之前提交
  1. 事务 A 继续
sql复制
UPDATE accounts SET balance = 1100 WHERE id = 1; -- 基于快照读的结果 1000 计算出的 1100COMMIT;

最终结果:

  • 事务 B 的更新 (-200) 被完全覆盖。
  • 账户 id=1 的余额变成了 1100
  • 逻辑错误: 事务 A 本意是增加 100,但它覆盖了事务 B 扣减 200 的操作。实际效果是余额从 800 被错误地设置为 1100,而不是预期的 800 + 100 = 900。100 元凭空产生!

为什么 MySQL (InnoDB) 的 RR 允许这样做?

  1. MVCC 的设计目标: MVCC 的核心优势是解决读-写阻塞,保证读的一致性(可重复读)。它不天然保证写操作一定能基于其之前读到的快照版本进行修改。
  2. 性能与锁的权衡: 强制让写操作也基于快照读版本,需要在写操作时回溯旧版本并加锁,实现复杂且可能引入更多锁争用。
  3. 标准隔离级别定义: SQL 标准的 RR 隔离级别只要求防止不可重复读和幻读,并不要求防止丢失更新。防止丢失更新通常是可串行化 (SERIALIZABLE) 级别的要求。

如何避免这种问题?(关键解决方案)

  1. 对依赖读的写操作使用 SELECT ... FOR UPDATE (悲观锁):

    1. 在事务 A 的初始读操作时,就使用 SELECT ... FOR UPDATE

    2.  sql复制
       BEGIN;
       SELECT balance INTO @old_bal FROM accounts WHERE id = 1 FOR UPDATE; -- 关键:加 X 锁-- 计算新余额 = @old_bal + 100UPDATE accounts SET balance = 1100 WHERE id = 1;
       COMMIT;
      
    3. 作用:

      • FOR UPDATE 会在读取记录时立即获取该行的排他锁 (X Lock)。
      • 阻塞其他事务对该行的 SELECT ... FOR UPDATEUPDATE/DELETE 操作。
      • 确保事务 A 在 SELECT 时读到的就是最新已提交的数据(FOR UPDATE 触发的是当前读,不是快照读!),并且在事务提交前,其他事务无法修改该行。
      • 完全避免了在计算和更新之间数据被其他事务修改的可能,保证了写操作基于其读取的准确最新值。
  2. 使用乐观锁 (Optimistic Locking - 通常基于版本号或时间戳):

    1. 表中增加一个版本号字段 version
    2. 事务 A 读取数据和当前的 version (e.g., v1)。
    3. 在更新时,检查 version 是否仍然是 v1
    4.  sql复制
       BEGIN;
       SELECT balance, version INTO @old_bal, @old_ver FROM accounts WHERE id = 1; -- 快照读获取数据和版本号-- 计算新余额 = @old_bal + 100UPDATE accounts SET balance = 1100, version = @old_ver + 1WHERE id = 1 AND version = @old_ver; -- 关键:检查版本号是否未变-- 检查 UPDATE 影响的行数 (ROW_COUNT())
       IF ROW_COUNT() = 0 THEN-- 版本号已变,更新失败!数据已被其他事务修改。需要回滚或重试业务逻辑。ROLLBACK; -- 或根据业务决定ELSECOMMIT;
       END IF;
      
    5. 作用: 如果其他事务在事务 A 的读和写之间修改了数据并更新了 version,事务 A 的 UPDATE 条件 version = @old_ver 会失败(影响行数为 0)。事务 A 可以捕获这个失败并进行回滚或重试。

总结

  • 在 RR 隔离级别下,先快照读 (SELECT) 后写 (UPDATE/DELETE) 且写依赖于读的结果,是一个高危操作模式。

  • 快照读 (SELECT) 看到的是历史快照,但写操作 (UPDATE/DELETE) 作用于最新物理数据并检查并发修改。

  • 这会导致基于过期快照数据做出的修改覆盖其他事务已提交的最新数据,引发丢失更新和严重业务逻辑错误。

  • 解决方案是显式加锁:

    • SELECT ... FOR UPDATE (悲观锁): 在读取时就锁定数据,确保后续写操作的安全性(首选推荐)。
    • 乐观锁: 通过版本号/时间戳在更新时检查数据是否被修改过,需要处理更新失败的情况。

因此,在可重复读级别下,如果一个事务内的写操作依赖于前面读操作的结果,强烈建议对该读操作使用 SELECT ... FOR UPDATE 来避免潜在的逻辑错误和数据不一致。

2、MySQL InnoDB如何在RR隔离条件下结合间隙锁解决幻读?

MySQL 间隙锁(Gap Lock)解决幻读的机制详解

在 InnoDB 引擎的 REPEATABLE READ 隔离级别下,通过间隙锁(Gap Lock) 完美解决了幻读问题。下面通过具体示例详细解释其工作原理:


幻读问题场景回顾

假设有一个订单表 orders

sql复制
CREATE TABLE orders (
  id INT PRIMARY KEY AUTO_INCREMENT,
  amount DECIMAL(10,2),
  INDEX idx_amount(amount)
);

INSERT INTO orders (amount) VALUES (500), (1000), (1500), (2000);

事务 A(范围查询)

sql复制
-- 事务 A (RR 隔离级别)BEGIN;
SELECT * FROM orders WHERE amount > 1000; -- 返回 (1500,2000)

事务 B(插入新数据)

sql复制
-- 事务 BBEGIN;
INSERT INTO orders (amount) VALUES (1800); -- 插入新订单COMMIT; -- 成功提交

事务 A 再次查询

sql复制
-- 事务 A 继续SELECT * FROM orders WHERE amount > 1000; -- 在 RR 级别下仍返回 (1500,2000)-- 但此时如果执行写操作:UPDATE orders SET status = 'processed' WHERE amount > 1000;
-- 会意外修改到新插入的 1800 订单!COMMIT;

问题本质:第二次查询虽然看不到新数据(快照读),但后续写操作会作用于最新数据,导致"幽灵数据"被意外修改。


间隙锁如何解决幻读

间隙锁工作原理

  1. 锁定范围而非单点:

    1. 不仅锁定存在的记录,还锁定记录之间的间隙
    2. 例如 amount > 1000 会锁定区间 (1000, +∞)
  2. 防止区间插入:

    1. 其他事务无法在锁定区间内插入新记录
    2. 插入操作会被阻塞直到锁释放
  3. 与记录锁结合使用:

    1. Next-Key Lock = 记录锁 (Record Lock) + 间隙锁 (Gap Lock)
    2. 如锁定 amount=1500 实际锁定 (1000, 1500] 区间

解决方案示例(使用间隙锁)

事务 A(显式加锁)

sql复制
BEGIN;
-- 使用 FOR UPDATE 触发当前读和间隙锁SELECT * FROM orders WHERE amount > 1000 FOR UPDATE;
-- 返回 (1500,2000) 同时锁定区间 (1000, +∞)

事务 B(尝试插入)

sql复制
BEGIN;
-- 尝试在锁定区间插入新数据INSERT INTO orders (amount) VALUES (1200); -- 阻塞等待INSERT INTO orders (amount) VALUES (1800); -- 阻塞等待INSERT INTO orders (amount) VALUES (2500); -- 阻塞等待-- 所有插入操作都会被阻塞!

事务 A 完成操作

sql复制
-- 执行更新操作UPDATE orders SET status = 'processed' WHERE amount > 1000;
COMMIT; -- 提交后释放锁

事务 B 继续

sql复制
-- 事务 A 提交后,插入操作解除阻塞-- 但此时事务 B 需要重新检查业务逻辑-- 因为数据状态已被事务 A 修改

间隙锁锁定范围示意图

diff复制
索引值: 500    1000    1500    2000
区间:   |---|-----|-----|-----|----->
        (-∞,500) (500,1000) (1000,1500) (1500,2000) (2000,+∞)

锁定 amount > 1000 时:
        锁定区间 (1000,1500], (1500,2000], (2000,+∞)
        
插入尝试:
  1200 → 落在 (1000,1500) → 被阻塞
  1800 → 落在 (1500,2000) → 被阻塞
  2500 → 落在 (2000,+∞) → 被阻塞

间隙锁的关键特性

1. 范围锁定

  • 锁定的是索引记录之间的"空隙"
  • 例如 (1000, 1500) 区间

2. 兼容性规则

请求类型已存在间隙锁已存在记录锁
间隙锁兼容兼容
记录锁冲突冲突
插入意向锁冲突冲突

3. 触发条件

  • 使用 SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE
  • 执行 UPDATE/DELETE 语句时自动应用

4. 唯一性例外

  • 对唯一索引的等值查询只加记录锁
  • 例:SELECT * FROM users WHERE id=5 FOR UPDATE 只锁 id=5 的行

实际开发中的建议

正确使用姿势 ✅

sql复制
BEGIN;
-- 1. 先锁定需要操作的范围SELECT * FROM orders WHERE amount > 1000 FOR UPDATE;

-- 2. 执行业务逻辑检查SET @total = (SELECT SUM(amount)...);

-- 3. 执行更新操作UPDATE orders SET status = 'processed'...;
COMMIT;

需要避免的陷阱 ❌

sql复制
BEGIN;
-- 危险:先快照读后更新SELECT * FROM orders WHERE amount > 1000; -- 快照读,不加锁-- 其他事务可能在此处插入新数据UPDATE orders SET ... WHERE amount > 1000; -- 意外修改新插入的数据COMMIT;

性能优化建议

  1. 精确锁定范围:
sql复制
-- 优于SELECT * FROM orders WHERE amount > 1000 FOR UPDATE;

-- 更精确SELECT * FROM orders WHERE amount BETWEEN 1001 AND 5000 FOR UPDATE;
  1. 控制事务粒度:

    1. 尽量缩短事务执行时间
    2. 避免在事务中包含耗时操作
  2. 索引优化:

sql复制
-- 无索引字段查询会导致全表间隙锁SELECT * FROM orders WHERE status = 'new' FOR UPDATE;

-- 解决方案:为 status 字段添加索引ALTER TABLE orders ADD INDEX idx_status(status);
  1. 监控锁争用:
sql复制
SHOW ENGINE INNODB STATUS; -- 查看锁信息SELECT * FROM information_schema.INNODB_LOCKS; -- 查看当前锁

总结:间隙锁如何解决幻读

机制作用
范围锁定锁定查询条件覆盖的索引区间,阻止新区间内的插入操作
阻塞插入其他事务无法在锁定区间内插入新记录
写操作一致性确保事务内所有操作(读+写)基于同一数据视图
防止幽灵修改避免事务后段操作意外修改"幽灵数据"
Next-Key Lock记录锁+间隙锁组合,同时防止记录修改和区间插入

关键结论:在 InnoDB 的 REPEATABLE READ 隔离级别下,正确使用当前读(如 FOR UPDATE) 触发间隙锁机制,可以完全解决幻读问题,同时保持较高的并发性能。