3.1 事务模型与隔离级别


3.1 事务模型与隔离级别

本节摘要:事务是一组要么全部成功、要么全部回滚的操作,ACID 是对它的四项承诺——原子性靠日志兑现,隔离性靠并发控制兑现。脏读、不可重复读、幻读是并发裸奔时最先暴露的三种异常,隔离级别就是按"能挡住几种异常"分出的档位。读完你能把四档级别逐一映射到具体异常上,并明白同一个"可重复读"在 MySQL 和 PostgreSQL 里其实是两套语义,选型时不能再只看名字,而要把级别和具体的引擎实现绑在一起看。

本节地图

阅读完本节,你应当能够:

  1. 用转账场景讲清原子性、一致性、隔离性、持久性各自解决什么问题
  2. 区分脏读、不可重复读、幻读三种异常,并能各举一个业务后果
  3. 画出读未提交到可串行化的阶梯,标注每档挡住的异常
  4. 解释为什么快照隔离会漏掉写偏斜这类跨行冲突
  5. 说出 MySQL 与 PostgreSQL 在"可重复读"档位上的实现差异及后果

一、问题与直觉

先看一个翻车现场。张三给李四转 100 元,这桩业务在数据库里其实是两条更新:张三的余额减 100,李四的余额加 100。假如系统在两条更新之间崩溃,数据库里就多出了"钱凭空消失"的半成品:张三的钱扣了,李四的钱没到。更糟的是,如果这时另一个事务恰好在读张三的余额,它读到的可能是扣款前、扣款后、甚至一个已经要回滚的中间值。谁也没写错代码,但数据就是不对。

问题不在某一行 SQL,而在于我们把"多条语句"默认当成了"一个动作"。数据库天然是逐条执行、逐条落盘的,它并不知道"减 100"和"加 100"在业务上是一体的。事务模型存在的全部理由,就是让数据库替我们把一组操作当成一个不可分割的单元来对待。 我们给它划个边界,它给我们一个承诺:这组操作要么整体生效,要么整体作废。

这听起来简单,落到工程上却要回答一串问题。什么算"整体生效"?并发下两个事务互相穿插,谁先谁后、谁看得见谁?机器随时可能断电,已提交的结果凭什么不会丢?ACID 这四个字母,就是对这串问题的四个独立回答。它们常被并列写在一起,但分工其实差得很远。

二、ACID:四个字母,两种分工

我的切法是把它拆成两组。原子性与持久性回答"能不能完成、完成了会不会丢",是事务的边界条件;一致性与隔离性回答"怎么完成才算对",是事务的内在约束。 前者偏物理,后者偏语义。

原子性(Atomicity)管的是"全成或全败"。注意,它承诺的不是"永不失败",而是"失败时不留下半截"。一条更新在磁盘上绝不是原子写,它的"原子"幻象是日志系统用重做与回滚两条路径硬造出来的:提交了就重做到底,失败了就回滚干净。没有日志,就没有原子性,这是内核工程师刻在骨头里的一句话。

持久性(Durability)管的是"提交了就不能反悔"。一旦数据库对客户端说提交成功,这个结果就得扛过进程崩溃、断电、甚至单块磁盘损坏。它靠的是整个存储栈的协同:文件系统把数据真正刷到介质上,而不是躺在操作系统缓存里自我感觉良好。我们后文会反复看到,很多"已提交却蒸发"的事故,根子都在这条因果链的某一环偷了懒。

一致性(Consistency)最容易被人误会。它不是说数据库永远处于某个静态的正确状态,而是说事务从执行前到执行后,必须从一个满足所有约束的状态,转到另一个同样满足这些约束的状态。主键不能重复、外键不能悬空、余额不能为负,这些约束由引擎校验,但"扣款和入账金额必须相等"这种业务规则,数据库是不知道的,得靠应用自己写对。所以一致性是应用和引擎共同签的一份合同,任何一方单方面签了都不算数。

隔离性(Isolation)则回答:事务 A 改到一半,事务 B 能看到什么?这是并发环境下一致性还能成立的前提。隔离性的本质不是消灭并发,而是给并发定义一组安全的交错方式,让任意穿插执行的结果,都等价于某种先来后到的串行执行。换句话说,它让我们在"真并行"和"假并行"之间,找到一条既快又不会错的路径。

把四性放到一笔转账里看,会清楚得多:原子性保证"减 100"和"加 100"要么都发生、要么都不发生;持久性保证一旦扣款成功,哪怕下一秒断电,这笔钱也不会凭空回来;一致性保证转账前后两边余额加起来不变;隔离性保证转账进行到一半时,别人查余额不会看到"钱刚扣走、还没到账"的中间态。四件事没有谁比谁高级,少了任何一个,这桩业务都会在某个瞬间漏出破绽。而它们真正生效的范围,取决于你怎么划事务边界——BEGIN 到 COMMIT 之间圈了多宽,承诺就覆盖多宽,圈太大锁太久,圈太小又护不住跨语句的完整性。

三、三种异常:并发裸奔的三种死法

要理解隔离级别,得先理解它要防的东西。SQL 标准列了三种经典异常,它们不是理论空谈,每种都能在真实业务里咬人一口。

脏读是三种里最"脏"的一种:事务读到了另一个事务还没提交的修改。脏就脏在那个值随时可能被回滚,读取方等于基于一个注定不存在的未来在往下推理。库存系统里一旦允许脏读,就可能出现超卖——A 把库存扣到负数后回滚,B 却已经读到了这个负数,还据此拒绝了下单。

不可重复读发生在同一个事务内部:两次读同一行,值不一样,差异来自别人已经提交的更新。它动摇的是事务对"观察"的稳定性。财务对账最怕这个——第一次读到应收 100 万,中间对方还了款,第二次读变成 0,对账逻辑就陷进"这笔债到底存不存在"的纠结里。

幻读是不可重复读的集合版本:同一个事务里,两次执行同一条范围查询,返回的行数不一样,因为别人插入或删除了符合条件的行。它动摇的是事务对"数据集边界"的认知。风控里先统计高风险客户数、再按这个数批量发预警,期间有人被新标记为高风险,就会漏警。数据本身没错,错在逻辑上裂了一道缝。

这三者有个递进关系:脏读最基础,挡住它的是读已提交;不可重复读和幻读更隐蔽,分别由可重复读和可串行化去堵。但"挡住"两个字,在不同引擎里的实现方式完全不同,这正是下一节要展开的麻烦。

四、隔离级别:一条光谱,不是四格楼梯

SQL 标准给了四个档位:读未提交、读已提交、可重复读、可串行化。很多人把它当成一个从松到严的四格楼梯,往上爬一级就多挡一种异常。这个直觉大体对,但只对了一半——因为标准写的是"要挡什么",而引擎实际干的是"怎么挡",两者之间有巨大的解释空间。

隔离级别 脏读 不可重复读 幻读 典型实现
读未提交 允许 允许 允许 几乎不锁读
读已提交 允许 允许 语句级快照
可重复读 看引擎 事务级快照或间隙锁
可串行化 两阶段锁或序列化快照

真正的分歧点在第两档往上。MySQL InnoDB 的"可重复读"因为带上了间隙锁,连幻读一起挡了,实际行为比标准要求的还强;PostgreSQL 的"可重复读"底层是快照隔离,它挡住了脏读和不可重复读,却允许幻读——因为它只保证已经存在的行的版本可见,不阻止别人往范围里插新行。同一个名词,在两套引擎里承载的是两套契约。 这是本节最值得记牢的一句话:隔离级别是接口,实现是艺术,选型时不能只看名字就拍板。

快照隔离还有个更隐蔽的软肋,叫写偏斜。设想两个医生同时查手术室空闲时段,都看到"只剩最后一格",于是各自提交预约,写的是不同的行,冲突检测发现不了,最终一格被订了两次。问题不在同一行,而在跨行的业务约束上。要堵住它,得用序列化快照隔离,在快照之上再加一张依赖图,发现循环依赖就中止其中一个事务。

五、工程实践:级别怎么选,坑在哪

我的经验法则是:默认从读已提交起步。它能挡住最脏的脏读,开销又低,绝大多数互联网业务够用。只有当你的事务内部需要多次读同一行、且要求这几次读彼此一致时,才往可重复读走;涉及范围统计与范围写入、需要严格一致的,再考虑可串行化。

选级的代价是实打实的。级别越高,锁或版本追踪的开销越大,冲突时回滚和重试也越频繁。所以选级前先问一句:这个业务真的需要那么强的保证吗?把"可重复读"当默认值往所有连接上套,是最常见的浪费,也是不少慢查询和锁等待的源头。

还有个容易被忽略的细节是自动提交。很多连接池默认每条语句单独提交,这意味着"减 100"和"加 100"在数据库眼里是两个独立事务,原子性形同虚设。真正要享受事务保护,必须显式地把多条语句圈进同一个事务里。反过来说,也别把一个跑几分钟的大事务拖得太长——长事务会让快照挂住大量旧版本,让锁长期不释放,成为并发系统的隐形炸弹。

⚠️ 常见坑:把可重复读等同于"没有幻读"。在 MySQL 里这个等式成立,在 PostgreSQL 里不成立。跨引擎迁移时,这段假设一旦写进业务逻辑,就会在范围查询上栽跟头。

💡 关键直觉:隔离级别买的是"看到的世界的稳定性",不是"数据本身的正确性"。再强的级别也救不了写错的约束——一致性永远是应用和引擎各负一半的责任。

要点串联

  • 事务是一组要么全成、要么全败的操作,是数据库替业务划出的不可分割边界
  • 原子性与持久性管完成,一致性与隔离性管正确,四者分工不同
  • 脏读、不可重复读、幻读是三种经典异常,分别对应读到未提交值、同行两读不同、范围行数变化
  • 隔离级别按"能挡几种异常"分档,但同名级别在不同引擎里语义可能不同
  • 快照隔离挡不住写偏斜,因为跨行约束不在它的冲突检测范围内
  • 选级从读已提交起步,按需上探,别把强级别当默认值浪费性能

下一节我们看隔离性如何真正落地:锁、MVCC、乐观并发这三套执法手段,各自在什么负载下划算。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U