LOADING
4440 words
22 minutes
事务并发三大隐患:脏读、不可重复读、幻读,到底是什么?(二)

数据库事务系列 · 第 2 篇

引言:一个银行柜台的真实困境

先回忆一下第 1 篇的核心内容。

我们讲了事务的四大特性(ACID),其中 隔离性(Isolation) 是最复杂的一个。它的目标是:多个事务同时执行时,互不干扰

但「互不干扰」说起来容易,做起来难。

让我们回到一个真实的银行场景:

张三的账户余额是 100 元

柜员小张(终端A)正在处理一笔转账:张三要给李四转 100 元。操作分两步:① 张三账户减 100;② 李四账户加 100。

就在小张刚执行完第一步(张三余额变为 0)、正准备执行第二步时,柜员小李(终端B)接到一个查询请求:张三的余额是多少?

问题来了——小李应该看到什么?

  • 如果看到 0 元,但小张的事务最后失败了(回滚了),那小李看到的 0 元就是个「假数据」。
  • 如果看到 100 元(事务开始前的值),那说明小李被「隔离」了,看不到中间状态。
  • 如果小张的事务已经提交了,小李查到的结果变了,那小李之前做的决策可能就错了。

这就是隔离性要解决的核心矛盾:既要让多个事务并发执行(提高效率),又要保证每个事务看到的数据是正确的(保证准确)

为了解决这个矛盾,数据库提供了不同的「隔离级别」。级别越高,数据越准确,但效率越低;级别越低,效率越高,但数据越容易出错。

在正式介绍隔离级别之前,我们必须先搞清楚一件事:如果不做隔离控制,会发生什么问题?

这就是我们今天要讲的——并发事务的三大隐患

一、三大并发隐患

1. 脏读(Dirty Read)——读到「临时变卦」的数据

一句话定义

一个事务读取了另一个尚未提交的事务修改过的数据。

「尚未提交」意味着这个数据随时可能被回滚。读到这种数据,就像看了一张还没定稿的草稿纸——你当真了,但它其实是「脏」的。

场景再现:

把上面的银行场景细化一下:

时间柜员小张(终端A)柜员小李(终端B)张三余额
T1BEGIN;100
T2UPDATE account SET balance = 0 WHERE id = 1; (扣钱)0(未提交)
T3BEGIN;
T4SELECT balance FROM account WHERE id = 1;读到 0
T5ROLLBACK; (转账失败,回滚)恢复为 100
T6SELECT balance FROM account WHERE id = 1;读到 100

小李在 T4 时刻读到了 0 元,但小张在 T5 回滚了。 0 元从未真正存在过——它是一个「脏数据」。

为什么叫「脏读」?

因为这个数据是「不洁的」——它来自于一个还未确定的事务,随时可能被擦掉(回滚)。就像从垃圾桶里捡起一张写了半截的支票,以为能兑付,结果人家根本没签完。

对业务的影响(这部分最重要,请仔细看):

柜员小李读到余额为 0 后,可能做了以下事情:

  • 告诉客户:「您的余额不足,转账失败」→ 客户投诉
  • 拒绝了一笔贷款申请 → 银行失去一个客户
  • 在系统里记录了一条「余额不足」的日志 → 后续审计出问题

而这一切,都建立在一个「从未真实存在过」的数据之上。

总结:脏读的本质是「读到未提交的数据」。解决脏读,就是禁止读取未提交的数据。

2. 不可重复读(Non-Repeatable Read)——两次读取「翻脸不认人」

一句话定义:

一个事务内两次读取同一行数据,得到的结果不同。

「不可重复读」这个名字很直白——同样的查询,重复做,读出来的东西不一样。

场景再现:

时间柜员小李(终端B)柜员小张(终端A)张三余额
T1BEGIN;100
T2SELECT balance FROM account WHERE id = 1;读到 100
T3BEGIN;
T4UPDATE account SET balance = 0 WHERE id = 1;
T5COMMIT; (提交)0
T6SELECT balance FROM account WHERE id = 1;读到 0

同一个事务(小李的事务)内,两次读取同一行数据,结果从 100 变成了 0。

脏读 vs 不可重复读(重点区分):

脏读不可重复读
读到的是其他事务未提交的数据其他事务已提交的数据
关键问题数据可能被回滚,读的是「假数据」数据已提交,但两次读结果不一样
谁的问题其他事务还没决定好,你就读了其他事务决定好了,你前后不一致

打个比方:

  • 脏读:你看到同事屏幕上写了一半的文档,以为定稿了 —— 结果他删掉了。
  • 不可重复读:你打印了一份文档,同事修改后重新打印了一份,你手上两份不一样。

对业务的影响:

小李第一次查询余额为 100 元,据此做了一份财务报告。第二次查询发现是 0 元,报告作废。

更严重的场景:小李第一次查询后,批准了一笔以「余额 100 元」为担保的贷款。第二次查询发现余额为 0,贷款已经放出去了。

总结:不可重复读的本质是「事务内多次读取不一致」。解决不可重复读,就是保证事务内多次读取同一数据的结果不变。

3. 幻读(Phantom Read)——数据「凭空出现」或「突然消失」

一句话定义:

一个事务内两次查询同一范围的数据,结果集的行数不同。

「幻读」这个名字很形象——多出来的那行数据,就像「幻影」一样凭空出现。

场景再现:

时间柜员小李(终端B)柜员小张(终端A)账户数量
T1BEGIN;3 个账户
T2SELECT COUNT(*) FROM account WHERE balance > 0;结果为 3
T3BEGIN;
T4INSERT INTO account VALUES (4, '赵六', 50);
T5COMMIT;4 个账户
T6SELECT COUNT(*) FROM account WHERE balance > 0;结果为 4

同一个事务内,两次查询同一条件,行数从 3 变成了 4。

多出来的那一行,就像「幻影」一样。

不可重复读 vs 幻读(重点区分):

不可重复读幻读
关注点同一行数据的变了结果集的行数变了
引发操作其他事务的 UPDATE其他事务的 INSERT / DELETE
结果同一行内容不同多了一行或少了一行

打个比方(继续用文档的比喻):

  • 不可重复读:文档内容被修改了,你手上的版本和最新版本不一样。
  • 幻读:文档里凭空多了一段话,或者少了一段话,你数了数段落数对不上。

对业务的影响:

小李统计「余额大于 0 的账户数」为 3 个,据此做了资金分配方案。执行时发现多了一个账户(赵六),方案需要重新制定。

电商场景:运营人员统计「待发货订单」为 10 笔,准备批量处理。处理过程中,用户又下了 1 笔新订单,结果漏掉了这笔。

总结:幻读的本质是「事务内数据行数变化」。解决幻读,就是防止事务执行期间其他事务插入或删除影响结果集的数据。

二、三大问题对比总结🥰✨✨

对比维度脏读不可重复读幻读
一句话定义读到未提交的数据两次读同一行,结果不同两次查同一个范围,行数不同
根本原因读取了其他事务的中间状态数据被其他事务修改并提交数据行被其他事务增删
涉及操作SELECT + UPDATE(未提交)SELECT + UPDATE(已提交)SELECT + INSERT / DELETE
对业务的影响基于「假数据」做决策前后判断不一致统计不准,漏掉或重复
解决思路禁止读未提交的数据保证事务内多次读取一致禁止或锁定范围内的增删

一张图总结三大问题:

三、解决方案:四种隔离级别

前面讲的三个问题,是「没有隔离控制」时会发生的。数据库当然不会坐视不管——它提供了四种隔离级别,逐一解决这些问题。

隔离级别的整体阶梯

从低到高,依次是:

安全性从低到高,性能从高到低——这是一个经典 trade-off(权衡)。

⚠️ 关于 Repeatable Read 和幻读:MySQL 的 InnoDB 引擎通过「间隙锁(Gap Lock)」机制,在 Repeatable Read 级别下也基本避免了幻读。这个我们会在第 3 篇详细讲。

各个隔离级别详解

级别 1:Read Uncommitted(读未提交)

规则: 允许读取其他事务未提交的数据。

  • 优点: 并发性能最高(基本不加锁)
  • 缺点: 脏读、不可重复读、幻读都可能发生
  • 一句话评价: 几乎不用,只有在完全不在乎数据准确性的场景才可能考虑

级别 2:Read Committed(读已提交)—— Oracle / PostgreSQL / SQL Server 默认

规则: 只能读取其他事务已提交的数据。

  • 优点: 避免了脏读
  • 缺点: 不可重复读和幻读仍可能发生
  • 一句话评价: 大多数数据库的默认选择,在「安全性」和「性能」之间取得了较好平衡
  • 典型场景: 社交平台、内容管理系统等对「可重复读」不敏感的业务

级别 3:Repeatable Read(可重复读)—— MySQL 默认

规则: 事务内多次读取同一数据,结果始终一致。

  • 优点: 避免了脏读和不可重复读
  • 缺点: 幻读理论上仍可能发生
  • 一句话评价: MySQL 的默认选择,通过 MVCC 实现了「事务内读取一致性」——即使其他事务提交了,当前事务读到的还是事务开始时的数据
  • 典型场景: 订单系统、库存管理等对数据一致性要求较高的业务

级别 4:Serializable(串行化)—— 最强但最慢

规则: 所有事务串行执行,一个做完下一个才能开始。

  • 优点: 避免了所有并发问题
  • 缺点: 性能极低(等于把并发请求变成了排队)
  • 一句话评价: 极少使用,只有在对一致性要求极高且并发量极小的场景才考虑
  • 典型场景: 银行核心账务的某些关键操作

不同数据库的默认隔离级别(常识积累)

数据库默认隔离级别
MySQL(InnoDB)Repeatable Read(可重复读)
OracleRead Committed(读已提交)
PostgreSQLRead Committed(读已提交)
SQL ServerRead Committed(读已提交)

面试中常问:「为什么 MySQL 用 Repeatable Read,而 Oracle 用 Read Committed?」——历史原因 + MySQL 的 InnoDB 通过间隙锁在 RR 下也能避免幻读,所以选择 RR 作为默认值。

四、实操演示

光看理论不够,我们来实际操作,亲眼看看每种隔离级别的表现。

准备工作

查看当前隔离级别:

-- MySQL 8.x 查看当前会话的隔离级别
SELECT @@transaction_isolation;
-- 或者
SHOW VARIABLES LIKE 'transaction_isolation%';

设置隔离级别:

-- 设置当前会话的隔离级别(推荐,不影响其他连接)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置全局隔离级别(影响所有新连接,需要权限)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

⚠️ 致命提醒:设置隔离级别必须在开启事务之前!一旦执行了 BEGIN;,再修改隔离级别就不生效了。

数据准备:

-- 确保只有一条记录
DELETE FROM account;
INSERT INTO account VALUES (1, '张三', 100);

演示一:Read Uncommitted —— 亲眼看到脏读

目标: 演示一个事务如何读到另一个事务未提交的数据。

Step 1:两个终端都设置隔离级别

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

Step 2:终端A 开启事务,修改张三余额但不提交

BEGIN;
UPDATE account SET balance = 0 WHERE id = 1;

现在张三的余额在终端A中变成了 0,但事务还没提交。

Step 3:终端B 查询张三余额

SELECT * FROM account WHERE id = 1;

关键来了: 终端B 读到的 0,是终端A 还没确定的数据(可能回滚)。

Step 4:终端A 回滚事务

ROLLBACK;

Step 5:终端B 再次查询

SELECT * FROM account WHERE id = 1;

结果展示:

终端B 第一次查询:balance = 0 ← 读到脏数据
终端B 第二次查询:balance = 100 ← 回滚后恢复

结论: Read Uncommitted 允许脏读。终端B 读到的是一个「从未真实存在过」的数据。

对业务的影响: 柜员小李看到余额为 0,拒绝了客户的取款请求。但实际上张三是 100 元,只是因为小张的操作还没完成。这就是脏读带来的真实业务风险。

演示二:Read Committed —— 脏读消失,但不可重复读出现

目标: 演示 Read Committed 如何避免脏读,以及它为什么仍会有不可重复读。

Step 1:两个终端切换隔离级别

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

Step 2:终端B 开启事务,第一次查询

BEGIN;
SELECT * FROM account WHERE id = 1;

Step 3:终端A 修改并提交

BEGIN;
UPDATE account SET balance = 0 WHERE id = 1;
COMMIT;

Step 4:终端B 同一个事务内第二次查询

SELECT * FROM account WHERE id = 1;

结果展示:

终端B 第一次查询(事务内):balance = 100
终端B 第二次查询(同一事务内):balance = 0 ← 不可重复读发生!

结论: Read Committed 避免了脏读(读不到未提交的数据),但无法避免不可重复读(同一事务内两次查询结果不同)。

对业务的影响: 柜员小李在同一个事务里,第一次看到 100 元,第二次看到 0 元。如果她第一次查询后做了财务记录,第二次查询后需要推翻重来。

演示三:Repeatable Read —— 不可重复读消失

目标: 演示 Repeatable Read 如何让事务内多次读取结果一致。

Step 1:两个终端切换隔离级别

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

Step 2:终端B 开启事务,第一次查询

BEGIN;
SELECT * FROM account WHERE id = 1;

Step 3:终端A 修改并提交

BEGIN;
UPDATE account SET balance = 0 WHERE id = 1;
COMMIT;

Step 4:终端B 同一个事务内第二次查询

SELECT * FROM account WHERE id = 1;

Step 5:终端B 提交事务,再次查询

COMMIT;
SELECT * FROM account WHERE id = 1;

结果展示:

终端B 事务内第一次查询:balance = 100
终端B 事务内第二次查询:balance = 100 ← 没变!
终端B 提交后再查:balance = 0 ← 事务结束后才看到新数据

结论: Repeatable Read 保证了事务内多次读取同一数据结果一致——即使其他事务已经提交了修改。

对业务的影响: 柜员小李在整个事务期间,看到的是一个「稳定快照」——无论小张怎么改,小李看到的数据始终是事务开始时的状态。这保证了决策的一致性。她提交事务后,才能看到最新的数据。

演示四:Serializable —— 最强隔离,完全串行

目标: 演示 Serializable 如何通过「排队执行」避免所有并发问题。

Step 1:两个终端切换隔离级别

SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

Step 2:终端B 开启事务,查询数据

BEGIN;
SELECT * FROM account WHERE id = 1;

Step 3:终端A 尝试修改数据

BEGIN;
UPDATE account SET balance = 0 WHERE id = 1;

此时终端A 被阻塞(等待中),因为终端B 的读锁还没释放。

Step 4:终端B 提交事务

COMMIT;

终端A 的 UPDATE 立即执行成功。

结果展示:

终端B 持有读锁 → 终端A 的 UPDATE 被阻塞
终端B 提交 → 终端A 的 UPDATE 才执行

结论: Serializable 通过强制串行执行,避免了所有并发问题,但代价是并发性能极低——所有操作排队。

对业务的影响: 银行核心账务中的某些操作,宁可慢,也不能错。Serializable 提供了「绝对正确」,但代价是其他人需要等待。

五、附录:完整操作命令速查

隔离级别相关命令

-- 查看当前会话隔离级别
SELECT @@transaction_isolation;
-- 设置当前会话隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

演示中使用的数据

-- 初始化数据
DELETE FROM account;
INSERT INTO account VALUES (1, '张三', 100);
-- 查看数据
SELECT * FROM account;
-- 清空数据(重新演示用)
TRUNCATE account;

六、全文总结

核心知识点回顾

问题定义解决级别
脏读读到其他事务未提交的数据Read Committed
不可重复读同一事务内两次读同一行,结果不同Repeatable Read
幻读同一事务内两次查同一范围,行数不同Serializable

隔离级别速记表

隔离级别脏读不可重复读幻读性能
Read Uncommitted最高
Read Committed
Repeatable Read⚠️
Serializable最低

一句话记住全部

脏读是读到了「未决定」的数据,不可重复读是「同一行数据变了」,幻读是「行数变了」。

Read Uncommitted 全都不防,Read Committed 防脏读,Repeatable Read 防脏读+不可重复读,Serializable 全都防。

下篇预告

本文我们用「张三的余额」作为线索,演示了四种隔离级别的差异。

但你可能会问:

  • Repeatable Read 是怎么做到的? 为什么终端A已经提交了,终端B还是读不到?
  • MySQL 在 Repeatable Read 下,到底有没有解决幻读?怎么解决的?
  • MVCC 到底是什么关系?
事务并发三大隐患:脏读、不可重复读、幻读,到底是什么?(二)
/posts/2026-6-27/2-事务并发三大隐患脏读不可重复读幻读/
Author
Atopos
Published at
2026-06-27
License
CC BY-NC-SA 4.0

Some information may be outdated