Smart Kitchen 智能厨房今天开业了——这是一间数字化的后厨,几位厨师(事务)在同一张操作台上同时备菜。操作台上摆着系统里的一份份“食材档案”(数据),每位厨师的每一步操作都会被实时记录。很快,问题就来了:有人按着别人做到一半的“半成品”菜谱备菜,结果那份菜被推翻重做,白忙一场;有人两次查看同一份食材档案,内容却对不上;还有人清点灶台上的锅,数着数着竟多出一口。这三种混乱,正是数据库并发世界里的三大经典异常:脏读、不可重复读和幻读。为了治理这些混乱,厨房出台了四套“协作守则”——也就是事务隔离级别。这篇文章,就用这间厨房的故事,把三兄弟、四级防护网、以及背后的锁机制和 MVCC 一次讲透,从此面试和开发都不再犯怵。
对 AI 从业者而言,事务隔离级别是数据库并发编程的“安全底线”。无论是训练数据管道、特征存储写入,还是在线推理服务的状态管理,理解脏读、幻读与 MVCC,能让你在设计存储方案时避开数据不一致的深坑。
可视化演示
动态演示数据库脏读幻读不可重复读的核心机制,通过入门/进阶/实战/优化四种模式展示关键概念与交互流程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
数据库脏读幻读不可重复读:脏读问题 脏读问题与读未提交隔离级别 不可重复读与读已提交级别 核心机制可视化 数据库脏读幻读不可重复读整体架构与关键组件
并发异常总览
Smart Kitchen 开业第一天就乱了套。三位厨师——事务A、B、C——在同一张操作台上忙碌,系统里存着每道菜的状态档案。厨师A在腌鱼,腌到一半去接电话;厨师B路过,看着这条“半成品”还挺像样,顺手拿来做配菜;厨师C则在一旁清点锅具。结果A回来觉得盐放多了,把鱼整个扔掉重做——B 手里那盘配菜瞬间成了“无源之水”。类似的混乱接二连三:B第一次看食材档案是草鱼,转头再看变成了鲈鱼;C数锅数到一半,锅里又凭空多了一口。这三种乱象,就是数据库并发世界里的三大经典异常:脏读、不可重复读、幻读。
它们有一个共同的根源:多个事务并发访问同一份数据时,读写顺序产生了交错。 如果没有规则约束,事务之间就会互相看到“不该看”的中间状态,导致基于错误数据的决策。
厨房管理员意识到,必须出台一套“协作守则”,约束厨师们什么时候能看什么、能碰什么。这套守则,就是事务隔离级别。
- 并发异常多个事务同时操作数据库时产生的一类错误统称。
- ∈(属于)表示左侧的元素是右侧集合中的一员,即三类问题的总称。
- 三元素集合花括号内列出三类异常的名称,它们是后续所有讨论的主角。
隔离级别体系
管理员很快贴出了四套协作守则,供不同场景选用。第一套守则“完全透明”:所有厨师可以看一切半成品,哪怕是别人正在腌的鱼——这叫“读未提交”。第二套守则“只看成品”:半成品一律不许碰,只有做完装盘的菜才能看——这叫“读已提交”。第三套守则“开工拍照”:每位厨师开工时给厨房拍一张全景快照,之后全程只对照照片干活——这叫“可重复读”。第四套守则“交替进场”:同一时间只允许一位厨师在厨房操作,做完下一位才能进——这叫“串行化”。
从第一套到第四套,规则的严格程度逐步升级,能挡住的异常也越来越多。
有了这四套守则,我们再回头细看每一种异常的具体场景、业务危害和避免手段。
- RU(读未提交)Read Uncommitted,能看到任何未提交的更改,防护最弱。
- RC(读已提交)Read Committed,只能看到已提交的数据,挡住脏读。
- RR(可重复读)Repeatable Read,事务内看到一致的快照,挡住不可重复读。
- SR(串行化)Serializable,事务完全串行执行,三类问题全部挡下。
- 强度序号小于号表示防护能力逐级递增,代价是并发性能逐级下降。
脏读详解
厨房里最让人窝火的,就是“半成品陷阱”。厨师B想给套餐配一份葱油鱼,他在系统里看到厨师A的食材档案上写着“鱼:已腌制”,于是放心地按这个状态准备配料。可 A 这边接到电话回来,觉得盐放多了,一个回滚把鱼重新洗过、换料重腌。B 辛辛苦苦备好的配料,全废了。
技术世界里,这就是典型的脏读:事务 T2 读到了事务 T1 尚未提交的修改,而 T1 随后回滚,导致 T2 读取的数据变成了“不存在过的脏数据”。
业务危害很直观:如果库存管理系统 A 事务扣减了库存但未提交,B 事务按这个虚假库存对外承诺“有货”,等 A 回滚后,库存其实没变,但订单已经多签了。避免脏读的办法很简单——把隔离级别至少设为读已提交,让事务只能看到已提交的数据。
- T1 写入未提交事务 T1 修改了数据 x,但还没有执行 COMMIT。
- T2 读取事务 T2 在 T1 未提交时读 x,拿到的是 T1 改过的新值。
- T1 回滚T1 最终撤销了自己的修改,x 恢复原值,T2 读到的值成了“空中楼阁”。
不可重复读
第二个场景更隐蔽。厨师B准备做“菜名对照表”,他先查看冰箱第二层的档案:“草鱼”。记录完毕,他去调料架拿了一瓶生抽,回来再看同一份档案——竟然变成了“鲈鱼”。原来厨师C在中间把鱼换了,而且已经提交入库。B 前后两次读的是同一条记录,结果却不同。
这就是不可重复读:同一个事务内,两次读取同一条记录,由于其他事务在中间提交了 UPDATE,两次结果不一致。
业务危害同样惊人:比如在转账场景,T1 先读到账户余额 1000 元用于计算利息,中间 T2 转入 500 元并提交,T1 再结算时余额变成了 1500,导致利息计算错误。避免不可重复读,需要将隔离级别提升到可重复读,或用行锁/共享锁保证读取期间记录不被他人修改。
- 第一次读取T1 读到 x 的值为 a,这是它的“第一印象”。
- T2 修改并提交T2 把 x 从 a 改为 b,并完成了提交,新值对所有人可见。
- 第二次读取T1 再读 x 时得到 b,与第一次的 a 不一致。
幻读详解
第三个场景,是最容易和不可重复读混淆的“罗生门”。厨师B负责清点灶台上的锅数,第一次数:5 口,他认真地记在台账本上。等他回头看一眼菜谱,再数一遍——6 口!厨师D在这中间新放了一口锅,并且已经记录在案。B 两次查询的是同一个“范围”(灶台所有锅),但行数变了。
这就是幻读:同一个事务内,两次执行相同的范围查询,由于其他事务在中间插入了新行或删除了旧行,结果集的行数不同。幻读和不可重复读的区别在于:不可重复读是针对某一条已存在的记录,幻读是针对整个记录集合的数量。
避免幻读是三个问题里最麻烦的。可重复读的快照读能挡住一部分,但要对“当前读”(UPDATE/DELETE 这类需要看到最新数据的操作)完全防幻读,需要串行化级别,或者 MySQL InnoDB 在可重复读下使用间隙锁(Gap Lock),把“还未出现的空位”也锁起来,让新数据插不进来。
- 第一次统计T1 对集合 S 执行 COUNT 查询,得到 n 行。
- T2 插入T2 向 S 中插入一条新记录并提交,集合的物理行数增大了。
- 第二次统计T1 再次执行相同查询,得到 n+1 行,多出来的行像“幻影”一样出现。
锁机制基础
厨房为了保证秩序,引入了一套“挂牌制度”。每道菜出锅前可以挂一块牌子:共享锁(S锁) 挂着“试吃”牌——多个厨师可以同时凑过来尝一口,但谁也不能把菜端走;排他锁(X锁) 挂着“制作中”牌——只有持有者能碰这道菜,其他厨师连看都不许看。共享锁之间可以共存(多人试吃不冲突),但共享锁和排他锁、排他锁和排他锁之间水火不容。
锁的粒度也各有讲究:行级锁精细到一盘菜,并发度高;表级锁把整个灶台锁死,简单但性能差;间隙锁则是幻读的克星——它锁住的是“记录之间的空隙”,让新的记录无法插进这个范围。理解了锁,就理解了数据库如何用“排斥”换“一致”。
- 共享锁共存S 锁与 S 锁可以同时加在同一数据上,就像多位厨师一起试吃。
- 共享与排他冲突S 锁与 X 锁不能共存,试吃的人在场时,厨师不能独占这道菜。
- 排他与排他冲突X 锁与 X 锁互斥,两个厨师不能同时“制作中”同一道菜。
MVCC原理
锁虽好,但代价太大——读的人多了,写的人就得排队。于是厨房管理员想了个聪明办法:给每道菜装上“时光相机”。系统为每份食材档案维护一串历史版本(版本链),每个版本都用 undo 日志记录着“改之前长什么样”。厨师开工时,系统给他拍一张当时的快照,之后不管别人怎么改这道菜,他看到的始终是开工那一刻的版本——这叫快照读。
但要注意,UPDATE、DELETE、INSERT 这类“当前读”必须看到最新已提交的版本,所以需要加锁。MVCC 的巨大价值在于:普通读操作不加锁、不被写阻塞,在可重复读级别下还能保证一致性快照,这就是为什么现代数据库(MySQL InnoDB、PostgreSQL)都把它作为并发控制的核心底座。
- 快照读事务执行 SELECT 时,不读取最新数据,而是读取快照版本。
- 版本下标表示取版本链中“事务开始时刻”对应的那个版本。
- 整体含义读写分离——读走快照,写走当前读,互不阻塞。
隔离级别选择
不同餐厅有不同经营策略。MySQL InnoDB 默认使用可重复读(RR),一个原因是 MySQL 主从复制在 RR 下更容易保证复制日志的一致性;而 Oracle 和 PostgreSQL 默认使用读已提交(RC),更追求吞吐量和低延迟。选择隔离级别,本质上是在一致性和并发性能之间做权衡。
实际选型时,大部分互联网业务用 RC + 应用层兜底(乐观锁、悲观锁)就足够:比如秒杀系统在 RC 下用 UPDATE 行锁防超卖,不需要 RR 的重型保障。而银行对账、用户余额清算这类强一致场景,才需要升级到 RR 甚至串行化。守住一致性底线,又不牺牲不必要的性能,才是成熟的架构决策。
- 分段函数按数据库类型给出各自的默认级别选择。
- MySQL 选 RR为了配合主从复制与 binlog 的格式要求。
- Oracle 选 RCRC 下只锁必要的行,吞吐量更高。
AI 应用场景
电商库存超卖与秒杀系统
阶段:特征存储与在线推理服务的数据一致性保障
解决的问题:库存系统中,多个并发用户在秒杀瞬间同时扣减库存。传统方式若在低隔离级别下操作,会出现脏读——用户看到“有货”并下单,但库存在事务回滚后其实已无货,导致超卖。AI 推荐系统若基于未提交的库存特征做决策,同样会推荐出已无货的商品。
具体案例:某 AI 驱动的电商平台在秒杀场景中用“读已提交 + 行级排他锁”:UPDATE inventory SET stock = stock - 1 WHERE sku_id = ? AND stock > 0,让扣减操作原子化,同时避免脏读带来的虚假库存展示。AI 助手推荐商品时,只读取已提交的库存快照。
金融对账与异常交易检测
阶段:离线批处理与在线特征计算的一致性读取
解决的问题:银行日终对账需要统计一个时间段内的所有流水。若在低隔离级别下运行,其他事务并发插入新流水,会导致对账任务第一次统计和第二次统计的行数不一致——幻读。AI 反欺诈模型读取交易特征时,也可能因为不可重复读而看到前后矛盾的账户余额特征,导致误判。
具体案例:某支付风控系统的 AI 模型在评估一笔交易时,需要在事务内多次读取该账户近 30 天的交易汇总。团队将查询事务设为“可重复读”,确保整个评估过程中读到一致的快照;日终对账报表则使用串行化级别,彻底杜绝幻读导致的总账不平。
高并发特征库的一致性快照
阶段:实时特征平台的数据读取与写入
解决的问题:在线推理服务需要读取多个特征源并组装成一条完整的特征向量。如果不同特征在不同时刻被更新,多次读取可能拼出“半新半旧”的错误向量。用 MVCC 的快照读,可以在服务启动时获取一个一致性快照,保证组装特征向量时所有字段来自同一时间点。
具体案例:某广告 CTR 预估系统,特征平台每天多次更新用户画像特征。推理服务启动一个长事务,用可重复读的快照一次性读取用户全部特征,避免因更新带来的特征错配,让 AI 模型的输入始终保持一致性。
Python 演示
"""模拟数据库脏读 / 不可重复读 / 幻读三类并发问题""" class MiniDB: """极简内存数据库:一张余额表和一张订单表""" def __init__(self): self.balance = 100 # 余额(已提交值) self.pending = None # 未提交的新余额 self.pending_owner = None # 未提交的事务名 self.orders = [1, 2, 3] # 订单表,用于幻读演示 def set_balance(self, txn, value): self.pending = value self.pending_owner = txn def commit(self): if self.pending is not None: self.balance = self.pending self.pending = None self.pending_owner = None def rollback(self): self.pending = None self.pending_owner = None def read_balance(self, use_pending=False): if use_pending and self.pending is not None: return self.pending return self.balance def demo_dirty_read(): db = MiniDB() print("=== 场景1:脏读(读未提交)===") db.set_balance("T1", 500) print("T1 把余额改成 500(未提交)") got = db.read_balance(use_pending=True) print("T2 读到余额 =", got, "<-- 脏读!") db.rollback() print("T1 回滚,余额恢复 =", db.balance) def demo_unrepeatable_read(): db = MiniDB() print("\n=== 场景2:不可重复读(读已提交)===") first = db.read_balance() db.set_balance("T2", 900) db.commit() second = db.read_balance() print("T1 第一次读 =", first) print("T2 提交修改后,T1 第二次读 =", second) print("两次结果不一致 -> 不可重复读") def demo_phantom(): db = MiniDB() print("\n=== 场景3:幻读(范围行数变化)===") n1 = len(db.orders) db.orders.append(4) n2 = len(db.orders) print("T1 第一次数订单数 =", n1) print("T2 插入一条订单后,T1 再次统计 =", n2) print("行数变化 -> 幻读") if __name__ == "__main__": demo_dirty_read() demo_unrepeatable_read() demo_phantom()
self.balance = 100保存已提交的正式值,是事务之间“公认的事实”。self.pending = None记录未提交的临时值,代表“半成品”状态。def set_balance(self, txn, value)模拟一个事务修改数据,只写 pending 不写正式值。db.read_balance(use_pending=True)模拟读未提交级别,直接读半成品。db.rollback()模拟回滚,把 pending 清空,正式值保持不变。db.commit()模拟提交,把 pending 正式写入 balance。db.orders.append(4)模拟其他事务插入新行,造成范围结果集变大。if __name__ == "__main__":程序入口,依次执行三个演示函数。
常见误区
误区感觉脏读和不可重复读都是“读到不该读的数据”,面试时常常混为一谈,分不清区别。
正解两者有本质区别。脏读是读到了未提交的数据,而这个数据后来被回滚了,等于读到了一个“从不存在的值”;不可重复读是读到了已提交的数据,但同一条记录在两次读取之间被别的 UPDATE 改写了。打个比方:脏读是吃了别人做到一半被倒掉的菜,不可重复读是菜已端上桌却被换成了另一道。
为何常见:因为两者都表现为“两次读取结果不一致”,初学者容易只看到表象,却忽略了“数据是否已提交”这个关键差异。
误区既然串行化能防所有问题,那所有业务都设置串行化不就好了?
正解串行化的代价是事务完全排队执行,并发度急剧下降。对于高吞吐的互联网系统,全部串行化会让接口响应时间成倍增加,甚至引发连接池耗尽。正确的做法是按业务场景分级:核心资金操作才用高隔离级别,普通读写用 RC 配合 MVCC。
为何常见:只看“安全性”不看“性能成本”,忽略了数据库隔离级别本质上是安全与并发之间的权衡。
误区以为 MVCC 的快照读解决了一切并发问题,数据库里就不存在锁了。
正解MVCC 主要优化的是“快照读”——普通 SELECT 不加锁、不被阻塞。但 UPDATE、DELETE、INSERT 这类“当前读”必须读取最新数据,仍然需要加锁。另外 RR 级别下防幻读还要靠间隙锁,写写冲突也要靠排他锁。MVCC 和锁是配合使用的,不是替代关系。
为何常见:把 MVCC 的“读优化”误解为“全场景优化”,忽略了写操作和当前读的数据结构支撑。
误区认为设置了 RR(可重复读)之后,幻读就彻底消失了。
正解MySQL InnoDB 的 RR 级别下,普通快照读确实看不到新插入的行,所以常规 SELECT 不会幻读;但如果事务中执行了 UPDATE/当前读,由于当前读要基于最新数据加锁,其他事务新插入的行仍可能影响范围结果。因此严格来说,RR 并没有在标准意义上彻底杜绝幻读,而是靠间隙锁在多数场景下规避了它。真正彻底防止幻读的是串行化级别。
为何常见:混淆了“快照读体验一致”和“范围查询并发安全”两个概念,把 InnoDB 的工程优化当成 SQL 标准的严格定义。
误区以为数据库性能会随隔离级别提升而变好,毕竟“数据更准了”。
正解隔离级别越高,数据库需要做的约束越多:加锁范围更大、阻塞更频繁、MVCC 版本链更长,整体吞吐量通常越低。RC 与 RR 相比,RC 只对需要的行加锁,范围查询不加间隙锁,并发度更高。提升隔离级别相当于用性能换一致性,绝不等于性能提升。
为何常见:把“数据更准确”与“系统更快”划等号,没有意识到隔离级别的本质是资源约束与权衡。
面试题
Q1. 脏读是指事务读到了什么数据?
- A.另一个事务已经提交的数据
- B.另一个事务尚未提交的数据← 正确答案
- C.自己事务里尚未提交的数据
- D.数据库里已经过期的旧数据
答案:B。脏读的核心是读到了“未提交”的数据,而且这个数据源事务之后可能回滚。A 是正常读取已提交数据;C 是自己修改自己当然能看;D 是旧版本而非“脏”数据。
Q2. 不可重复读与幻读的核心区别是什么?
- A.前者针对单行 UPDATE,后者针对集合 INSERT/DELETE← 正确答案
- B.前者针对INSERT,后者针对UPDATE
- C.两者在本质上没有任何区别
- D.前者发生在读未提交,后者发生在串行化
答案:A。不可重复读是同一条记录被 UPDATE 导致两次读值不同;幻读是范围查询因为 INSERT/DELETE 导致行数变化。B 弄反了两者的操作类型;C 忽略了关键差异;D 的隔离级别归属也不对。
Q3. MySQL InnoDB 引擎的默认隔离级别是?
- A.读未提交(RU)
- B.读已提交(RC)
- C.可重复读(RR)← 正确答案
- D.串行化(SR)
答案:C。MySQL InnoDB 默认使用可重复读(RR),这是为了配合主从复制与 binlog 的一致性需求。Oracle 和 PostgreSQL 才默认使用读已提交。
Q4. 哪一个隔离级别能够完全避免脏读、不可重复读和幻读?
- A.读未提交
- B.读已提交
- C.可重复读
- D.串行化← 正确答案
答案:D。串行化让事务完全排队执行,任何并发异常都不可能发生。可重复读在标准定义下仍可能出现幻读(InnoDB 靠间隙锁规避但不彻底)。
Q5. 读已提交(RC)级别主要能防止哪一类问题?
- A.脏读← 正确答案
- B.不可重复读
- C.幻读
- D.所有并发问题
答案:A。RC 保证只能读到已提交的数据,所以避免了脏读。但它无法保证同一事务内两次读取同一行值不变,也无法阻止范围结果集数量变化,因此不可重复读和幻读仍可能发生。
Q6. 关于 MVCC 快照读的优势,以下说法正确的是?
- A.普通读操作不需要加锁← 正确答案
- B.读操作不会被写操作阻塞← 正确答案
- C.可重复读下能读到一致快照← 正确答案
- D.能够彻底替代所有锁机制
答案:ABC。MVCC 快照读让 SELECT 不加锁且不被写阻塞,还能在 RR 下提供一致的开始快照。但 D 错误:UPDATE/DELETE 等当前读仍需加锁,MVCC 与锁是配合关系。
Q7. 关于幻读,下列说法正确的是?
- A.表现为两次范围查询行数不同← 正确答案
- B.可能由其他事务插入新行引发← 正确答案
- C.可能由其他事务更新行值引发
- D.可重复读下绝对不可能出现
答案:AB。幻读的本质是范围集合的大小变化,由 INSERT/DELETE 引发,所以 A、B 正确。C 是 UPDATE 引发的不可重复读。D 错误:RR 标准下幻读仍可能发生,InnoDB 靠间隙锁规避但不绝对。
Q8. InnoDB 在可重复读级别下使用哪种锁机制来规避幻读?
- A.表级共享锁
- B.间隙锁与 next-key 锁← 正确答案
- C.全表扫描加读锁
- D.不做任何特殊处理
答案:B。InnoDB 通过间隙锁(Gap Lock)锁住索引记录之间的“空位”,再配合 next-key 锁(记录锁+间隙锁),让其他事务无法在范围内插入新记录,从而规避幻读。
Q9. 排他锁(X 锁)具有以下哪个特点?
- A.多个事务可以同时持有
- B.与共享锁可以共存
- C.持有期间其他事务不可读写该数据← 正确答案
- D.只阻塞写入但不阻塞读取
答案:C。排他锁意味着独占:持有者可以读写,其他事务既不能读也不能写。A 是共享锁的特征;B 与兼容性规则矛盾;D 与排他锁的定义不符。
Q10. 在真实业务中选择隔离级别时,应该主要考虑哪些因素?
- A.业务对数据一致性的要求← 正确答案
- B.系统的并发性能与吞吐量← 正确答案
- C.开发调试的复杂度← 正确答案
- D.网络带宽的大小
答案:ABC。选择隔离级别是在一致性、性能、可维护性之间做权衡:资金类业务倾向更高隔离级别,高并发互联网系统倾向 RC+应用层兜底。D 的网络带宽与隔离级别选择没有直接关系。