想象你是一所大学食堂的厨师长。清晨六点,六位厨师同时开工:有人切菜,有人颠勺,有人往冷柜里补货。麻烦在于——每位厨师都能看到别人手上的半成品吗?一位厨师刚从冰箱拿出番茄还没洗,另一位就把它切了做沙拉;偏偏前者发现番茄不新鲜随手扔掉,后者的沙拉却已经端上了桌。数据库里的“事务”就是厨师手里的一道道菜,而“隔离级别”就是食堂制定的规矩:谁在什么时候,能看见什么样的食材状态。更高的隔离级别像更严的纪律,能挡住更多事故,却也让下厨节奏变慢。这篇文章将带你走进这间“数据库食堂”:先看清四种并发事故,再理解四档纪律的定义,最后钻入 MVCC 的“时间相册”——版本链与快照,掌握如何为业务选出最合适的隔离级别。

岗位关联

隔离级别是数据库并发安全的“交通规则”——它是面试高频考点、线上事故排查的必备视角,也是 AI 工程师把特征、标签、元数据安全落库的基础功。

目录
  1. 可视化演示
  2. 事务基本概念
  3. 并发异常问题
  4. 隔离级别定义
  5. MVCC核心机制
  6. 隔离级别对比
  7. 实现机制差异
  8. 工程实践选型
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。

本节配有交互动效(进入视口后自动加载,约 3KB)。

系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T

事务基本概念

想象你管理着一所大学食堂的厨房。凌晨五点半,六位厨师同时开工,每个人手头都攥着一道菜:有人煎蛋,有人熬粥,有人切水果。你作为厨师长,定下了一条铁规矩:任何一道菜,要么从备料到装盘完整地端出厨房,要么在发现问题时整锅倒掉重做,绝不允许“做了一半的菜”留在灶台上。这条规矩保证了大家吃到的每样东西都是完整、卫生的。

数据库世界里的“事务”(Transaction),正是这样一道“菜”。它把一组读取和写入数据库的操作打包成一个整体:要么全部执行成功并保存(提交 Commit),要么全部不生效(回滚 Rollback)。转账就是最经典的例子——扣款和入账必须同生共死,绝不能出现“钱扣了但对方没收到”的半个操作。

为了确保“菜”的质量,事务必须满足四大特性,合称 ACID。原子性(Atomicity)保证操作全做或全不做;一致性(Consistency)保证数据在事务前后都符合业务规则;隔离性(Isolation)保证并发事务互不干扰;持久性(Durability)保证事务一旦提交,即使断电数据也不会丢。四者相辅相成,而隔离性正是本文的主角——它决定了多个厨师(事务)在共享厨房(数据库)里能互相看到什么。

从你喊出“开工”到顾客吃到热腾腾的早餐,每一道菜都经历着完整的生命周期。事务也是如此:从 BEGIN 开启,到若干读写操作,再到 COMMIT 或 ROLLBACK 收尾。理解了“一道菜”的边界,我们才能继续讨论:当多道菜同时下厨时,厨师们会碰撞出哪些“事故”。

T={r(x), w(x), …}⇒COMMIT∣ROLLBACKT = \{ r(x),\ w(x),\ \ldots \} \quad \Rightarrow \quad \text{COMMIT} \mid \text{ROLLBACK}
一个事务就是一组读写操作的集合,最终只有两种结局:全部生效(COMMIT)或全部作废(ROLLBACK)。
  1. T 表示一个事务事务是数据库操作的最小工作单元,类似一道菜的完整制作流程,任何半成品状态都不允许对外暴露。
    TT
  2. r(x) 与 w(x) 代表读写读取操作 r(x) 拿到某个数据的值,写入操作 w(x) 修改它的值,一个事务可以包含若干这样的操作。
    r(x), w(x)r(x),\ w(x)
  3. COMMIT 提交全部操作成功验证后,一次性写入磁盘生效,就像菜肴通过验收端上桌。
    COMMIT\text{COMMIT}
  4. ROLLBACK 回滚任一环节出错,撤销全部修改,回到事务开始前的状态,等于把半成品倒进垃圾桶重来。
    ROLLBACK\text{ROLLBACK}

并发异常问题

厨房越来越忙,问题来了。六个厨师共享一块砧板、一口炒锅和一台冰箱。A 厨师正把西红柿从冰箱里拿出来准备清洗,B 厨师伸手就把它切了做沙拉——结果 A 发现西红柿不新鲜,顺手扔了,B 的沙拉却已经端上了桌。这就是“用了一个即将被丢弃的半成品”。更糟的是,同样的碰撞还有三种:同一个厨师隔五分钟看同一颗白菜,一次完整、一次只剩半颗;洗碗工数盘子,第一次 100 个、第二次 103 个;两个人同时改菜谱,后写的把先写的覆盖了。

这些场景在数据库里对应着四种大名鼎鼎的并发异常。第一,脏读(Dirty Read):一个事务读到另一个未提交事务的修改,而后者随后回滚,前者读到的就是“脏数据”——数据从未真实存在过。第二,不可重复读(Non-repeatable Read):同一事务内两次读取同一数据,值却不同,因为另一事务在这期间提交了修改。第三,幻读(Phantom Read):同一事务内两次查询同一范围,记录条数变了,另一事务插入或删除了新行。第四,更新丢失(Lost Update):两个事务同时读改写同一数据,后提交的覆盖了先提交的,导致一个更新凭空蒸发。

四种事故中,脏读最致命也最基础,因为它直接破坏了“已提交数据才可信”的底线。其他三种则在不同维度上挑战一致性。值得注意的是,更新丢失在 SQL 标准中并未与隔离级别直接挂钩,但它同样频繁出现在真实事故里,尤其在“读改写”(read-modify-write)这种经典业务模式中。

这四种事故,就像贴在厨房墙上的安全事故清单。把它们一一列清楚,接下来要制定的“厨房纪律”——隔离级别——就有了明确的靶子:每档纪律能防住哪几种事故,一目了然。

T2 reads x (written by uncommitted T1)⇒T1 ROLLBACK ⇒脏读T_2 \text{ reads } x\ (\text{written by uncommitted } T_1) \quad \Rightarrow \quad T_1 \text{ ROLLBACK } \Rightarrow \text{脏读}
脏读的本质:读到了一份还没“定稿”的数据,而这份数据随后被提交者本人撤销。
  1. T_1 写入 x 但未提交T1 修改了数据 x,但还没验收(COMMIT),此时修改是临时的、随时可能作废。
    w1(x) uncommittedw_1(x)\ \text{uncommitted}
  2. T_2 读到 xT2 在 T1 提交之前就读取了 x,它只知道 x 的当前值,不知道这个值“还没盖章定稿”。
    T2 reads xT_2 \text{ reads } x
  3. T_1 回滚T1 最终放弃修改,x 还原为旧值,T2 之前读到的值成了从未存在过的“脏数据”。
    T1 ROLLBACK ⇒dirty dataT_1 \text{ ROLLBACK } \Rightarrow \text{dirty data}

隔离级别定义

为了管理共享厨房的风险,你制定了四档“纪律”。第一档“随便看”:厨师们可以随时看任何人的半成品,哪怕对方还没决定要不要用;第二档“看成品”:只能看别人已端上桌的菜,锅里的半成品一概不准碰;第三档“开饭前定菜单”:一旦开始做自己的菜,你看到的食材状态就锁定不变,别人之后怎么折腾都与你无关;第四档“一人一灶”:所有人排队做菜,做完一个再来一个,彻底没有同时下厨这回事。

这四档纪律,正是 SQL 标准定义的四种隔离级别:读未提交(Read Uncommitted,RU)、读已提交(Read Committed,RC)、可重复读(Repeatable Read,RR)和可串行化(Serializable,S)。隔离级别完整地定义了一个事务能看到其他事务的哪些修改:RU 完全不隔离,所有临时修改都可见;RC 只允许读取已提交的数据,脏读被避免;RR 保证事务内多次读同一数据结果一致,不可重复读被避免;S 是最高级别,事务像排队一样执行,幻读也被禁止。

从工程视角看,隔离级别本质是一份“可见性合同”:级别越低,约定越宽松,事务看到的数据越“新鲜”但也越不可靠;级别越高,约定越严格,事务看到的数据越稳定但并发能力越差。选择哪档,就是选择用多少性能换多少一致性。

纪律定好了,问题也随之而来:“看成品”和“菜单锁定”说起来容易,数据库到底怎么实现?答案就是 MVCC——一场聪明的时间管理艺术,它让读操作几乎不阻塞写操作,写操作也不阻塞读操作。

RU≺RC≺RR≺Serializable\text{RU} \prec \text{RC} \prec \text{RR} \prec \text{Serializable}
四种隔离级别构成一条从宽松到严格的光谱,每升一级,被禁止的异常就多一种。
  1. RU 读未提交最低级别,事务能读其他事务尚未提交的修改,饿极了什么都能看,四种异常全可能发生。
    RU:no protection\text{RU}: \text{no protection}
  2. RC 读已提交只能读到已提交的数据,脏读被拦截,但同一事务内两次读可能结果不同。
    RC:prevents dirty read\text{RC}: \text{prevents dirty read}
  3. RR 可重复读事务首次读取时的快照被长期复用,同一数据多次读取保持一致,不可重复读被杜绝。
    RR:prevents non-repeatable read\text{RR}: \text{prevents non-repeatable read}
  4. S 可串行化所有事务近似串行执行,相当于食堂里只有一个厨师,幻读也被禁止。
    S:prevents phantom read\text{S}: \text{prevents phantom read}

MVCC核心机制

假设你要求每位厨师开工前先拍一张食材全景照(快照),之后整个做菜过程中,无论其他厨师怎么折腾食材,你都只认自己照片里的样子。不仅如此,每次有人修改食材,你并不扔掉旧的,而是在旁边贴上一张写着“修改人编号”的新照片,再用一根线把新旧照片串起来——这就是“版本链”。

MVCC(Multi-Version Concurrency Control,多版本并发控制)正是这个思路:每一行数据被修改时不会覆盖旧值,而是生成一个新版本。每个版本都带两个隐藏列:trx_id 记录最后修改该版本的事务编号,roll_pointer 是回滚指针,指向上一个版本。这些版本通过指针串成一条链条,读取数据时从最新往最旧找,用 ReadView(读视图)判断哪个版本对自己可见。

ReadView 在快照读(普通 SELECT)时创建,记录三样关键信息:所有尚未提交事务的编号集合 m_ids、其中最小编号 min_trx_id、以及系统下一个待分配的编号 max_trx_id。判断版本可见性的规则可以归纳为四句话:版本编号在 m_ids 里,说明修改者还没下班,不可见;编号大于等于 max_trx_id,说明是快照之后才开启的“未来事务”,不可见;编号小于 min_trx_id,说明在快照之前就已提交,可见;编号落在中间区间且不在 m_ids 里,说明快照创建时它已提交,可见。

有了 ReadView 这把“时间标尺”,RC 和 RR 的实现差异立刻清晰了:RC 每次读取都重新拍一张合影(新建 ReadView),所以能看到别人最新提交的修改;RR 整场做菜只拍一张合影(复用 ReadView),所以“不可重复读”自然被杜绝。这就是 MVCC 的精髓——用版本和时间,而不是用锁,换来高并发下的读安全。

visible(trx_id)={不可见,trx_id∈m_ids(活跃未提交)不可见,trx_id≥max_trx_id(未来事务)可见,trx_id<min_trx_id(早已提交)可见,trx_id∉m_ids∧trx_id<max_trx_id\text{visible}(trx\_id) =\begin{cases}\text{不可见}, & trx\_id \in m\_ids \quad (\text{活跃未提交}) \\\text{不可见}, & trx\_id \ge max\_trx\_id \quad (\text{未来事务}) \\\text{可见}, & trx\_id < min\_trx\_id \quad (\text{早已提交}) \\\text{可见}, & trx\_id \notin m\_ids \wedge trx\_id < max\_trx\_id\end{cases}
ReadView 就像一张“合影名单”:名单上的人还没下班不能信,名单外的人按编号时间判断能否信任。
  1. m_ids 活跃事务集合创建快照那一刻尚未提交的所有事务编号,相当于“合影时还在厨房里的人”。
    m_idsm\_ids
  2. trx_id ∈ m_ids → 不可见数据仍被某个没下班的事务攥在手里,它可能随时回滚,绝对不能信。
    trx_id∈m_ids⇒不可见trx\_id \in m\_ids \Rightarrow \text{不可见}
  3. trx_id ≥ max_trx_id → 不可见这个事务在快照之后才开启,是“未来的人”,它写的东西当然不能出现在过去。
    trx_id≥max_trx_id⇒不可见trx\_id \ge max\_trx\_id \Rightarrow \text{不可见}
  4. trx_id < min_trx_id → 可见事务编号比所有活跃事务都小,说明它早已提交并下班,数据安全可用。
    trx_id<min_trx_id⇒可见trx\_id < min\_trx\_id \Rightarrow \text{可见}
  5. 中间区间且不在 m_ids → 可见编号在快照区间内但不在合影名单上,说明它当时已提交,可以放心使用。
    trx_id∉m_ids∧trx_id<max_trx_id⇒可见trx\_id \notin m\_ids \wedge trx\_id < max\_trx\_id \Rightarrow \text{可见}

隔离级别对比

四档纪律摆在面前,你该选哪一档?决策的关键,是画一张“事故防护对照表”,看看每档纪律到底守住了什么、漏掉了什么。食堂如此,数据库亦然。把四种并发异常与四种隔离级别对应起来,就得到了那张著名的对照表。

对照表揭示了一个清晰的规律:每升一级,多堵住一种异常,也多付出一分代价。RU 档位不设防,四种异常全可能发生,实践中几乎不用;RC 档位挡住了脏读,但不可重复读和幻读仍可能发生;RR 档位在 SQL 标准下仍不防幻读,但 MySQL InnoDB 通过 MVCC 快照读加间隙锁,在绝大多数场景下连幻读也一并堵住了;S 档位全副武装,所有异常都被禁止,代价是并发度急剧下降,吞吐量可能只有 RC 的几分之一。

理解这张表,还要把握一对关键矛盾:一致性(Correctness)与性能(Performance)的此消彼长。隔离级别越低,版本链遍历和锁等待越少,吞吐量越高,但数据可信度越低;级别越高,每次读取要做的可见性判断越多,写操作要穿的“锁门”越多,系统越安全也越慢。大多数生产事故,恰恰发生在工程师误以为“默认级别就够了”的时刻。

对照表贴出来后,不同食堂能找到自己的位置:早点铺子客人多、菜品简单,选 RC 就够了;高档西餐厅追求极致一致,宁可排队也要 S。回到你的厨房,“看成品”通常最均衡,“开饭前锁定”适合对一致性要求更高的订单。

级别脏读不可重复读幻读RU可能可能可能RC避免可能可能RR避免避免标准仍可能S避免避免避免\begin{array}{c|ccc}\text{级别} & \text{脏读} & \text{不可重复读} & \text{幻读} \\\hline\text{RU} & \text{可能} & \text{可能} & \text{可能} \\\text{RC} & \text{避免} & \text{可能} & \text{可能} \\\text{RR} & \text{避免} & \text{避免} & \text{标准仍可能} \\\text{S} & \text{避免} & \text{避免} & \text{避免}\end{array}
每升一级,多堵住一种异常,也多付出一分性能代价——一致性收益与并发损耗必须一起算账。
  1. RU 行——全不设防读未提交档位不隔离任何并发修改,三种异常都可能发生,只适合对数据可信度无要求的场景。
    RU:可能脏读、不可重复读、幻读\text{RU}: \text{可能脏读、不可重复读、幻读}
  2. RC 行——挡住脏读读已提交档位每次读都新建快照,未提交版本不可见,但快照不复用导致两次读取结果可能不同。
    RC:避免脏读\text{RC}: \text{避免脏读}
  3. RR 行——重复读一致快照复用让重复读取保持一致,但按 SQL 标准幻读仍可能发生;MySQL 靠 MVCC 加间隙锁使其实际也避免幻读。
    RR:避免脏读与不可重复读\text{RR}: \text{避免脏读与不可重复读}
  4. S 行——全副武装可串行化把所有事务排队执行,三种异常全部杜绝,是“宁可慢也要对”的终极方案。
    S:避免全部三类异常\text{S}: \text{避免全部三类异常}

实现机制差异

同样是“看成品”或“菜单锁定”的纪律,不同食堂的执行方式截然不同。有的食堂靠“拍照存档”(MVCC),有的靠“锁门挂牌”(锁机制),有的两者混用。走进三家知名“食堂”——MySQL、PostgreSQL、Oracle,你会发现它们对同一份菜单的理解各有千秋。

MySQL InnoDB 默认使用 RR,这是它与 SQL 标准推荐 RC 的最大差异。InnoDB 的 RR 之所以敢拍胸脯说防幻读,靠的是“双轨制”:普通 SELECT 走 MVCC 快照读,不加锁;UPDATE、DELETE、SELECT …… FOR UPDATE 走当前读,加记录锁和间隙锁。间隙锁锁住查询范围中的“空隙”,阻止其他事务在范围内插入新行,幻读被彻底堵死。PostgreSQL 默认 RC,同样用 MVCC,但它的 RR 靠真正的全局快照(快照隔离)实现,不引入间隙锁,性能模型完全不同。Oracle 则只提供 RC 和 S 两档(其余映射到 RC),用 undo 回滚段构建一致性读,读操作全程不加读写互斥锁。

三家的共同点是:读取靠 MVCC,写入靠锁,只是分工比例不同。理解这个差异,对跨数据库迁移和踩坑排查至关重要——在 MySQL 上“从来没事”的 RR 语义,迁到 PostgreSQL 或 Oracle 后可能行为大变。

了解三家食堂的“后厨机密”,你在选型时就能超脱“隔离级别”的表象:MySQL 的 RR 之所以出名,是因为它用 MVCC 换来了“既严格又高效”;PostgreSQL 的 RC 更“诚实”,把范围的锁留给了用户;Oracle 则用回滚段的魔法让读永远不堵写。没有最好的食堂,只有最合适的后厨。

MySQL RR=MVCC 快照读⏟普通 SELECT+记录锁 + 间隙锁⏟UPDATE / DELETE / FOR UPDATE\text{MySQL RR} = \underbrace{\text{MVCC 快照读}}_{\text{普通 SELECT}} + \underbrace{\text{记录锁 + 间隙锁}}_{\text{UPDATE / DELETE / FOR UPDATE}}
MySQL 在 RR 级别下之所以能防幻读,靠的是“读走快照、写走锁”的双轨制配合。
  1. MVCC 快照读普通 SELECT 语句直接从版本链中挑可见版本,不加锁,并发性能极高。
    快照读=普通 SELECT\text{快照读} = \text{普通 SELECT}
  2. 记录锁对命中的行加锁,防止其他事务同时修改或删除这些行,解决写写冲突。
    记录锁:锁住目标行\text{记录锁}: \text{锁住目标行}
  3. 间隙锁对查询范围中不存在的“空隙”加锁,阻止其他事务插入新行,从而堵住幻读。
    间隙锁:锁住范围空隙⇒防幻读\text{间隙锁}: \text{锁住范围空隙} \Rightarrow \text{防幻读}

工程实践选型

菜谱终于齐了。现在的问题是:你的食堂到底该执行哪档纪律?人流稀少的深夜食堂,“一人一灶”完全可行;可到了中午十二点的大学食堂,如果还让所有人排队做菜,学生们怕是要揭竿而起。纪律必须匹配客流量和菜品类型,隔离级别的选择也必须匹配业务的一致性需求和并发特性。

工程师选隔离级别,没有万能答案,但有清晰的决策路径。第一步,识别业务对一致性的敏感度:资金、库存、票务这类“错一分钱都是事故”的系统,关键操作必须上 RR 甚至 S,且常用 SELECT …… FOR UPDATE 做当前读加锁;内容社区、商品详情、日志统计这类读多写少、允许轻微抖动的系统,选 RC 或数据库默认级别即可。第二步,评估并发压力:高并发写入场景下,S 的锁等待会让吞吐量断崖下跌,应尽量用 MVCC 快照读消化读请求。第三步,做好兜底监控:观察锁等待超时、死锁日志和全表扫描,找出热点行,用拆分、乐观锁版本号或幂等键化解冲突。

性能调优层面还有几个实用技巧:优先使用 RR 加 MVCC 快照读,把普通查询彻底变成无锁操作;对只读事务显式标记为 read-only,减少无谓的锁持有;把大事务拆小,缩短锁的持有时间;用 read replica 分担快照读流量,让主库专心处理当前读。

夜深了,食堂打烊。你站在后厨门口回看这一天:从“一道菜”的边界,到四种“事故”,再到四档“纪律”,最后深入 MVCC 的“时间魔法”——数据库并发安全的全貌,其实不过是一间共享厨房的管理哲学。而你的菜(数据)之所以万无一失,是因为你既懂纪律,更懂纪律背后的机制。

总成本=C一致性风险⏟数据错乱代价+C并发损耗⏟锁等待与快照开销,选型=arg⁡min⁡级别 总成本\text{总成本} = \underbrace{C_{\text{一致性风险}}}_{\text{数据错乱代价}} + \underbrace{C_{\text{并发损耗}}}_{\text{锁等待与快照开销}},\quad \text{选型} = \arg\min_{\text{级别}}\ \text{总成本}
隔离级别不是越高越好,而是让一致性风险与并发损耗的总成本最低。
  1. 一致性风险成本脏读、不可重复读、幻读一旦发生,可能带来数据错乱、业务故障甚至资损,代价是概率与损失的乘积。
    C一致性风险=异常概率×单次损失C_{\text{一致性风险}} = \text{异常概率} \times \text{单次损失}
  2. 并发性能损耗隔离级别越高,锁等待、死锁重试、版本链遍历的开销越大,吞吐量越低。
    C并发损耗=锁等待时间+版本链遍历开销C_{\text{并发损耗}} = \text{锁等待时间} + \text{版本链遍历开销}
  3. 总成本最小化选型综合两边成本找到均衡点——多数业务落在 RC 或 RR,只有强一致场景才升级 S。
    选型=arg⁡min⁡级别(C一致性风险+C并发损耗)\text{选型} = \arg\min_{\text{级别}} \left( C_{\text{一致性风险}} + C_{\text{并发损耗}} \right)

AI 应用场景

特征平台的实时特征更新

阶段:特征工程 / 在线推理

解决的问题:推荐系统需要实时更新用户特征(点击、收藏、加购),多个服务并发写入同一张特征表。传统方式没有事务保护时,脏读会导致召回服务读到“用户刚加购又删除”的中间态特征,推荐结果剧烈震荡。利用 RC/RR 隔离级别加 MVCC,召回服务只读到已提交的最终特征,排序结果稳定可靠。

具体案例:某电商推荐平台,用户行为日志服务以每秒上千次写入更新特征表。上线前使用 RU 级别,脏读导致线上 CTR 抖动 15%;切换为默认 RR 后,召回服务通过快照读拿到一致的已提交特征,推荐排序恢复稳定。

AI 训练数据的标注管道

阶段:数据准备 / 数据清洗

解决的问题:多 worker 并发更新样本标签时,若两个 worker 同时读改写同一条样本的标签,会发生更新丢失。传统“最后写入者胜”的粗暴策略会让先提交的标注被无意识覆盖。利用 RC 级别配合 SELECT …… FOR UPDATE 当前读,对样本行加锁,确保同一时刻只有一个 worker 能改一条样本。

具体案例:某标注平台 50 个 worker 并发处理百万级样本。使用 RC + 当前读后,重复标注冲突率从 3.2% 降到 0.01% 以下;后台质检任务用 RR 快照读,保证一次巡检看到一致的标注版本,避免了指标前后矛盾。

模型实验元数据管理

阶段:模型训练 / MLOps

解决的问题:多个实验并发写模型指标表,若发生脏读,实验对比看板会展示未提交的中间指标,导致优劣判断失准。利用 RC 级别保证看板只读到已提交的完整指标;用 RR 会话生成训练报告时,多次查询结果一致,避免同一指标在报告中前后矛盾。

具体案例:某训练平台每晚并发跑 200 个实验,每个实验的指标分阶段写入元数据库。配置 RC 后,看板展示的 loss、AUC 等指标全部来自已提交的事务;同时把报告生成会话设为 RR,确保一份报告内的数据出自同一时间快照。

Python 演示

python86 行
class MVCCDatabase:    """简易 MVCC 模拟器:版本链 + ReadView 快照读"""     def __init__(self):        self.chains = {}        # key -> 最新版本节点        self.next_id = 1        # 下一个待分配事务编号        self.running = set()    # 活跃(未提交)事务编号     def begin(self):        tid = self.next_id        self.next_id += 1        self.running.add(tid)        return Transaction(tid, self)     def commit(self, trx):        self.running.discard(trx.tid)     def write(self, trx, key, value):        node = {            'trx_id': trx.tid,            'value': value,            'roll_pointer': self.chains.get(key)        }        self.chains[key] = node        trx.writes[key] = node        return node     def read(self, trx, key):        if trx.snapshot is None:  # RR: 首次读创建快照并一直复用            trx.snapshot = {                'min': min(self.running),                'max': max(self.running) + 1,                'active': set(self.running)            }        node = self.chains.get(key)        while node is not None:            if self._visible(node['trx_id'], trx):                return node['value']            node = node['roll_pointer']  # 沿版本链找更旧的版本        return None     def _visible(self, trx_id, trx):        if trx_id == trx.tid:            return True  # 自己写的一定可见        snap = trx.snapshot        if trx_id < snap['min']:            return True  # 快照创建前已提交        if trx_id >= snap['max']:            return False  # 快照之后的未来事务        return trx_id not in snap['active']  # 区间内且已提交  class Transaction:    def __init__(self, tid, db):        self.tid = tid        self.db = db        self.snapshot = None  # ReadView,RR 模式下复用        self.writes = {}     def read(self, key):        return self.db.read(self, key)     def write(self, key, value):        return self.db.write(self, key, value)  if __name__ == '__main__':    db = MVCCDatabase()    t1 = db.begin()    t1.write('balance', 100)     t2 = db.begin()    print('脏读检查: t2 读到', t2.read('balance'))   # None    db.commit(t1)     t3 = db.begin()    print('提交后: t3 读到', t3.read('balance'))      # 100    db.commit(t3)     t4 = db.begin()    print('RR 快照: t4 读到', t4.read('balance'))    # 100    t5 = db.begin()    t5.write('balance', 200)    db.commit(t5)    print('RR 复用: t4 再读', t4.read('balance'))     # 仍是 100    db.commit(t4)
目的:实现一个微型 MVCC 引擎,演示版本链、ReadView 快照与可见性判断,验证脏读被拦截、提交后可见、RR 快照复用的三大行为。
  1. 'roll_pointer': self.chains.get(key)新版本的回滚指针指向当前最新版本,串成一条版本链
  2. if trx.snapshot is None:RR 模式下整个事务只创建一次 ReadView,之后一直复用
  3. if self._visible(node['trx_id'], trx):从版本链头部开始逐个判断版本可见性
  4. node = node['roll_pointer']版本不可见就沿着回滚指针跳向更旧的版本
  5. if trx_id == trx.tid:自己事务写入的版本永远对自己可见
  6. if trx_id < snap['min']:编号小于快照最小活跃编号,说明早已提交
  7. return trx_id not in snap['active']区间内且不在活跃集合,即为已提交的可见版本

常见误区

误区我用的 MySQL 默认可重复读,从来没见过幻读,所以 SQL 标准的可重复读就是能防幻读的,所有数据库都一样。

正解SQL 标准中,RR 只保证同一事务内多次读同一行结果一致,并不防止幻读。MySQL InnoDB 的 RR 之所以防幻读,是 MVCC 快照读 + 当前读间隙锁的组合拳特例。PostgreSQL 的 RR 快照隔离同样防幻读,但 Oracle 没有真正的 RR 档,它把 RR 映射为 RC。脱离具体数据库谈隔离级别,等于脱离食堂谈菜谱。

为何常见:大多数开发者只接触过 MySQL,把 InnoDB 的行为当成了通用标准;面试写“RR 不防幻读”又和直觉打架,容易自我怀疑。

误区MVCC 就是每个事务一个快照,读自己的旧版本,不产生锁竞争,所以 MVCC 可以完全替代锁机制。

正解MVCC 只对快照读(普通 SELECT)无锁;对当前读(UPDATE、DELETE、SELECT …… FOR UPDATE)仍然必须加锁,否则两个事务同时修改同一行必然产生更新丢失。MySQL RR 下当前读还要加间隙锁来防幻读。MVCC 和锁是“双轨制”:读走时间线,写走锁。

为何常见:很多教程强调“MVCC 解决读写阻塞”,让人误以为 MVCC 消灭了所有锁,忽略了写-写冲突始终需要锁协调这一事实。

误区数据安全第一,把隔离级别设成最高的 Serializable,什么问题都不会有,反正机器性能足够。

正解S 级会用锁和串行化执行,锁等待与死锁概率显著上升,吞吐量可能跌到 RC 的几分之一。多数业务在 RC 或 RR 下配合业务逻辑(如乐观锁版本号、幂等键)就能满足需求。隔离级别是工具不是保险,过度的隔离会惩罚所有请求;选型应遵循“总成本最低”原则。

为何常见:新手容易把“保守”当作“安全”,忽略了并发系统的本质是权衡;缺少线上压测数据支撑,凭感觉定了最高档。

误区既然 MVCC 都是靠 ReadView 判断可见性,RC 和 RR 都是“读快照”,不就是同一个东西吗?

正解关键差异在 ReadView 的复用策略:RC 每条 SELECT 语句都新建 ReadView,所以能看到其他事务最新提交的修改;RR 整个事务只复用第一个 ReadView,后续读永远停留在事务开始时的“合影”。这正是不可重复读在 RR 下被消除、在 RC 下仍存在的原因。

为何常见:只学了 ReadView 的判定规则,没学它何时创建和复用;把“读快照”当成单一行为,忽略了快照的生命周期。

面试题

Q1. 事务的 ACID 特性中,“隔离性(Isolation)”主要解决什么问题?

单选题 · easy

  • A.保证事务内操作全做或全不做
  • B.防止并发事务相互读到中间状态← 正确答案
  • C.保证事务提交后数据持久不丢
  • D.保证事务前后数据满足业务约束

答案:B。隔离性关注多个事务并发执行时对彼此的可见性,防止读到未提交的中间状态。A 是原子性,C 是持久性,D 是一致性。就像厨房里定规矩:别人没端上桌的菜你绝不能动。

Q2. 以下哪种情况属于“脏读”?

单选题 · easy

  • A.读到另一个事务已提交的数据
  • B.读到另一个事务未提交的数据← 正确答案
  • C.同一事务两次读取结果不同
  • D.同一事务两次查询行数不同

答案:B。脏读的核心特征是“读到未提交的数据”,且该事务随后回滚,数据作废。C 是不可重复读,D 是幻读,A 是正常读操作。相当于你拿走了别人正准备扔掉的菜。

Q3. MySQL InnoDB 引擎的默认隔离级别是?

单选题 · easy

  • A.读未提交(RU)
  • B.读已提交(RC)
  • C.可重复读(RR)← 正确答案
  • D.可串行化(S)

答案:C。MySQL InnoDB 默认 RR,这是它与 SQL 标准推荐 RC 的最大差异。RU 几乎不用,S 性能太差很少直接选。这就像食堂默认执行“开饭前锁定菜单”的纪律。

Q4. 读已提交(RC)级别下,仍然可能发生哪种异常?

单选题 · mid

  • A.读取到未提交的脏数据
  • B.两个事务相互覆盖更新← 正确答案
  • C.事务提交后数据永久丢失
  • D.事务执行到一半即失败

答案:B。RC 只保证读到已提交数据,脏读被避免,但两个事务同时读改写同一行时,后提交者会覆盖先提交者,形成更新丢失。A 在 RC 下不可能,C 属于持久性问题,D 属于原子性问题。

Q5. ReadView 中的 m_ids 集合记录的是什么?

单选题 · mid

  • A.所有已提交事务的编号集合
  • B.创建快照时活跃事务的编号集合← 正确答案
  • C.系统全部历史事务的编号集合
  • D.正在执行写操作事务的编号集合

答案:B。m_ids 是 ReadView 创建那一刻尚未提交的事务编号集合,用于判断某版本是否由“仍在厨房的人”修改。已提交事务不会出现在 m_ids 中,已提交与否还要结合 min/max 编号区间判断。

Q6. 下列关于可重复读(RR)的说法正确的是?

单选题 · mid

  • A.SQL 标准中 RR 能完全避免幻读
  • B.RR 保证事务内重复读同一行结果一致← 正确答案
  • C.RR 是四种隔离级别中最高的档位
  • D.RR 的实现中完全不使用任何锁

答案:B。RR 保证同一事务内重复读取同一行结果一致。SQL 标准中 RR 不防幻读(A 错),最高级别是 S(C 错),RR 仍需配合锁处理写冲突(D 错)。MySQL 的 RR 防幻读是特例。

Q7. MySQL InnoDB 在 RR 级别下,下列哪种语句会使用当前读并加锁?

单选题 · hard

  • A.普通 SELECT 查询语句
  • B.加锁 SELECT FOR UPDATE← 正确答案
  • C.事务开始的 BEGIN 语句
  • D.数据库版本信息查询

答案:B。SELECT …… FOR UPDATE 是“当前读”,会对命中的行加排他锁;普通 SELECT 走 MVCC 快照读不加锁;BEGIN 只是开启事务;版本查询不涉及行数据。当前读是防幻读的第二条轨道。

Q8. 在读未提交(RU)级别下,可能发生哪些并发异常?(多选)

多选题 · mid

  • A.脏读(读到未提交数据)← 正确答案
  • B.不可重复读(两次结果不同)← 正确答案
  • C.幻读(范围行数变化)← 正确答案
  • D.以上三种都不可能出现

答案:ABC。RU 档位不设防,三种异常全部可能发生,D 是反向干扰项。RU 的唯一好处是读取无延迟、无锁等待,但数据可信度极低,实践中几乎不用。

Q9. 关于 MVCC 版本可见性判断,下列说法正确的有?(多选)

多选题 · hard

  • A.版本编号小于 min_trx_id 时可见← 正确答案
  • B.版本编号在 m_ids 活跃集合中可见
  • C.版本编号大于等于 max_trx_id 不可见← 正确答案
  • D.区间内且不在 m_ids 时可见← 正确答案

答案:ACD。m_ids 是活跃事务集合,编号在其中说明该事务还没提交,对应版本不可见(B 错)。小于 min_trx_id 说明早已提交可见;大于等于 max_trx_id 是未来事务不可见;区间内不在 m_ids 则已提交可见。

Q10. 关于不同数据库隔离级别实现,下列说法正确的有?(多选)

多选题 · extreme

  • A.MySQL InnoDB 默认隔离级别为 RR← 正确答案
  • B.PostgreSQL 默认隔离级别为 S
  • C.Oracle 默认隔离级别为读已提交 RC← 正确答案
  • D.MySQL RR 完全不需要任何锁机制

答案:AC。InnoDB 默认 RR;Oracle 默认 RC,用 undo 构建一致性读;PostgreSQL 默认也是 RC(B 错);MySQL RR 防幻读需要 MVCC 快照读加当前读间隙锁两者配合,并非完全无锁(D 错)。