数据库事务系列 · 第 2 篇
引言:一个银行柜台的真实困境
先回忆一下第 1 篇的核心内容。
我们讲了事务的四大特性(ACID),其中 隔离性(Isolation) 是最复杂的一个。它的目标是:多个事务同时执行时,互不干扰。
但「互不干扰」说起来容易,做起来难。
让我们回到一个真实的银行场景:
张三的账户余额是 100 元。
柜员小张(终端A)正在处理一笔转账:张三要给李四转 100 元。操作分两步:① 张三账户减 100;② 李四账户加 100。
就在小张刚执行完第一步(张三余额变为 0)、正准备执行第二步时,柜员小李(终端B)接到一个查询请求:张三的余额是多少?
问题来了——小李应该看到什么?
- 如果看到 0 元,但小张的事务最后失败了(回滚了),那小李看到的 0 元就是个「假数据」。
- 如果看到 100 元(事务开始前的值),那说明小李被「隔离」了,看不到中间状态。
- 如果小张的事务已经提交了,小李查到的结果变了,那小李之前做的决策可能就错了。
这就是隔离性要解决的核心矛盾:既要让多个事务并发执行(提高效率),又要保证每个事务看到的数据是正确的(保证准确)。
为了解决这个矛盾,数据库提供了不同的「隔离级别」。级别越高,数据越准确,但效率越低;级别越低,效率越高,但数据越容易出错。
在正式介绍隔离级别之前,我们必须先搞清楚一件事:如果不做隔离控制,会发生什么问题?
这就是我们今天要讲的——并发事务的三大隐患。
一、三大并发隐患
1. 脏读(Dirty Read)——读到「临时变卦」的数据
一句话定义:
一个事务读取了另一个尚未提交的事务修改过的数据。
「尚未提交」意味着这个数据随时可能被回滚。读到这种数据,就像看了一张还没定稿的草稿纸——你当真了,但它其实是「脏」的。
场景再现:
把上面的银行场景细化一下:
| 时间 | 柜员小张(终端A) | 柜员小李(终端B) | 张三余额 |
|---|---|---|---|
| T1 | BEGIN; | 100 | |
| T2 | UPDATE account SET balance = 0 WHERE id = 1; (扣钱) | 0(未提交) | |
| T3 | BEGIN; | ||
| T4 | SELECT balance FROM account WHERE id = 1; | 读到 0 | |
| T5 | ROLLBACK; (转账失败,回滚) | 恢复为 100 | |
| T6 | SELECT balance FROM account WHERE id = 1; | 读到 100 |
小李在 T4 时刻读到了 0 元,但小张在 T5 回滚了。 0 元从未真正存在过——它是一个「脏数据」。
为什么叫「脏读」?
因为这个数据是「不洁的」——它来自于一个还未确定的事务,随时可能被擦掉(回滚)。就像从垃圾桶里捡起一张写了半截的支票,以为能兑付,结果人家根本没签完。
对业务的影响(这部分最重要,请仔细看):
柜员小李读到余额为 0 后,可能做了以下事情:
- 告诉客户:「您的余额不足,转账失败」→ 客户投诉
- 拒绝了一笔贷款申请 → 银行失去一个客户
- 在系统里记录了一条「余额不足」的日志 → 后续审计出问题
而这一切,都建立在一个「从未真实存在过」的数据之上。
总结:脏读的本质是「读到未提交的数据」。解决脏读,就是禁止读取未提交的数据。
2. 不可重复读(Non-Repeatable Read)——两次读取「翻脸不认人」
一句话定义:
一个事务内两次读取同一行数据,得到的结果不同。
「不可重复读」这个名字很直白——同样的查询,重复做,读出来的东西不一样。
场景再现:
| 时间 | 柜员小李(终端B) | 柜员小张(终端A) | 张三余额 |
|---|---|---|---|
| T1 | BEGIN; | 100 | |
| T2 | SELECT balance FROM account WHERE id = 1; | 读到 100 | |
| T3 | BEGIN; | ||
| T4 | UPDATE account SET balance = 0 WHERE id = 1; | ||
| T5 | COMMIT; (提交) | 0 | |
| T6 | SELECT balance FROM account WHERE id = 1; | 读到 0 |
同一个事务(小李的事务)内,两次读取同一行数据,结果从 100 变成了 0。
脏读 vs 不可重复读(重点区分):
| 脏读 | 不可重复读 | |
|---|---|---|
| 读到的是 | 其他事务未提交的数据 | 其他事务已提交的数据 |
| 关键问题 | 数据可能被回滚,读的是「假数据」 | 数据已提交,但两次读结果不一样 |
| 谁的问题 | 其他事务还没决定好,你就读了 | 其他事务决定好了,你前后不一致 |
打个比方:
- 脏读:你看到同事屏幕上写了一半的文档,以为定稿了 —— 结果他删掉了。
- 不可重复读:你打印了一份文档,同事修改后重新打印了一份,你手上两份不一样。
对业务的影响:
小李第一次查询余额为 100 元,据此做了一份财务报告。第二次查询发现是 0 元,报告作废。
更严重的场景:小李第一次查询后,批准了一笔以「余额 100 元」为担保的贷款。第二次查询发现余额为 0,贷款已经放出去了。
总结:不可重复读的本质是「事务内多次读取不一致」。解决不可重复读,就是保证事务内多次读取同一数据的结果不变。
3. 幻读(Phantom Read)——数据「凭空出现」或「突然消失」
一句话定义:
一个事务内两次查询同一范围的数据,结果集的行数不同。
「幻读」这个名字很形象——多出来的那行数据,就像「幻影」一样凭空出现。
场景再现:
| 时间 | 柜员小李(终端B) | 柜员小张(终端A) | 账户数量 |
|---|---|---|---|
| T1 | BEGIN; | 3 个账户 | |
| T2 | SELECT COUNT(*) FROM account WHERE balance > 0; | 结果为 3 | |
| T3 | BEGIN; | ||
| T4 | INSERT INTO account VALUES (4, '赵六', 50); | ||
| T5 | COMMIT; | 4 个账户 | |
| T6 | SELECT 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(可重复读) |
| Oracle | Read Committed(读已提交) |
| PostgreSQL | Read Committed(读已提交) |
| SQL Server | Read 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 到底是什么关系?
Some information may be outdated