《凤凰架构》阅读笔记(三):事务处理
数据源是指提供数据的逻辑设备,不必与物理设备一一对应。
本地事务
适用于单个服务使用单个数据源的场景。
依赖于数据源提供的事务能力,在代码层面只做标准化封装,不能深入参与事务过程(比如调用 JDBC rollback()
方法,不能保证事务必然被回滚)。
事务的四大要素是原子性、隔离性、持久性和一致性。
一致性(Consistency):保证数据正确不会产生矛盾。
原子性(Atomic):同一项业务处理过程中,保证多个对数据的修改必须同时成功或撤销。
隔离性(Isolation):不同的业务处理过程中,保证各自业务读、写数据不会相互影响。
持久性(Durability):所有被成功提交的数据修改都能够正确地被持久化。
前三者是手段,一致性是目的,应着眼于前三者。
原子性和持久性
前者保证多个操作生效情况统一,后者保证事务生效后不会丢失修改(即成功写入磁盘)。
数据入库过程中可能发生以下情形:
未提交事务,写入后崩溃:程序未写完数据,数据库已将其中部分数据写入磁盘,此时崩溃,重启后数据库必须得知崩溃前发生过不完整的写操作,将修改过的数据从磁盘中恢复原状,以保证原子性。
已提交事务,写入前崩溃:程序写完数据,数据库还未将全部变动写入到磁盘,此时崩溃,重启后数据库必须得知崩溃前发生过完整的写操作,将未写入磁盘的部分数据重新写入,以保证持久性。
按照事务提交时点为界,写入数据分为两类情况:
FORCE:事务提交后要求变动数据同时完成写入,否则是 NO-FORCE。为了优化磁盘 I/O 性能考虑,没有必要强制数据写入立即进行(确保有日志即可)。
STEAL:事务提交前允许变动数据提前写入,否则是 NO-STEAL。提前提前写有利于利用空闲 I/O 资源、节省缓存区内存。
提交日志(Commit Logging)
提交日志允许 NO-FORCE,但不允许 STEAL。
修改数据发生在事务提交后(假如事务提交前就有部分变动写入磁盘,一旦崩溃或事务回滚就会出错),在写数据时:
将修改操作的全部信息顺序追加到 重做日志(Redo Log),包括修改的动作、修改前后的数据、数据的物理位置(内存页和磁盘块)等。
在日志落盘后,数据库在日志中检查到事务执行成功的 提交记录(Commit Record)才根据日志修改数据,完成后再在日志加入 结束记录(End Record),表示事务已持久化。
日志完整即事务成功,否则就是失败;即使发生崩溃,重启后根据日志提交或回滚即可。
存在问题:如果磁盘 I/O 空闲,即使某个事务修改数据量巨大、占用大量内存缓冲,都不允许写磁盘,导致性能较差。
预写日志(Write-Ahead Logging)
增加了回滚日志(Undo Log),允许 NO-FORCE 和 STEAL,特点是高性能。
在修改落盘前先记录 Undo Log,注明修改了位置、变更值等,以便在事务回滚或崩溃恢复时根据 Undo Log 擦除提前写入的数据。在崩溃恢复时:
分析(Analysis):从最后一次检查点(Checkpoint,此前变动已落盘)扫描日志,找出没有 End Record 的事务组成待恢复事务集合(至少包括 Transaction Table 和 Dirty Page Table 两个部分)。
重做(Redo):依据上阶段的事务集合来重演(Repeat History):找出所有包含 Commit Record 的日志,将数据变动写入磁盘,完成后在日志中增加一条 End Record,从事务集合移除。要求幂等。
回滚(Undo):处理前量阶段剩余的恢复事务集合,根据 Undo Log 将已提前写入磁盘的信息改写回去。要求幂等。
影子分页
先在复制出来的数据副本上修改,事务提交、所有修改都持久化后,即修改数据的引用指针指向该副本,其中修改指针操作将被认为是原子的(磁盘在硬件层面上保证)。
相比起写日志实现事务更简单,但涉及隔离性与并发锁时事务并发能力就相对有限。
隔离性
现代数据库提供了三种锁:
排他锁(X-Lock):即写锁,数据加写锁后只有持有写锁的事务才能写入,其他事务不能写入数据也不能施加读锁(注意此时其它事务是可以读取的)。
共享锁(S-Lock):即读锁,数据加读锁后不能再被加上写锁,但可以被添加多个读锁,即允许多个事务同时读取;持有读锁的事务可以升级为写锁。
范围锁(Range Lock):对某个范围加写锁,该范围不能被写入。比如
SELECT ... FOR UPDATE
。
可串行化(Serializable)
表现和串行事务一样,要求所有读写操作都加锁、事务结束才释放,性能极差。
可重复读(Repeatable Read)
对事务数据加的读锁和写锁直至事务结束才释放。由于没有加范围锁,因此可能出现 幻读(Phantom Reads)问题:在事务执行过程中,两个完全相同的范围查询得到了不同的结果集(读取到不该读的)。
只读事务:比如事务 A 的两个相同的查询操作之间,被事务 B 在其查询范围内插入一条数据,不会发生幻读。
读写事务:比如事务 A 有先查询后更新两个操作(针对同一个范围,比如 id < 100),其间被事务 B 在其操作范围内插入一条数据,将会影响到 A 后面的更新(即发生幻读,也有说法是 写倾斜)。
读已提交(Read Committed)
对事务数据加写锁会持续到事务结束,但读锁在读完成后即释放。可能出现 不可重复读(Non-Repeatable Reads)问题:事务执行过程中,对同一行数据的两次查询得到不同结果。
比如事务 A 的两次查询操作之间,被事务 B 在其访问的数据上做出修改,A 第二次查询的结果就会与第一次不同。原因是第一次查询完成后读锁就被释放,其它事务可以申请写锁、完成写操作并提交。如果是可重复读,A 的第一次查询时就已占有读锁,在第二次查询结束前 B 的修改会被阻塞。
读未提交(Read Uncommitted)
对事务数据加写锁会持续到事务结束。由于不加读锁,可能出现 脏读(Dirty Reads)问题:在事务执行过程中,一个事务读取到了另一个事务未提交的数据。
比如事务 A 的两次查询操作之间,被事务 B 在其访问的数据上做出修改,在 A 提交后 B 回滚,则 A 的第二次查询读取到无效的数据。如果是读已提交,则在 B 提交或回滚之前,A 的第二次查询会被阻塞,不会读到脏数据。
比读未提交更弱的是无隔离,由于不能保证原子性,一个事务提交前的修改都可能会被其它事务覆盖,即 脏写。
多版本并发控制(MVCC)
作为 无锁 优化方案被提出:每个事务都有全局递增的 ID,每行数据都包括记录事务 ID 的两个版本号。
插入:CREATE_VERSION 记录事务 ID,DELETE_VERSION 为空。
删除:DELETE_VERSION 记录事务 ID,CREATE_VERSION 为空。
修改:即删除和插入的组合。先将原数据复制一份,原有数据的 DELETE_VERSION 记录事务 ID,CREATE_VERSION 为空;复制的数据的 CREATE_VERSION 记录事务 ID,DELETE_VERSION 为空。
查询:
可重复读:读取 CREATE_VERSION <= 当前事务 ID 的版本集合,并取其中的最新版本(事务 ID 最大)。
读已提交:总是读取最新(最近被 Commit)版本。
读未提交总是读取最新数据、可串行化所有读写都加锁,这两者都不必考虑。
MVCC 只是针对“读+写”场景的优化,对于“写+写”场景就只能加锁。此时值得考虑的是使用乐观锁还是悲观锁。前者常用于竞争不频繁的情况,否则效率比后者更低。
全局事务
适用于单个服务使用多个数据源的场景。
两阶段提交
XA(eXtended Architecture)是语言无关的通用规范,其定义了 全局事务管理器(协调全局事务)和 局部事务管理器(驱动本地事务)的通信接口。XA 接口是双向的,可在一个事务管理器和多个资源管理器之间协调,实现全局事务统一提交和回滚。
两段式提交协议(2 Phase Commit,2PC):
准备阶段:又称投票阶段。协调者询问事务参与者是否准备好(Redo Log 中记录提交操作的内容,但此时暂不写入 Commit Record)提交,参与者回复 Prepared 或 Non-Prepared。参与者在数据落盘后仍继续持有锁,维持数据隔离。
提交阶段:又称执行阶段。如果协调者在上一阶段收到所有参与者回复 Prepared,先在本地持久化事务状态为 Commit,再向所有参与者发送 Commit 指令,参与者提交(写入 Commit Record);如果任意一个参与者回复 Non-Prepared 或超时未回复,协调者将事务状态持久化为 Abort,再向所有参与者发送 Abort 指令,参与者回滚(清理已提交数据,负担较重)。

保证一致性的前提:
提交阶段网络可靠,不会丢失消息,且全过程中网络都没有误差(不传递错误消息。因此不能解决 拜占庭将军 一类问题)。准备阶段失败可以回滚,而提交阶段失败就无法补救(等崩溃节点恢复)。此阶段耗时应尽可能短。
网络分区、机器崩溃等导致失联的节点最终都能恢复,不会永久丢失。在准备阶段已写入完整的 Redo Log,失联节点恢复后会从日志中找到未提交的事务,再向协调者查询状态,确定之后提交或回滚。
存在缺陷:
单点问题:参与者等待协调者指令时无法做超时处理,协调者宕机所有参与者都会受到影响。如协调者没有恢复或没有正常发送 Commit 或 Rollback 指令,所有参与者都会一直等待。
性能问题:统一调度参与者,期间两次远程服务调用,三次数据持久化,完成时间取决于最慢的节点。准备阶段负担很重,协调者发出准备消息,每个参与者即对数据加锁、写 Redo Log,当某参与者无法提交,则所有节点都做了无用功。
一致性风险:当网络不稳定或宕机不确定可恢复时,就可能出现一致性问题。在分布式系统中,宕机故障必然导致一致性无法完全保证(FLP 不可能原理),而网络稳定性带来的风险:提交阶段网络被断开,协调者无法向所有参与者发出 Commit 指令,就导致部分协调者提交、部分参与者未提交但无法回滚。
针对以上问题发展出 三段式提交(3PC):准备阶段细分为 CanCommit、PreCommit,提交阶段改称 DoCommit,参与者错过 Docommit 消息则默认提交。
询问阶段:CanCommit,协调者让参与者评估事务是否有可能完成,如得到都是正面响应,即因部分提交时失败导致全部回滚的风险减低。
事务失败回滚概率变小,如果在 PreCommit 后协调者宕机,参与者没等到 DoCommit 消息,默认的操作策略是提交而不是回滚或者持续等待,因此避免了协调者单点问题的风险。在回滚场景 3PC 性能比 2PC 好很多,但在提交场景性能依然很差(白做了一次询问)。
但 3PC 甚至增加了一致性风险。比如在 PreCommit 阶段后协调者发出 Abort 指令,因网络问题部分参与者一直未能收到指令,则将错误地提交事务。

共享事务
适用于多个服务使用一个数据源的场景(但这更可能是伪需求)。
在同一进程中很容易做到:比如 WebSphere 内置可共享连接以供 JDBC、ORM、JMS 等不同的持久化工具使用。
由于数据库连接与 IP 和端口绑定,不同节点共享数据库连接就需要引入中间服务:对外接口按照 JDBC 规范实现,可视为独立于各个服务的远程数据库连接池,或直接作为数据库代理。
各个服务共享数据库连接。下面三个服务发出请求交由中间服务器上的同一个数据库连接,通过本地事务方式完成。比如根据不同服务节点传来的同一个事务 ID,使用同一个数据库连接来处理跨越多个服务的事务。
这与实际生产系统中的压力方向相悖:服务集群中数据库压力最大、最不容易伸缩拓展。因此只有类似为多个数据库实例做负载均衡的代理,而不是反过来由一个数据库为多个应用提供事务协调的服务代理(如果确实如此,应该反思为何要拆分微服务…= =)。
1 | [Service1]---------+ |
分布式事务
适用于多个服务使用多个数据源的场景。
CAP 与 ACID
CAP 定理指出,在分布式系统中涉及到数据共享时,以下三个特性最多可满足两个:
一致性(Consistency):在任何时刻、任何节点中所看到的数据都是符合预期的。
可用性(Availability):系统不间断地提供服务的能力,包括两个指标,可靠性(Reliability,常用平均无故障时间度量)和可维护性(Serviceability,常用平均可修复时间度量)。
分区容忍性(Partition Tolerance):分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务。
其中放弃分区容忍性的例子是关系型数据库集群,每个节点都独立拥有各种部件,但由于没有分区、保存的是同一份数据(不是通过网络共享),因此即使由多个实例组成,也不在考虑范围内。
通常考虑两种系统:
CP 系统:放弃可用性,一旦发生分区,节点间信息同步时间可无限延长。问题退化到 全局事务 的场景,可通过 2PC、3PC 等获得 C 和 P。常用于对数据质量要求很高的场景,比如 DTP 模型的分布式数据库事务、HBase。
AP 系统:放弃一致性,一旦发生分区,节点之间所提供的数据可能不一致。这是分布式系统的主流选择,因为如果可用性随节点数量增加而下降,则失去分布式的价值。比如大多数的 NoSQL 或分布式缓存框架。
最终一致性(Eventual Consistency):如果数据在一段时间内没有被其它操作更改,则最终将会达到与强一致性过程相同的结果,面向最终一致性的算法被称为“乐观复制算法”。这是探讨分布式事务所追求的目标(也是 AC 系统尽可能得到正确结果的途径)。
相较于 ACID,在分布式场景达到一致性还有另一种途径:BASE,即:
基本可用性(Basically Available)
柔性事务(Soft State)
最终一致性(Eventually Consistent)
可靠事件队列
最大努力交付(Best-Effort Delivery,参考 TCP 中未收到 ACK 应答自动重新发包):节点 A 写数据操作需要关联到其它节点(B、C),可在本地数据库中建立一个本地消息表,在系统中建立一个用于跟踪事务状态消息服务:
A 写数据时,利用本地事务在消息表记录事务 ID 与变更操作,标记为“进行中”。
A 定时轮询消息表,将“进行中”的消息发送到事务关联的 B、C 节点。
如果 B、C 都完成了操作并返回结果为成功,A 把消息状态标记修改为“已完成”,最终一致性达成。
如果 B、C 至少一个未收到消息或返回结果未成功,该消息在表中一直处于“进行中”状态,在 A 轮询时不断重试(要求消息具备幂等性)。
轮询确保自动重试直到成功或人工介入,因此不存在回滚。

一些支持分布式事务的消息框架原生支持分布式事务操作(如 RocketMQ),轮询重试可交由消息框架来保障。
可靠事件队列还有 最大努力一次提交(Best-Effort 1PC)的形式,指将最有可能出错的业务以本地事务的方式完成后,不断重试(不限于消息系统)来促使该事务中的其他关联业务全部完成。
这种方法足够简单且可保证最终一致性,但不能保证隔离性。
TCC 事务(回滚)
一些业务必须考虑隔离性,比如电商场景下的 超售问题:两个客户在短时间内都成功购买了同一件商品(数量允许),但他们购买的数量之超过了库存。在刚性事务的 可重复读 级别下必然可避免(保证后提交的事务无法获得锁而失败),但在分布式事务下,可考虑 TCC 方案,其优势是可提供强隔离性。
TCC 分为三个阶段:
Try:尝试执行,完成所有业务可执行性检查(保障一致性)、预留业务资源(保障隔离性)。
Confirm:确认执行,使用 Try 阶段的资源完成业务处理。可能会重复执行,需要具备幂等性。
Cancel:取消执行,释放 Try 阶段的资源。可能会重复执行,需要具备幂等性。

TCC 具备高性能、强隔离的优点(在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,因此性能很高),其类似 2PC 的准备和提交阶段,但前者在用户代码层面实现,后者在基础设施层面实现,因此 TCC 更灵活、可设计资源锁定的粒度。
缺点是业务侵入性强,意味着更高的开发成本和替换成本;且所要求的技术可控性上约束较大:比如与第三方对接,操作权限和数据结构不能随心所欲自行定义,不能要求对接方配合操作,Try 阶段就无法施行。
通常会基于某些分布式事务中间件(譬如阿里开源的 Seata)完成,尽量减轻编码工作量。
SAGA 事务(补偿)
SAGA 最初是通过把一个大事务分解为可交错运行的一系列子事务集合,避免大事务长时间锁定资源、提升运作效率的方法。
后来发展成将分布式大事务分解为本地小事务集合的设计模式,其由两部分组成:
大事务 T 拆分若干个小事务 Ti,每个子事务被视为是原子行为,如分布式事务能正常提交,其最终一致性等价于连续按序提交的子事务。
为每个子事务 Ti 设计对应的补偿动作 Ci,需满足:
Ti 与 Ci 都具备幂等性。
Ti 与 Ci 满足交换律。
Ci 必须成功提交(失败则重试,直到成功或人工介入)。
如果所有的 Ti 都成功提交则事务完成,否则以下取其一:
正向恢复(Forward Recovery):重试 Ti 直到成功(不补偿)。适用于所有事务最终都必须成功的场景。
反向恢复(Backward Recovery):执行 Ci 补偿直到成功。执行模式是 T1, T2, … Ti(失败), Ci(补偿), …, C2, C1。
不需为资源设计冻结和撤销冻结的操作,补偿操作更容易实现(尤其与第三方对接时,自行补偿比要求对方配合撤销容易)。
SAGA 系统本身也有可能崩溃,必须设计成类似数据库的日志机制(SAGA Log),保证系统恢复后可追踪到子事务的执行情况(执行或补偿);保证正向、反向恢复需要通过服务编排、可靠事件队列等方式完成。
通常不会直接靠裸编码实现,一般是在事务中间件的基础上完成,比如 Seata 也支持 SAGA 事务模式。
AT 事务(补偿)
阿里的 GTS 提出“AT 事务模式”也是基于 补偿代替回滚 的应用。
AT 事务参照了 XA 规范实现,针对 2PC 的 木桶效应 设计了解决方案(在准备阶段中,协调者必须等待所有数据源都返回成功,才能统一发出 Commit 命令)。
在事务提交时自动拦截所有 SQL,将 SQL 对数据修改前、修改后的结果分别保存快照,生成行锁,通过本地事务一起提交到操作的数据源中,即自动记录了 Redo Log 和 Undo Log。
如果分布式事务提交,则清理每个数据源中对应的日志;如果回滚,就根据日志数据自动产生补偿的“逆向 SQL”。
基于这种补偿,分布式事务中每个数据源都可以单独提交即释放锁和资源,相比起 2PC 极大提升了系统吞吐量。但也牺牲了隔离性、影响到原子性:缺乏隔离性后的补偿不一定总能成功,比如在本地事务提交后、分布式事务完成前,数据被补偿前又被其他操作修改、出现了脏写。一旦出现事务需要回滚,只能由人工介入处理。
GTS 增加 全局锁(Global Lock) 机制实现写隔离:本地事务提交前,要先拿到针对修改记录的全局锁后才能提交,否则等待。即以性能为代价避免两个分布式事务包含的本地事务造成的脏写。
AT 事务默认的隔离级别是 读未提交(Read Uncommitted),可能产生脏读。虽然也可以采用全局锁解决读隔离问题,但阻塞读代价将会非常大。