【架构设计】分布式事务概述

目录

1. 前言

1.1 本地事务

1.2 分布式一致性

1.3 事务分类

1.4 XA规范

2. 两阶段提交(2PC)

2.1 准备阶段

2.2 提交阶段

2.3 整体流程如下所示

2.4 2PC的缺陷有那些

扫描二维码关注公众号,回复: 12521656 查看本文章

2.5 2PC总结

2. 三阶段提交(3PC)

3.1 CanCommit阶段

3.2 PreCommit阶段

3.3 doCommit阶段

3.4 2PC与3PC 总结

4. 补偿型事务(TCC)

 4.1 案例分析

4.2 TCC 注意事项

 4.3 TCC 变体

4.4 TCC 小结

5. 本地消息表+定时任务(ebay研发)

5.1 场景分析

5.2 小优化

6. 本地消息表+消息队列

6.1 本地消息表+普通消息队列

6.2 本地消息表+RocketMQ

7. 最大努力通知

7.1 使用局限性

7.2 行业应用案例

8. 事务消息(Seata) 


1. 前言

在业务发展初期,“一块大饼”的单业务系统架构,能满足基本的业务需求。但是随着业务的快速发展,系统的访问量和业务复杂程度都在快速增长,单系统架构逐渐成为业务发展瓶颈,解决业务系统的高耦合、可伸缩问题的需求越来越强烈。

按照面向服务架构(SOA)的设计原则,将单业务系统拆分成多个业务系统,降低了各系统之间的耦合度,使不同的业务系统专注于自身业务,更有利于业务的发展和系统容量的伸缩。

业务系统按照服务拆分之后,一个完整的业务往往需要调用多个服务,如何保证多个服务间的数据一致性成为一个难题。

业务数据库起初是单库单表,但随着业务数据规模的快速发展,数据量越来越大,单库单表逐渐成为瓶颈。所以我们对数据库进行了水平拆分,将原单库单表拆分成数据库分片。

如下图所示,分库分表之后,原来在一个数据库上就能完成的写操作,可能就会跨多个数据库,这就产生了跨数据库事务问题。

一个完整的业务往往需要调用多个子业务或服务,随着业务的不断增多,涉及的服务及数据也越来越多,越来越复杂。传统的系统难以支撑,出现了应用和数据库等的分布式系统。分布式系统又带来了数据一致性的问题,从而产生了分布式事务。

说到事务,首先要清楚什么是ACID,然后再来思考什么是分布式事务和常见的分布式事务包括 2PC、3PC、TCC、本地消息表、消息事务、最大努力通知。

1.1 本地事务

严格意义上的事务实现应该是具备原子性、一致性、隔离性和持久性,简称 ACID。

  • 原子性(Atomicity),可以理解为一个事务内的所有操作要么都执行,要么都不执行。
  • 一致性(Consistency),可以理解为数据是满足完整性约束的,也就是不会存在中间状态的数据,比如你账上有400,我账上有100,你给我打200块,此时你账上的钱应该是200,我账上的钱应该是300,不会存在我账上钱加了,你账上钱没扣的中间状态
  • 隔离性(Isolation),指的是多个事务并发执行的时候不会互相干扰,即一个事务内部的数据对于其他事务来说是隔离的。
  • 持久性(Durability),指的是一个事务完成了之后数据就被永远保存下来,之后的其他操作或故障都不会对事务的结果产生影响。

而通俗意义上事务就是为了使得一些更新操作要么都成功,要么都失败。

redis事务

Redis中的事务与关系型数据库是不一样的,Redis 通过 MULTI 命令开始,之后输入一连串的操作,最终以 EXEC 结束,在这之间输入的所有的命令都会在 EXEC 之后一起发给Redis执行,所以在这之间用户无法通过读取到的结果做处理,这与关系型数据库的事务是由很大的不同的。Redis会在执行完成之后返回一组执行结果,Redis中并没有回滚的操作。

序号 命令及描述
1 DISCARD
取消事务,放弃执行事务块内的所有命令。
2 EXEC
执行所有事务块内的命令。
3 MULTI
标记一个事务块的开始。
4 UNWATCH
取消 WATCH 命令对所有 key 的监视。
5 WATCH key [key ...]
监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。

但是可以借助watch进行取消事务,并重新提交(间接的实现事务回滚)

1.2 分布式一致性

分布式事务顾名思义就是要在分布式系统中实现事务,它其实是由多个本地事务组合而成。对于分布式事务而言几乎满足不了 ACID,其实对于单机事务而言大部分情况下也没有满足 ACID,不然怎么会有四种隔离级别呢?所以更别说分布在不同数据库或者不同应用上的分布式事务了。

分布式场景下,多个服务之间的通信流程,比如电商下单场景,需要支付服务进行支付、库存服务扣减库存、订单服务进行订单生成、物流服务更新物流信息等。如果某一个服务执行失败,或者网络不通引起的请求丢失,那么整个系统可能出现数据不一致的原因。

上述场景就是分布式一致性问题,追根到底,分布式一致性的根本原因在于数据的分布式操作,引起的本地事务无法保障数据的原子性引起。

分布式一致性问题的解决思路有两种,

  • 一种是分布式事务
  • 一种是尽量通过业务流程避免分布式事务。

分布式事务是直接解决问题,而业务规避其实通过解决出问题的地方(解决提问题的人)。其实在真实业务场景中,如果业务规避不是很麻烦的前提,最优雅的解决方案就是业务规避。

1.3 事务分类

分布式事务实现方案从类型上去分刚性事务、柔型事务。

  • 刚性事务:通常无业务改造,强一致性,原生支持回滚/隔离性,低并发,适合短事务。
  • 柔性事务:有业务改造,最终一致性,实现补偿接口,实现资源锁定接口,高并发,适合长事务。

其中主要比较成熟的分布式事务技术有如下几种:

  • 刚性事务:XA 协议(2PC、JTA、JTS)、3PC
  • 柔型事务:TCC/FMT、Saga(状态机模式、Aop模式)、本地事务消息、消息事务(半消息)、最多努力通知型事务

1.4 XA规范

X/Open 组织(即现在的 Open Group )定义了分布式事务处理模型。 X/Open DTP 模型( 1994 )包括应用程序( AP )、事务管理器( TM )、资源管理器( RM )、通信资源管理器( CRM )四部分。一般,常见的事务管理器( TM )是交易中间件,常见的资源管理器( RM )是数据库,常见的通信资源管理器( CRM )是消息中间件。    通常把一个数据库内部的事务处理,如对多个表的操作,作为本地事务看待。数据库的事务处理对象是本地事务,而分布式事务处理的对象是全局事务。

  • 全局事务:是指分布式事务处理环境中,多个数据库可能需要共同完成一个工作,这个工作即是一个全局事务,

例如,一个事务中可能更新几个不同的数据库。对数据库的操作发生在系统的各处但必须全部被提交或回滚。此时一个数据库对自己内部所做操作的提交不仅依赖本身操作是否成功,还要依赖与全局事务相关的其它数据库的操作是否成功,如果任一数据库的任一操作失败,则参与此事务的所有数据库所做的所有操作都必须回滚。     一般情况下,某一数据库无法知道其它数据库在做什么,因此,在一个 DTP 环境中,交易中间件是必需的,由它通知和协调相关数据库的提交或回滚。而一个数据库只将其自己所做的操作(可恢复)影射到全局事务中。    

XA 就是 X/Open DTP 定义的交易中间件与数据库之间的接口规范(即接口函数),交易中间件用它来通知数据库事务的开始、结束以及提交、回滚等。 XA 接口函数由数据库厂商提供。 

二阶提交协议三阶提交协议就是根据这一思想衍生出来的。可以说二阶段提交其实就是实现XA分布式事务的关键(确切地说:两阶段提交主要保证了分布式事务的原子性:即所有结点要么全做要么全不做)

与之类似的阿里产品:全局事务服务 GTS 也有规定:

  • GTS 不支持跨地域访问。创建事务分组时,需要选择和业务系统相同的地域,否则使用时会显示连不上 GTS Server  证据

2. 两阶段提交(2PC)

2PC(Two-phase commit protocol),中文叫二阶段提交。 二阶段提交是一种强一致性设计,2PC 引入一个事务协调者的角色来协调管理各参与者(也可称之为各本地资源)的提交和回滚,二阶段分别指的是准备(投票)和 提交阶段(执行阶段)两个阶段。

  • 注意这只是协议或者说是理论指导,只阐述了大方向,具体落地还是有会有差异的。

2.1 准备阶段

事务协调者(事务管理器)给每个参与者(资源管理器)发送Prepare消息,每个参与者要么直接返回失败(如权限验证失败),要么在本地执行事务,写本地的redo和undo日志,但不提交,到达一种“万事俱备,只欠东风”的状态。

可以进一步将准备阶段分为以下三个步骤:

  1. 协调者节点向所有参与者节点询问是否可以执行提交操作(vote),并开始等待各参与者节点的响应。
  2. 参与者节点执行询问发起为止的所有事务操作,并将Undo信息和Redo信息写入日志。(注意:若成功这里其实每个参与者已经执行了事务操作)
  3. 各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个”同意”消息;如果参与者节点的事务操作实际执行失败,则它返回一个”中止”消息,后续执行回滚。

2.2 提交阶段

如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源)

接下来分两种情况分别讨论提交阶段的过程:

(1)当协调者节点从所有参与者节点获得的相应消息都为”同意”时:

      success

  1. 协调者节点向所有参与者节点发出”正式提交(commit)”的请求。
  2. 参与者节点正式完成操作,并释放在整个事务期间内占用的资源。
  3. 参与者节点向协调者节点发送”完成”消息。
  4. 协调者节点受到所有参与者节点反馈的”完成”消息后,完成事务。

(2)如果任一参与者节点在第一阶段返回的响应消息为”中止”,或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:

        fail

  1. 协调者节点向所有参与者节点发出”回滚操作(rollback)”的请求。
  2. 参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源。
  3. 参与者节点向协调者节点发送”回滚完成”消息。
  4. 协调者节点受到所有参与者节点反馈的”回滚完成”消息后,取消事务。

不管最后结果如何,第二阶段都会结束当前事务。

2.3 整体流程如下所示

核心组件定义

分布式事务包含以下 3 个核心组件:

  • Transaction Coordinator(TC):事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。
  • Transaction Manager(TM):控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。
  • Resource Manager(RM):控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。

一个典型的事务过程包括:

  1. TM 向 TC 申请开启(Begin)一个全局事务,全局事务创建成功并生成一个全局唯一的 XID。
  2. XID 在微服务调用链路的上下文中传播。
  3. RM 向 TC 注册分支事务,将其纳入 XID 对应全局事务的管辖。
  4. TM 向 TC 发起针对 XID 的全局提交(Commit)或回滚(Rollback)决议。
  5. TC 调度 XID 下管辖的全部分支事务完成提交(Commit)或回滚(Rollback)请求。

二阶段提交看起来确实能够提供原子性的操作,但是不幸的事,二阶段提交还是有几个缺点的:

2.4 2PC的缺陷有那些

(1)同步阻塞问题

可以看到在第一阶段执行了准备命令后,我们每个本地资源都处于锁定状态,因为除了事务的提交之外啥都做了。所以这时候如果本地的其他请求要访问同一个资源,比如要修改商品表 id 等于 100 的那条数据,那么此时是被阻塞住的,必须等待前面事务的完结,收到提交/回滚命令执行完释放资源后,这个请求才能得以继续。

所以假设这个分布式事务涉及到很多参与者,然后有些参与者处理又特别复杂,特别慢,那么那些处理快的节点也得等着,所以说效率有点低。

(2)单点故障

由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)

  • 如果协调者在发送准备命令前挂了还行,毕竟每个资源都还未执行命令,那么资源是没被锁定的。
  • 可怕的是在发送完准备命令之后挂了,这时候每个本地资源都执行完处于锁定状态了,这就很僵硬了,如果是某个热点资源都阻塞了,这估计就要GG了。

(3)数据不一致

在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接收到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。

(4)二阶段无法解决的问题

二阶段无法解决的问题:协调者再发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。

2.5 2PC总结

至此我们来先小结一些 2PC ,它是一个同步阻塞的强一致性两阶段提交协议,分别是准备阶段和提交/回滚阶段。

  • 2PC 的优势:在于对业务没有侵入,可以利用数据库自身机制来进行事务的提交和回滚。
  • 2PC的缺点:是一个同步阻塞协议,会导致高延迟和性能的下降,并且存在协调者单点故障问题,极端情况下会有数据不一致的问题。

当然这只是协议,具体的落地还是可以变通了,比如协调者单点问题,我就搞个主从来实现协调者,对吧。

还有一点不知道你们看出来没,2PC 适用于数据库层面的分布式事务场景,而我们业务需求有时候不仅仅关乎数据库,也有可能是上传一张图片或者发送一条短信。

而且像 Java 中的 JTA 只能解决一个应用下多数据库的分布式事务问题,跨服务或者跨地域就不能用了。

简单说下 Java 中 JTA,它是基于XA规范实现的事务接口,这里的 XA 你可以简单理解为基于数据库的 XA 规范来实现的 2PC。(至于XA规范到底是啥,前言中可以简单了解一下)

由于二阶段提交存在着诸如同步阻塞、单点问题、脑裂等缺陷,所以,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。

2. 三阶段提交(3PC)

3PC 的出现是为了解决 2PC 的一些问题,相比于 2PC 它在参与者中也引入了超时机制,并且新增了一个阶段使得参与者可以利用这一个阶段统一各自的状态。

也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有准备阶段、预提交阶段和提交阶段,对应的英文就是:CanCommit、PreCommit 和 DoCommit

    图片

3.1 CanCommit阶段

3PC的CanCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。

  1. 事务询问 协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
  2. 响应反馈 参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No

3.2 PreCommit阶段

协调者根据参与者的反应情况来决定是否可以进行事务的PreCommit操作。根据响应情况,有以下两种可能。

(1)假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行。

  1. 发送预提交请求 协调者向参与者发送PreCommit请求,并进入Prepared阶段。
  2. 事务预提交 参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
  3. 响应反馈 如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。

(2)假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。

  1. 发送中断请求 协调者向所有参与者发送abort请求。
  2. 中断事务 参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。

3.3 doCommit阶段

该阶段进行真正的事务提交,也可以分为以下两种情况。

(1)执行提交

  1. 发送提交请求 协调接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
  2. 事务提交 参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
  3. 响应反馈 事务提交完之后,向协调者发送Ack响应。
  4. 完成事务 协调者接收到所有参与者的ack响应之后,完成事务。

(2)中断事务 

协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。

  1. 发送中断请求 协调者向所有参与者发送abort请求
  2. 事务回滚 参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
  3. 反馈结果 参与者完成事务回滚之后,向协调者发送ACK消息
  4. 中断事务 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。

3.4 2PC与3PC 总结

相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。

可以看到 3PC 的引入并没什么实际突破,而且性能更差了,所以实际只有 2PC 的落地实现。再提一下,2PC 还是 3PC 都是协议,可以认为是一种指导思想,和真正的落地还是有差别的。

所以说 3PC 就是通过引入预提交阶段来使得参与者之间的状态得到统一,也就是留了一个阶段让大家同步一下。

但是这也只能让协调者知道该如果做,但不能保证这样做一定对,这其实和上面 2PC 分析一致,因为挂了的参与者到底有没有执行事务无法断定。

所以说 3PC 通过预提交阶段可以减少故障恢复时候的复杂性,但是不能保证数据一致,除非挂了的那个参与者恢复。

让我们总结一下, 3PC 相对于 2PC 做了一定的改进:引入了参与者超时机制,并且增加了预提交阶段使得故障恢复之后协调者的决策复杂度降低,但整体的交互过程更长了,性能有所下降,并且还是会存在数据不一致问题

所以 2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制。

我再说下 3PC 我没有找到具体的实现,所以我认为 3PC 只是纯的理论上的东西,而且可以看到相比于 2PC 它是做了一些努力但是效果甚微,所以只做了解即可。

2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务,就像我前面说的分布式事务不仅仅包括数据库的操作,还包括发送短信等,这时候 TCC 就派上用场了!

4. 补偿型事务(TCC)

Atomikos公司在商业版本事务管理器ExtremeTransactions中提供了TCC方案的实现,但是由于其是收费的,因此相应的很多的开源实现方案也就涌现出来,如:ByteTCC、Himly、TCC-transaction。但是笔者都不推荐使用。

TCC 是一种补偿型事务,该模型要求应用的每个服务提供 try、confirm、cancel 三个接口,它的核心思想是通过对资源的预留(提供中间态,如账户状态、冻结金额等),尽早释放对资源的加锁,如果事务可以提交,则完成对预留资源的确认,如果事务要回滚,则释放预留的资源。

TCC模型完全交由业务实现,每个子业务都需要实现Try-Confirm-

  • Try 指的是预留,即资源的预留和锁定,注意是预留
  • Confirm 指的是确认操作,这一步其实就是真正的执行了。
  • Cancel 指的是撤销操作,可以理解为把预留阶段的动作撤销了。

Cancel三个接口,对业务侵入大,资源锁定交由业务方。

其实从思想上看和 2PC 差不多,都是先试探性的执行,如果都可以那就真正的执行,如果不行就回滚。

 4.1 案例分析

假如说一个事务(下单)要执行A(支付)、B(扣减库存)、C(积分计算)三个操作,那么先对三个操作执行预留动作。如果都预留成功了那么就执行确认操作,如果有一个预留失败那就都执行撤销动作。

我们来看下流程,TCC模型还有个事务管理者的角色,用来记录TCC全局事务状态并提交或者回滚事务。

图片

虽说对业务有侵入,但是 TCC 没有资源的阻塞,每一个方法都是直接提交事务的,如果出错是通过业务层面的 Cancel 来进行补偿,所以也称补偿性事务方法。

这里有人说那要是所有人 Try 都成功了,都执行 Comfirm 了,但是个别 Confirm 失败了怎么办?

这时候只能是不停地重试调失败了的 Confirm 直到成功为止,如果真的不行只能记录下来,到时候人工介入了。

4.2 TCC 注意事项

这几个点很关键,在实现的时候一定得注意了。

图片

  • 幂等问题,因为网络调用无法保证请求一定能到达,所以都会有重调机制,因此对于 Try、Confirm、Cancel 三个方法都需要幂等实现,避免重复执行产生错误。
  • 空回滚问题,指的是 Try 方法由于网络问题没收到,超时了,此时事务管理器就会发出 Cancel 命令,那么需要支持 Cancel  在未执行 Try 的情况下能正常的 Cancel。
  • 悬挂问题,这个问题也是指 Try 方法由于网络阻塞超时触发了事务管理器发出了 Cancel 命令,但是执行了 Cancel 命令之后 Try 请求到了,你说气不气。

这都 Cancel 了你来个 Try,对于事务管理器来说这时候事务已经是结束了的,这冻结操作就被“悬挂”了,所以空回滚之后还得记录一下,防止 Try 的再调用。

 4.3 TCC 变体

上面我们说的是通用型的 TCC,它需要改造以前的实现,但是有一种情况是无法改造的,就是你调用的是别的公司的接口。

(1)没有 Try 的 TCC

比如坐飞机需要换乘,换乘的又是不同的航空公司,比如从 A 飞到 B,再从 B 飞到 C,只有 A - B 和 B - C 都买到票了才有意义。

这时候的选择就没得 Try 了,直接调用航空公司的买票操作,当两个航空公司都买成功了那就直接成功了,如果某个公司买失败了,那就需要调用取消订票接口。

也就是在第一阶段直接就执行完整个业务操作了,所以要重点关注回滚操作,如果回滚失败得有提醒,要人工介入等。

这其实就是 TCC 的思想。

图片

(2)异步TCC

这 TCC 还能异步?其实也是一种折中,比如某些服务很难改造,并且它又不会影响主业务决策,也就是它不那么重要,不需要及时的执行。

这时候可以引入可靠消息服务,通过消息服务来替代个别服务来进行 Try、Confirm、Cancel 。

Try 的时候只是写入消息,消息还不能被消费,Confirm 就是真正发消息的操作,Cancel 就是取消消息的发送。

这可靠消息服务其实就类似于等下要提到的事务消息,这个方案等于糅合了事务消息和 TCC。

4.4 TCC 小结

可以看到 TCC 是通过业务代码来实现事务的提交和回滚,对业务的侵入较大,它是业务层面的两阶段提交,。

它的性能比 2PC 要高,因为不会有资源的阻塞,并且适用范围也大于 2PC,在实现上要注意上面提到的几个注意点。

它是业界比较常用的分布式事务实现方式,而且从变体也可以得知,还是得看业务变通的,不是说你要用 TCC 一定就得死板的让所有的服务都改造成那三个方法。

5. 本地消息表+定时任务(ebay研发)

本地消息列表与业务数据表处于同一个数据库中,这样就能利用本地事务来保证在对这两个表的操作满足事务特性。

5.1 场景分析

订单系统收到第三方支付平台支付成功的通知后,需要进行

  1. 更新订单的状态为已支付 
  2. 扣减商品库存

订单库

  • 订单表order:orderID、userID、status

商品库

  • 商品库存表productStock:productStockID、productID、stock
  • 出库单outStockBill:outStockBillID、orderID、stock

正常流程:

  • 支付成功后根据orderID更新对应订单的状态status为已支付,并且扣减商品库存ProductStock(将stock减少),插入出库单outStockBill(其中的orderID为支付的订单ID)

更新订单状态和扣减商品库存是2个相互独立的服务,这就产生了分布式事务的问题,如订单状态更新了,但是扣减库存失败;当然解决方案有多种,这里考虑的做法是通过消息表的形式来实现。

在订单库里新增一个更新库存消息表,整个系统现有的数据库和表如下

订单库:

  • 订单表order:orderID、userID、status
  • 库存扣减消息表stockMsg:stockMsgID、orderID、status(1:待确认   2:已确认)

商品库:

  • 商品库存表productStock:productStockID、productID、stock
  • 出库单outStockBill:outStockBillID、orderID、stock

处理流程如下:

  1. 更新订单状态为已支付,并插入一条待确认的记录到库存扣减消息表,该记录的orderID为支付成功的订单ID,这一步在同一个事务中进行;(第一个事务)
  2. 更新商品库存,并插入出库单,出库单中的orderID为支付成功的订单ID,这一步也在同一个事务中进行,(第二个事务)
  3. 更新库存扣减消息表的状态为已确认;(定时扫描出库单

系统后台开启一个定时任务,定时轮训消息表中待确认的消息,

  1. 首先调用接口判断该消息中的订单是否存在于出库单中(消息中有订单ID,出库单中也有订单ID)
  2. 如果不存在则并调用接口更新商品库存、插入出库单,更新消息状态为已确认;如果更新消息失败,继续上一步操作
  3. 如果存在,则将消息状态更新为已确认,如果这一步中更新消息失败,回到第一步

那么会出现下面几种情况:

编号

更新订单、插入消息

更新库存、插入出库单

更新消息表

1

成功

成功

成功

2

成功

成功

失败

3

成功

失败

不操作

4

失败

不操作

不操作

  • 情况1:数据一致性没有问题
  • 情况2:这个时候消息是待确认状态,定时轮训的时候会查询到对应订单的出库单,所以不再进行库存更新操作,而是直接更新消息状态为已确认,数据最终一致
  • 情况3:这个时候消息是待确认状态,定时轮训的时候会查询不到对应订单的出库单,所以会进行库存更新操作,库存更新后将消息状态为已确认,数据最终一致

整个流程失败,数据一致性没有破坏

总的来说,这种场景依赖于数据的可查性(在定时轮训的时候可以查询到对应的订单是否已经生成出库单)

5.2 小优化

可以去掉消息的定时任务,然后就是在生成订单的事务中如果失败,那么就再开启一个事务(事务中会强制走主库查询)查询下该订单是否存在,如果存在就正常进行流程,如果不存在那么就直接设置消息的状态是释放未售出,然后进行库存的回滚(有点TCC的意思)

6. 本地消息表+消息队列

有一些第三方的MQ是支持事务消息的,比如RocketMQ,他们支持事务消息的方式也是类似于采用的二阶段提交,但是市面上一些主流的MQ都是不支持事务消息的,比如 RabbitMQ 和 Kafka 都不支持。

6.1 本地消息表+普通消息队列

下面给出正确的实现方式,如下所示:

上图所示的方案,利用消息中间件如 rabbitMQ 或者Kafka 来实现分布式下单及库存扣减过程的最终一致性。对这幅图做以下说明:

(1)主要流程说明

order-service 中,这两个过程要在一个事务中完成,保证过程的原子性。

在 t_order 表添加订单记录 &&
在 t_local_msg 添加对应的扣减库存消息

repo-service 中,这四个过程也要在一个事务中完成,保证过程的原子性

检查本次扣库存操作是否已经执行过 &&
执行扣减库存如果本次扣减操作没有执行过 &&
写判重表 &&
向 MQ sever 反馈消息消费完成 ACK

(2)order-service 中有一个后台程序,源源不断地把消息表中的消息传送给消息中间件,成功后则删除消息表中对应的消息。如果失败了,也会不断尝试重传。由于存在网络 2 将军问题,即当 order-service 发送给消息中间件的消息网络超时时,这时候消息中间件可能收到了消息但响应 ACK 失败,也可能没收到,order-service 会再次发送该消息,直至消息中间件响应 ACK 成功,这样可能发生消息的重复发送,不过没关系,只要保证消息不丢失,不乱序就行,后面 repo-service 会做去重处理。

(3)消息中间件向 repo-service(库存服务) 推送 repo_deduction_msg,repo-service 成功处理完成后会向中间件响应 ACK,消息中间件收到这个 ACK 才认为 repo-service 成功处理了这条消息,否则会重复推送该消息。但是有这样的情形:repo-service 成功处理了消息,向中间件发送的 ACK 在网络传输中由于网络故障丢失了,导致中间件没有收到 ACK 重新推送了该消息。这也要靠 repo-service 的消息去重特性来避免消息重复消费

(4)在(2)和 (3)中提到了两种导致 repo-service 重复收到消息的原因,一是生产者重复生产,二是中间件重传。为了实现业务的幂等性,repo-service 中维护了一张判重表,这张表中记录了被成功处理的消息的 id。repo-service 每次接收到新的消息都先判断消息是否被成功处理过,若是的话不再重复处理。

6.2 本地消息表+RocketMQ

流程说明如下:

上图说明了事务消息的大致方案,其中分为两个流程:正常事务消息的发送及提交、事务消息的补偿流程。

(1)事务消息发送及提交

  1. 发送消息(half消息)。
  2. 服务端存储消息并响应消息状态。
  3. 根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)。
  4. 根据本地事务状态执行Commit或者Rollback(Commit操作生成消息索引,消息对消费者可见)

(2)事务补偿

  1. 对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”
  2. Producer收到回查消息,检查回查消息对应的本地事务的状态(需要本地查DB状态表判断)
  3. 根据本地事务状态,重新Commit或者Rollback

其中,补偿阶段用于解决消息Commit或者Rollback发生超时或者失败的情况。

(3)使用限制

  1. 事务消息不支持延时消息和批量消息。
  2. 为了避免单个消息被检查太多次而导致半队列消息累积,我们默认将单个消息的检查次数限制为 15 次,但是用户可以通过 Broker 配置文件的 transactionCheckMax参数来修改此限制。如果已经检查某条消息超过 N 次的话( N = transactionCheckMax ) 则 Broker 将丢弃此消息,并在默认情况下同时打印错误日志。用户可以通过重写 AbstractTransactionCheckListener 类来修改这个行为。
  3. 事务消息将在 Broker 配置文件中的参数 transactionMsgTimeout 这样的特定时间长度之后被检查。当发送事务消息时,用户还可以通过设置用户属性 CHECK_IMMUNITY_TIME_IN_SECONDS 来改变这个限制,该参数优先于 transactionMsgTimeout 参数。
  4. 事务性消息可能不止一次被检查或消费。
  5. 提交给用户的目标主题消息可能会失败,目前这依日志的记录而定。它的高可用性通过 RocketMQ 本身的高可用性机制来保证,如果希望确保事务消息不丢失、并且事务完整性得到保证,建议使用同步的双重写入机制。
  6. 事务消息的生产者 ID 不能与其他类型消息的生产者 ID 共享。与其他类型的消息不同,事务消息允许反向查询、MQ服务器能通过它们的生产者 ID 查询到消费者。

7. 最大努力通知

最大努力通知也被称为定期校对,其实在方案二中已经包含,这里再单独介绍,主要是为了知识体系的完整性。这种方案也需要消息中间件的参与,其过程如下:

                   

  1. 上游系统在完成任务后,向消息中间件同步地发送一条消息(通知),确保消息中间件成功持久化这条消息,然后上游系统可以去做别的事情了;
  2. 消息中间件收到消息后负责将该消息同步投递给相应的下游系统(消费端),并触发下游系统的任务执行;
  3. 当下游系统处理成功后,向消息中间件反馈确认应答,消息中间件便可以将该条消息删除,从而该事务完成。
  4. 下游(消费端)若没有回应ack则MQ会重复通知(重复推送消息), MQ会按照间隔1min、5min、10min、30min、1h、2h、5h、10h的方式,逐步拉大通知间隔(如果MQ采用rocketMq,在broker中可进行配置),直到达到通知要求的时间窗口上限(15次)。
  5. 下游也会定期回查上游失败消息表(需要上游提供查询接口),校对并保证消息的一致性

上面是一个理想化的过程,但在实际场景中,往往会出现如下几种意外情况:

  • 消息中间件向下游系统投递消息失败(重试—如果重试失败,则归入死信队列(预警,并人工干预处理))
  • 上游系统向消息中间件发送消息失败(重试—如果重试失败,则记录在失败消息表中,然后下游定期查询这个上游的失败消息表进行处理)

对于第一种情况,消息中间件具有重试机制,我们可以在消息中间件中设置消息的重试次数和重试时间间隔

对于第二种情况,网络不稳定导致的消息投递失败的情况,往往重试几次后消息便可以成功投递,如果超过了重试的上限仍然投递失败,那么消息中间件不再投递该消息,而是记录在失败消息表中,消息中间件需要提供失败消息的查询接口,下游系统会定期查询失败消息,并将其消费,这就是所谓的“定期校对”。

7.1 使用局限性

  • 约束:被动方的业务处理结果不影响主动方的业务处理
  • 成本:业务查询与校对系统建设成本
  • 适用范围
    • 对时间敏感性较低的业务
    • 对账
  • 用到的服务模式:可查询操作

7.2 行业应用案例

  • 银行通知,商户通知等
  • 对账文件

8. 事务消息(Seata) 

参考文章

  1. 两天,把分布式事务搞完了
  2. 分布式事务六种解决方案
  3. 分布式事务--本地消息表(定时轮询扫描)

猜你喜欢

转载自blog.csdn.net/qq_41893274/article/details/113622006