前置知识: SQL

脏读、不可重复读与幻读

3 min中级

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

1. 并发异常概述

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

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

2. 脏读(Dirty Read)

2.1 定义

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

2.2 场景示例

方言前提:PostgreSQL 不会发生脏读——它的 MVCC 实现根本不暴露未提交数据, READ UNCOMMITTED 会被静默当作 READ COMMITTED 处理。下面的脏读序列要在 MySQL(SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED)或 SQL Server 中才能复现。

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

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

-- 事务B(READ UNCOMMITTED)
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- MySQL 下返回 5000 ← 脏读!读到事务A未提交的修改
-- PostgreSQL 下返回 1000(READ UNCOMMITTED 形同 READ COMMITTED,无脏读)

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

-- 事务B基于 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 场景示例(MySQL InnoDB 专有行为)

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

-- 事务A(REPEATABLE READ,MySQL InnoDB)
BEGIN;
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;
-- 返回 3(普通 SELECT 是快照读,无幻读)
-- 但当前读会出现幻读:

-- 事务A执行更新(UPDATE 是当前读,读最新已提交版本)
UPDATE employees SET salary = salary + 100 WHERE dept_id = 5;
-- 影响了4行!(包括事务B插入的行)

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

方言对照:同样的序列在 PostgreSQL 的 REPEATABLE READ 下不会走到最后—— 事务快照固定后,UPDATE 试图修改快照外的新行会直接报 could not serialize access due to concurrent update。 “快照读看不到、当前读看得到”的幻读是 MySQL InnoDB 的特有模型。

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幻读(MySQL 已基本消除)
SERIALIZABLE无

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;