(微服务)分布式事务-最大努力交付 && 消息最终一致性方案

1,143 阅读10分钟
原文链接: segmentfault.com

本文对比 二阶段事务、最大努力交付以及消息最终一致性,并给出部分解决方案,最终一致性方案参考阿里RockMQ事务消息:blog.csdn.net/chunlong...

一 2阶段事务

分布式系统最终一致性有N种方案,比如2PC(2阶段事务) ,以及三段提交等等,但开销较大,实现起来复杂,比如2阶段事务为例,需要引入一个协调者(Coordinator)来统一掌控所有参与者(Participant)的操作结果

以开会为例:
甲乙丙丁四人要组织一个会议,需要确定会议时间,不妨设甲是协调者,乙丙丁是参与者。
投票阶段:
(1)甲发邮件给乙丙丁,周二十点开会是否有时间;
(2)甲回复有时间;
(3)乙回复有时间;
(4)丙迟迟不回复,此时对于这个活动,甲乙丙均处于阻塞状态,算法无法继续进行;
(5)丙回复有时间(或者没有时间);
提交阶段:
(1)协调者甲将收集到的结果反馈给乙丙丁(什么时候反馈,以及反馈结果如何,在此例中取决与丙的时间与决定);
(2)乙收到;
(3)丙收到;
(4)丁收到;
不仅要锁住参与者的所有资源,而且要锁住协调者资源,开销大。一句话总结就是:2PC效率很低,分布式事务很难做。

在对事实性要求没有那么高的情况下,可以用基于最大努力交付 && 消息队列以及消息存储来解决最终一致性。

二 消息最大努力交付

所谓最大努力交付,就是俺反正用最大努力做,能不能成功,不做完全保证会涉及到三个模块

  1. 上游应用,发消息到 MQ 队列。

  2. 下游应用(例如短信服务、邮件服务),接受请求,并返回通知结果。

  3. 最大努力通知服务,监听消息队列,将消息存储到数据库中,并按照通知规则调用下游应用的发送通知接口。

具体流程如下

  1. 上游应用发送 MQ 消息到 MQ 组件内,消息内包含通知规则和通知地址

  2. 最大努力通知服务监听到 MQ 内的消息,解析通知规则并放入延时队列等待触发通知

  3. 最大努力通知服务调用下游的通知地址,如果调用成功,则该消息标记为通知成功,如果失败则在满足通知规则(例如 5 分钟发一次,共发送 10 次)的情况下重新放入延时队列等待下次触发。

最大努力通知服务表示在不影响主业务的情况下,尽可能地确保数据的一致性。它需要开发人员根据业务来指定通知规则,在满足通知规则的前提下,尽可能的确保数据的一致,以达到最大努力的目的。

实现上也比较简单,目前主流消息队列都有ack机制,当没收到ack的时候用规则做定时重发即可。
优点:实现简单
缺点:无补偿机制,不保证能够送达
实现要点: 保证消息发送失败之后能够和业务一起回滚;消息接受方保证冥等性;定时重发机制,采用一定的重发策略,例如说指数增长,据说阿里采用redis的zset来完成,参考zhuanlan.zhihu.com/p/...
消息进到zset后,DelayQ会通过timer触发(比如秒级),fork相应的消费线程去处理zset里ExecuteTime大于当前时间的消息。DelayQ拿到一条消息后,解析其中的callbackurl,并组装参数,push业务消息给Consumer.
Consumer返回处理成功,那么zrem Codis里的消息。如果处理失败,则计算其下次尝试时间,并更新其ExecuteTime.

三 可靠消息最终一致性方案

此方案涉及 3 个模块:

  1. 上游应用,执行业务并发送 MQ 消息。

  2. 可靠消息服务和 MQ 消息组件,协调上下游消息的传递,并确保上下游数据的一致性。

  3. 下游应用,监听 MQ 的消息并执行自身业务。

第一阶段:上游应用执行业务并发送 MQ 消息

上游应用将本地业务执行和消息发送绑定在同一个本地事务中,保证要么本地操作成功并发送 MQ 消息,要么两步操作都失败并回滚。

上游应用和可靠消息之间的业务交互图如下:

  1. 上游应用发送待确认消息到可靠消息系统

  2. 可靠消息系统保存待确认消息并返回

  3. 上游应用执行本地业务

  4. 上游应用通知可靠消息系统确认业务已执行并发送消息。

  5. 可靠消息系统修改消息状态为发送状态并将消息投递到 MQ 中间件。

以上每一步都可能出现失败情况,分析一下这 5 步出现异常后上游业务和消息发送是否一致:

上游应用执行完成,下游应用尚未执行或执行失败时,此事务即处于 BASE 理论的 Soft State 状态。

第二阶段:下游应用监听 MQ 消息并执行业务

下游应用监听 MQ 消息并执行业务,并且将消息的消费结果通知可靠消息服务。

可靠消息的状态需要和下游应用的业务执行保持一致,可靠消息状态不是已完成时,确保下游应用未执行,可靠消息状态是已完成时,确保下游应用已执行。

下游应用和可靠消息服务之间的交互图如下:

  1. 下游应用监听 MQ 消息组件并获取消息

  2. 下游应用根据 MQ 消息体信息处理本地业务

  3. 下游应用向 MQ 组件自动发送 ACK 确认消息被消费

  4. 下游应用通知可靠消息系统消息被成功消费,可靠消息将该消息状态更改为已完成。

以上每一步都可能出现失败情况,分析一下这 4 步出现异常后下游业务和消息状态是否一致:

通过分析以上两个阶段可能失败的情况,为了确保上下游数据的最终一致性,在可靠消息系统中,需要开发 消息状态确认消息重发 两个功能以实现 BASE 理论的 Eventually Consistent 特性。

异常处理一:消息状态确认

可靠消息服务定时监听消息的状态,如果存在状态为待确认并且超时的消息,则表示上游应用和可靠消息交互中的步骤 4 或者 5 出现异常。

可靠消息则携带消息体内的信息向上游应用发起请求查询该业务是否已执行。上游应用提供一个可查询接口供可靠消息追溯业务执行状态,如果业务执行成功则更改消息状态为已发送,否则删除此消息确保数据一致。具体流程如下:

  1. 可靠消息查询超时的待确认状态的消息

  2. 向上游应用查询业务执行的情况

  3. 业务未执行,则删除该消息,保证业务和可靠消息服务的一致性。业务已执行,则修改消息状态为已发送,并发送消息到 MQ 组件。

异常处理二:消息重发

消息已发送则表示上游应用已经执行,接下来则确保下游应用也能正常执行。

可靠消息服务发现可靠消息服务中存在消息状态为已发送并且超时的消息,则表示可靠消息服务和下游应用中存在异常的步骤,无论哪个步骤出现异常,可靠消息服务都将此消息重新投递到 MQ 组件中供下游应用监听。

下游应用监听到此消息后,在保证幂等性的情况下重新执行业务并通知可靠消息服务此消息已经成功消费,最终确保上游应用、下游应用的数据最终一致性。具体流程如下:

  1. 可靠消息服务定时查询状态为已发送并超时的消息

  2. 可靠消息将消息重新投递到 MQ 组件中

  3. 下游应用监听消息,在满足幂等性的条件下,重新执行业务。

  4. 下游应用通知可靠消息服务该消息已经成功消费。

通过消息状态确认和消息重发两个功能,可以确保上游应用、可靠消息服务和下游应用数据的最终一致性。

四 可靠消息最终一致性的实现

模块以及划分如下

一 消息服务模块

1.1 预存接口

发送消息前先用数据库将消息信息存储下来,消息状态为“待确认”,同时,保存该消息对应的消息队列

public int saveMessageWaitingConfirm(RpTransactionMessage message) {
    message.setEditTime(new Date());
    message.setStatus(MessageStatusEnum.WAITING_CONFIRM.name());
    message.setAreadlyDead(PublicEnum.NO.name());
    message.setMessageSendTimes(0);
    return rpTransactionMessageDao.insert(message);
}

1.2 消息确认并发送接口

业务发起方接受到“预存储消息保存成功后”,就可以开始业务处理了,业务处理完后就发送消息到消息中心,此时为消息确认并发送接口:将消息的状态设置为“发送中”,并发送消息到消息队列

public void confirmAndSendMessage(String messageId) {
    //在数据库中更新消息状态
    final RpTransactionMessage message = getMessageByMessageId(messageId)        
    message.setStatus(MessageStatusEnum.SENDING.name());
    message.setEditTime(new Date());
    rpTransactionMessageDao.update(message);
    //发送消息
    notifyJmsTemplate.setDefaultDestinationName(message.getConsumerQueue());
    notifyJmsTemplate.send(new MessageCreator() {
        public Message createMessage(Session session) throws JMSException {
            return session.createTextMessage(message.getMessageBody());
        }
    });
}

1.3 存储并发送

如果第一阶段没有业务操作,可以将消息状态设置为“发送中”,并直接向消息队列发送消息。

1.4 直接发送,透传

不需要消息可靠性,直接发送消息。

1.5 消息重新发送接口

根据messageId重新发送消息,当然,到其它模块检测到消息发送有问题时,会重发消息

public void reSendMessage(final RpTransactionMessage message) {
        //增加重发次数
        message.addSendTimes();
        message.setEditTime(new Date());
        rpTransactionMessageDao.update(message);
        //发送消息
        notifyJmsTemplate.setDefaultDestinationName(message.getConsumerQueue());
        notifyJmsTemplate.send(new MessageCreator() {
            public Message createMessage(Session session) throws JMSException {
                return session.createTextMessage(message.getMessageBody());
            }
        });
    }

1.6将消息标记为死亡

1.7 消息删除

确认消息已被消费,或者是业务处理失败,都没有必要再保存消息了,直接删除

1.8 重发所有死亡消息

二 消息异常处理模块

2.1 对“待确认”但超时的消息进行处理

如果消息一直是“待确认”状态,但业务操作已经完成,证明消息是并未发送成功,那么需要重新发送消息。

用一个线程池

threadPool.execute(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    settScheduled.handleWaitingConfirmTimeOutMessages();                                
                      try {
                        log.info("[waiting_confirm]睡眠60秒");
                        Thread.sleep(60000);
                    } catch (InterruptedException e) {
                    }
                }
            }
        });

定时的查询出处于“待确认”,但超时的消息

paramMap.put("createTimeBefore", dateStr);// 取存放了多久的消息
paramMap.put("status", MessageStatusEnum.WAITING_CONFIRM.name());// 取状态为“待确认”的消息
Map<String, RpTransactionMessage> messageMap = getMessageMap(numPerPage, maxHandlePageCount, paramMap);

重发这些消息

//对业务进行查询
RpTradePaymentRecord record = rpTradePaymentQueryService.getRecordByBankOrderNo(bankOrderNo);
    // 如果订单成功,把消息改为待处理,并发送消息
if (TradeStatusEnum.SUCCESS.name().equals(record.getStatus())) {
    // 确认并发送消息
    rpTransactionMessageService.confirmAndSendMessage(message.getMessageId());
                
} else if (TradeStatusEnum.WAITING_PAYMENT.name().equals(record.getStatus())) {
    // 订单状态是等到支付,可以直接删除数据
   log.debug("订单没有支付成功,删除[waiting_confirm]消息id[" + message.getMessageId() + "]的消息");
    rpTransactionMessageService.deleteMessageByMessageId(message.getMessageId());
}

2.2 对“已发送”的消息进行处理

如果消息是一直“发送中”,并未被删除,说明消息接收方并未处理这个消息,需要对此进行改进查询处于“sending”状态的消息

paramMap.put("createTimeBefore", dateStr);// 取存放了多久的消息
paramMap.put("status", MessageStatusEnum.SENDING.name());// 取状态为发送中的消息
paramMap.put("areadlyDead", PublicEnum.NO.name());// 取存活的发送中消息

Map<String, RpTransactionMessage> messageMap = getMessageMap(numPerPage, maxHandlePageCount, paramMap);

进行重发

// 如果超过最大发送次数直接退出
if (maxTimes < message.getMessageSendTimes()) {
// 标记为死亡
    rpTransactionMessageService.setMessageToAreadlyDead(message.getMessageId());
    continue;
}
// 判断是否达到发送消息的时间间隔条件
int reSendTimes = message.getMessageSendTimes();
int times = notifyParam.get(reSendTimes == 0 ? 1 : reSendTimes);
long currentTimeInMillis = Calendar.getInstance().getTimeInMillis();
long needTime = currentTimeInMillis - times * 60 * 1000;
long hasTime = message.getEditTime().getTime();
// 判断是否达到了可以再次发送的时间条件
if (hasTime > needTime) {
        log.debug("currentTime[" + sdf.format(new Date()) + "],[SENDING]消息上次发送时间[" + sdf.format(message.getEditTime()) + "],必须过了[" + times + "]分钟才可以再发送。");
                continue;
            }

// 重新发送消息
rpTransactionMessageService.reSendMessage(message);

三 消息接受模块

接受消息并进行业务处理,并删除DB中的消息