第二十七章 事务隔离级别
本章目标
通过本章学习,你将能够:
- 深入理解三种并发问题:脏读、不可重复读、幻读,并能用具体场景说明
- 掌握四种隔离级别的定义、解决的问题和各自特点
- 理解 MVCC(多版本并发控制)的实现原理:ReadView、undo log 版本链
- 区分快照读(Snapshot Read)和当前读(Current Read)
- 理解 InnoDB 在 REPEATABLE READ 下如何通过 Gap Lock 防止幻读
- 掌握 SELECT ... FOR UPDATE 和 SELECT ... FOR SHARE 的用法和区别
- 能够根据业务场景选择合适的隔离级别
前置知识
在学习本章之前,你需要:
- 理解上一章中事务的基本概念(START TRANSACTION、COMMIT、ROLLBACK)
- 理解 ACID 中 I(隔离性)的含义
- 了解并发编程中的基本概念(竞态条件、锁)
- 了解 InnoDB 的行级锁基本概念
- 知道 Undo Log 的作用
27.1 并发问题的三种场景
27.1.1 脏读(Dirty Read)
定义:一个事务读取了另一个事务中未提交的修改数据。如果那个事务最终回滚,则读取到了"从未存在过"的数据。
-- 场景演示(需要两个会话)
-- 会话 A(事务 1):
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- 结果: 5000
UPDATE accounts SET balance = balance - 2000 WHERE id = 1;
-- 余额变为 3000(但未提交!)
-- 会话 B(事务 2):
-- 隔离级别为 READ UNCOMMITTED 时:
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- 结果: 3000 ← 脏读!读取到了事务 1 未提交的修改
-- 回到会话 A:
ROLLBACK; -- 事务 1 回滚,余额恢复为 5000
-- 会话 B 读到的 3000 是"脏数据"——这个值从未真正存在于数据库中
-- 事务 1 回滚后,会话 B 的后续操作基于错误的数据脏读的危害:基于"脏数据"做出的业务决策可能是完全错误的。例如,银行基于未提交的扣款判断余额不足而拒绝支付,但扣款最终被回滚了。
27.1.2 不可重复读(Non-Repeatable Read)
定义:在同一个事务中,两次读取同一行数据得到了不同的结果。因为两次读取之间,另一个事务修改并提交了该行数据。
-- 场景演示
-- 会话 A(事务 1):
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- 结果: 5000 ← 第一次读取
-- 会话 B(事务 2):
START TRANSACTION;
UPDATE accounts SET balance = 4000 WHERE id = 1;
COMMIT; -- 修改并提交
-- 回到会话 A(仍在同一个事务中):
SELECT balance FROM accounts WHERE id = 1;
-- 结果: 4000 ← 第二次读取,与第一次不同!不可重复读!
-- 在同一个事务中,相同的查询返回了不同的结果
-- 此问题在 READ COMMITTED 级别下存在
-- 在 REPEATABLE READ 级别下不存在不可重复读的危害:事务内的业务逻辑基于两次不一致的数据。例如,第一次读余额判断可以转账,第二次读余额时发现余额已经变了。
27.1.3 幻读(Phantom Read)
定义:在同一个事务中,两次执行相同的范围查询得到了不同的结果行数。因为两次查询之间,另一个事务插入了符合条件的新行(或删除了符合条件的行)。
-- 场景演示
-- 会话 A(事务 1):
START TRANSACTION;
SELECT * FROM orders WHERE amount > 1000;
-- 结果: 3 行(id=1, 2, 3)
-- 会话 B(事务 2):
START TRANSACTION;
INSERT INTO orders (user_id, amount) VALUES (5, 1500);
COMMIT; -- 插入并提交一条符合 amount>1000 条件的新行
-- 回到会话 A(仍在同一个事务中):
SELECT * FROM orders WHERE amount > 1000;
-- 结果: 4 行 ← 多了 id=4 这一行!像"幽灵"一样出现!
-- 第一次查询 3 行,第二次 4 行
-- 幻读与不可重复读的区别:
-- 不可重复读:同一行的数据被修改了(UPDATE)
-- 幻读: 出现了新的行(INSERT)或行消失了(DELETE)幻读的危害:事务中基于范围条件的统计和决策不准确。例如,第一次查询高价值订单只有 3 个并分配了 3 个处理员,第二次发现变成了 4 个。
27.1.4 三种问题的对比总结
┌──────────────────┬──────────┬─────────────────┬──────────┐
│ │ 脏读 │ 不可重复读 │ 幻读 │
├──────────────────┼──────────┼─────────────────┼──────────┤
│ 问题类型 │ 读取未提交│ 同一行两次读不同│ 范围查询 │
│ │ 的修改 │ (被UPDATE修改) │ 出现新行 │
├──────────────────┼──────────┼─────────────────┼──────────┤
│ 制造问题的操作 │ UPDATE │ UPDATE │ INSERT │
│ │ │ │ DELETE │
├──────────────────┼──────────┼─────────────────┼──────────┤
│ 是否已提交 │ 未提交 │ 已提交 │ 已提交 │
└──────────────────┴──────────┴─────────────────┴──────────┘27.2 四种事务隔离级别
SQL 标准定义了四种隔离级别,从低到高依次提供更强的数据一致性保证。
27.2.1 隔离级别总览
隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED ✓ ✓ ✓ (最低隔离)
READ COMMITTED ✗ ✓ ✓
REPEATABLE READ ✗ ✗ ✓* (MySQL 默认)
SERIALIZABLE ✗ ✗ ✗ (最高隔离)
* InnoDB 的 REPEATABLE READ 通过 Gap Lock 部分防止了幻读27.2.2 READ UNCOMMITTED(读未提交)
最低的隔离级别,允许读取其他事务未提交的修改。
-- 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- 或:SHOW VARIABLES LIKE 'transaction_isolation';
-- READ UNCOMMITTED 特点:
-- 1. 无 MVCC 快照读,每次读取最新的数据(无论是否已提交)
-- 2. 并发性能最高,因为读不加锁
-- 3. 数据一致性最差
-- 实际应用:极少使用,仅适用于对数据一致性要求极低
-- 的场景(如某些日志统计、近似计数等)27.2.3 READ COMMITTED(读已提交)
只能读取已提交的数据,防止了脏读。但存在不可重复读和幻读。
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- READ COMMITTED 特点:
-- 1. 每次 SELECT 都创建新的 ReadView,看到最新的已提交数据
-- 2. 同一个事务内多次读取同一行可能看到不同版本
-- 3. 是 Oracle 和 PostgreSQL 的默认隔离级别
-- 4. 在 InnoDB 中,READ COMMITTED 下没有 Gap Lock
-- 适用场景:
-- - 不需要事务内一致读的场景
-- - 读写分离架构中从库的默认级别
-- - 高并发 OLTP 系统27.2.4 REPEATABLE READ(可重复读)—— MySQL 默认
防止了脏读和不可重复读。标准 SQL 中允许幻读,但 InnoDB 通过 Gap Lock 部分防止了幻读。
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- REPEATABLE READ 特点:
-- 1. 事务中第一个 SELECT 创建 ReadView,整个事务使用同一个 ReadView
-- 2. 同一个事务内多次读取同一行总是看到相同的值
-- 3. MySQL InnoDB 的默认级别
-- 4. 通过 Next-Key Lock(Record Lock + Gap Lock)防止幻读
-- 适用场景:
-- - 大多数通用业务场景(MySQL 默认选择的理由)
-- - 需要事务内一致读的场景
-- - 金融、电商等对数据一致性要求较高的场景27.2.5 SERIALIZABLE(可串行化)
最高的隔离级别,事务完全串行执行,防止所有并发问题。
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- SERIALIZABLE 特点:
-- 1. 所有 SELECT 隐式变为 SELECT ... FOR SHARE(读加共享锁)
-- 2. 事务如同串行执行,并发度最低
-- 3. 死锁概率增加
-- 4. 性能最差
-- 适用场景:
-- - 对数据一致性要求极高的场景(如金融对账)
-- - 数据量小、并发低的系统
-- - 极少作为全局设置使用,通常仅特定事务使用27.2.6 隔离级别设置
-- 全局设置(影响所有新连接,重启后失效)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 会话设置(仅当前连接)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 下一个事务的设置(仅影响下一个事务)
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
-- ...
COMMIT;
-- 永久设置(配置文件 my.cnf / my.ini)
-- [mysqld]
-- transaction-isolation = READ-COMMITTED
-- 查看当前级别
SELECT @@global.transaction_isolation, @@session.transaction_isolation;27.3 MVCC(多版本并发控制)
MVCC 是 InnoDB 实现 READ COMMITTED 和 REPEATABLE READ 隔离级别的核心机制。它通过保存数据的多个版本来实现"读不阻塞写、写不阻塞读"。
27.3.1 隐藏列
InnoDB 为每一行数据维护了三个隐藏列:
-- InnoDB 行的隐藏列(用户不可见):
-- DB_TRX_ID(6 字节):最后修改该行的事务 ID
-- DB_ROLL_PTR(7 字节):指向 Undo Log 中该行前一个版本的指针
-- DB_ROW_ID(6 字节):如果没有主键,InnoDB 自动生成的行 ID
-- 行数据示例(简化):
-- ┌────┬──────────┬─────────┬───────────┬──────────────┐
-- │ id │ name │ balance │ DB_TRX_ID │ DB_ROLL_PTR │
-- ├────┼──────────┼─────────┼───────────┼──────────────┤
-- │ 1 │ Alice │ 5000 │ 100 │ → undo log │
-- └────┴──────────┴─────────┴───────────┴──────────────┘27.3.2 Undo Log 版本链
当一行数据被修改时,InnoDB 将旧版本写入 Undo Log,并通过 DB_ROLL_PTR 形成版本链。
当前数据页(最新版本):
id=1, balance=3000, DB_TRX_ID=103, DB_ROLL_PTR → [undo 2]
Undo Log 版本链:
[undo 2] balance=4000, DB_TRX_ID=102, DB_ROLL_PTR → [undo 1]
[undo 1] balance=5000, DB_TRX_ID=101, DB_ROLL_PTR → NULL (原始值)
版本链:3000 (trx103) → 4000 (trx102) → 5000 (trx101, 原始)27.3.3 ReadView(读视图)
ReadView 是事务在执行快照读时创建的数据结构,用于判断哪些数据版本对当前事务可见。
ReadView 包含的信息:
- m_ids:创建 ReadView 时,当前系统中"活跃的"(未提交的)事务 ID 列表
- min_trx_id:m_ids 中的最小值
- max_trx_id:创建 ReadView 时,系统下一个待分配的事务 ID
- creator_trx_id:创建该 ReadView 的事务 ID
可见性判断规则(对于数据版本 trx_id):
1. trx_id == creator_trx_id → 可见(本事务自己的修改)
2. trx_id < min_trx_id → 可见(修改该版本的事务在 ReadView 创建前已提交)
3. trx_id >= max_trx_id → 不可见(修改该版本的事务在 ReadView 创建后开始)
4. min_trx_id <= trx_id < max_trx_id:
a. trx_id 在 m_ids 中 → 不可见(修改该版本的事务仍未提交)
b. trx_id 不在 m_ids 中 → 可见(修改该版本的事务已提交)27.3.4 REPEATABLE READ 下的 MVCC
-- REPEATABLE READ 下 ReadView 的创建时机:
-- 在事务中第一次执行 SELECT(快照读)时创建
-- 整个事务期间使用同一个 ReadView
-- 时间线示例:
-- t1: 事务 A 开始
START TRANSACTION;
-- t2: 事务 B UPDATE 并 COMMIT
-- t3: 事务 A 第一次 SELECT(此时创建 ReadView)
SELECT balance FROM accounts WHERE id = 1; -- 看到 t2 之前的值!
-- t4: 事务 C UPDATE 并 COMMIT
-- t5: 事务 A 第二次 SELECT(仍使用 t3 时创建的 ReadView)
SELECT balance FROM accounts WHERE id = 1; -- 仍看到 t3 时的值!可重复读!
COMMIT;27.3.5 READ COMMITTED 下的 MVCC
-- READ COMMITTED 下 ReadView 的创建时机:
-- 每次执行 SELECT(快照读)时都创建新的 ReadView
-- 时间线示例:
-- t1: 事务 A 开始
START TRANSACTION;
-- t2: 事务 B UPDATE 并 COMMIT
-- t3: 事务 A 第一次 SELECT(创建 ReadView → 看到已提交的 t2 修改)
SELECT balance FROM accounts WHERE id = 1; -- 看到 t2 修改后的值
-- t4: 事务 C UPDATE 并 COMMIT
-- t5: 事务 A 第二次 SELECT(创建新的 ReadView → 看到已提交的 t4 修改)
SELECT balance FROM accounts WHERE id = 1; -- 看到 t4 修改后的值!不可重复读!
COMMIT;27.3.6 快照读 vs 当前读
快照读(Snapshot Read / 一致性非锁定读):
- 普通的 SELECT 语句
- 使用 MVCC 读取快照版本
- 不加锁,不会阻塞其他事务的写操作
- 示例:SELECT * FROM users WHERE id = 1;
当前读(Current Read / 一致性锁定读):
- 读取数据的最新版本,并加锁
- 会阻塞其他事务的写操作
- 包括以下语句:
- SELECT ... FOR UPDATE (加 X 锁)
- SELECT ... FOR SHARE (加 S 锁)
- INSERT / UPDATE / DELETE (加 X 锁)
-- 关键区别示例:
-- 会话 A:
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- 快照读:返回 5000(MVCC 快照中的值)
-- 会话 B:
UPDATE accounts SET balance = 4000 WHERE id = 1;
COMMIT;
-- 会话 A(同一事务中):
SELECT balance FROM accounts WHERE id = 1;
-- 快照读:仍返回 5000(使用同一个 ReadView)
-- REPEATABLE READ 保证了可重复读
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 当前读:返回 4000(读取最新已提交值 + 加锁)
-- 当前读看到的是最新的数据!27.4 InnoDB 的幻读预防机制
27.4.1 标准 SQL vs InnoDB
标准 SQL 中:
- REPEATABLE READ 允许幻读
InnoDB 中:
- REPEATABLE READ 通过 Next-Key Lock 部分防止了幻读
- 快照读(普通 SELECT):通过 MVCC 避免了幻读(一直看到同一个快照)
- 当前读(SELECT FOR UPDATE):通过 Gap Lock 防止了幻读27.4.2 Next-Key Lock = Record Lock + Gap Lock
-- Record Lock(记录锁):锁定索引上的具体记录
-- Gap Lock(间隙锁):锁定索引记录之间的间隙
-- Next-Key Lock:Record Lock + 它前面的 Gap Lock
-- 假设表中有 id=5, id=10, id=15 三条记录
-- 索引间隙如下:
-- (-∞, 5) (5, 10) (10, 15) (15, +∞)
-- SELECT * FROM t WHERE id = 10 FOR UPDATE;
-- Next-Key Lock 锁定:
-- - 间隙 (5, 10]——即 id=5 和 id=10 之间 + id=10 本身
-- 在 REPEATABLE READ 下,这阻止了其他事务在 id=5 和 id=10 之间插入新行
-- SELECT * FROM t WHERE id BETWEEN 8 AND 12 FOR UPDATE;
-- Next-Key Lock 锁定:
-- - 间隙 (5, 10](因为 8 落入此间隙)
-- - 间隙 (10, 15](因为 12 落入此间隙)
-- 阻止了在 5 到 15 之间插入任何新行27.4.3 Gap Lock 的规则
-- 1. Gap Lock 只在 REPEATABLE READ 及以上级别生效
-- 在 READ COMMITTED 中,Gap Lock 被禁用
-- 2. 在唯一索引上使用等值查询时
-- 如果记录存在:退化为 Record Lock,不锁定间隙
-- 如果记录不存在:锁定间隙(防止幻读)
-- 唯一索引 + 等值查询(记录存在):
SELECT * FROM users WHERE id = 10 FOR UPDATE;
-- id=10 是主键,记录存在
-- 只锁定 id=10 这一条记录(Record Lock)
-- 不会锁定间隙
-- 唯一索引 + 等值查询(记录不存在):
SELECT * FROM users WHERE id = 11 FOR UPDATE;
-- id=11 不存在
-- 锁定 id=10 和下一个记录之间的间隙
-- 阻止其他事务在这个间隙中插入
-- 3. 在非唯一索引上使用等值查询时
-- 使用 Next-Key Lock,可能锁定多个间隙
-- 非唯一索引 + 等值查询:
-- 假设 age 上有非唯一索引,age=25 的记录有多条
SELECT * FROM users WHERE age = 25 FOR UPDATE;
-- 对每个 age=25 的记录,加上 Next-Key Lock
-- 并且对索引中最后一个 age=25 记录之后的下一个间隙也加 Gap Lock
-- 4. 范围查询总是使用 Next-Key Lock
SELECT * FROM users WHERE id > 10 FOR UPDATE;
-- 对 id > 10 的所有记录加 Next-Key Lock
-- 并锁定 supremum 伪记录(表示正无穷)
-- 5. 没有索引时,全表加锁
SELECT * FROM users WHERE name = 'Alice' FOR UPDATE;
-- 如果 name 上没有索引 → 对所有行加 Next-Key Lock
-- 等同于锁全表!性能灾难!27.4.4 幻读防护验证
-- 验证 InnoDB 在 REPEATABLE READ 下防止幻读
-- 会话 A:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM orders WHERE amount > 1000 FOR UPDATE;
-- 结果: id=1(1200), id=3(1500) 两行
-- 同时,这些记录和它们周围的间隙被锁定
-- 会话 B:
START TRANSACTION;
INSERT INTO orders (user_id, amount) VALUES (5, 1800);
-- 阻塞!因为无法在锁定的间隙中插入新行
-- (等待直到事务 A 提交或锁超时)
-- 如果使用 READ COMMITTED:
-- 会话 B 的 INSERT 会成功(Gap Lock 被禁用)
-- 会话 A 再次 SELECT FOR UPDATE 会看到新增的行 → 幻读发生27.5 SELECT ... FOR UPDATE 和 SELECT ... FOR SHARE
27.5.1 锁定读的类型
-- SELECT ... FOR UPDATE(排他锁 / X 锁)
-- 用途:读取数据并打算随后修改(如在转账前锁定余额行)
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 对 id=1 加 X 锁
-- 其他事务的 FOR UPDATE / FOR SHARE / UPDATE / DELETE 会被阻塞
-- 其他事务的普通 SELECT 不受影响(快照读)
-- SELECT ... FOR SHARE(共享锁 / S 锁,MySQL 8.0+)
-- 用途:读取数据,确保在事务提交前数据不会被修改
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1 FOR SHARE;
-- 对 id=1 加 S 锁
-- 其他事务的 FOR UPDATE / UPDATE / DELETE 会被阻塞
-- 其他事务的 FOR SHARE 可以成功(S 锁兼容)
-- 其他事务的普通 SELECT 不受影响
-- 注意:FOR SHARE 取代了 MySQL 5.7 中的 LOCK IN SHARE MODE
-- SELECT ... LOCK IN SHARE MODE 仍然可用但已被标记为过时27.5.2 锁定读的实践
-- 典型的"先查后改"模式(防止竞态条件)
-- 错误做法(可能超卖):
START TRANSACTION;
SELECT quantity FROM inventory WHERE product_id = 100;
-- 返回 10
-- 此时另一个事务也可能读到 10,并下订单
-- ...
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 100;
COMMIT;
-- 如果两个事务都读到 10 并都减 1,最终库存变成 8 而不是 9!
-- 正确做法(使用 FOR UPDATE):
START TRANSACTION;
SELECT quantity FROM inventory WHERE product_id = 100 FOR UPDATE;
-- 对 product_id=100 的行加 X 锁
-- 其他事务的 FOR UPDATE 会被阻塞,等待当前事务提交
-- 返回 10(确定是当前最新的值)
-- 业务逻辑检查
IF quantity < 1 THEN
ROLLBACK;
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足';
END IF;
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 100;
COMMIT;
-- 保证了并发安全
-- 死锁预防:始终按相同顺序锁定资源
-- 事务 1:锁定 product=1, 然后 product=2
-- 事务 2:也锁定 product=1, 然后 product=2
-- 避免:事务 1 锁 1→2,事务 2 锁 2→1(会导致死锁)27.5.3 锁等待和超时
-- 查看锁等待超时设置
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 默认 50 秒
-- 设置锁等待超时(会话级别)
SET innodb_lock_wait_timeout = 5; -- 5 秒超时
-- 查看当前锁等待情况
-- MySQL 8.0+
SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
TIMESTAMPDIFF(SECOND, r.trx_wait_started, NOW()) AS wait_seconds,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx r ON w.requesting_trx_id = r.trx_id
JOIN information_schema.innodb_trx b ON w.blocking_trx_id = b.trx_id;
-- MySQL 8.0+ 也可以使用 performance_schema
SELECT
waiting_pid,
waiting_age,
waiting_query,
blocking_pid,
blocking_age,
blocking_query
FROM sys.innodb_lock_waits;27.5.4 NOWAIT 和 SKIP LOCKED(MySQL 8.0+)
-- NOWAIT:如果无法立即获取锁,立即报错而不是等待
SELECT * FROM orders WHERE status = 'pending'
FOR UPDATE NOWAIT;
-- 如果行被锁定 → 立即返回错误:ERROR 3572
-- SKIP LOCKED:跳过已被锁定的行,返回未被锁定的行
SELECT * FROM orders WHERE status = 'pending'
FOR UPDATE SKIP LOCKED;
-- 非常适合任务队列场景:
-- 多个消费者并发出队任务时,自动跳过已被其他消费者锁定的任务
-- 任务队列示例:
START TRANSACTION;
SELECT * FROM task_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- 如果返回了一行:处理该任务
-- 如果没有返回行:说明没有待处理的任务(或所有任务都被锁定)
UPDATE task_queue SET status = 'processing' WHERE id = <返回的id>;
COMMIT;27.6 隔离级别选择指南
场景分析决策树:
1. 是否需要事务内一致读(同一事务中多次读须一致)?
├── 是 → REPEATABLE READ 或 SERIALIZABLE
└── 否 → READ COMMITTED 或 READ UNCOMMITTED
2. 是否存在高并发写 + 范围查询?
├── 是 → READ COMMITTED(Gap Lock 会严重影响并发)
└── 否 → REPEATABLE READ(默认,适用大多数场景)
3. 是否只是日志/统计类查询(允许近似值)?
├── 是 → READ UNCOMMITTED(性能最优)
└── 否 → 选择更高隔离级别
4. 金融/账务类场景,数据正确性高于一切?
├── 是 → SERIALIZABLE(仅在关键事务中使用)
└── 否 → REPEATABLE READ
默认建议:
- 大多数应用:使用 MySQL 默认的 REPEATABLE READ
- 高并发互联网应用:可考虑 READ COMMITTED(和 Oracle/PostgreSQL 对齐)
- 绝不全局使用 SERIALIZABLE(仅在特定关键事务中使用)常见错误
混淆快照读和当前读
- 错误:在 REPEATABLE READ 下,先执行
SELECT balance FROM accounts(快照读),得到余额 5000。然后另一个事务更新余额为 3000 并提交。接着执行SELECT balance FROM accounts FOR UPDATE(当前读),得到 3000。用户困惑为什么同一个事务中读到不同的值。 - 原因:快照读使用 MVCC 读快照,当前读读取最新已提交值。
- 解决:理解两种读的区别,需要一致性时全程使用当前读或全程使用快照读。
- 错误:在 REPEATABLE READ 下,先执行
忘记 FOR UPDATE 导致丢失更新
- 错误:先 SELECT 读值,在应用层计算后再 UPDATE,没有加 FOR UPDATE 锁。
- 后果:并发场景下,两个事务同时读到旧值,各自计算后写入,后提交的覆盖了先提交的。
- 解决:
SELECT ... FOR UPDATE读取并锁定,或使用乐观锁(version 字段)。
SERIALIZABLE 级别下忘记处理死锁
- 错误:在 SERIALIZABLE 级别下正常执行 SQL,遇到死锁后直接报错退出。
- 后果:应用层未重试,交易丢失。
- 解决:任何隔离级别下都应捕获死锁异常并重试(合理次数)。
Gap Lock 导致的插入阻塞不解
- 错误:在 REPEATABLE READ 下,一个事务执行了范围查询(如
SELECT ... WHERE id BETWEEN 10 AND 20 FOR UPDATE),另一个事务尝试在 id=15 位置插入时被阻塞,不理解原因。 - 原因:范围查询的当前读会加 Next-Key Lock,包括间隙锁阻止其他事务在范围内插入。
- 解决:了解 Gap Lock 机制,必要时使用 READ COMMITTED 禁用 Gap Lock。
- 错误:在 REPEATABLE READ 下,一个事务执行了范围查询(如
认为 REPEATABLE READ 完全防止了幻读
- 错误:在 REPEATABLE READ 下用快照读发现没有幻读,就认为完全防止了幻读。
- 原因:快照读通过 MVCC 天然避免了幻读,但当前读(FOR UPDATE)如果没有合适索引仍可能受幻读影响。
- 解决:理解 MVCC 和 Gap Lock 的区别,并在需要绝对防幻读的场景正确使用索引。
本章练习
并发问题复现:在两个 MySQL 会话中,分别设置不同的隔离级别,复现脏读、不可重复读、幻读三种问题。记录实验步骤和观察到的现象,说明什么隔离级别解决了什么问题。
MVCC 版本链练习:手动执行一系列 UPDATE 和 SELECT 操作,理解 Undo Log 版本链的形成过程。画出每次修改后版本链的示意图,标出各版本的 DB_TRX_ID 和 DB_ROLL_PTR。
FOR UPDATE 竞态条件:创建一个库存表,模拟 10 个并发"先查后改"的库存扣减操作,记录不使用 FOR UPDATE 和 使用 FOR UPDATE 的结果差异(是否出现超卖)。
Gap Lock 实验:在 REPEATABLE READ 级别下,使用不同索引类型(主键、唯一索引、非唯一索引)执行 FOR UPDATE 查询,观察其他事务插入数据时的阻塞情况,总结 Gap Lock 在不同条件下的加锁范围。
隔离级别选择分析:给出 3 个业务场景(电商下单、用户签到统计、银行转账),分析每个场景的并发风险,推荐合适的隔离级别并说明理由。
NOWAIT 和 SKIP LOCKED:模拟一个任务队列(多个消费者并发取任务),分别使用 FOR UPDATE、FOR UPDATE NOWAIT、FOR UPDATE SKIP LOCKED 三种方式,对比它们在被锁定的情况下的行为差异。