可靠消息一致性的分布式事务 设计思路

什么是基于可靠消息一致性的分布式事务 ?

可靠消息最终一致性方案是指 当事务发起方执行完成本地事务后 发出一条消息,事务的消费者一定能够接收消息,然后处理事务成功,这个方案强调的是只要消息发给事务参与方最终事务要达到一致。

可靠消息一致性的原理是什么?

事务发起方将消息发给消息中间件,事务参与方从消息中间件中接收消息,如果消息中间件因为网络原因等等消息发送失败了,它会一直重复发送直到成功为止。

这种方案的要点就是可以基于 mq 来进行不断重试,最终一定会执行成功的。 因为一般执行失败的原因是网络抖动或者数据库瞬间负载太高,都是暂时性问题。

通过这种方案,99.9%的情况都是可以保证数据最终一致性的,剩下的 0.1%出问题的时候,就人工修复数据。

什么时候合适用它?

可靠消息最终一致性适合执行周期长且实时性要求不高的场景。

引入消息机制后,同步的事务操作变为基于消息执行的异步操作,避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。

它的优点有哪些?缺点有哪些?

避免阻塞,解耦,引入消息机制后,同步的事务操作变为基于消息执行的异步操作,避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。

缺点:会带来额外的复杂性,需要考虑并发、幂等、高可用等其他问题

可靠消息一致性的两种方案

方案一:如果MQ服务不是独立的

优点:

  • 消息时效性比较高

  • 从应用设计开发的角度实现了消息数据的可靠性,消息数据的可靠性不依赖于MQ中间件,弱化了对MQ中间件的依赖

  • 方案轻量,容易实现

缺点:

  • 与具体的业务绑定,耦合性强,不可公用

  • 消息数据与业务数据同库,占用业务系统资源

  • 业务系统在使用关系型数据库,消息服务性能会收关系型数据库并发性能局限

方案二:独立的MQ消息系统(推荐)

优点:

  • 消息服务独立部署、独立维护、独立伸缩
  • 消息存储可以选择不同数据库操作
  • 消息服务可以被多个应用场景公用,降低重复建设消息服务成本
  • 从分布式服务设计开发的角度实现了消息数据的可靠性,消息数据的可靠性不依赖MQ中间件,弱化了对MQ中间件特性依赖
  • 降低了业务系统与消息系统的耦合,有利于系统扩展维护

缺点

  • 一次消息发送需要两次请求(预发送,确认发送)
  • 主动方应用系统需要实现业务操作状态校验的查询接口

可靠消息最终一致性方案要解决的几个问题

1. 本地事务与消息发送的原子性问题

事务发起方在本地事务执行成功后消息必须发出去,否则就丢弃消息。

即实现本地事务和消息发送的原子性,要么都成功,要么都失败。本地事务与消息发送的原子性问题是实现可靠消息最终一致性方案的关键问题。

先来尝试下这种操作,先发送消息,再操作数据库 :

begin transaction;
		// 1.发送MQ
		// 2.数据库操作
commit transation;		

这种情况下无法保证数据库操作与发送消息的一致性,因为可能发送消息成功,数据库操作失败。

你立马想到第二种方案,先进行数据库操作,再发送消息 :

begin transaction;
		// 1.数据库操作
		// 2.发送MQ
commit transation;		

这种情况下貌似没有问题,如果发送MQ消息失败,就会抛出异常,导致数据库事务回滚。但如果是超时异常,数据库回滚,但MQ其实已经正常发送来,同样会导致不一致。

2. 事务参与方接收消息的可靠性

事务参与方必须能够从消息队列接收到消息,如果接收消息失败可以重复接收消息。

3. 消息重复消费的问题

由于网络的存在,若某一个消费节点超时但是消费成功,此时消息中间件会重复投递此消息,就导致来消息的重复消费。

要解决消息重复消费的问题就要实现事务参与方的方法幂等性。

项目举例

spring-cloud-rabbitmq
├── shop-eureka 	-- Eureka 注册中心
├── shop-mq 		-- MQ服务,供消息预发送、消息发送、消息确认、消息恢复、消息管理等功能
├── shop-account 	-- 账户服务,业务上游,转账功能入口,调用 shop-mq 发送转账消息
└── shop-account02 	-- 账户服务,业务下游,监听、消费消息队列,实现到账功能

项目业务说明

该实例通过 RabbitMQ 中间件实现可靠消息最终一致性分布式事务,模拟两个账户的转账交易过程。

两个账户在分别在不同的银行(张三在 account、李四在 account02),account、account02是两个微服务。

交易过程是,张三给李四转账指定金额。

上述交易步骤,张三扣减金额与给 account02 发转账消息,两个操作必须是一个整体性的事务。

流程说明

上游服务-->消息服务:1.预发送消息
消息服务-->消息服务:2.消息持久化
消息服务-->上游服务:3.返回消息ID
上游服务-->上游服务:4.执行扣钱业务
上游服务-->消息服务:5.确认发送消息
消息服务-->消息服务:6.标记消息已确认
消息服务-->下游服务:7.将消息投递给下游服务
下游服务-->下游服务:8.收到MQ消息,执行加钱业务
下游服务-->消息服务:9.ack确认消费
消息服务-->消息服务:10.收到ack响应,删除消息
  1. 在上层系统处理业务前,先向消息服务发送一条消息,消息服务收到后将该条消息持久化,但不向下投递。

    此时下层系统仍然不知道该条消息的存在。持久化成功后,向上层系统返回消息id。

  2. 上层系统收到应答后,执行业务。业务处理完成后,请求消息服务确认消息接口(异步)。该请求发出后,对上层系统而言,该事务的处理过程就结束了,此时它可以处理别的任务了。

为什么要异步请求消息确认接口?

(1) 假如使用同步请求,消息服务确认消息成功,已经把MQ消息发送给下层业务系统。但是响应时由于网络波动或其他原因抛出异常,使已经正常执行的业务A回滚,但是消息已经发出,下层业务已处理,就会造成数据不一致。

(2) 异步请求可以忽略确认消息请求的异常。假如确认请求异常,由消息确认子系统来处理,下文会介绍。

  1. 消息服务收到确认消息请求后,标记该消息为已确认,向下层系统投递该消息,从而执行业务B。

  2. 业务B执行完成后,下层系统调用消息服务确认消费消息,通知消息服务消息已经成功消费,消息服务会删除已成功消费的消息。

上述过程可以得出以下结论:

  • 消息服务扮演着分布式事务协调者的角色。
  • 业务A完成后,到业务B完成之间,会存在一定的时间差。在这个时间差内,整个系统处于数据不一致的状态,但这短暂的不一致性是可以接受的,因为经过短暂的时间后,系统又可以保持数据一致性,满足BASE理论。

消息发送阶段异常流程

假设在消息投递给下层系统前,每一步都有可能因为网络或其他原因,发生异常。

  • 如果异常不影响数据一致性,则无需处理。
  • 如果异常影响数据一致性,则由消息服务的消息确认子系统,定时回查长时间未确认的消息。然后再根据业务处理结果投递或删除消息。如果回查请求异常(上层系统Down机或其他原因),则下次继续回查,直至上层系统返回明确结果。

image-20201017231623895

异常情况 数据状态 数据一致性 处理
预发送消息异常 消息未存储,业务未执行 一致 无需处理
消息持久化异常 消息未存储,业务未执行 一致 无需处理
返回应答异常 消息已存储,业务未执行 不一致 回查
执行业务异常 消息已存储,业务未执行 不一致 回查
确认发送消息异常 消息已存储(消息未确认),业务已执行 不一致 回查
标记消息已确认异常 消息已存储(消息未确认),业务已执行 不一致 回查

1. 预发送消息异常

预发送消息异常 ,上层业务回滚,消息服务消息未存储,业务未执行,一致 无需处理

2. 消息持久化异常

消息持久化异常,消息服务服务回滚,上层业务回滚,消息未存储,业务未执行 ,一致,无需处理

3. 消息服务返回应答异常

消息服务返回应答异常,消息已存储,上层业务未收到应答,超时回滚,不一致,需要回查

根据队列,从数据库查询消息状态为0(未确认),同时超时的消息。

对于这种消息,可以删除。

4. 上层业务系统执行业务异常

上层业务系统执行业务异常,消息已储存,业务执行异常回滚了,不一致,需要回查

根据队列,从数据库查询消息状态为0(未确认),同时超时的消息。

对于这种消息,可以删除。

5. 上层业务系统确认发送消息异常

上层业务系统异步发送确认消息异常,消息已存储(未确认状态),业务已执行,可能因为网络问题消息服务没有收到消息,不一致,需要回查

根据队列,从数据库查询消息状态为0(未确认),同时超时的消息。

这个时候,上层业务已经执行完了,只是消息的状态没有改变,我们可以修改消息的状态,然后发送消息到 MQ主机上了。

6. 消息服务标记消息已确认异常

消息服务标记消息已确认异常,消息已存储,业务已执行,不一致,需要回查。

此时的处理和步骤5的一样。

消息消费阶段异常流程

假设在消息投递给下层系统后,每一步都有可能因为网络或其他原因,发生异常。

消息服务的消息恢复子系统,定时重发长时间未消费的消息。由于消息可能会重新投递,因此需要下层业务系统实现业务的幂等性

消息恢复子系统重发消息有次数限制,超过一定次数,消息将会被标记为死亡,不再重发。可在消息管理子系统人工干预(删除或继续重发)。

image-20201017232643645

1. 消息投递失败

消息投递失败,下层业务没用消费,通过消息恢复子系统,定时重发长时间未消费的消息。

2. 下层业务执行失败

下层业务执行失败,也就无法确认消息消费,消息还是未消费状态,通过消息恢复子系统,定时重发长时间未消费的消息。

3. 下层业务确认消费失败

下层业务确认消费失败,消息还是未消费状态,通过消息恢复子系统,定时重发长时间未消费的消息。

4. 消息服务 删除消息失败

通过人工干预

5. 为什么要使用中间服务,而不是上游服务直接保存消息和重发?

因为服务太多的时候,如果每一个接收者都要保存消息,管理消息,那么重复的代码就太多了,耦合太大、难以维护。所以需要消息服务中间件。

6. 业务上游为何要经过三个阶段来发送消息?

假设:上游业务执行完业务之后才发送消息给 shop-mq,那在发送消息的时候网络断了,业务执行了,但是消息没有保存,那也就失去了中间件的意义了。

所以,MQ要在业务执行前保存消息,由上游应用执行业务后再确定是否发送消息

那么,上游应用如何知道 MQ 已经将消息持久化了呢?所以 MQ 需要应答,也就是保存消息之后返回消息id告诉上游我已经保存消息了,在上游业务收到消息后执行业务,然后给MQ回信,确定发送消息。

这就是为什么上游业务和 MQ 要经过3个阶段来发送消息的原因了

幂等的解决方案

幂等性不能脱离业务来讨论。 在不同的需求场景下,实现幂等的思路和方案也会不同,一般有如下通用方案:

1. 去重表(当前使用)

这是利用数据库唯一索引的特性来实现幂等,将消息id设为去重表的唯一索引,每次消息消费成功就往去重表中插入一条数据,只有插入成功才继续执行支付操作,相当于在事务的开始阶段加锁。

考虑两种失败的情况:

  • Insert去重表失败,事务回滚,无任何影响;
  • Insert去重表成功,支付业务操作失败,事务回滚,删除之前插入去重表的记录,无任何影响;

以上两种失败的情况下,事务的幂等性是可以保持的,避免了单个订单同时多次进行支付的情况。

2. 分布式锁

与去重表思路相同,只是将对数据库的的访问转移到了对缓存(如redis)的访问,提高了效率。具体操作如下:

  • 订单发起支付请求,支付系统会去redis缓存中查询是否存在该订单号 orderId 的key,如果不存在,则向 redis 增加 key 为订单号,然后开始实际支付操作;
  • 如果查询到存在该订单号的 key,则不进行实际支付操作。无论支付操作成功或失败,在支付操作结果返回后,在缓存中删除该订单号key。

本文作者:元宇宙的元

本文链接:https://www.cnblogs.com/ludg/p/17011250.html

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @ 2022-12-28 20:42  元宇宙的元  阅读(82)  评论(1编辑  收藏  举报