前置知识: SQL

脏读不可重复读幻读

3 minIntermediate2026/6/14

SQL并发异常:脏读、不可重复读、幻读的定义、示例、区别与防护策略

1. 并发异常概述

当多个事务并发执行时,可能产生三种数据不一致问题:

异常英文影响严重程度
脏读Dirty Read读到未提交数据
不可重复读Non-Repeatable Read同一查询结果不同
幻读Phantom Read行数变化

2. 脏读(Dirty Read)

2.1 定义

事务A读取了事务B未提交的修改,如果事务B回滚,事务A读到的就是无效数据。

2.2 场景示例

-- 初始状态:账户1余额 = 1000

-- 事务A(READ UNCOMMITTED)
BEGIN ISOLATION LEVEL READ UNCOMMITTED;
SELECT balance FROM accounts WHERE id = 1;
-- 返回 1000

-- 事务B
BEGIN;
UPDATE accounts SET balance = 5000 WHERE id = 1;
-- 未提交

-- 事务A
SELECT balance FROM accounts WHERE id = 1;
-- 返回 5000 ← 脏读!读到事务B未提交的修改

-- 事务B回滚
ROLLBACK;
-- balance 恢复为 1000

-- 事务A基于 5000 做出的决策是错误的

2.3 脏读的危害

场景:电商库存
T1: UPDATE stock SET count = 0 WHERE product_id = 100;  -- 库存清零
T2: SELECT count FROM stock WHERE product_id = 100;     -- 读到 0
T2: -- 判断库存不足,拒绝用户下单
T1: ROLLBACK;  -- 库存恢复为 10
-- 结果:用户被错误拒绝,实际有库存

2.4 防护

  • 使用 READ COMMITTED 及以上隔离级别
  • 几乎所有生产环境都不使用 READ UNCOMMITTED

3. 不可重复读(Non-Repeatable Read)

3.1 定义

事务A两次读取同一行数据,中间事务B修改并提交了该行,导致两次读取结果不同。

3.2 场景示例

-- 初始状态:账户1余额 = 1000

-- 事务A(READ COMMITTED)
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1;
-- 返回 1000

-- 事务B
BEGIN;
UPDATE accounts SET balance = 2000 WHERE id = 1;
COMMIT;

-- 事务A再次读取同一行
SELECT balance FROM accounts WHERE id = 1;
-- 返回 2000 ← 不可重复读!同一事务内两次读取结果不同

3.3 不可重复读的危害

场景:审计对账
T1: SELECT SUM(balance) FROM accounts;       -- 总额 10000
T2: UPDATE accounts SET balance = balance + 1000 WHERE id = 1; COMMIT;
T1: SELECT SUM(balance) FROM accounts;       -- 总额 11000
-- 两次汇总结果不一致,审计报告不准确

3.4 防护

-- 方法1:使用 REPEATABLE READ 隔离级别
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;  -- 1000
-- 事务B修改并提交
SELECT balance FROM accounts WHERE id = 1;  -- 仍然是 1000

-- 方法2:使用 SELECT FOR UPDATE 锁定行
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- 加锁
-- 事务B无法修改该行(被锁阻塞)
SELECT balance FROM accounts WHERE id = 1;  -- 一致
COMMIT;

4. 幻读(Phantom Read)

4.1 定义

事务A两次执行相同的范围查询,中间事务B插入并提交了新行,导致第二次查询多出”幻影行”。

4.2 场景示例

-- 初始状态:dept_id = 5 有3名员工

-- 事务A(REPEATABLE READ)
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM employees WHERE dept_id = 5;
-- 返回 3

-- 事务B
INSERT INTO employees (name, dept_id) VALUES ('新员工', 5);
COMMIT;

-- 事务A再次查询
SELECT COUNT(*) FROM employees WHERE dept_id = 5;
-- MVCC 下:返回 3(快照读无幻读)
-- 但当前读可能出现幻读:

-- 事务A执行更新
UPDATE employees SET salary = salary + 100 WHERE dept_id = 5;
-- 影响了4行!(包括事务B插入的行)

SELECT COUNT(*) FROM employees WHERE dept_id = 5;
-- 返回 4 ← 幻读!

4.3 幻读 vs 不可重复读

特性不可重复读幻读
影响对象已存在的行被修改新行被插入或删除
范围行级锁间隙锁/谓词锁
SQL 语句UPDATE/DELETEINSERT
防护方式REPEATABLE READSERIALIZABLE

4.4 防护

-- 方法1:使用 SERIALIZABLE 隔离级别
BEGIN ISOLATION LEVEL SERIALIZABLE;

-- 方法2:MySQL InnoDB 使用 Next-Key Lock
BEGIN;
SELECT * FROM employees WHERE dept_id = 5 FOR UPDATE;
-- 锁定 dept_id = 5 的所有行及间隙
-- 事务B无法插入 dept_id = 5 的新行

-- 方法3:应用层使用 advisory lock(PostgreSQL)
SELECT pg_advisory_lock(5);  -- 锁定部门5
-- 执行操作
SELECT pg_advisory_unlock(5);

5. 三种异常的完整对比

5.1 时间线对比

脏读

T1: BEGIN;     UPDATE → value=200 (未提交)
T2:                    SELECT → 200 (脏读!)
T1: ROLLBACK;  (value 恢复为 100)

不可重复读

T1: BEGIN;     SELECT → 100
T2:                    UPDATE → 200; COMMIT;
T1:             SELECT → 200 (不可重复读!)

幻读

T1: BEGIN;     SELECT COUNT → 3
T2:                    INSERT; COMMIT;
T1:             SELECT COUNT → 4 (幻读!)

5.2 隔离级别与异常关系

READ UNCOMMITTED{脏读, 不可重复读, 幻读}READ COMMITTED{不可重复读, 幻读}REPEATABLE READ{幻读}SERIALIZABLE=\begin{aligned} \text{READ UNCOMMITTED} &\supseteq \{\text{脏读, 不可重复读, 幻读}\} \\ \text{READ COMMITTED} &\supseteq \{\text{不可重复读, 幻读}\} \\ \text{REPEATABLE READ} &\supseteq \{\text{幻读}\} \\ \text{SERIALIZABLE} &= \emptyset \end{aligned}

6. 实战防护策略

6.1 选择合适的隔离级别

-- 大多数 OLTP 场景:READ COMMITTED 足够
-- 需要一致性读取:REPEATABLE READ
-- 严格一致性:SERIALIZABLE(性能代价大)

6.2 乐观锁替代高隔离级别

-- 使用版本号实现乐观锁
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 5 AND stock > 0;
-- 如果影响行数为0,说明并发冲突,重试

6.3 SELECT FOR UPDATE 精确加锁

-- 只锁定需要的行,避免提升隔离级别
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- 检查余额
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;