事务隔离级别
导学
多个事务同时操作数据库时,会出现脏读、不可重复读、幻读等并发问题。隔离级别定义了一个事务对其他事务的"可见程度",在一致性和并发性能之间做权衡。
定义
事务隔离级别(Transaction Isolation Level):定义事务之间隔离程度的标准。SQL 标准定义了 4 个隔离级别,MySQL 5.7 的 InnoDB 默认使用可重复读(REPEATABLE READ)。
并发问题速查
| 问题 | 描述 |
|---|---|
| 脏读(Dirty Read) | 读到其他事务未提交的数据 |
| 不可重复读(Non-repeatable Read) | 同一事务内两次读同一行,结果不同(被其他事务修改并提交) |
| 幻读(Phantom Read) | 同一事务内两次查询,结果集行数不同(被其他事务插入或删除) |
隔离级别与并发问题
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED | ✅ 允许 | ✅ 允许 | ✅ 允许 | 不加锁,直接读最新 |
| READ COMMITTED | ❌ 禁止 | ✅ 允许 | ✅ 允许 | 每次读生成新 ReadView |
| REPEATABLE READ | ❌ 禁止 | ❌ 禁止 | ⚠️ 部分允许 | 事务开始时生成 ReadView |
| SERIALIZABLE | ❌ 禁止 | ❌ 禁止 | ❌ 禁止 | 所有操作加排他锁 |
MySQL 5.7 的 InnoDB 在
REPEATABLE READ下通过 MVCC + Next-Key Lock 基本解决了幻读问题(详见《MVCC》和《锁机制》文档)。
设置隔离级别
示例:查看和设置隔离级别
-- 查看当前隔离级别(MySQL 5.7.20+)
SELECT @@transaction_isolation;
-- 旧版本使用
SELECT @@tx_isolation;
-- 设置会话级隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置全局隔离级别(需 SUPER 权限,新连接生效)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 验证修改后的隔离级别
SELECT @@transaction_isolation;
操作前状态: 默认输出:
| @@transaction_isolation |
|---|
| REPEATABLE-READ |
设置后的结果:
| @@transaction_isolation |
|---|
| READ-COMMITTED |
结果解读:
- 会话级设置只影响当前连接
- 全局设置影响新建立的连接,已有连接不受影响
SQL 示例:各种隔离级别的并发演示
准备测试数据
CREATE TABLE employees (
emp_id INT PRIMARY KEY AUTO_INCREMENT,
emp_name VARCHAR(20),
dept VARCHAR(20),
score DECIMAL(5,2)
);
INSERT INTO employees (emp_name, dept, score) VALUES
('大翔', '技术部', 100),
('白歌', '技术部', NULL);
employees 表初始数据:
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
| 2 | 白歌 | 技术部 | NULL |
场景一:脏读(READ UNCOMMITTED)
脏读是指一个事务读到了另一个事务尚未提交的修改。只有在 READ UNCOMMITTED 隔离级别下才会发生。
操作过程:两个会话并发执行
| 时间顺序 | 会话 A(事务1) | 会话 B(事务2) |
|---|---|---|
| T1 | SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; | |
| T2 | START TRANSACTION; | |
| T3 | START TRANSACTION; | |
| T4 | SELECT score FROM employees WHERE emp_id = 1; -- 结果:100 | |
| T5 | UPDATE employees SET score = 90 WHERE emp_id = 1; -- 未提交 | |
| T6 | SELECT score FROM employees WHERE emp_id = 1; -- 结果:90(脏读!) | |
| T7 | ROLLBACK; -- 回滚修改 | |
| T8 | SELECT score FROM employees WHERE emp_id = 1; -- 结果:100 | |
| T9 | COMMIT; |
完整 SQL 演示:
-- ========== 会话 A ==========
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
-- 第一次查询:读到原始数据
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态(T4):
| score |
|---|
| 100 |
-- 此时切换到会话 B 执行 UPDATE(未提交)
-- ========== 会话 B ==========
START TRANSACTION;
UPDATE employees SET score = 90 WHERE emp_id = 1;
-- 注意:此时不要 COMMIT 或 ROLLBACK
-- ========== 会话 A 继续 ==========
-- 第二次查询:读到了会话 B 未提交的数据(脏读)
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态(T6,脏读):
| score |
|---|
| 90.00 |
结果解读:
- 会话 A 在 T6 读到了
90,但这是会话 B 尚未提交的修改 - 如果会话 B 最终回滚(T7),这个
90就是"脏数据",从未真正存在过 - 这就是脏读:读到了其他事务未提交的、可能回滚的数据
-- 会话 B 回滚
ROLLBACK;
-- ========== 会话 A 继续 ==========
-- 第三次查询:数据恢复为 100
SELECT score FROM employees WHERE emp_id = 1;
COMMIT;
操作后的数据状态(T8):
| score |
|---|
| 100 |
场景二:不可重复读(READ COMMITTED)
不可重复读是指同一事务内,两次读取同一行数据,结果不一致(因为其他事务在中间提交了修改)。在 READ COMMITTED 和 READ UNCOMMITTED 下会发生。
操作过程:两个会话并发执行
| 时间顺序 | 会话 A(事务1,RC级别) | 会话 B(事务2) |
|---|---|---|
| T1 | SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; | |
| T2 | START TRANSACTION; | |
| T3 | START TRANSACTION; | |
| T4 | SELECT score FROM employees WHERE emp_id = 1; -- 结果:100 | |
| T5 | UPDATE employees SET score = 80 WHERE emp_id = 1; | |
| T6 | COMMIT; | |
| T7 | SELECT score FROM employees WHERE emp_id = 1; -- 结果:80(不可重复读!) | |
| T8 | COMMIT; |
完整 SQL 演示:
-- ========== 会话 A ==========
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- 第一次查询
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态(T4):
| score |
|---|
| 100 |
-- ========== 会话 B ==========
START TRANSACTION;
UPDATE employees SET score = 80 WHERE emp_id = 1;
COMMIT;
-- ========== 会话 A 继续 ==========
-- 第二次查询:读到了会话 B 已提交的修改
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态(T7,不可重复读):
| score |
|---|
| 80.00 |
结果解读:
- 会话 A 在同一事务内,两次查询同一行,结果从
100变成了80 - 虽然
80是已提交的合法数据,但对会话 A 来说,"同一事务内数据变了",这就是不可重复读 - 如果会话 A 的业务逻辑依赖"同一事务内数据不变",就会出现问题
-- 会话 A 提交
COMMIT;
-- 验证最终数据
SELECT score FROM employees WHERE emp_id = 1;
操作后的数据状态:
| score |
|---|
| 80.00 |
场景三:可重复读(REPEATABLE READ,MySQL 默认)
在 REPEATABLE READ 隔离级别下,同一事务内多次读取同一行数据,结果始终保持一致。InnoDB 通过 MVCC 机制实现这一点。
操作过程:两个会话并发执行
| 时间顺序 | 会话 A(事务1,RR级别) | 会话 B(事务2) |
|---|---|---|
| T1 | SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; | |
| T2 | START TRANSACTION; | |
| T3 | START TRANSACTION; | |
| T4 | SELECT score FROM employees WHERE emp_id = 1; -- 结果:80 | |
| T5 | UPDATE employees SET score = 60 WHERE emp_id = 1; | |
| T6 | COMMIT; | |
| T7 | SELECT score FROM employees WHERE emp_id = 1; -- 结果:80(可重复读!) | |
| T8 | COMMIT; | |
| T9 | SELECT score FROM employees WHERE emp_id = 1; -- 结果:60 |
完整 SQL 演示:
-- ========== 会话 A ==========
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
-- 第一次查询
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态(T4):
| score |
|---|
| 80.00 |
-- ========== 会话 B ==========
START TRANSACTION;
UPDATE employees SET score = 60 WHERE emp_id = 1;
COMMIT;
-- ========== 会话 A 继续 ==========
-- 第二次查询:仍然读到 80,不受会话 B 提交的影响
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态(T7,可重复读):
| score |
|---|
| 80.00 |
结果解读:
- 会话 B 已经提交,将 score 修改为
60 - 但会话 A 在同一事务内再次查询,仍然读到
80 - 这是 InnoDB MVCC 机制的效果:事务 A 看到的是事务开始时的快照,不受其他事务提交的影响
- 只有当事务 A 结束(COMMIT 或 ROLLBACK)后,新事务才能看到
60
-- 会话 A 提交
COMMIT;
-- 新查询:现在可以看到最新数据
SELECT score FROM employees WHERE emp_id = 1;
操作后的数据状态(T9):
| score |
|---|
| 60 |
场景四:幻读演示
幻读是指同一事务内,两次执行相同条件的范围查询,结果集的行数不同。在标准的 REPEATABLE READ 下,MVCC 只能解决快照读的幻读;当前读(FOR UPDATE)可能遇到幻读,但在 MySQL 5.7 中通过 Gap Lock 基本解决了。
操作过程:快照读下的幻读防护
| 时间顺序 | 会话 A(事务1,RR级别) | 会话 B(事务2) |
|---|---|---|
| T1 | START TRANSACTION; | |
| T2 | SELECT * FROM employees WHERE score > 50; -- 1行 | |
| T3 | START TRANSACTION; | |
| T4 | INSERT INTO employees VALUES (3, '孔蓝', '产品部', 70); | |
| T5 | COMMIT; | |
| T6 | SELECT * FROM employees WHERE score > 50; -- 仍然1行(MVCC防护) | |
| T7 | COMMIT; |
完整 SQL 演示:
-- ========== 会话 A ==========
START TRANSACTION;
-- 第一次范围查询
SELECT * FROM employees WHERE score > 50;
当前数据状态(T2):
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
-- ========== 会话 B ==========
START TRANSACTION;
INSERT INTO employees (emp_name, dept, score) VALUES ('孔蓝', '产品部', 70);
COMMIT;
-- ========== 会话 A 继续 ==========
-- 第二次范围查询:在 RR 隔离级别下,结果集仍然是 1 行
SELECT * FROM employees WHERE score > 50;
当前数据状态(T6,快照读无幻读):
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
结果解读:
- 会话 B 插入了一行
score = 70的新记录并提交 - 但会话 A 在 RR 级别下的普通 SELECT(快照读)仍然只看到 1 行
- 这是因为 MVCC 机制让会话 A 看到事务开始时的快照,新插入的行对会话 A 不可见
-- 会话 A 提交后再查询
COMMIT;
SELECT * FROM employees WHERE score > 50;
操作后的数据状态(新事务):
| emp_id | emp_name | dept | score |
|---|---|---|---|
| 1 | 大翔 | 技术部 | 100 |
| 3 | 孔蓝 | 产品部 | 70 |
注:演示后执行
DELETE FROM employees WHERE emp_name = '孔蓝';恢复为 2 条数据。
当前读下的幻读与 Gap Lock 防护
-- ========== 会话 A ==========
START TRANSACTION;
-- 当前读(加锁)
SELECT * FROM employees WHERE score > 50 FOR UPDATE;
当前数据状态:同上,1 行
-- ========== 会话 B ==========
START TRANSACTION;
-- 尝试插入 score = 70 的记录
INSERT INTO employees (emp_name, dept, score) VALUES ('黄俪', '产品部', 70);
-- 结果:被阻塞!等待会话 A 释放锁
结果解读:
- 会话 A 使用
FOR UPDATE进行当前读,InnoDB 会加 Next-Key Lock(行锁 + 间隙锁) - 会话 B 的 INSERT 操作被阻塞,因为间隙锁阻止了在
score > 50范围内的插入 - 这就是 MySQL 5.7 InnoDB 在 RR 级别下防止幻读的机制
-- 会话 A 提交后,会话 B 的 INSERT 才能继续
COMMIT;
场景五:SERIALIZABLE 隔离级别
SERIALIZABLE 是最高隔离级别,所有操作都加锁,完全串行化执行。
操作过程
-- ========== 会话 A ==========
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT score FROM employees WHERE emp_id = 1;
当前数据状态:
| score |
|---|
| 60 |
-- ========== 会话 B ==========
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
-- 尝试查询同一行
SELECT score FROM employees WHERE emp_id = 1;
-- 在 SERIALIZABLE 下,普通 SELECT 也会加共享锁
-- 但如果会话 A 已经加了锁,这里可能需要等待
-- 会话 A 更新数据
UPDATE employees SET score = 500 WHERE emp_id = 1;
COMMIT;
结果解读:
SERIALIZABLE下,即使是普通SELECT也会加共享锁- 并发事务对同一行的读写会被严格串行化
- 这种级别一致性最好,但并发性能最差,生产环境很少使用
隔离级别的选择
| 场景 | 推荐级别 |
|---|---|
| 强一致性要求(金融、库存) | REPEATABLE READ 或 SERIALIZABLE |
| 读多写少,允许一定不一致 | READ COMMITTED |
| 报表、日志分析 | READ COMMITTED |
| 极高并发,可接受脏读 | READ UNCOMMITTED(极少使用) |
常见误区
| 误区 | 正解 |
|---|---|
| 隔离级别越高越好 | 越高并发性能越差。SERIALIZABLE 基本等同于单线程。 |
| MySQL 的 REPEATABLE READ 有幻读 | InnoDB 通过 MVCC + Gap Lock 在 RR 级别基本解决了幻读(非完全,特殊场景仍可能出现)。 |
| RC 比 RR 性能一定好 | RC 减少了锁的持有范围,但某些场景下 RR 的读操作无需加锁(MVCC),反而更高效。 |
面试考点
Q:MySQL 默认的隔离级别是什么?为什么?
REPEATABLE READ。这是为了兼容早期的 Binlog 格式(Statement-based replication),在 RR 下复制更安全。READ COMMITTED 配合 Row-based replication 也是现代推荐方案。
Q:READ COMMITTED 和 REPEATABLE READ 的区别?
RC 每次查询生成新的 ReadView,能看到其他事务已提交的修改,存在不可重复读;RR 在事务开始时生成 ReadView,事务内始终看到同一快照,避免不可重复读。
Q:幻读和不可重复读的区别?
不可重复读是同一行数据被修改,两次读取内容不同;幻读是结果集的行数发生变化(新增或删除了符合条件的行)。
小结
- 四种隔离级别:RU、RC、RR、SERIALIZABLE
- MySQL 5.7 InnoDB 默认 RR,通过 MVCC 实现快照读
- 隔离级别越高,一致性越好,并发性能越差
- 大多数 OLTP 系统使用 RC 或 RR
下一章引子:隔离性通过锁和 MVCC 共同实现。接下来深入了解 InnoDB 的锁机制。