数据库事务
事务通过把系统从一个一致态转到另一个一致态来守住一致性
-- Jim Gray
什么是事务
事务的概念,高度概括如下:
事务是一个作为单个逻辑单元执行的有限操作序列,保证把数据库从一致状态转换到一致状态;要么全部生效(持久),要么全部撤销(原子),并在并发下保持隔离。
事务是数据库操作的基础,也是异常恢复的前提,可以说是数据库的核心机制。它具有以下要素
事务要素
根据事务定义,事务具备以下四个要素:原子性(atomicity),一致性(consistency),隔离性(Isolation),持久性(durability)。
原子性
原子性的目标是要么全部成功、要么全部失败。原子性保证事务是一个不可再分的提交单元;任何部分成功都必须在故障或放弃时被彻底撤销。原子性的定义如下:
给定数据库状态集合 S 与一致性子集 I( I 是 S 的子集),事务 T 是从 I 到 I 的部分函数 f_T。原子性规定:执行 T 要么观察到 f_{T}(S),要么回到 S。
与其他要素不同,原子性不讨论并发可见性,并发可见性是由隔离性保证。原子性也不保证事务提交后的生存,这部分由持久性来保证,数据库崩溃后再恢复时依靠撤销未提交来维持“全无”,可以支持这个观点。搞清楚事务要素间的边界可以更好的理解事务的整体概念。
现实系统多采用 steal + no-force的缓冲管理与日志策略,在该策略中,未提交页可能被偷刷,提交时不强制把所有页刷盘。这样可能出现“未提交在盘上”与“提交却未完全落盘”,为性能与吞吐折中,必须引入日志预写入(WAL)与崩溃恢复来兑现原子性与持久性。
WAL(Write-Ahead Logging)与提交顺序如下:
日志内容
LSN10 <BEGIN T1>
LSN20 <UPDATE T1, page P, slot s, before=5, after=7>
LSN30 <COMMIT T1>
可以使用以下命令查看wal日志内容
# 常用:-n 100(只看前100条),-r Transaction(按 rmgr 过滤),-b(显示块信息) pg_waldump -p $PGDATA/pg_wal -s 0/1702AB0 -e 0/17304C0 000000010000000000000001
只要 COMMIT 记录已持久,事务就算提交成功;即使数据页尚未落盘,崩溃恢复也会根据日志重做(REDO)其效果。
原子性要素最重要的体现是在崩溃恢复这一非常重要的场景中,其核心思想有两条:1.对未提交的更新执行 UNDO(回滚到操作前);2.对已提交但未完全落盘的更新执行 REDO(重做到盘上)。
按照ARIES风格,其流程抽象表述如下:
UNDO 常用补偿日志记录(CLR)保证撤销本身也可重放(幂等)
保存点(savepoint)和局部回滚,仍然不会破坏原子性。保存点把事务划分为多个子段,允许回滚到某点并继续执行;最终要么整个事务提交、要么整个事务回滚,因此从外部观察仍是单个原子单元。其伪SQL可以表述如下:
BEGIN;
SAVEPOINT s1; -- ...
-- 若中途失败
ROLLBACK TO s1; -- 局部撤销
-- 继续其他操作
COMMIT; -- 仍是“一次原子提交”
在数据库系统内部,原子性要素可以很好的被保证。但是在数据库系统外部,发送邮件、调用第三方API、发MQ消息并非数据库中的状态,事务提交与外部动作很难同原子。为此,一般工程上有如下几种做法来尽量保证原子性:
事务外盒(Transactional Outbox):把要发送的消息先写入表 outbox(与业务更新同一事务提交),后台可靠转发;失败可重试。
幂等接收/去重键:外部消费方确保重复投递不会造成副作用。(API幂等性的重要性)
Saga(长事务):把大事务拆为一系列本地事务+补偿动作(最终一致性),但这已不是传统意义的“单事务原子性”。
还有一类是分布式数据库系统中的原子性实现,这和单实例数据库系统的事务原子性也不一样。分布式数据库系统中的原子性需要跨多个资源管理器实现“全有或全无”,其原理是两阶段提交的最小抽象。2PC精要表达如下:
准备阶段:协调者询问参与者“能否提交”;各参与者预提交并持久化 Yes/No。
提交阶段:若全体 Yes,写入全局 Commit 并通知各方提交;否则回滚。
以上就是事务原子性的一个概要式的总结,以原子性实现闭环图作为总结:
日志先行、提交持久、未提交必可撤销。这三点共同构成原子性的工程保障。
一致性
这里的数据库一致性和分布式所谓的一致性是完全不同的两个概念。前者更关注单一数据库状态是否满足不变量,而后者关注多副本/多节点对同一读写的可见顺序与结果一致。语义层面两者也是大相径庭,事务一致性以 ACID 为框架(尤其是 C 与 I 的配合),而分布式一致性以一致性模型刻画(如 Linearizability/线性一致性、Sequential Consistency/顺序一致性、Eventual Consistency/最终一致性)。
在一致性保证手段上,数据库层面的一致性侧重封锁/2PL/谓词锁/序列化等局部并发控制,分布式一致性侧重副本复制 + 共识/仲裁(Paxos/Raft、Quorum 读写、租约/时钟等)。最本质的区别在于失败和代价方面,事务一致性主要权衡锁冲突、幻读/写偏差与吞吐,分布式一致性则在 CAP 约束下权衡一致性 vs 可用性与网络分区,并处理复制延迟/脑裂/读陈旧。
了解完毕上面这个区别后,我们开始事务一致性的深入讨论。
事务一致性的目标是事务前后都处于“业务允许”的一致状态
事务一致性的数学定义如下:
设数据库状态集合为 S,业务不变量集合为 I⊆S(所有满足约束的状态)。一个事务 T 是部分函数 f_T: S ⇀ S,满足: ∀s∈I∩dom(f_T),f_T(s)∈I。即:从一致到一致
原子性保证“全有或全无”;一致性要求“有”时必须仍在 I 中。隔离解决并发可见性;一致性关注最终状态是否满足约束(并不规定中间读到什么)。提交后状态在故障后可恢复(持久),也应满足 I;如果提交时已违背约束,持久性只会“把错误保住”。
事务的一致性在实现层面有多个层次,具体如下:
结构/模式一致性
这类涉及数据库元信息的一致性往往容易理解,比如类型、NOT NULL、长度、域约束这类不变量。
键与唯一性
主键、唯一键(含组合/条件)这类不变量是数据库维护的重要属性
引用完整性
同2,外键和级联策略(RESTRICT/CASCADE/SET NULL)等,考虑到性能问题,往往这类不变量在实际工程中是被抛弃的。
业务不变量
比如余额非负、库存≥0、排班不重叠等,这类约束往往可以在客户端内实现,当然也可以用check键进行约束,相应的会损失性能。同3的性能问题一样,不推荐工程中使用。
为了保证上面各层次的一致性可以落地,一致性机制的抽象模型如下:
声明式约束(Declarative):PRIMARY KEY / UNIQUE / FOREIGN KEY / CHECK / (概念性)ASSERTION / 排斥约束(非标准,但表达“区间不重叠”这类谓词)。
过程式约束(Procedural):触发器(BEFORE/AFTER)、存储过程、守护任务(如对账/校验作业)
并发控制的配合:当不变量涉及范围/集合时,需在校验到提交期间锁住相同谓词的范围(谓词/范围锁)或提升到可串行化,否则会被并发写穿透(写偏差)。
延迟校验(Deferred):允许在事务结束时一次性检查约束(如“临时违反、提交前修复”)。
一致性往往需要配合锁定来实现。考虑以下场景:
场景:不变量“每天最多 1 人值班”。两个事务 T1/T2 都看到 count=0,于是各自插入,最后得到 2 条——违背不变量。
为了避免上面出现的写偏差,需要引入范围/谓词锁进行修复
把不变量编码为唯一约束:对 (date) 做唯一性;或对 (resource, [start,end)) 做不重叠的排斥约束。
或在“读-校验-写”期间持有S(P)/升级为 X(P) 的谓词/范围锁,使会改变集合的并发操作与之互斥。
或隔离级别提升到可串行化,让系统以冲突检测回滚其中一方。
C,I这部分将在下面的封锁协议中集中重点讨论,本初只是一个概要介绍。
一致性检查可以分为立即检查和延迟检查,两者的区别在于每条语句执行时是否立刻判断,进而违规立刻报错。后者便于进行“先删后插/批量重排”之类的操作;但需要与锁或序列化配合以避免并发穿透。
我们举例如下:
场景:用户表
users(email UNIQUE)
,把 A 的a@example.com
改为b@example.com
,而b@example.com
目前绑定在 B 上,需要把 B 改到c@example.com
后再让 A 占用b@example.com
。
如果我们使用立即检查,可能会失败。
BEGIN; -- 立即检查模式
UPDATE users SET email = 'b@example.com' WHERE user_id = A; -- ✖ 立刻违反 UNIQUE
-- 因为此时 B 仍占用 'b@example.com',语句级就被拦截
ROLLBACK;
但是如果我们使用延迟检查,这个失败将会被避免
BEGIN; -- 延迟检查模式(所有 UNIQUE/FK 等提交时统一检查)
UPDATE users SET email = 'c@example.com' WHERE user_id = B; -- 暂时让出 b@
UPDATE users SET email = 'b@example.com' WHERE user_id = A; -- A 占用 b@
COMMIT;
延迟检查允许“中间态短暂违规、终态不违规”的改绑工作流在单事务内完成;立即检查则在第一条语句就失败。
若并发事务同时想占用 b@example.com
,需要对键值或其谓词加锁(如对 email = 'b@example.com'
这一范围/谓词加 S/X 锁),或使用可串行化级别,避免穿透导致两个事务同时认为“可用”。
延迟检查的风险主要源于并发穿透,如下图所示:
提交路径中的一致性检查如下图
事务的一致性总结就是:把不变量写成约束,用锁(或序列化)把校验到提交之间的窗口“围住”。
持久性
持久性要素的目标是一旦事务提交成功,其效果在任意崩溃后都可恢复。持久性描述的是“提交确认”的语义边界与工程保障。其定义如下:
若系统在时间 t上对事务T返回 COMMIT OK
,则对任意随后发生的崩溃/重启,恢复过程都能将数据库带到一个包含T效果的状态。
持久性只讨论提交后的生存;不直接约束是否满足不变量(那是“一致性”)或并发可见性(那是“隔离性”)
持久性依赖各级存储实现,具体路径为用户缓冲区 → DB 缓冲池 → OS 页缓存 → 文件系统/日志 → 设备缓存 → 介质
所谓“稳定存储”是指即使突然断电也能幸存的层级。有两个机制对此提供支持
刷写原语:
fsync/fdatasync
或等价的写屏障/刷新,确保日志/页已跨过易失缓存写到介质;部分设备支持 FUA(Force Unit Access)直写介质。写序约束:必须保证“先日志,后数据”的持久化顺序(WAL)
为了保证持久性,现代数据库系统采用wal日志+检查点实现,具体持久化的顺序如下:
WAL(日志先行):所有修改先以Redo 记录持久化到顺序日志;提交时写入并持久化 Commit 记录。
数据页延迟刷盘:提交后数据页可异步落盘(bgwriter进程负责,减少检查点压力);崩溃后依靠 Redo“补上”页内容。
Checkpoint:周期性地将“截至某 LSN 的所有脏页”落盘(chk进程负责),并记录检查点元数据,缩短恢复所需的 Redo 范围。
页一致性:为防撕裂写(torn write) 与位腐烂,常配合页校验与双写/写时复制(Copy‑on‑Write)策略。
这里前面三条都有比较多的文档和内容进行学习,这里详细讨论一下第四条。
为了达成页面一致性,有如下工程方面的机制作为支撑:
页校验(Checksum + 页版本/LSN)
在每个数据页头部写入 校验码(常见:CRC32C/Fletcher/XOR)与页版本号/LSN。
读路径:读取后重新计算校验并比对;失败即判定为页损坏/撕裂写。若页上的 LSN < 日志中的相应 LSN,说明页落后,可通过 REDO 恢复。
全页镜像日志(Full‑Page Image in WAL/Redo)
在一次检查点之后,某页首次被修改时,把当时的整页镜像写入日志。
恢复时:若发现该页校验失败/撕裂,直接用最近的整页镜像 + 后续增量 REDO 重建。
全页刷写需要进行权衡,首写后 WAL 膨胀(只在“首次脏化”时发生),换取强恢复能力与对底层撕裂写的免疫力。
双写(Double‑Write)缓冲
写路径:先将即将刷盘的页顺序写到一块“安全区”(双写区)并
fsync
;随后再写回各自的物理位置。崩溃恢复:若发现目标页校验失败,则用双写区中的完整副本覆盖修复。
双写的优点在于不依赖文件系统/设备的原子性;对任意半写有自愈能力。代价就是写放大(同一页写两次)与额外空间;可用批量凑页降低随机写成本。
写时复制(Copy‑on‑Write, COW)
思路:不在原地覆盖;为页分配新块并写满,最后用一次原子元数据更新把指针从旧页切到新页。
cow的优点在于避免就地半写,天然防撕裂;便于做快照/校验链。缺点是产生碎片、元数据更新变多;需要保证“指针切换”的原子持久(通常靠日志/写序屏障)
原子写与写屏障(设备/文件系统协作)
原子写:设备支持与数据库页等长(如 4K/8K/16K)的原子写入,一次失败不会留下半页。
写屏障/缓存绕过:使用
fsync/fdatasync
、写屏障、FUA(Force Unit Access)确保数据越过设备易失缓存落到介质,并保持“先日志,后数据”的顺序。对齐:页大小应为物理扇区的整数倍,避免跨扇区撕裂
位腐烂(Bit Rot)与端到端校验/巡检(Scrubbing)
后台巡检:定期顺序读取页并校验,发现不一致即从副本/快照或 WAL 重建。
端到端校验:从数据库到介质都携带校验/校验和(含可能的 DIF/DIX 类保护),跨层发现并阻断静默数据损坏。
单页恢复流程如下:
若最近有全页镜像日志 → 应用镜像 + 后续 REDO;
否则检查双写区/影子页 → 拷贝覆盖 + REDO;
若有副本/快照 → 提取该页 + REDO;
以上皆无 → 标记坏块并由业务/备份介入(可能丢页)。
各种页面持久机制对比如下:
持久性往往要在性能和RPO之间权衡,典型的就是检查点时间的设定,较短的检查点时间损失的数据也就较少,但是要承担更多更密集的刷写,对磁盘的吞吐性能有要求。往往采用组提交(group commit)的方法进行刷盘,其路径如下:
组提交的好处是多事务共享一次 fsync
,显著降低尾延迟与设备放大。代价则是引入微小等待窗口;窗口越大,吞吐越高但个别事务的确认更晚,这对数据丢失容忍度低的业务是不允许的。
提交耐久等级
以上只是语义层示意;具体产品名称与参数各异,但权衡点相同:确认越早→风险越大。
持久性的耐久性策略常见于主备之间,主节点的事务提交受制于备节点的事务回放提交策略,可以通过下表粗略的一览。
选择提交规则时,应当权衡灾难允许RPO数量和主备系统的刷写能力。
数据库事务持久性要素除了通过检查点刷盘间隔来观察,还需要关注最新持久 LSN(durable_lsn
)与当前产生 LSN(current_lsn
)的差距(表示落后程度)。WAL 写入与 fsync
延迟分布,尤其是主备之间的延迟也要注意。周期性的观察内存中的脏页比例以及wal日志的写放大问题。
事务的持久性要素作为数据库选型的重要依据,下面对比了开源数据库和商业数据库在工程收敛成本、可审计性与支持半径上的区别
隔离性
隔离性是本文重点讲述的内容,MVCC和锁等相关概念需要另外各开一文进行讨论,在本文中不会深入讨论这些并发概念,内核还是收敛到事务这样一个概念.隔离性部分仅做概要描述,具体分解在下面的部分中讨论。
隔离性的目的是在并发下控制可见性,使每个事务的行为好像在与其他事务按某种顺序(或受限的等价条件)执行一样,从而避免特定异常并维持不变量。
事务的隔离性既然是针对事务并发,那么以下是并发带来的四种异常,
P1 脏读(Dirty Read)
P2 不可重复读(Nonrepeatable Read)
P3 幻读(Phantom)
写偏差(Write Skew)
对应的几类隔离级别分别处理不同的异常(ANSI 隔离级别)
表格是原理版(ANSI 定义)。具体产品实现可能以 MVCC + 额外机制达到同等现象约束,但本文不展开产品差异。
事务生命周期与SQL控制
事务边界
事务的边界可以如下图示意:
自动提交 = 每条语句各自成为一个独立事务:执行 → 成功即隐式
COMMIT
;失败即隐式ROLLBACK
(语句级)。显式事务:关闭自动提交或手工
BEGIN
,把多条语句纳入同一原子单元,以一次COMMIT/ROLLBACK
结束。
状态机
控制语句
错误模型
事务的错误模型主要分为两类,语句级错误和事务级错误。
语句级错误:只回滚当前语句的修改,事务仍可继续(适合配合保存点细粒度控制)。
事务级错误:系统将事务置为失败态,此后任何查询都会报“当前事务已中止/回滚仅可用”,必须
ROLLBACK
。典型来源:死锁检测、序列化冲突、管理员终止等。
并发下的一致性异常与封锁协议
S/X锁是什么
在开始本节目前,需要先介绍一下两种锁
S(Shared,读锁/共享锁):允许多个读者共享;与 S 兼容、与 X 不兼容。
X(Exclusive,写锁/互斥锁):只允许一个写者独占;与任何 S/X 都不兼容。
我们简单的讲锁的种类归纳为这两类是为了方便讲述下面的封锁协议。读写锁之间的关系如下
简单一句话,涉及写锁的操作,都需要排他,也就是说是独占的
锁的对象可以是行、键、页、表、索引范围甚至是谓词(满足某条件的一整个集合)
锁的颗粒度越细并发越高,但锁的数量与管理成本越大;粒度越粗开销小但更容易互斥
锁定对象的不同造就了锁的颗粒度一说,多数系统采用层级锁(库→表→页→行/键…),这样做的好处在于,不同事务之间的高效运转,如果每个事务去更新一行数据,都需要锁定一张表,其他事务只能等待,那也太低效了。往往一个事务内对同一对象有着不同层次上的不同锁定要求,比如表A上是S锁定,但是表A下有些行要修改,即X锁定,针对类似这样的场景就出现了意向锁。
意向锁
若事务需要在同一对象的下一层级对象上进行锁定(增加S/X锁),上层会先增加意向锁:
IS(Intent Shared):声明“我在更细粒度要加 S”。
IX(Intent eXclusive):声明“我在更细粒度要加 X”。
SIX:表上 S,同时声明下层会有 X(读大范围、改少量行场景)。
这样的好处是当别的事务想对整表加 S/X 时,上层只看意向锁就能快速判断是否兼容(无需扫描每一行的锁)
二段锁(2PL)
所谓的段,就是阶段,二段锁将整个事务中的锁管理分为增长和收缩两个阶段,增长阶段只加锁不释放锁,收缩阶段不允许新增加锁操作,只能进行锁释放。这样可以保证冲突可串行化。由于只关注什么时候加锁,什么时候释放锁,而不关注锁什么,所以可能出现脏读/级联回滚。
同样的严格二段锁(S2PL),关注所有 X 锁(写锁)直到提交/回滚才释放,可串行化 + 无脏写/无脏读/无级联回滚(恢复更简单)。比其更严苛的是 强严格二段锁(Rigorous 2PL),它规定所有 X 锁(写锁)直到提交/回滚才释放。
二段锁是实现序列化隔离级别的重要组成部分,但是二段锁往往无法独自处理并发异常,这在下面的讨论中可以看到。
锁的生命周期与转换
一个事务进行某个对象上的锁定请求,若与已持有锁兼容则立即授予;否则进入等待队列。即我们常说的阻塞状态。我们常见的场景一般是先读后写,那么就涉及到锁的升级和转换(S升级为X),转换时若与队列中已授予锁的其他事务冲突,该事务将会等待。长期等待会导致锁升级失败,也就是所谓的饿死。针对此类异常,部分系统推出了转换队列来确保升级不会饿死。
转换队列(conversion queue)
核心思路:一旦有人申请把自己手里的 S 升级为 X,就立刻竖一道“写者栅栏”,阻止后续新读者进来,让现存读者慢慢“漏干”,等只剩申请者时原地升为 X
具体机制如下:
每把锁有三张表:granted list:已授予的锁(可能有多个 S),wait queue:新来的申请(还没拿到任何锁的请求),conversion queue:已持 S/X 的事务请求升级成更强模式(最典型是 S→X)
栅栏规则(writer-pending barrier)
当某对象出现第一个S→X 申请,被放入 conversion queue 的队首时,在该锁对象上打
pending_writer=true
,从此时刻起,不再授予任何新的 S(即便和当前状态兼容也不发),这些新来的 S 全部进入 wait queue。其目的在于冻结读者增长,让现有 S 持有者自然结束并释放。队头优先(head-of-convert priority)
当
pending_writer=true
时,只考虑 conversion queue 的队头是否可升级。队头事务 Ti 之外的 S 持有者数量降到 0(即只剩 Ti 自己的 S)时,原地原子地升级为 X,同时清除pending_writer
。若有多个升级者排队,严格 FIFO:前一个升级成功/释放后,才轮到下一个成为新的队头并再次设置pending_writer=true
。空转选择(drain-then-choose)
若没有 conversion queue,在锁空闲时按 FIFO 从 wait queue 里发放;若 conversion queue 非空,优先处理 conversion,避免“新来读者把升级者永远困住”。
升级和转换完毕后,就来到了锁的生命末期,释放。释放后,队列中按顺序唤醒能被满足的等待者(FIFO+同类合并)。整个锁的生命周期可以概要为下图:
通过上面的锁升级机制,我们在开发过程中要注意尽量一次性拿齐必要的锁,减少“读后升级”的等待窗口。有了锁的基础概念,下面我们进入本节正题,并发异常。
并发异常及应对策略
P = Phenomenon(现象) —— 出自 ANSI/ISO SQL-92 标准对隔离级别的说明部分。
标准里列了三种“现象”:
P1:Dirty Read(脏读)
P2:Non-repeatable Read(不可重复读)
P3:Phantom(幻读)
后来社区常用扩展:
P0:Dirty Write(脏写,标准里没列)
P4:Lost Update(丢失更新,便于与 P0 区分)
A = Anomaly(异常) —— 来自 Berenson 等人在 SIGMOD’95 的论文《A Critique of ANSI SQL Isolation Levels》提出的补充异常编号体系。
他们把标准里的三种现象也用 A 重述(A1≈P1,A2≈P2,A3≈P3),并新增:
A4:Lost Update(丢失更新)
A5 被细分为两种相关但不同的“偏斜(skew)”:
A5A:Read Skew(读偏序/Fractured Read)
一个事务分多次读取相关对象,读到的是不同时刻的已提交版本,无法归并为同一快照。
A5B:Write Skew(写偏差)
两个事务各自读到“允许写”,分别写不同行,合并后破坏了集合级不变量(SI 下常见)。
P0 脏写(Dirty Write)
脏写定义
A事务在B事务未提交的情况下,覆盖了对同一数据项的写入,这种异常就叫脏写。这是最糟糕的异常情况了,回滚路径不唯一,可能造成错误的 UNDO,覆盖 committed 值。总之就是随心所欲,一顿乱写乱画。不要说数据库系统,就是我们平常做笔记,也会尽量避免脏写发生的。工业界禁止P0脏写,这也是为什么我们常见脏读这样一个概念,但是很少见到脏写,因为这个概念压根在工业界的产品中被抹除了。禁止 P0 是简化恢复与保证严格性的基本工程前提。
设数据项集合 X,事务集合 T。历史 H 是读/写/提交/中止操作的交错序列。
记号: w_i(x) 表示事务 T_i 对项 x 的写; c_i 提交; a_i 中止;用 < 表示在历史中的先后。
P0(脏写)定义:存在 i\neq j 与项 x,使得
w_i(x) < w_j(x) \;\land\; c_i \not< w_j(x)即: T_j 在 T_i 提交或回滚之前就写了同一项 x。若随后 a_i( T_i 中止),则 w_j(x) 的语义与回滚顺序将变得不确定。
脏写产生的条件
脏写产生有两个必要条件,一是写写不互斥,第二个写可以在第一个写未提交时落下去(未加 X 锁或 X 锁未保持到提交;自定义“乐观写回”)。二是写入可见/可落盘,未提交的写可能被偷刷(steal)或被他人读取/覆盖(缓冲策略为 steal+no-force 且无严格约束)。那么两个条件合并,就会出现未提交写被另一个写覆盖
消除脏写
消除脏写有以下解法:
一级封锁协议(最小充分)
写前取得 X 锁,并且 X 锁保持到事务结束(提交/回滚)。第二个写在第一个写提交前拿不到 X ⇒ 不能覆盖未提交写 ⇒ P0 不发生。
二级封锁协议
在一级封锁协议的基础之上,增加了对读操作对象增加S锁的要求。当然可以避免脏写,脏读也避免了。
其他方法
三级封锁协议,2PL,MVCC,封锁协议一层层递进当然会处理掉初级的并发异常,下面有详细介绍,这里不复述。
从恢复视角来看,P0会使得Undo变得复杂。假设采用 steal + no-force的缓冲区策略,T1 的未提交更新可能先落盘;若 T2 又覆盖了该页并提交,随后 T1 回滚,UNDO 按“前像”写回将覆盖 T2 的已提交版本。
避免脏写总结成一句话,写写互斥,禁止 P0。
P1 脏读(Dirty Read)
脏读的定义
A事务读到了B事务尚未提交的写入值,这种现象我们称之为脏读。由于B事务随后可能不进行提交而是回滚,那么A事务之前读到B事务写入的值,就是一个错误的值,而且这个错误是不可恢复调度的,如果要修复需要级联回滚。脏读的严谨数学描述如下:
设数据项集合为 X,事务集合为 T。并发历史 H 是读写提交中止操作的交错序列。
记号: w_j(x) 为事务 T_j 对项 x 的写; r_i(x) 为事务 T_i 的读; c_j 提交; a_j 中止。
reads-from 关系: r_i(x) \leftarrow w_j(x) 表示 r_i(x) 读取到了 w_j(x) 写入的版本。
P1 定义(脏读):
r_i(x) \leftarrow w_j(x) \;\land\; c_j \not< r_i(x)即: T_i 在 T_j 提交之前就读到了 T_j 的写入。若随后存在 a_j( T_j 中止),则 r_i(x) 读到的是永远不应被外界观察到的值。
形成脏读的必要条件
脏读形成的必要条件有两个,读端放松和写端暴露。具体而言就是读操作不持有 S 锁或被允许越过写锁读取未提交版本,写操作产生的新版本被提前暴露,且写锁未持续到提交。两个条件一旦同时满足,就可能产生: T_i 在 T_j 提交前读到其新值 → 脏读。
具体的时序图如下:
消除脏读
那么如何消除脏读呢?由弱到强,有以下三层级别:
可恢复级别
若 T_i 读过 T_j,则必须 c_j < c_i。这种级别只是延迟提交以降低风险,但是仍然无法避免脏读的发生。
无级联级别
这种级别只从已提交版本读取,可以消除P1脏读,对应读已提交(Read Committed)或者等价策略。
严格级别
不允许读/写未提交版本,不但可以消除脏读,而且恢复过程会被简化。严格的2PL就是代表。
针对脏读的封锁解法有以下几种:
二级封锁协议(最小充分):
读操作对 x 加 S 锁;写操作对 x 加 X 锁并保持到提交(一级封锁)。因 S 与 X 不兼容,读者等到写者提交/回滚后才可读,故看不到未提交值。
严格 2PL
另外一种办法是严格的二段锁,所有 X 锁直到提交才释放,天然满足“严格”调度,既防脏读也避免级联回滚。
MVCC 解法(RC 快照)
读者从最近一次提交的快照读取;未提交版本对其他事务不可见。也就是说,多版本并发控制的核心思想是放弃极小的时间意义上的真实性(所有的读者读到的都是之前的快照),而获得读和写的不冲突。这里不展开讨论mvcc,之后会单独一文讨论它。
最后一句话总结避免脏读,“读者不读未提交,写者不让未提交被读”,P1 就消失了。
P2 不可重复读(Nonrepeatable Read)
不可重复读的定义
不可重读的异常现象是同一事务A对同一数据项两次读取,结果不同(第二次看到了B事务中途写入并提交的值)。不可重复读的本质是单行读取不稳定,即第一次读之后到本事务提交之前,该行被他事务改写并提交。
设历史 H 为读写提交中止的交错序列; r_i(x) 是事务 T_i 对项 x 的读, w_j(x) 是 T_j 的写, c_j 为提交。
P2 定义(语句版):存在 i\neq j、 x,以及 T_i 的两次读 r_i^1(x), r_i^2(x) 满足:
即:第二次读之前,他事务对同一 x 写并提交,导致两次读值不同。
若把“集合/范围”也要求稳定,则会升级为 P3 幻读 的范畴;本节只讨论同一行的不稳定。
不可重复读的必要条件
不可重复读有两个必要条件:
读后无保护,第一次读后未对该行持有 S 锁到提交,行处于可被他人改写的状态。封锁级别仅到 L2(读完即放)或系统采用 RC 语义。
他人可写并提交,在本事务第二次读之前,他事务成功写并提交同一项。
不可重复读会导致应用在一次事务中的计算基础不稳定(例如“读余额→判断→再次读取时余额变了”),导致逻辑判断与更新不一致。其与P3 幻读的区别在于P2只针对一行,而P3则是相同谓词/范围的结果集变化了。另外P2与丢失更新的异常不同,P2仅描述“读值不一致”,不必发生写覆盖。
消除不可重复读
消除不可重读的办法有以下几种:
三级封锁协议(最小充分解)
L3在L2的基础之上,只是读时在对象上增加S锁,并保持到提交(而不是读操作完毕就释放)。也就是说,从第一次读取该行,就钉住该行,直到本事务提交之前,其他事务不得修改该行。
MVCC和2PL
与 严格 2PL 搭配最常见:写锁到提交 + 读锁到提交,既无 P1/P2,也利于恢复。MVCC的解决方案则是:事务在开始时绑定一个一致性快照;整个事务内的读取都从该快照取值。即使他事务在中途提交新版本,当前事务的下一次读仍返回快照内的旧版本 ⇒ 两次读一致。
总结消除P2的一句话,“读完不放手,直到我提交”——P2 就没了。
P3 幻读(Read Phantom)
幻读的定义
同一事务对相同谓词/范围两次读取,结果集不同(期间他事务插入/删除/更新,使成员跨越谓词边界),这种现象就叫幻读。幻读的本质是集合不稳定,这点和不可重复读的区别在于,不可重读是针对单独的行,而幻读是一个范围的集合。 常伴随的风险是写偏差(Write Skew):两个事务各自认为“允许写”,合并后破坏不变量。
设数据库状态 S,谓词/条件 P(\cdot) 对应的集合为 R_P = \{ x\mid P(x) \}。令 r_i^t(P) 表示 T_i 第 t 次按谓词 P 读取集合(可视为对 R_P 的投影/聚合读)。
P3 定义:存在事务 T_i \ne T_j、对象 y,使得:
等价地:两次读取 R_P 的成员集合发生变化(插入/删除/更新导致越界)。
与 P2 的区别:P2 讲“同一行两次读不同”,P3 讲“同一集合两次读不同”。
幻读产生的必要条件
只锁已存在的行。读取时仅持有行级 S(读完释放或即使持到提交也只覆盖已存在行),未锁住“边界/间隙”。
并发写能改变集合成员。另一个事务在同一谓词范围内 INSERT/DELETE/UPDATE 并提交。根因一句话:行锁≠集合锁。集合的“边界/间隙”未被保护时,就会出现“幻影行”。
幻读的危害在于:
COUNT/EXISTS/NOT EXISTS/MAX/MIN
等在事务内不稳定,容易误判,也就是聚合与门禁不稳定基于“先读再判断”的逻辑在并发下易被穿透,造成结果不唯一。
集合级不变量需“锁集合”或用约束表达
写偏差,幻读往往是写偏差异常的诱因,两个事务分别读到“可写”,各自插入后破坏不变量。
消除幻读
最小充分解是,2PL + 谓词/范围锁
总体的思路是,把 “读取的谓词/范围” 也作为锁对象:第一次读时对 P
取 S(P),提交前若要修改则升级为 X(P);其他事务对同一谓词范围的插入/删除/越界更新与之互斥。
索引范围锁/间隙锁/next-key 锁(抽象为“范围锁”);实现细节不展开,但本质都是锁住会影响集合成员的那段键空间。
“二阶段封锁(2PL)只是规定“什么时候加/放锁”,并不告诉你“锁什么”。只用行级 S/X + 2PL,确实锁不到“不存在的那一行/集合边界”。2PL = 时序规则,要消灭幻读/写偏差,必须再回答“锁什么”——即谓词/范围锁(或把不变量交给约束)。
另外一种办法是基于2PL的可串行化(Serializable)
其核心思路是把“读到的集合”也当成锁对象。将谓词 P
映射为一个或多个有序键空间的范围(如索引区间)。读时加 S(P) 并在 2PL 下保持到提交;若需要在该集合内插入/删除/越界更新,则升级为 X(P)。其兼容规则为任何会改变 R_P
成员的操作(INSERT/DELETE/UPDATE
使记录进入/离开 P)与 S(P)/X(P)
不兼容,因此会被阻塞直到持锁事务提交。在释放之前不再获取新锁 ⇒ 冲突图无环;由于读-写对“集合”也冲突,幻读无法出现,并发效果等价于某个串行顺序。
抽象本质为,把谓词映射为若干“需加锁的区间”
基于冲突检测的 Serializable(SSI/Serializable Snapshot):允许快照读、后台跟踪 rw-dependency,一旦检测到可能导致不可串行化的环,就回滚其中一个事务。
MVCC 的快照读本身不能防 P3;需要“锁集合”或在 Serializable 下由系统检测并打断导致集合变化的依赖环。
最后一句话总结防止幻读,“锁住集合边界,幻影才不会出现。”
P4 丢失更新(Lost Update)
丢失更新的定义
ANSI 最初只定义了 P1/P2/P3(脏读/不可重复读/幻读)。工业实践中常将丢失更新(Lost Update)单列为 P4,便于与 P0(脏写)等现象区分。本文采用这一约定。
丢失更新的现象是,两个事务都基于同一个旧值做出修改,后提交者的写覆盖了先提交者的修改,导致先前的更新“丢失”。P0(脏写)涉及未提交写被覆盖,P4 不必涉及未提交,两个事务都可在合法提交路径上发生覆盖。其数学模型如下:
设历史 H 为读写提交的交错序列; r_i(x) 是 T_i 对项 x 的读, w_i(x) 是写, c_i 是提交。记 r_i(x)\leftarrow w_k(x) 表示读到了写 w_k 产生的版本。
P4 定义(抽象):存在 i\neq j 与 x 使得:
并且 最终值等于 w_j(x)(后写覆盖先写),而 w_i(x) 的效果不可见(被“丢失”)。常见最小历史:
与 P0 不同,P4 中两次写都可在严格/无脏的语义下各自提交,只是业务语义上产生了覆盖。
丢失更新发生的必要条件
基于旧读的“读-改-写”,两个事务都从同一旧版本出发计算新值,典型来源为:在 RC/RR/SI 等语义下常见(快照读或语句级提交读)。
写前没有把行钉住,读后没有 S→X /
FOR UPDATE
;到写入时才申请 X。写入时可序列化,但计算所基于的读是过期的。写入彼此覆盖,第二个写无条件地写回 f_j(v0),覆盖了 f_i(v0)。
丢失更新发生的过程示意如下:
举个库存余额的例子:客户端读出余额 100,两人分别算出 100−10 与 100−5,后提交者覆盖先提交者,结果 95 而非 85。
如何消除丢失更新
悲观并发(最小解)
这种方法第一次读取时就对目标行/键加锁,通常用
FOR UPDATE
(抽象为获取 X 或 S→X 升级),并持有到提交。受到这种锁策略影响,第二个事务在读阶段就会被阻塞或排队,无法基于过期值做决策。其变体是:若先读后写,可先取 S 再原地升级到 X;或直接一次性申请 X(C2PL)。
乐观并发
乐观并发为每行维护
version
/ts
;读出时拿到版本v
,提交更新使用。UPDATE t SET value=?, version=version+1 WHERE id=? AND version=v; -- 受影响行数=1 才算成功;否则说明有人先改过,需重读重试
当并发事务试图覆盖一个已变更的行时,更新不会生效(0 行受影响),从而检测到冲突并重试,避免静默丢失。
使用
WHERE id=? AND value=old_value
的 CAS 语义;或数据库内置的 “可更新快照检测”。
特殊算子
特殊算子是在库内进行“基于当前值”的原子更新,将
读-改-写
拆成一条原子 UPDATEUPDATE accounts SET bal = bal - 10 WHERE id=1;
由数据库在同一条语句内完成读取与写入,并按行级 X 互斥顺序化,避免客户端计算时的竞态。
串行化(Serializable)
选择 可串行化 隔离级别:要么通过 2PL 锁住将要写的行,要么通过冲突检测在提交时回滚其中一个事务,也能避免 P4。串行化固然严格,但是也是四种隔离级别中开销最大的一个。
基于2PL的串行化,用长持锁把写顺序化(阻塞 → 吞吐下降)
基于 SSI 的可串行化,提交处检测冲突(回滚重试 → 成本上升)
高峰期出现“覆盖率异常”“计数少增/少减”告警,多是 P4 迹象。这是需要搜索 read→compute→update
的模式,是否缺少 FOR UPDATE
/ 版本检查。对热点键构造并发 r→w
的回放,检查最终值是否等于顺序语义。
最后总结一句话避免丢失更新,先把要改的行抓住,或在提交时核对版本。
A5A 读偏序(Read Skew)
读偏序定义
同一事务 T 在一次业务动作中,分两次或多次读取互相关联的数据对象(如 A 与 B),各次读取看到的是不同时间点的已提交版本,无法归并到同一个一致性快照。这种现象就叫做读偏序。Read Committed(RC)、或“语句级快照”的实现,若使用两条 SELECT更易触发。事务级快照(RR/SI)会一次性固定读视图,因此不会发生 A5A。其数学定义如下:
设对象集合 X;每个对象 x 有一系列提交版本 x@t
(t 为提交时间/LSN)。事务 T 的两次读取分别得到:
第一次:r_T^1(x) = x@t1
第二次:r_T^2(y) = y@t2
若存在另一个事务 S 对 (x,y) 做“成对更新”:w_S(x@t2), w_S(y@t2)
且 t1 < t2
,并且 T 满足:
则 T 在一次业务动作中看到 x@t1
与 y@t2
,不存在单一时间点 t 使得两者同时可见 ⇒ A5A。
读偏序发生的过程
T 先读到
A=10
(旧版本),S 在中途把A
与B
成对更新并提交,T 再读B=30
(新版本)。若业务不变量是
A+B=40
:T 观察到10 + 30 = 40
看似自洽,但不是任何时刻的真实状态(真实状态在t1
:10+20=30
;在t2
:20+30=50
)。
语句级快照只能保证“单条语句内一致”,跨语句仍可能 A5A;把两个对象合在同一条 SELECT 往往可避免。
如何避免读偏序
事务级快照(最小解)
事务开始时固定一致性读视图,整个事务内所有读取都来自同一快照 ⇒ 没有 A5A。注意:RR/SI 可以消除 A5A,但 SI 仍可能 A5B(写偏差);若有集合约束,再考虑 Serializable 或范围锁。
读锁到提交
也就是三级封锁协议,或严格 2PL。在第一次读时对所有需要一致的对象加 S 并持到提交;写者在此期间不能更新这些对象。其适用场景为:强一致但并发度低;读事务会阻塞写事务。
把一致性放进同一条语句
将多对象的读取合并为一条 SELECT(JOIN/子查询/CTE):语句级快照保证同一时刻的数据视图。比如:
-- 不好:两条语句可能 A5A SELECT a FROM t WHERE id=:a; -- 语句1 SELECT b FROM t WHERE id=:b; -- 语句2 -- 更好:单条语句在 RC 也提供一致视图 SELECT ta.a, tb.b FROM t ta JOIN t tb ON ta.id=:a AND tb.id=:b;
若读取的是较大集合且需要后续基于该集合做修改,仍建议使用 RR/SI 或 Serializable/范围锁。
在我们代码实现的过程中,一次业务动作中分散的多条
SELECT
,读取相互关联的对象,但隔离级别是 RC。遇到这种场景的时候,要特别注意出现读偏序的异常。其症状是同一事务的“跨对象不变量”偶发不成立;监控中出现“读取自不一致视图”的业务告警。最后总结一句话,避免读偏序,把需要一起看的东西,要么一次看(单语句/快照),要么看完之前不准别人动(读锁到提交)。
A5B 写偏序( Write Skew)
写偏序的定义
两个并发事务各自读取到“允许写”的状态(读的是同一集合的不同时刻或各自快照),随后分别对不同对象写入;合并后破坏集合级不变量,这就是写偏序。常见的隔离场景有:SI(快照隔离) 下高发,在 RC/RR 亦可出现(若只用行锁,不锁集合)。P3 是“集合读不稳定”(幻读);A5B 是“集合读后各自写,合并破坏不变量”。P3 常是 A5B 的前置症状。P4 是“同一行基于旧值覆盖”;A5B 是“不同对象各写各的”导致逻辑冲突,不一定覆盖彼此写入。写偏序的数学定义如下:
设谓词 P(\cdot) 定义目标集合 R_P=\{x\mid P(x)\}。有集合不变量 I(如 |R_P|\le 1 或集合上某约束)。两个事务 T_1,T_2 的历史片段为:
其中 r_i(P) 是按谓词 P 的读取(基于各自快照), w_i 在集合边界附近写入新成员或改变成员属性,使合并后的状态违反 I。
在 SI 中,
读→写
之间仅在“同一行写写”时冲突;若两个事务写的是不同行,就不会硬性冲突,因而 A5B 得以发生。
写偏序的发生机制
下面给出两个A5B异常的例子
值班上限(每天最多 1 人)
H = r1^1(count(day=D)=0) ; \\r2^1(count(day=D)=0) ; \\w1(insert A,D) ; \\w2(insert B,D) ; c1 ; c2合并后出现 2 行,违反“不变量:每天 ≤ 1 人”。上面P3幻读的写偏序部分有相关的时序图。
至少一人在线(两行互斥改写)
H = r1(oncall(A)=true, oncall(B)=true) ; \\r2(oncall(A)=true, oncall(B)=true) ; \\w1(set oncall(A)=false) ;\\ w2(set oncall(B)=false) ; c1 ; c2合并后两人都不在线,违反“至少一人在线”。
写偏序发生的机制图如下:
避免写偏序
避免写偏序有以下方法:
2PL + 谓词/范围锁(最小充分的封锁法)
把 P 所定义的集合边界当作锁对象;读时对 S(P) 持到提交,写入前升级为 X(P);其他事务在该范围内的插入/删除/越界更新与之互斥。
工程落地常见为索引范围锁/间隙锁/next-key 锁;核心是“锁集合,而非仅锁已存在的行”。
Serializable(包含 2PL 与 SSI 两路)
2PL‑Serializable:显式锁集合,冲突可串行化。
SSI(Serializable Snapshot Isolation):保持快照读的并发表现,但在后台跟踪 rw‑anti 依赖,检测到成环(危险结构)时在提交处回滚其中一方。
在消除幻读部分已经有讨论,这里不再复述。
把不变量落到约束
根据上面举的2个反例
例 1(每天最多 1 行):
UNIQUE(day)
或UNIQUE(resource, day)
。例 2(区间不重叠):对
(resource, [start,end))
使用排斥/区间不重叠约束(抽象写法):-- 抽象:资源同一时间段不重叠 CREATE EXCLUDE CONSTRAINT ON schedule USING gist (resource WITH =, during WITH &&);
把“集合不变量”转交给数据库;在提交处一次性判定并冲突;配合索引层的键/间隙锁,兼得正确性与吞吐。
原子化写法(尽量避免“读‑判断‑写”)
把判断与写合并为单条语句并让它在库内原子执行,例如:
-- 仅当当天还没有记录时插入 INSERT INTO duty(user, day) SELECT :u, :d WHERE NOT EXISTS ( SELECT 1 FROM duty WHERE day = :d );
下面举例如何处理写偏序
复现 A5B(在 SI/RC/RR 且无谓词锁/约束)
-- Session A BEGIN; SELECT COUNT(*) FROM duty WHERE day=:D; -- n=0 -- Session B BEGIN; SELECT COUNT(*) FROM duty WHERE day=:D; -- n=0 -- 两边各自插入 INSERT INTO duty(user, day) VALUES ('A', :D); -- A 提交 COMMIT; INSERT INTO duty(user, day) VALUES ('B', :D); -- B 提交 COMMIT; -- 最终 day=:D 有 2 行
封锁修复(抽象谓词锁)
BEGIN; -- A LOCK PREDICATE day=:D IN SHARE MODE; -- S(P) -- 读取与业务逻辑 -- 若要写入则升级为 X(P) INSERT INTO duty(user, day) VALUES ('A', :D); COMMIT; -- B 对 day=:D 的插入在此期间会等待
约束修复
ALTER TABLE duty ADD CONSTRAINT uniq_day UNIQUE(day); -- 之后并发插入将有一个在提交时失败,应用按重复键重试
SSI 修复(冲突检测回滚)
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- 抽象 BEGIN; SELECT COUNT(*) FROM duty WHERE day=:D; INSERT INTO duty(user, day) VALUES ('A', :D); COMMIT; -- 若并发另一事务也在 day=:D 写入,提交处可能收到 serialization failure
最后总结一句话,避免写偏序。锁集合,或让约束替你锁。A5B 的本质是集合级不变量在并发下被穿透。
异常及策略小结
异常和处理方法总结如下表,处理方法按照代价由低到高:
符号:✓=可消除;△=部分/条件可消除;✗=无效/不针对。
处理并发异常的选型顺序如下:
能用约束就用约束(行/集合不变量) → 性价比最高。
单行读改写 → 优先 原子 UPDATE;需要业务计算 → FOR UPDATE 或 版本重试。
需要“行值稳定/跨对象一致视图” → RR/SI 或 三级封锁。
需要“锁住集合/防幻读/防写偏差” → 2PL+谓词/范围锁 或 Serializable(SSI/2PL)。
冲突高或事务长 → 倾向 约束 + 原子语句 + 乐观重试,慎用大范围长持锁。
上面的一致性异常是基础,但是我们工作中往往遇到的是另外一类异常,即事务进度的异常。其中包括死锁、饥饿(starvation)、活锁(livelock)等,关于这些我们碍于主题和篇幅,将在另开一篇讨论。MVCC过于复杂,在编写和讨论此内容前,我希望多写一些铺垫性的内容,本文就是开篇基础,只有很好的理解事务的隔离机制,一致性并发异常及锁策略后,才能更进一步的理解MVCC在此基础上的权衡取舍。
本文成文仓促,难免有不足错误之处,欢迎批评指正。