分布式事务处理

保证系统中所有的数据都是符合期望的,且相互关联的数据之间不会产生矛盾,即数据状态的一致性Consistency)。

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

 

本地事务

实现原子性和持久性

Commit Logging

以日志的形式——即仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。只有在日志记录全部都安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化

 

缺点:即使磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论有何种理由,都决不允许在事务提交之前就修改磁盘上的数据

解决方法:提前写入Write-Ahead,Write-Ahead Logging 先将何时写入变动数据,按照事务提交时点为界,划分为 FORCE 和 STEAL

  • FORCE

    当事务提交后,要求变动数据必须同时完成写入则称为 FORCE,如果不强制变动数据必须同时完成写入则称为 NO-FORCE(绝大多数数据库采用)

  • STEAL

    在事务提交前,允许变动数据提前写入则称为 STEAL,不允许则称为 NO-STEAL

Commit Logging 允许 NO-FORCE,但不允许 STEAL。

Write-Ahead Logging 允许 NO-FORCE,也允许 STEAL,解决方法:Undo Log回滚日志

记录回滚日志Undo Log ---> 变动数据写入磁盘 ---> 记录重做日志Redo Log

 

Write-Ahead Logging 在崩溃恢复时执行以下三个阶段的操作:

  • 分析阶段

    从最后一次检查点开始扫描日志,找出所有没有 End Record 的事务,组成待恢复的事务集合

  • 重做阶段

    找出所有包含 Commit Record 的日志,将这些日志修改的数据写入磁盘,写入完成后在日志中增加一条 End Record,然后移除出待恢复事务集合

  • 回滚阶段

    根据 Undo Log 中的信息,将已经提前写入磁盘的信息重新改写回去

 

 

 

实现隔离性

  • 写锁/排他锁

  • 读锁/共享锁

  • 范围锁:在某个范围直接加排他锁,在这个范围内的数据不能写入,for update

  1. 可串行化

    对事务所有读、写的数据全都加上读锁、写锁和范围锁

     

  2. 可重复读

    对事务所涉及的数据加读锁和写锁,一直持有至事务结束

     

    “幻读” :在事务执行过程中,两个完全相同的范围查询得到了不同的结果集

    没加范围锁,导致插入数据

    SELECT count(1) FROM books WHERE price < 100                    /* 时间顺序:1,事务: T1 */
    INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90) /* 时间顺序:2,事务: T2 */
    SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:3,事务: T1 */

    MySQL-InnoDB通过MVCC+next_key锁避免“幻读”问题

    在快照读(select)时使用MVCC,在当前读(delete,update,insert)时使用next_key锁

     

  3. 读已提交

    对事务涉及的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放

    "不可重复读" 起因:读锁在查询完成后就释放

    在事务执行过程中,对同一行数据的两次查询得到了不同的结果

    SELECT * FROM books WHERE id = 1;                           /* 时间顺序:1,事务: T1 */
    UPDATE books SET price = 110 WHERE id = 1; COMMIT; /* 时间顺序:2,事务: T2 */
    SELECT * FROM books WHERE id = 1; COMMIT;

     

  4. 读未提交 脏读

    对事务涉及的数据只加写锁,会一直持续到事务结束,但完全不加读锁,导致能读到其他事务加了写锁的数据

 

MVCC多版本并发控制 Multi-Version Concurrency Control

针对"读+写"场景,无锁,指读取时不需要加锁。

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

每一行加上CREATE_VERSION 和 DELETE_VERSION,记录的都是事务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 的那个版本的数据记录

 

全局事务

单服务多数据源

XA处理事务框架,定义了全局的事务管理器和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通信接口。

public void buyBook(PaymentBill bill) {
   userTransaction.begin();
   warehouseTransaction.begin();
   businessTransaction.begin();
try {
       userAccountService.pay(bill.getMoney());
       warehouseService.deliver(bill.getItems());
       businessAccountService.receipt(bill.getMoney());
       userTransaction.commit();
       warehouseTransaction.commit();
       businessTransaction.commit();
} catch(Exception e) {
       userTransaction.rollback();
       warehouseTransaction.rollback();
       businessTransaction.rollback();
}
}

存在问题:如果在businessTransaction.commit()中出现错误,代码转到catch块中执行,此时userTransactionwarehouseTransaction已经完成提交,再去调用rollback()方法已经无济于事,这将导致一部分数据被提交,另一部分被回滚,整个事务的一致性也就无法保证。

 

XA解决方法:将事务提交拆分为两个阶段 两段式协议2PC

协调者——参与者

准备阶段:又叫作投票阶段,在这一阶段,协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复 Prepared,否则回复 Non-Prepared。对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条 Commit Record

提交阶段:又叫作执行阶段,协调者如果在上一阶段收到所有事务参与者回复的 Prepared 消息,则先自己在本地持久化事务状态为 Commit,在此操作完成后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;若有任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者将自己的事务状态持久化为 Abort 之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。

 

缺点:

  1. 协调者单点问题

  2. 性能问题

    两次远程服务调用,三次数据持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入 Commit Record)

  3. 一致性风险

    网络稳定性(提交阶段,接收到各个参与者准备好的消息确认事务状态是可以提交的,持久化事务状态后提交自己的事务,此时网络断开,无法再通过网络向所有参与者发出 Commit 指令)和宕机恢复能力

 

三段式提交 3PC:准备阶段再细分为两个阶段,分别称为 CanCommit、PreCommit,把提交阶段改称为 DoCommit 阶段。

准备阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,它们所涉及的数据资源即被锁住,如果此时某一个参与者宣告无法完成提交,相当于大家都白做了一轮无用功。

增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,这也意味着因某个参与者提交时发生崩溃而导致大家全部回滚的风险相对变小。

如果在 PreCommit 阶段之后发生了协调者宕机,即参与者没有能等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待,这就相当于避免了协调者单点问题的风险。

 

共享事务

多个服务共用同一个数据源

使用消息队列服务器,通过消息将所有对数据库的改动传送到消息队列服务器,通过消息的消费者来统一处理,实现由本地事务保障的持久化操作。

 

分布式事务

多个服务同时访问多个数据源

CAP定理,三个特性最多只能同时满足其中两个。

  • 一致性Consistency)

  • 可用性Availability)

    代表系统不间断地提供服务的能力,

  • 分区容忍性Partition Tolerance)

    代表分布式环境中部分节点因网络原因而彼此失联后,即与其他节点形成“网络分区”时,系统仍能正确地提供服务的能力。

 

  • 放弃可用性 CP

    一旦网络发生分区,节点之间的信息同步时间可以无限制地延长

    退化到“全局事务”中讨论的一个系统使用多个数据源的场景之中,通过 2PC/3PC 等手段,同时获得分区容忍性和一致性。

  • 放弃一致性AP 分布式系统的主流选择

    一旦发生分区,节点之间所提供的数据可能不一致。

    P 是分布式网络的天然属性, A 通常是建设分布式的目的

在 CAP、ACID 中讨论的一致性称为“强一致性”(Strong Consistency)

最终一致性”:如果数据在一段时间之内没有被另外的操作所更改,那它最终将会达到与强一致性过程相同的结果,有时候面向最终一致性的算法也被称为“乐观复制算法”。

 

使用 ACID 的事务称为“刚性事务”,分布式事务的常见做法统称为“柔性事务”。

 

可靠消息队列

使用 BASE 来达成一致性目的。

基本可用性(Basically Available)、柔性事务(Soft State)和最终一致性(Eventually Consistent)

RocketMQ,原生就支持分布式事务操作

 

整个过程完全没有任何隔离性可言,带来"超售"等问题

可靠消息队列无法保障“可重复读”(Repeatable Read)的隔离级别,后面提交的事务会因为可以获得锁而导致失败。

 

TCC事务 "Try-Confirm-Cancel"

预留业务资源”和“确认/释放消费资源”两个子过程

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

  • Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。

    Confirm 阶段可能会重复执行,操作需具备幂等性。

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

 

 

 

TCC与2PC的准备阶段和提交阶段相类似,但 TCC 是位于用户代码层面,而不是在基础设施层面,这为它的实现带来了较高的灵活。

优点:TCC 在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用,具有很高的性能潜力。

缺点:

  • 带来了更高的开发成本和业务侵入性

  • 要求的技术可控性上的约束(银行U盾/扫码)

 

基于分布式事务中间件(如阿里开源的Seata)去完成,减轻一些编码工作量。

 

SAGA事务

把一个大事务分解为可以交错运行的一系列本地事务集合,基于数据补偿来代替回滚。

由两部分操作组成:

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

  • 为每一个子事务设计对应的补偿动作,命名为 C1,C2,…,Ci,…,Cn。Ti与 Ci必须满足以下条件:

    • Ti与 Ci都具备幂等性。

    • Ti与 Ci满足交换律(Commutative),即先执行 Ti还是先执行 Ci,其效果都是一样的。

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

 

若T1到Tn提交出现差错,采取以下两种恢复策略之一:

  • 正向恢复(Forward Recovery):如果 Ti事务提交失败,则一直对 Ti进行重试,直至成功为止(最大努力交付)。适用于事务最终都要成功的场景,譬如在别人的银行账号中扣了款,就一定要给别人发货。

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

日志机制,SAGA Log。

Seata 同样支持 SAGA 事务模式

 

AT事务

参照 XA 两段提交协议实现,在业务数据提交时自动拦截所有 SQL,将 SQL 对数据修改前、修改后的结果分别保存快照,生成行锁,通过本地事务一起提交到操作的数据源中,相当于自动记录了重做和回滚日志。

如果分布式事务成功提交,那后续清理每个数据源中对应的日志数据即可;

如果分布式事务需要回滚,就根据日志数据自动产生用于补偿的“逆向 SQL”。

 

代价就是大幅度地牺牲了隔离性,读未提交

避免脏读

GTS 增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,没有获得全局锁之前就必须一直等待。

posted @   roibin  阅读(42)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
点击右上角即可分享
微信分享提示