MySQL幻读问题深度剖析
InnoDB 的 RR 级别能否完全避免幻读
在 MySQL InnoDB 存储引擎中,REPEATABLE READ(可重复读)是默认的事务隔离级别。虽然 SQL-92 标准定义 RR 级别无法防止幻读,但 InnoDB 通过 MVCC + 间隙锁 的组合,在大部分场景下能够避免幻读的发生。
然而,这并不意味着 RR 级别完全解决了幻读问题。在某些特定场景下,幻读依然会发生。
核心结论
InnoDB 的 RR 级别:
- ✓ 通过 MVCC 解决了快照读场景下的幻读
- ✓ 通过 间隙锁 解决了部分当前读场景下的幻读
- ✗ 无法完全避免所有幻读,在混合使用快照读和当前读时仍可能发生
要彻底解决幻读,只能使用 SERIALIZABLE 隔离级别。
graph TB
A["InnoDB RR 级别"] --> B["快照读场景"]
A --> C["当前读场景"]
A --> D["混合读场景"]
B --> B1["✓ MVCC 解决<br/>读取固定快照版本"]
C --> C1["✓ 间隙锁解决<br/>锁定范围防止插入"]
D --> D1["✗ 仍可能幻读<br/>快照读+当前读切换"]
style A fill:#2196F3,stroke:#1976D2,rx:10,ry:10
style B fill:#4CAF50,stroke:#388E3C,rx:10,ry:10
style C fill:#4CAF50,stroke:#388E3C,rx:10,ry:10
style D fill:#F44336,stroke:#D32F2F,rx:10,ry:10
style B1 fill:#81C784,stroke:#66BB6A,rx:10,ry:10
style C1 fill:#81C784,stroke:#66BB6A,rx:10,ry:10
style D1 fill:#E57373,stroke:#EF5350,rx:10,ry:10MVCC 如何解决快照读的幻读
快照读是指普通的 SELECT 语句(不加锁),在 RR 级别下通过 MVCC 机制实现。
快照读工作原理
在 RR 隔离级别下:
- 事务第一次执行 SELECT 时生成 ReadView
- 后续所有快照读都复用这个 ReadView
- 只能读取在 ReadView 创建时已存在的数据版本
示例场景:
创建测试表:
CREATE TABLE employee (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50),
department VARCHAR(50),
salary DECIMAL(10, 2)
) ENGINE=InnoDB;
INSERT INTO employee (name, department, salary) VALUES
('李明', 'Sales', 8000),
('王芳', 'Sales', 9000),
('张伟', 'Sales', 7500);事务时序:
sequenceDiagram
participant T1 as 事务 1 (RR)
participant DB as 数据库
participant T2 as 事务 2
T1->>DB: BEGIN
T1->>DB: SELECT COUNT(*) FROM employee<br/>WHERE department='Sales'
Note over T1: 生成 ReadView<br/>返回 3 条记录
T2->>DB: BEGIN
T2->>DB: INSERT INTO employee VALUES<br/>(4, '赵敏', 'Sales', 8500)
T2->>DB: COMMIT
Note over DB: 新增 1 条记录
T1->>DB: SELECT COUNT(*) FROM employee<br/>WHERE department='Sales'
Note over T1: 复用 ReadView<br/>仍返回 3 条记录
T1->>DB: COMMIT实际操作演示:
-- 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 事务 1
BEGIN;
SELECT * FROM employee WHERE department = 'Sales';
-- 返回 3 条记录:李明、王芳、张伟
-- 事务 2(在另一个会话中)
BEGIN;
INSERT INTO employee (name, department, salary)
VALUES ('赵敏', 'Sales', 8500);
COMMIT;
-- 事务 1:再次查询
SELECT * FROM employee WHERE department = 'Sales';
-- 仍然返回 3 条记录(不包括赵敏)
-- 没有发生幻读!
COMMIT;原理解释:
事务 1 第一次查询时生成 ReadView,包含当前活跃事务列表。事务 2 插入新记录时,新记录的 trx_id 大于事务 1 的 ReadView 范围,因此对事务 1 不可见。
间隙锁如何解决当前读的幻读
当前读是指加锁的 SELECT 或写操作(UPDATE、DELETE、INSERT),需要读取最新版本的数据。
间隙锁工作原理
在 RR 级别下,当前读会使用 Next-Key Lock(记录锁 + 间隙锁) 锁定查询范围:
- Record Lock:锁定已存在的索引记录
- Gap Lock:锁定索引记录之间的间隙,防止其他事务插入新记录
graph LR
A["(-∞, 10]"] --> B["索引值 10"]
B --> C["(10, 20]"]
C --> D["索引值 20"]
D --> E["(20, 30]"]
E --> F["索引值 30"]
F --> G["(30, +∞)"]
style B fill:#4CAF50,stroke:#388E3C,rx:10,ry:10
style D fill:#4CAF50,stroke:#388E3C,rx:10,ry:10
style F fill:#4CAF50,stroke:#388E3C,rx:10,ry:10
style A fill:#FF9800,stroke:#F57C00,rx:10,ry:10
style C fill:#FF9800,stroke:#F57C00,rx:10,ry:10
style E fill:#FF9800,stroke:#F57C00,rx:10,ry:10
style G fill:#FF9800,stroke:#F57C00,rx:10,ry:10示例场景:
假设员工表有索引 (department, salary),当前数据:
| id | name | department | salary |
|---|---|---|---|
| 1 | 李明 | Sales | 8000 |
| 2 | 王芳 | Sales | 9000 |
| 3 | 张伟 | Sales | 7500 |
-- 事务 1(RR 级别)
BEGIN;
SELECT * FROM employee
WHERE department = 'Sales' AND salary > 7000
FOR UPDATE;
-- 锁定范围:
-- 1. Record Lock: 锁定 id=1,2,3 的记录
-- 2. Gap Lock: 锁定 salary 在 (7000, +∞) 的间隙
-- 事务 2:尝试插入新销售人员
INSERT INTO employee (name, department, salary)
VALUES ('赵敏', 'Sales', 8500);
-- 阻塞!因为 salary=8500 在锁定的间隙内
-- 事务 2:尝试插入其他部门人员
INSERT INTO employee (name, department, salary)
VALUES ('孙丽', 'Marketing', 8500);
-- 成功!department 不是 'Sales',不在锁定范围事务时序图:
sequenceDiagram
participant T1 as 事务 1 (RR)
participant DB as 数据库
participant T2 as 事务 2
T1->>DB: BEGIN
T1->>DB: SELECT * FROM employee<br/>WHERE dept='Sales' FOR UPDATE
Note over DB: 加 Next-Key Lock<br/>锁定记录和间隙
T2->>DB: BEGIN
T2->>DB: INSERT INTO employee<br/>(name, dept, salary)<br/>VALUES ('赵敏', 'Sales', 8500)
Note over T2: 阻塞等待...
T1->>DB: 再次查询
Note over T1: 仍是 3 条记录<br/>没有幻读
T1->>DB: COMMIT
Note over DB: 释放锁
T2->>DB: 插入成功
T2->>DB: COMMIT通过间隙锁,事务 1 成功避免了幻读。
RR 级别无法解决的幻读场景
尽管 MVCC 和间隙锁能解决大部分幻读问题,但在以下场景下,RR 级别仍可能发生幻读。
场景 1:快照读后执行当前读
当一个事务先进行快照读,然后在其他事务插入数据后执行当前读,就会发生幻读。
问题原因: 快照读和当前读使用不同的读取机制:
- 快照读:基于 ReadView 读取历史版本
- 当前读:读取最新版本并加锁
示例演示:
-- 事务 1
BEGIN;
-- 1. 快照读
SELECT * FROM employee WHERE department = 'Sales';
-- 返回 3 条记录
-- 事务 2(在另一个会话中)
BEGIN;
INSERT INTO employee (name, department, salary)
VALUES ('赵敏', 'Sales', 8500);
COMMIT;
-- 事务 1
-- 2. 当前读(加锁查询)
SELECT * FROM employee WHERE department = 'Sales' FOR UPDATE;
-- 返回 4 条记录(包括赵敏)
-- 发生幻读!同一事务两次查询结果不同
COMMIT;时序图分析:
sequenceDiagram
participant T1 as 事务 1
participant RV as ReadView
participant DB as 最新数据
participant T2 as 事务 2
T1->>RV: 1. SELECT (快照读)
RV-->>T1: 返回 3 条记录
T2->>DB: INSERT 新记录
T2->>DB: COMMIT
T1->>DB: 2. SELECT FOR UPDATE (当前读)
DB-->>T1: 返回 4 条记录
Note over T1: 幻读发生!<br/>第一次 3 条,第二次 4 条场景 2:快照读后执行 UPDATE
如果事务先进行快照读,然后执行 UPDATE 操作(当前读),再进行快照读,也会发生幻读。
问题原因: UPDATE 是当前读操作,会更新事务的 ReadView,后续快照读会读到新版本。
示例演示:
-- 事务 1
BEGIN;
-- 1. 快照读
SELECT * FROM employee WHERE department = 'Sales';
-- 返回 3 条记录
-- 事务 2
BEGIN;
INSERT INTO employee (name, department, salary)
VALUES ('赵敏', 'Sales', 8500);
COMMIT;
-- 事务 1
-- 2. 更新操作(当前读)
UPDATE employee SET salary = salary + 100
WHERE department = 'Sales';
-- 更新了 4 条记录(包括赵敏)
-- 3. 快照读
SELECT * FROM employee WHERE department = 'Sales';
-- 返回 4 条记录(包括赵敏)
-- 发生幻读!第一次 3 条,第三次 4 条
COMMIT;原理解释:
- 第一次 SELECT:生成 ReadView,读取 3 条记录
- 事务 2 插入新记录并提交
- UPDATE 操作是当前读,能看到事务 2 插入的记录,并更新了这条记录
- 由于事务 1 修改了新记录,该记录的
trx_id变成了事务 1 的 ID - 第三次 SELECT:快照读时,新记录的
trx_id是自己的事务 ID,因此可见
graph TB
A["事务 1 开始"] --> B["快照读:3 条记录"]
B --> C["事务 2 插入新记录"]
C --> D["UPDATE 当前读<br/>更新 4 条记录"]
D --> E["ReadView 更新<br/>新记录 trx_id=事务1"]
E --> F["快照读:4 条记录"]
F --> G["幻读发生!"]
style A fill:#4CAF50,stroke:#388E3C,rx:10,ry:10
style G fill:#F44336,stroke:#D32F2F,rx:10,ry:10场景 3:间隙锁未及时加锁
如果事务开始时没有立即使用当前读加锁,而是先进行了快照读,那么间隙锁无法生效。
示例演示:
-- 事务 1
BEGIN;
-- 1. 快照读(未加锁)
SELECT * FROM employee WHERE department = 'Sales';
-- 此时没有加间隙锁
-- 事务 2
BEGIN;
INSERT INTO employee (name, department, salary)
VALUES ('赵敏', 'Sales', 8500);
COMMIT;
-- 成功插入!因为事务 1 没有加间隙锁
-- 事务 1
-- 2. 当前读(加锁)
SELECT * FROM employee WHERE department = 'Sales' FOR UPDATE;
-- 返回 4 条记录(包括赵敏)
-- 发生幻读!
COMMIT;关键点: 间隙锁只有在当前读时才会加锁。如果事务开始时使用快照读,就无法阻止其他事务插入新记录。
如何避免幻读
基于以上分析,在 InnoDB 的 RR 级别下避免幻读的方法:
方法 1:使用 SERIALIZABLE 隔离级别(彻底解决)
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM employee WHERE department = 'Sales';
-- 自动加共享锁,完全串行化
COMMIT;优点: 彻底解决幻读
缺点: 性能最差,并发度极低
方法 2:在 RR 级别下优先使用快照读
BEGIN;
-- 只使用快照读
SELECT * FROM employee WHERE department = 'Sales';
SELECT * FROM employee WHERE department = 'Sales';
-- 两次查询结果一致,不会幻读
COMMIT;适用场景: 只读事务或读多写少的场景
方法 3:在 RR 级别下事务开始时立即加锁
BEGIN;
-- 立即使用当前读加锁
SELECT * FROM employee WHERE department = 'Sales' FOR UPDATE;
-- 加上间隙锁,防止其他事务插入
-- 后续操作
UPDATE employee SET salary = salary + 100
WHERE department = 'Sales';
COMMIT;关键: 事务一开始就用 FOR UPDATE 或 LOCK IN SHARE MODE 加锁,启用间隙锁保护。
注意: 间隙锁可能导致死锁,需谨慎使用。
方法 4:避免在同一事务中混用快照读和当前读
-- 不推荐:混用快照读和当前读
BEGIN;
SELECT * FROM employee WHERE department = 'Sales'; -- 快照读
UPDATE employee SET salary = salary + 100 WHERE department = 'Sales'; -- 当前读
SELECT * FROM employee WHERE department = 'Sales'; -- 快照读
COMMIT;
-- 推荐:统一使用当前读
BEGIN;
SELECT * FROM employee WHERE department = 'Sales' FOR UPDATE; -- 当前读
UPDATE employee SET salary = salary + 100 WHERE department = 'Sales'; -- 当前读
SELECT * FROM employee WHERE department = 'Sales' FOR UPDATE; -- 当前读
COMMIT;总结
InnoDB 的 RR 隔离级别对幻读的处理:
| 场景 | 是否解决幻读 | 机制 |
|---|---|---|
| 纯快照读 | ✓ 解决 | MVCC,ReadView 固定 |
| 纯当前读(事务开始立即加锁) | ✓ 解决 | 间隙锁防止插入 |
| 快照读 → 当前读 | ✗ 未解决 | ReadView 与最新数据不一致 |
| 快照读 → UPDATE → 快照读 | ✗ 未解决 | UPDATE 更新 ReadView |
| 快照读后其他事务插入 | ✗ 未解决 | 未及时加间隙锁 |
实践建议:
- 对于只读查询,使用快照读,性能高且避免幻读
- 对于需要修改的事务,开始时立即用
FOR UPDATE加锁 - 避免在同一事务中混用快照读和当前读
- 如需彻底避免幻读,使用 SERIALIZABLE 级别(性能代价大)
- 理解间隙锁的工作原理,避免死锁
幻读问题的根本在于并发事务的数据可见性。理解 MVCC 和锁机制的原理,能够帮助我们设计更可靠的事务逻辑。
更新: 2025-12-04 17:37:42
原文: https://www.yuque.com/u22210564/zoxfmt/doc-01-mysql-11-mysql-15