想象你和一个朋友合租,厨房冰箱公用,谁都能做饭。但如果没有规矩,麻烦会像滚油一样溅开:一个室友炒菜到一半跑去接电话,油锅还烧着;另一个室友把他切好的菜倒进去,两人一搅和,谁的菜都毁了,冰箱里还少了食材。于是你们坐下来,定了一本《公共厨房公约》。 公约一共四条铁律:第一,做饭必须一气呵成——要么端上桌,要么中途搞砸了就把厨房恢复原样,仿佛你从没来过(这正是原子性);第二,冰箱里的食材只能有借有还,账目永远守恒,不能凭空多出三斤肉(这是一致性);第三,两个人同时做饭时互不干涉,你只管按自己开火那一刻看到的厨房来操作(这是隔离性);第四,每次拿放食材都要先在一本永久流水账本上记一笔,哪怕半夜停电,第二天照着重做一遍(这是持久性)。 数据库世界的“事务”(Transaction),就是这套厨房公约。它背后的两大功臣,一个叫 undo 日志(撤销记账本),一个叫 redo 日志(永久流水账本),两者又合力撑起了 MVCC 多版本并发控制。这篇文章就借这间厨房的烟火气,把 ACID 的每个角落、每条实现细节,通通讲透。

岗位关联

给 AI 从业者的话:模型训练记录、特征库更新、用户行为落库,数据可靠性差一步,整个系统的可信度就崩一步。搞懂事务与 MVCC,是你从“会调接口”进阶到“能设计健壮数据系统”的分水岭。

目录
  1. 可视化演示
  2. 事务概念基础——一件事的完整仪式
  3. 原子性原理——全有或全无的烟火
  4. 一致性保障——账目永不失衡
  5. 隔离性级别——厨房的四扇门
  6. 持久性机制——永久流水账本
  7. MVCC多版本——各看各的历史快照
  8. 可见性判断——按号码牌认账
  9. 工程权衡——完美公约的代价
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

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

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

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

事务概念基础——一件事的完整仪式

室友阿明今晚要做鱼香肉丝,从开冰箱拿肉、切菜、开火、爆炒、上桌,到最后一并洗碗收拾,这是一套不能拆开的完整仪式。任何一步半途而废,这顿饭就不成立。数据库里,这个“完整仪式”就叫事务(Transaction):把多条 SQL 语句打包成一个逻辑单元,要么一起成功,要么一起失败,不允许只成功半截。

为什么需要打包?最经典的例子是转账:从张三账户扣 100 元,同时给李四账户加 100 元。这两条 SQL 必须绑定在一起。如果只扣不加,钱就凭空消失了;只加不扣,银行就得破产。数据库用事务把“扣”和“加”焊成一块,谁也别想拆开。

在工程上,事务以 BEGIN 或 START TRANSACTION 开始,以 COMMIT(提交,确认结果永久生效)或 ROLLBACK(回滚,撤销全部操作)结束。它的一生有明确规定:执行中、执行完待提交、已提交;或执行中、失败、已回滚。MySQL 的 InnoDB 引擎会在执行每条写操作时顺手把旧值记入 undo 日志,为随时回滚做好准备。

事务的开端,就是阿明系上围裙的那一瞬间。数据库从此记住:这个人要么完整地做完一顿饭,要么把厨房恢复原样。

Tlife={ACTIVE→PART_COMMIT→COMMIT}  ∪  {ACTIVE→FAILED→ABORT}T_{\text{life}} = \{\text{ACTIVE} \to \text{PART\_COMMIT} \to \text{COMMIT}\} \;\cup\; \{\text{ACTIVE} \to \text{FAILED} \to \text{ABORT}\}
这个公式给出了事务的两条生命路径:一条通向成功提交,一条通向失败回滚。中间任何环节出问题,都只能走回滚这条路。
  1. ACTIVE 状态事务刚开始执行,正在处理业务操作,就像阿明刚把肉从冰箱里拿出来,还没下锅。
    ACTIVE\text{ACTIVE}
  2. PART_COMMIT 状态所有 SQL 都执行完,但还没写最终确认,相当于菜已出锅,就差端上桌那一刻。
    ACTIVE→PART_COMMIT\text{ACTIVE} \to \text{PART\_COMMIT}
  3. COMMIT 路径提交成功,整个事务永久生效,菜上桌了,洗碗收盘都按规矩来。
    ACTIVE→PART_COMMIT→COMMIT\text{ACTIVE} \to \text{PART\_COMMIT} \to \text{COMMIT}
  4. ABORT 路径执行中出现失败,事务中止并回滚,一切恢复到开始之前。
    ACTIVE→FAILED→ABORT\text{ACTIVE} \to \text{FAILED} \to \text{ABORT}

原子性原理——全有或全无的烟火

锅里已经开始冒烟,阿明发现酱油瓶是空的,油温又太高。他当机立断:这顿饭不做了。于是把肉放回冰箱、菜倒回袋里、锅刷干净、台面抹平——厨房恢复到开火之前的样子,仿佛一切都没发生过。这就是原子性(Atomicity):全有或全无,不允许中间状态。

数据库里,原子性意味着一个事务中的所有操作被视为一个不可分割的整体。要么全部执行成功并留下结果,要么全部不执行,数据回到事务开始前的状态。绝不存在“转账扣了款,但入账失败”这种半吊子结果。它像一道闸门:整道工序不过关,就把闸门后面所有东西冲回原点。

支撑这道闸门的,是 undo 日志(撤销日志)。InnoDB 在每条写操作执行前,会把旧值记入 undo 日志,形成一条“逆操作链”。回滚时,数据库沿着这条链从后往前逐个执行逆操作:INSERT 的逆操作是 DELETE,UPDATE 的逆操作是恢复旧值,DELETE 的逆操作是重新插入。旧值链有多长,回滚就能走多远。

阿明凭着一张“撤销备忘清单”把厨房恢复原样。数据库的 undo 日志,就是那张清单——不是靠记忆,而是靠逐条记录的旧值,把烟火气一点点抹干净。

Safter=UndoAll(Abort(T))=SbeforeS_{\text{after}} = \text{UndoAll}\big(\text{Abort}(T)\big) = S_{\text{before}}
这个公式是说:事务 T 中止后,把所有 undo 操作全部执行一遍,最终状态必然等于事务开始前的状态。原子性由此得到保证。
  1. S_after 最终状态事务结束后数据库所处的状态,也就是我们实际能看到的数据模样。
    SafterS_{\text{after}}
  2. Abort(T) 中止事务事务执行过程中发生失败,主动或被动进入中止流程。
    Abort(T)\text{Abort}(T)
  3. UndoAll 回滚全部按照 undo 日志从后往前执行所有逆操作,把每一步的旧值都还原回去。
    UndoAll(⋯ )\text{UndoAll}(\cdots)
  4. 等于 S_before回滚完成后,数据库状态与事务执行前完全一致,旧值链被完整消耗。
    Safter=SbeforeS_{\text{after}} = S_{\text{before}}

一致性保障——账目永不失衡

公寓公约里有一条硬规矩:公共冰箱里的东西必须账实相符。阿明拿走一瓶酱油,就得在记账板上写“借酱油一瓶”;用完放回来再划掉。如果谁拿了东西却不肯记账,月底一盘点,账就平不了。数据库的一致性(Consistency)讲的就是这个:无论事务怎么折腾,数据必须从一种合法状态变到另一种合法状态,不允许出现“负余额”这种荒谬账目。

这些合法性由两类规则定义:一类是数据库内置的完整性约束——主键不能重复、外键必须存在、字段不能有空值;另一类是业务规则——转账总量不变、库存不能为负、余额不能透支。前者数据库自动强制,后者写进应用逻辑。事务提交后,所有规则必须同时成立。

一致性不是某一个机制单独实现的,而是“原子性 + 隔离性 + 应用正确性”共同作用的结果。原子性保证中途失败不会留下残缺账目;隔离性保证其他事务看不到还没算完的中间账;而业务的正确逻辑保证约束本身被满足。四者合在一起,才凑齐了“账永远平”这一结果。它也解释了为什么一致性常被称作 ACID 中“最含糊”的一条——它更像一个目标,而不是一种具体机制。

账目守恒的公约,从来不是靠单独一条规矩生效的,而是所有规矩形成合力。一致性也一样——它是数据库约束与业务规则共同守卫的那杆秤。

Consistent(S)≡⋀i=1nCi(S)\text{Consistent}(S) \equiv \bigwedge_{i=1}^{n} C_i(S)
这个公式表明:数据库状态 S 是一致的,当且仅当所有约束 C₁ 到 Cₙ 同时为真。只要有一条约束被破坏,整个状态就不合法。
  1. Consistent(S) 一致状态数据库处于某种状态 S,称其一致当且仅当它不违反任何规则。
    Consistent(S)\text{Consistent}(S)
  2. C_i(S) 单条约束第 i 条约束(主键、外键、非空、唯一、业务规则)在当前状态下成立。
    Ci(S)C_i(S)
  3. 合取符号对 i 从 1 到 n 全部取“且”,任何一条不满足,整体就不一致。
    ⋀i=1n\bigwedge_{i=1}^{n}

隔离性级别——厨房的四扇门

公寓里常有两个人同时想做饭。为了避免互相泼油、抢灶台,你们设计了四种厨房使用模式,由紧到松:第一种全开放,谁都能进来随便看甚至用半成品,这叫读未提交;第二种半开放,能看到别人橱柜里最终的成品,但看不到还没做完的锅,这叫读已提交;第三种个人包间,关上门后你看到的一切自始至终不变,门外的动静与你无关,这叫可重复读;第四种严格排队,一人做完下一位再进,这叫串行化。

隔离性(Isolation)就是这个意思:多个并发事务各自看到的数据世界不能互相干扰。标准 SQL 定义了四个隔离级别,每个级别解决一些并发问题,又留下另一些问题。读未提交可能读到别人没提交的脏数据(脏读);读已提交解决了脏读,但同一事务内两次查询结果可能不同(不可重复读);可重复读保证事务内读到的数据始终一致,但另一个事务插入新行,导致查询多出数据(幻读);串行化把一切并发都排成队,彻底消除异常,代价是吞吐量骤降。

MySQL InnoDB 默认使用可重复读,但它并非放任幻读不管——它靠 MVCC 快照读屏蔽了幻读的“读半边”,再用间隙锁(Gap Lock)管住“写半边”。也就是说,四个标准级别是通用分档,具体引擎可以在同一档位上做出更强的保证。

厨房选哪扇门,取决于今天是想快速做个简餐,还是想安静地招待客人。数据库选隔离级别,就是在“做菜效率”和“不被打扰”之间,挑一扇恰到好处的门。

RU⊏RC⊏RR⊏SR\text{RU} \sqsubset \text{RC} \sqsubset \text{RR} \sqsubset \text{SR}
这个公式按隔离强度给四个级别排序:从 RU 到 SR,隔离保证逐级增强,但并发能力逐级减弱。
  1. RU 读未提交最松的级别,事务能读到其他事务未提交的数据,脏读随时可能发生。
    RU\text{RU}
  2. RC 读已提交只能读到已提交的数据,脏读被禁止,但两次读的结果可能不一致。
    RC\text{RC}
  3. RR 可重复读事务内第一次读之后,之后的读到的是同一个快照,不可重复读被阻止。
    RR\text{RR}
  4. SR 串行化事务完全排队执行,等价于没有并发,所有异常都被消灭。
    SR\text{SR}

持久性机制——永久流水账本

公寓有一次半夜跳闸,冰箱的凉气跑光,刚买的肉差点坏掉。好在你们有一本永不撕页的“永久流水账本”:谁在什么时间把什么食材放进冰箱,每一笔都记得清清楚楚。第二天照着账本,把食材挪进备用冷柜,损失被完全挽回。持久性(Durability)正是这本账本的意义——事务一旦提交,修改就永久保存,哪怕断电、崩溃,数据也绝不丢失。

数据库靠两条腿走路:一条是 undo(撤销),一条是 redo(重做)。redo 日志是物理日志,记录“数据文件中第几页、哪个偏移量、被写成了什么字节”,精确到细胞的病历。事务提交前,数据库先把这批 redo 记录落盘,再动数据页面本身——这就是传说中的 WAL(Write-Ahead Logging,预写日志)。先记账、后干活,账本永远比现场多一步。

如果提交后系统崩溃,但数据页还没来得及写进磁盘怎么办?崩溃恢复时,数据库扫描 redo 日志,把已提交但未落盘的操作重放一遍,就像对照账本把食材重新归位。因为“先写日志再写页”的顺序保证,日志里有的必然能恢复。redo 管“重做”,undo 管“撤销”,一个向前救火,一个向后灭火,方向正好相反,分工互不重叠。

厨房里最值钱的不是食材本身,而是那本怎么撕都撕不烂的流水账。数据库的 redo 日志也一样——只要账本还在,任何灾难都只是重做一遍的事。

Commit(T)⇒fsync(redo_log)≺write(data_page)\text{Commit}(T) \Rightarrow \text{fsync}(\text{redo\_log}) \prec \text{write}(\text{data\_page})
这个公式是持久性的核心顺序约束:事务提交成功的先决条件是 redo 日志先刷入磁盘,然后才允许写数据页。顺序一旦颠倒,崩溃时日志会缺页。
  1. Commit(T) 提交成功事务到达提交点,应用层向用户宣布“这笔操作成功了”。
    Commit(T)\text{Commit}(T)
  2. 蕴含符号表示“必须推出”——提交成立的前提是后面这句话为真。
    ⇒\Rightarrow
  3. fsync(redo_log) 日志刷盘把 redo 日志从内存缓冲区强制写入磁盘,确保断电不丢。
    fsync(redo_log)\text{fsync}(\text{redo\_log})
  4. 先于写数据页数据页刷盘必须发生在日志刷盘之后,用"≺"表示严格的时间先后。
    fsync(redo_log)≺write(data_page)\text{fsync}(\text{redo\_log}) \prec \text{write}(\text{data\_page})

MVCC多版本——各看各的历史快照

阿明和阿强同时在厨房做菜。阿明开火时看到冰箱里鸡肉剩 500 克,阿强晚五分钟开火,看到的也是那 500 克。两人各拿各的“进门快照”,互相不干扰。可阿明中途炖掉 100 克鸡肉,阿强并不知道,也不影响他按自己的快照做菜。这就是数据库的多版本并发控制(MVCC,Multi-Version Concurrency Control)——让每个事务活在属于自己的“厨房照片”里。

老派的做法是锁:读的时候锁住数据不让写,写的时候锁住数据不让读,读和写互相堵车。MVCC 换了个思路:同一行数据不只存一个当前值,而是通过 undo 日志保留一串历史版本。写操作生成新的“当前版本”,旧版本不删除,而是挂在一条版本链上;读操作去读某个合适的旧版本,压根不用等写操作完成。于是读不阻塞写,写不阻塞读,两条车道各自通行。

在 InnoDB 中,每行记录头都藏着两个隐藏列:trx_id(生成这个版本的事务 ID)和 roll_pointer(回滚指针,指向 undo 日志里的上一个版本)。所有版本通过 roll_pointer 串成一条完整的版本链,最新版本在链头,最古老的在链尾。不同事务根据自己的“快照”决定从链上取哪个版本。

版本链就是厨房里的整本“老照片档案”。想看清旧厨房的模样,翻出对应日期的照片就行,完全没必要把现在桌上的菜端走。MVCC 的智慧就在这三言两语之间。

Version(r)=⟨trx_id, roll_ptr⟩⟶PrevVersion(r)\text{Version}(r) = \langle \text{trx\_id},\ \text{roll\_ptr} \rangle \longrightarrow \text{PrevVersion}(r)
这个公式描述版本链的节点结构:每个版本由“事务ID + 回滚指针”构成,回滚指针把当前版本指向上一个历史版本。链条由此形成。
  1. Version(r) 数据行版本一行数据 r 的某个历史版本,记录着当时的数据内容及其元信息。
    Version(r)\text{Version}(r)
  2. trx_id 生成者创建这个版本的事务编号,用来在可见性判断中对号入座。
    trx_id\text{trx\_id}
  3. roll_ptr 回滚指针指向 undo 日志中构造上一个版本的记录,形成链表结构。
    roll_ptr\text{roll\_ptr}
  4. 指向上一个版本箭头表示从当前版本沿着指针找到历史前驱,链不断往前追溯。
    ⟶PrevVersion(r)\longrightarrow \text{PrevVersion}(r)

可见性判断——按号码牌认账

阿明拿着进门时拍的快照,想知道“冰箱里的鸡肉到底应该看哪个版本”。光有快照不够,还得有一份号码牌名单:哪些室友在我进门时已经在厨房里忙活(事务还在活跃),哪些早就做完走了(事务已提交)。号码牌一对,该认账还是不该认账,一清二楚。

这份号码牌名单在数据库里叫 ReadView(读视图),它记录四个关键值:creator_trx_id(生成视图的事务 ID,即“我自己”)、min_trx_id(活跃事务中最小的 ID)、max_trx_id(下一个将被分配的事务 ID,即上界)、active_ids(当前所有活跃事务的 ID 集合)。拿到一个历史版本,看它的 trx_id 是谁生成的,再用下面的规则判断是否对当前事务可见:

ReadView 的生成时机决定了隔离级别的行为差异:读已提交(RC)每次 SELECT 都会生成新视图,所以两次查询可能看到不同的最新提交;可重复读(RR)只在事务第一次查询时生成一次视图,之后一直复用,所以整个事务看到的是同一个冻结的厨房。号码牌名单一旦定下,厨房的景象就凝固了。

visible(t)=(t<m)  ∨  (m≤t<M  ∧  t∉A)  ∨  (t=c)\text{visible}(t) = (t < m) \;\lor\; \big(m \le t < M \;\land\; t \notin A\big) \;\lor\; (t = c)
这是 MVCC 最核心的判定公式:版本生成者 t 要么已经早于所有活跃事务,要么在活跃区间内但已不在活跃列表,要么就是当前事务自己——满足任一条件即可见。
  1. t 为版本生成者待判断版本是由事务 t 创建生成的,t 是该事务的 ID。
    tt
  2. t < m 已结束的事务t 比所有活跃事务都小,说明它的工作早已完成提交,当前快照必然可见。
    t<mt < m
  3. min ≤ t < max 区间内t 落在活跃区间内,进一步要查它是否还活跃。若 t ∉ A,说明已经提交,可见。
    m≤t<M  ∧  t∉Am \le t < M \;\land\; t \notin A
  4. t = c 自己可见如果版本是自己生成的,当然能看见自己写的数据。
    t=ct = c
  5. 三种情况取“或”三个条件只要满足任意一个,版本就可见;全部不满足则不可见。
    ∨\lor

工程权衡——完美公约的代价

如果把厨房四壁都加上防弹玻璃,每个室友完全隔离,安全是安全了,可谁都进不去做饭了。数据库也一样:隔离级别拉到串行化,并发全部排成队,吞吐量断崖式下跌。工程上从不存在“绝对安全”的免费午餐,只有“够用即可”的取舍。

MVCC 也不是万能药。它解决的是读与写之间的冲突,让两边互不阻塞;但两个事务同时改写同一行,写与写之间的冲突仍然存在,绕不开锁或版本号。更隐蔽的是快照隔离下的丢失更新:两个事务都基于同一旧快照读到了库存 100,各自减 1,先后提交,结果库存变成 99 而不是 98——其中一个更新被静默吞掉了。

所以工程师的真实工作,是学会选门和补漏:读多写少的长查询业务,用可重复读 + MVCC 性价比最高;金融转账、库存扣减这类强一致场景,就要上显式锁、乐观锁甚至串行化。给公寓选门的智慧,从来都是“刚刚好”,而不是“最安全”。

W1(x)∥W2(x)  ⇒  x=v2,v1 丢失W_1(x) \parallel W_2(x) \;\Rightarrow\; x = v_2, \quad v_1\ \text{丢失}
这个公式描绘了丢失更新:事务 T₁ 和 T₂ 并发地写同一数据 x,后提交者把先提交者的结果覆盖,v₁ 就像没发生过一样消失。
  1. 并行写同一数据W₁ 和 W₂ 同时修改 x,"∥"表示两者并发执行。
    W1(x)∥W2(x)W_1(x) \parallel W_2(x)
  2. 最终值为后写者并发写的结果是后提交事务的版本 v₂。
    x=v2x = v_2
  3. 先写值丢失v₁ 被覆盖,如同没写过,导致业务上的一次更新静默消失。
    v1 丢失v_1\ \text{丢失}

AI 应用场景

金融交易系统:一次转账的生死时速

阶段:数据存储与管道(可靠数据落库)

解决的问题:传统转账由“扣款 + 入账”两条 SQL 组成,任何一条失败都会造成账目不平。AI 风控系统实时判定的结果(如冻结银行卡)也需原子写入——判定成功但落库失败,诈骗交易就漏过去了。

具体案例:在银行核心系统中,每次转账都在一个事务内完成。若程序在中途崩溃,undo 日志自动回滚扣款;若转账成功但机房断电,redo 日志在重启后把已提交数据重放出来。AI 反欺诈系统写入“冻结标记”时同样要走事务,确保风控结论和资金操作同生共死。

电商秒杀:库存不被超卖的 MVCC 大战

阶段:高并发在线推理与状态写入

解决的问题:秒杀场景下成千上万个请求同时扣库存,如果各自读到旧快照,就会发生超卖。MVCC 的快照读让查询不排队,但写写冲突必须用行锁或乐观锁版本号兜底。

具体案例:一个 AI 推荐系统在用户点击“立即抢购”时,先通过快照读快速判断库存,再对库存行执行带版本号的 UPDATE ... WHERE version = ?,更新失败则重试。把 MVCC 当作“读优化器”,把乐观锁当作“写守门员”,两者配合让秒杀系统既高效又不超卖。

AI模型训练:checkpoint 的原子保存

阶段:模型训练与持久化(MLOps)

解决的问题:大模型训练几十小时,中途断电就前功尽弃。定期保存的 checkpoint 必须原子性写入——写一半的文件等于没有。

具体案例:PyTorch Lightning 保存模型时,先把权重写进临时文件,再通过原子改名替换旧文件。这套思路和数据库事务同源:临时文件是“未提交状态”,改名是“提交点”,一旦崩溃恢复,旧 checkpoint 仍然完好。若把训练元数据(超参数、指标曲线)存进数据库,同样需要事务保护,防止指标表和参数表对不上账。

Python 演示

python49 行
class SimpleTxnDB:    def __init__(self):        self.data = {}          # 数据表: key -> value        self.undo_log = []      # 撤销日志: [(key, old_value)]     def set(self, key, value):        old = self.data.get(key)        self.undo_log.append((key, old))        self.data[key] = value        print("SET {} = {} | undo_log_len = {}".format(key, value, len(self.undo_log)))     def rollback(self):        while self.undo_log:            key, old = self.undo_log.pop()            if old is None:                self.data.pop(key, None)            else:                self.data[key] = old        print("rollback done, data = {}".format(self.data))     def commit(self):        self.undo_log.clear()        print("commit done")  def mvc_visible(trx_id, min_id, max_id, active_ids, creator_id):    if trx_id < min_id:        return True    if trx_id >= max_id:        return False    if trx_id not in active_ids:        return True    return trx_id == creator_id  if __name__ == "__main__":    db = SimpleTxnDB()    db.set("alice", 100)    db.set("bob", 0)    db.set("alice", 70)    db.set("bob", 30)    db.rollback()    assert db.data == {}, "atomicity: all changes undone"     assert mvc_visible(1, 2, 5, [2, 3], 4) is True   # t < min_id    assert mvc_visible(4, 2, 5, [2, 3], 4) is True   # t == creator    assert mvc_visible(3, 2, 5, [2, 3], 4) is False  # t in active_ids    assert mvc_visible(5, 2, 5, [2, 3], 4) is False  # t >= max_id    print("MVCC visibility rules passed")
目的:用不到 60 行 Python 模拟了事务的 undo 回滚与 MVCC 可见性判定,把抽象概念变成可运行、可断言的逻辑。
  1. self.undo_log.append((key, old))每次写之前先把旧值存入撤销日志,这是“先记账后干活”。
  2. while self.undo_log:从日志尾部开始逐个弹出,执行逆操作恢复旧值。
  3. if old is None: ... else:旧值为空表示原本没有该键,回滚时删除;否则恢复原值。
  4. db.rollback()一次回滚把四次 set 全部撤销,assert db.data == {} 验证原子性。
  5. mvc_visible(1, 2, 5, [2, 3], 4)用具体数字验证可见性规则的四条分支:小于最小ID可见、自己是创建者可见、活跃列表不可见、大于上界不可见。

常见误区

误区我一开始以为 ACID 里的原子性就是编程课里讲的 CAS、自旋锁那种“单条指令不可分割”,所以只要用 synchronized 包住事务就万事大吉了。

正解这两个“原子”完全是两码事。多线程的原子操作是底层 CPU/内存层面的“一次性完成”;事务的原子性是逻辑层面的“一组操作要么全成要么全无”,由 undo 日志在失败时逐条回滚实现。事务可能跑了上百条 SQL,物理上绝不是一瞬间的事,但有日志兜底,最终效果如同没做过。

为何常见:同一个词在不同领域各有一套含义,而大部分入门教程先教并发编程,容易把先入为主的印象带到数据库里。

误区串行化最安全,脏读、不可重复读、幻读全都没有,那把数据库隔离级别全部设成 SERIALIZABLE 不就好了?

正解串行化意味着事务完全排队执行,并发度几乎归零。一个秒杀系统如果用串行化,请求会排成长龙,吞吐量断崖式下跌。现实中绝大多数业务用读已提交或可重复读就能满足需求,只需用锁或版本号处理少量关键写冲突。隔离级别是正确性和性能之间的杠杆,不是越高越好。

为何常见:教材通常只讲“高级别堵住了哪些异常”,不强调成本,容易让人产生“越严越好”的线性错觉。

误区MVCC 让读写互不阻塞,那是不是数据库就可以完全抛弃锁了?以后并发随便写就行。

正解MVCC 只解决了“读写冲突”,让读不阻塞写、写不阻塞读。但“写写冲突”依然存在:两个事务同时改同一行,需要一个排队机制,否则会互相覆盖。此外快照隔离下还可能发生丢失更新——两个事务读同一旧快照各自提交,后提交者吞掉先提交者的结果。MVCC 不是免死金牌,只是把读车道的拥堵挪开了。

为何常见:“读写互不阻塞”的宣传听多了,容易忽略写与写之间永远存在的竞争关系。

误区undo 能回滚,那 redo 应该也能回滚吧?或者 redo 就是数据库的备份文件,用来恢复误删数据的。

正解方向正好相反。undo 是“撤销日志”,管事务回滚和 MVCC 历史版本构建,记的是逻辑逆操作;redo 是“重做日志”,管崩溃恢复时把已提交但没写盘的操作重放一遍,记的是物理页修改。一个向后灭火,一个向前救火,职责完全不同,也互不替代。

为何常见:两个名字只差一个字母,讲课时又常放在一起对比,初学阶段极易混淆。

误区可重复读下同一个事务里两次 SELECT 结果完全一样,所以幻读已经彻底不存在了,不用再管。

正解MVCC 快照读确实能屏蔽掉大多数幻读,但“当前读”(SELECT ... FOR UPDATE、UPDATE、DELETE)读的是最新版本,仍可能看到其他事务新插入的行。MySQL InnoDB 在可重复读下额外用间隙锁(Gap Lock)解决当前读的幻读,这不是标准 SQL 隔离级别自带的属性,而是引擎的加强措施。

为何常见:只盯着快照读的行为,忽略了“读最新数据”的那条路径,就把“堵住了一半”当成了“彻底堵住”。

面试题

Q1. 关于事务(Transaction)的定义,最准确的是?

单选题 · easy

  • A.一组要么全部提交要么全部回滚的操作← 正确答案
  • B.一条能够在数据库正常运行的SQL语句
  • C.一种用于加密数据库敏感数据的工具
  • D.一个存储数据库备份数据的物理文件

答案:A。事务是把多条 SQL 打包成一个逻辑单元,具备全成或全无的特性。单条 SQL 只是事务的组成元素,加密、备份则是完全无关的数据库能力。A 抓住了“打包 + 全成或全无”的本质。

Q2. 原子性(Atomicity)的核心含义是?

单选题 · easy

  • A.事务内操作要么全部成功,要么全部恢复← 正确答案
  • B.多个事务之间互相隔离,互不干扰对方
  • C.事务一旦提交,数据永久保存不会丢失
  • D.事务执行前后,数据总量严格保持不变

答案:A。原子性是“全有或全无”,靠 undo 日志回滚支撑。B 是隔离性,C 是持久性,D 是一致性的一种表现。四个选项恰好对应四个特性,是最经典的区分题。

Q3. 数据库实现事务回滚的关键机制是?

单选题 · easy

  • A.redo日志的崩溃重放过程
  • B.undo日志的逆操作链← 正确答案
  • C.数据页的双写缓冲区机制
  • D.主键索引的B+树结构

答案:B。undo 日志记录了每次写操作前的旧值,回滚时沿着逆操作链从后往前恢复,就像按备忘清单把厨房恢复原样。redo 用于崩溃后“前滚”,双写缓冲和 B+ 树与回滚无直接关系。

Q4. 在读已提交(READ COMMITTED)隔离级别下,不会出现的现象是?

单选题 · mid

  • A.脏读,读到未提交事务的临时数据← 正确答案
  • B.不可重复读,两次查询结果不相同
  • C.幻读,查询结果的行数凭空增减
  • D.更新丢失,后写覆盖先写的提交

答案:A。读已提交每次查询都生成新的 ReadView,未提交的数据一律不可见,因此堵住了脏读。但不可重复读和幻读在标准定义下仍可能发生;更新丢失属于快照隔离下的写并发问题,也不由该级别直接解决。

Q5. MVCC 中 ReadView 的主要作用是?

单选题 · mid

  • A.决定当前事务可见哪些历史版本← 正确答案
  • B.保存数据表所有物理块的地址
  • C.锁住版本链上所有旧版本数据
  • D.记录redo日志的最新LSN序号

答案:A。ReadView 相当于进厨房时领到的号码牌名单,记录活跃事务集合,用来判断版本链上哪个版本对当前事务可见。它不存地址、不加锁、也不管 LSN。

Q6. 可重复读(REPEATABLE READ)与读已提交相比,核心区别是?

单选题 · mid

  • A.快照在首次查询生成且整个事务复用← 正确答案
  • B.每次查询都重新生成全新的快照视图
  • C.完全不使用锁和版本链机制
  • D.允许读到其他事务未提交的数据

答案:A。可重复读在事务第一次 SELECT 时生成 ReadView 并一直复用,所以事务内每次读到一致快照;读已提交每次查询生成新视图。C 说法错误,RR 仍用锁和版本链;D 是读未提交的特征。

Q7. 事务 T 生成 ReadView:m=20,M=30,活跃列表 A={21,25,28}。版本 v 由 trx_id=25 生成,v 对 T 是否可见?

单选题 · hard

  • A.可见,因为25大于最小活跃ID20
  • B.不可见,因为25在活跃事务列表中← 正确答案
  • C.可见,因为25等于当前事务自身ID
  • D.不可见,因为25小于最小活跃ID20

答案:B。25 落在 [20, 30) 区间内,且在活跃列表 A 中,也不是事务 T 自己,所以版本生成者尚未提交,当前事务不可见该版本。这对应核心公式中的第二条分支“t∈A 且 t≠c ⇒ 不可见”。

Q8. 事务的 ACID 四特性包括以下哪几项?

多选题 · easy

  • A.原子性、一致性、隔离性、持久性← 正确答案
  • B.可用性、分区容错性、原子性、隔离性
  • C.一致性、隔离性、持久性、可扩展性
  • D.原子性、持久性、聚集性、规范性

答案:A。ACID 是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)四个英文首字母的缩写。B 混淆了分布式系统的 CAP 定理,C 的“可扩展性”和 D 的“聚集性、规范性”都不属于 ACID。

Q9. 关于 redo 与 undo 日志,下列说法正确的是?

多选题 · mid

  • A.undo 用于事务回滚和构建历史版本← 正确答案
  • B.redo 用于崩溃恢复时重放已提交数据← 正确答案
  • C.undo 是物理日志,记录页的字节修改
  • D.redo 与 undo 的恢复方向正好相反← 正确答案

答案:ABD。undo 是逻辑逆操作链,负责回滚和 MVCC 旧版本构建;redo 是物理日志,负责崩溃后前滚重放。方向上一个向后撤销、一个向前重做,正好相反。C 说反了——记录“页字节修改”的是 redo。

Q10. MySQL InnoDB 在可重复读隔离级别下,仍可能出现的并发异常场景是?

多选题 · hard

  • A.两个事务读同一快照后写入覆盖彼此← 正确答案
  • B.当前读 INSERT……FOR UPDATE 检测到幻读← 正确答案
  • C.两个事务并行修改不同行并等待行锁
  • D.两个事务同时更新同一条记录发生死锁

答案:AB。可重复读的快照读能挡住部分幻读,但当前读仍需要间隙锁来防幻读,B 是可能出现的;快照隔离下的丢失更新(A)也依然存在。C 中修改不同行通常不冲突,不构成异常;D 中的死锁是锁机制的正常调度现象,并非数据异常。