事物处理

本地事物

三事物处理的最终目的是保持数据库状态的一致性。有3个需要努力的方向:

  1. 原子性(Atomic)。同一个业务处理过程中,所有的操作,要么全部成功,要么全部失败。

  2. 隔离性(Isolation)。不同的业务处理过程中,对数据的修改、操作互不影响。

  3. 持久性(Durability)。保证所有提交的数据的修改,都能持久化。

A、I、D是手段,C是目的。

持久性

Commit Logging

只有在日志记录全部都安全落盘,见到代表事务成功提交的“Commit Record”后,数据库才会根据日志上的信息对真正的数据进行修改,修改完成后,在日志中加入一条“End Record”表示事务已完成持久化,这种事务实现方法被称为“Commit Logging”。

即:写入日志->提交事物(commit record)->修改数据->end record

保障数据持久性、原子性。

首先,日志一旦成功写入 Commit Record,那整个事务就是成功的,即使修改数据时崩溃了,重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这保证了持久性。

其次,如果日志没有写入成功就发生崩溃,系统重启后会看到一部分没有 Commit Record 的日志,那将这部分日志标记为回滚状态即可,整个事务就像完全没有发生过一样,这保证了原子性。

但是此机制有个缺陷:

所有对数据的真实的修改,都必须在提交了事物,并且写到了Commit Logging之后。不允许在事物提交之前,修改磁盘上的数据。如果是修改大量的数据,会占用更多的内存buffer,不利于高性能使用。

Write-Ahead-Logging

提前写入数据。在数据修改到磁盘前,增加一种undo log日志的写入,标明数据发生了怎样的变换,旧数据是什么。以便数据恢复时,对提前写入的数据进行擦出。

崩溃恢复处理流程:

  1. 分析阶段(Analysis):该阶段从最后一次检查点(Checkpoint,可理解为在这个点之前所有应该持久化的变动都已安全落盘)开始扫描日志,找出所有没有 End Record 的事务,组成待恢复的事务集合(一般包括 Transaction Table 和 Dirty Page Table)。

  2. 重做阶段(Redo):该阶段依据分析阶段中,产生的待恢复的事务集合来重演历史(Repeat History),找出所有包含 Commit Record 的日志,将它们写入磁盘,写入完成后增加一条 End Record,然后移除出待恢复事务集合。

  3. 回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务(被称为 Loser),根据 Undo Log 中的信息回滚这些事务。

隔离性

要实现隔离性,最简单的方法是加上读锁、写锁和范围锁。

  1. 写锁:排他锁。只有写锁的事物才能过写入数据,其他事物无法施加读锁和写入数据。

  2. 读锁:共享锁。数据可被多个事物施加读锁。数据被加上读锁后,不可被写入数据。

  3. 范围锁:对范围数据加锁,范围里面的数据不可被读取和写入数据。

施加范围锁例子:select * from user where id <10 for update;

隔离级别

  1. RED UNCOMMITTED(未提交读)

在RED UNCOMMITTED级别,事务中的修改,即使没提交,对其他事务也是可见的。事务可以读取未提交的数据,这被称为“脏读”(Dirty Read),因为读取的很可能是中间过程的脏数据,而不是最终数据。

  1. RED COMMITTED(提交读)

读已提交对事务涉及到的数据写的时候加写锁,会一直持续到事务结束;读的时候加读锁,但加的读锁在查询操作完成后就马上会释放。这个事务级别也叫做不可重复读(nonrepeatableread),因为两次同样的查询,可能会得到不同的结果。

select * from user where id = 1; //事物T1
update user set name = 'aa' where id = 1; //事物T2
select * from user where id = 1; //事物T1
  1. REPEATABLE READ(可重复读)

可重复对对数据施加的读锁,会一直持续到事物结束,因此可以解决不可重复读问题。但是没有加范围锁,所以无法解决幻读的问题。

所谓幻读,指的是当某个事务再读取某个范围内的记录时,另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围内的记录时,发现多了一行,会产生幻行。

select * from user where id <10; //事物1
insert into user (id,name) values (3,'bb');//事物2
select * from user where id <10; //事物1
  1. SERIALIZABLE(可串行化)

SERIALIZABLE是最高级别的隔离。它通过强制事务串行执行,避免了前面说的幻读的问题。简单来说,SERIALIZABLE会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用的问题。

MVCC机制

MVCC是一种读写优化策略,它在读取时不需要加锁。 MVCC 的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。

可以理解为:每行记录上都默认增加了2个字段:一个自增的事物id字段和回滚指针字段。

  1. 当数据新增时:事物id字段为当前事物id,回滚指针是个空的。

  2. 当数据修改时:新增一条同样的数据记录,事物id字段为当前事物id,回滚指针指向被修改的数据行。

  3. 当数据删除时:新增一条同样的数据记录,事物id字段为当前事物id,回滚指针指向被修改的数据行,删除标记位设置为已删除。

当有另外一个事务要读取这些发生了变化的数据时,会根据隔离级别来决定到底应该读取哪个版本的数据:

  1. 可重复读:假设当前事物id是10,只读取行事物id<=10的,最大的事物id数据行。

  2. 读已提交:读取事物id最大数据行。

MVCC是读+写场景的优化,如果是写+写场景,加锁是唯一可行的方案。具体是乐观锁和悲观锁,要看竞争的程度,竞争激烈的情况下,乐观锁效率不如悲观锁。

全局事物

指的是单个服务,使用多个数据源事物的场景。

XA 协议

XA是一个事务管理器和多个资源管理器之间通信的桥梁,通过协调多个数据源的动作保持一致,来实现全局事务的统一提交或者统一回滚。

它有两个最主要的接口:

  1. 事务管理器的接口:javax.transaction.TransactionManager,这套接口是给 Java EE 服务器提供容器事务(由容器自动负责事务管理)使用的。另外它还提供了另外一套javax.transaction.UserTransaction 接口,用于给程序员通过程序代码手动开启、提交和回滚事务。

  2. 满足 XA 规范的资源定义接口:javax.transaction.xa.XAResource。任何资源(JDBC、JMS 等)如果需要支持 JTA,只要实现 XAResource 接口中的方法就可以了。

两段式提交

  1. 准备阶段,又叫做投票阶段。在这一阶段,协调者询问事务的所有参与者是否准备好提交,如果已经准备好提交回复 Prepared,否则回复 Non-Prepared。这里的“准备”操作,其实和我们通常理解的“准备”不太一样:对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条 Commit Record。这意味着在做完数据持久化后并不会立即释放隔离性,也就是仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。

  2. 提交阶段,又叫做执行阶段,协调者如果在准备阶段收到所有事务参与者回复的 Prepared 消息,就会首先在本地持久化事务状态为 Commit,然后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者都会将自己的事务状态持久化为“Abort”之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。

2阶段提交

不足:

  1. 单点问题。一旦协调者宕机,所有参与者都会受到影响。

  2. 性能问题。两段提交过程中,所有参与者相当于被绑定成为一个统一调度的整体。整个过程将持续到参与者集群中最慢的那一个处理操作结束为止。

  3. 一致性风险。如果协调者在发出准备指令后,根据各个参与者发回的信息确定事务状态是可以提交的,协调者就会先持久化事务状态,并提交自己的事务。如果这时候网络忽然断开了,无法再通过网络向所有参与者发出 Commit 指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)既未提交也没办法回滚,导致数据不一致。

三段式提交

三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为 CanCommit、PreCommit,把提交阶段改为 DoCommit 阶段。其中,新增的 CanCommit 是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。

三段式提交

三段式提交对单点问题和回滚时的性能问题有所改善,但是对一致性风险问题并未有任何改进。

共享事物

共享事务是指多个服务共用同一个数据源。

这个方案,与实际生产系统中的压力方向相悖的。一个服务集群里,数据库才是压力最大、最不容易伸缩拓展的重灾区。一般不会使用共享事物方案。

分布式事务

CAP理论

在分布式系统中,当涉及到共享数据的场景时,以下三个特性只能同时满足2个:

一致性

同一时刻,在任何分布式节点中,看到的共享数据都是一样的。

可用性

系统可不间断的提供服务。

分区容忍性

在分布式环境中,部分节点出现故障,依然能对外提供服务。

取舍

如果放弃分区容忍性

这意味着我们假设网络通讯是永远可靠的。这显然是不成立的。

如果放弃可用性

当网络分区发生时,服务将保持离线,直到节点恢复正常。

如果放弃一致性

当网络分区发生时,节点之间提供的数据将不一致。AP 系统目前是分布式系统设计的主流选择,通常采用的是最终一致性。

最终一致性

可靠事件队列

账户扣款 → 仓库出库 → 商家收款三个流程,在代码中,先执行出错概率大的流程。

流程:

第一步,最终用户向 Fenix's Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。

第二步,Fenix's Bookstore 应该对用户账户扣款、商家账户收款、库存商品出库这三个操作有一个出错概率的先验评估,根据出错概率的大小来安排它们的操作顺序(这个一般体现在程序代码中,有一些大型系统也可能动态排序)。比如,最有可能出错的地方,是用户购买了,但是系统不同意扣款,或者是账户余额不足;其次是商品库存不足;最后是商家收款,一般收款不会遇到什么意外。那么这个顺序就应该是最容易出错的最先进行,即:账户扣款 → 仓库出库 → 商家收款。

第三步,账户服务进行扣款业务,如果扣款成功,就在自己的数据库建立一张消息表,里面存入一条消息:“事务 ID:UUID;扣款:100 元(状态:已完成);仓库出库《深入理解 Java 虚拟机》:1 本(状态:进行中);某商家收款:100 元(状态:进行中)”。注意,这个步骤中“扣款业务”和“写入消息”是依靠同一个本地事务写入自身数据库的。

第四步,系统建立一个消息服务,定时轮询消息表,将状态是“进行中”的消息同时发送到库存和商家服务节点中去。

注意事项

可靠消息队列的整个实现过程完全没有任何隔离性。比如售卖商品,两个客户在短时间内都成功购买了同一件商品,而且他们各自购买的数量都不超过目前的库存,但他们购买的数量之和,却超过了库存。这个时候会发生“超售”现象。

这里提到的库存,是一个多个节点组成的集群。在库存不是一个整体的情况下,就会发生超售现象。

TCC 方案

TCC(Try-Confirm-Cancel),天生适合用于需要强隔离性的分布式事务中。

在具体实现上,TCC 的操作其实有点儿麻烦和复杂,它是一种业务侵入性较强的事务方案,要求业务处理过程必须拆分为“预留业务资源”和“确认 / 释放消费资源”两个子过程。

TCC 的实现过程分为了三个阶段:

  1. Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好事务需要用到的所有业务资源(保障隔离性)。

  2. Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。注意,Confirm 阶段可能会重复执行,因此需要满足幂等性。

  3. Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。注意,Cancel 阶段也可能会重复执行,因此也需要满足幂等性。

场景举例

但是,由于 TCC 的业务侵入性比较高,需要开发编码配合,在一定程度上增加了不少工作量,也就给我们带来了一些使用上的弊端,那就是我们需要投入更高的开发成本和更换事务实现方案的替换成本。

所以,通常我们并不会完全靠裸编码来实现 TCC,而是会基于某些分布式事务中间件(如阿里开源的Seata)来完成,以尽量减轻一些编码工作量。

注意事项

如果用户、商家的账户余额由银行管理的话,其操作权限和数据结构就不可能再随心所欲地自行定义了,通常也就无法完成冻结款项、解冻、扣减这样的操作,因为银行一般不会配合你的操作。所以,在 TCC 的执行过程中,第一步 Try 阶段往往就已经无法施行了。

SAGA 事务

SAGA 由两部分操作组成。

一部分是把大事务拆分成若干个小事务,将整个分布式事务 T 分解为 n 个子事务,我们命名为 T1,T2,…,Ti,…,Tn。每个子事务都应该、或者能被看作是原子行为。如果分布式事务 T 能够正常提交,那么它对数据的影响(最终一致性)就应该与连续按顺序成功提交子事务 Ti 等价。

另一部分是为每一个子事务设计对应的补偿动作,我们命名为 C1,C2,…,Ci,…,Cn。

  1. Ti 与 Ci 必须满足以下条件:

  2. Ti 与 Ci 都具备幂等性;

  3. Ti 与 Ci 满足交换律(Commutative),即不管是先执行 Ti 还是先执行 Ci,效果都是一样的;

  4. Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情况,如果出现就必须持续重试直至成功,或者要人工介入。

如果 T1 到 Tn 均成功提交,那么事务就可以顺利完成。否则,我们就要采取以下两种恢复策略之一:

  • 正向恢复(Forward Recovery):如果 Ti 事务提交失败,则一直对 Ti 进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,比如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。

  • 反向恢复(Backward Recovery):如果 Ti 事务提交失败,则一直执行 Ci 对 Ti 进行补偿,直至成功为止(最大努力交付)。这里要求 Ci 必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。

与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。

注意事项

  1. SAGA 必须保证所有子事务都能够提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log),以保证系统恢复后可以追踪到子事务的执行情况,比如执行都到哪一步或者补偿到哪一步了。

  2. 尽管补偿操作通常比冻结 / 撤销更容易实现,但要保证正向、反向恢复过程能严谨地进行,也需要你花费不少的工夫。比如,你可能需要通过服务编排、可靠事件队列等方式来完成。所以,SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成。

  3. Seata 就同样支持 SAGA 事务模式。

Last updated

Was this helpful?