RocketMQ篇

为什么要使用MQ?

因为项目做了分布式系统,所有远程服务调用请求都是同步执行经常出问题,所以引入了mq。

主要作用有以下几点:

  • 解耦:系统耦合度降低,没有强依赖关系
  • 异步:不需要同步执行的远程调用可以有效提高响应时间
  • 削峰:请求达到峰值后,后端service还可以保持固定消费速率消费,不会被压垮

RocketMQ由哪些角色组成,每个角色作用和特点是什么?

  • Nameserver:无状态,动态列表;这也是和zookeeper的重要区别之一。zookeeper是有状态的。
  • Producer:消息生产者,负责发消息到Broker。
  • Broker:就是MQ本身,负责收发消息、持久化消息等。
  • Consumer:消息消费者,负责从Broker上拉取消息进行消费,消费完进行ack。

RocketMQ Broker中的消息被消费后会立即删除吗?

不会,每条消息都会持久化到CommitLog中,每个Consumer连接到Broker后会维持消费进度信息,当有消息消费后只是当前Consumer的消费进度(CommitLog的offset)更新了。

追问:那么消息会堆积吗?什么时候清理过期消息?

4.6版本默认48小时后会删除不再使用的CommitLog文件。

  • 检查这个文件最后访问时间
  • 判断是否大于过期时间
  • 指定时间删除,默认凌晨4点

RocketMQ消费模式有几种?

消费模型由Consumer决定,消费维度为Topic。

集群消费

  • 一条消息只会被同Group中的一个Consumer消费
  • 多个Group同时消费一个Topic时,每个Group都会有一个Consumer消费到数据

广播消费

消息将对一 个Consumer Group 下的各个 Consumer 实例都消费一遍。即同组的的每个 Consumer 都消费一次。

消费消息是push还是pull?

RocketMQ没有真正意义的push,都是pull,虽然有push类,但实际底层实现采用的是长轮询机制,即拉取方式

broker端属性 longPollingEnable 标记是否开启长轮询。默认开启

为什么要主动拉取消息而不使用事件监听方式?

事件驱动方式是建立好长连接,由事件(发送数据)的方式来实时推送。

如果broker主动推送消息的话有可能push速度快,消费速度慢的情况,那么就会造成消息在consumer端堆积过多,同时又不能被其他consumer消费的情况。而pull的方式可以根据当前自身情况来pull,不会造成过多的压力而造成瓶颈。

推模式中broker则需要知道哪些consumer拥有哪些topic和tags,但在consumer重启或更换topic时,broker无法及时获取信息,可能将消息推送到旧的consumer中。对应consumer主动获取topic,这样确保每次主动获取时他对应的topic信息都是最新的。

RocketMQ如何保证高可用?

① master和slave 配合,master 支持读、写,slave 只读,producer 只能和 master 连接写入消息,consumer 可以连接 master 和 slave。

② 当 master 不可用或者繁忙时,consumer 会被自动切换到 slave 读。即使 master 出现故障,consumer 仍然可以从 slave 读消息,不受影响。

③ 创建 topic 时,把 message queue 创建在多个 broker 组上(brokerName 一样,brokerId 不同),当一个 broker 组的 master 不可用后,其他组的 master 仍然可以用,producer 可以继续发消息。

如何让RocketMQ保证消息的顺序消费?

首先多个queue只能保证单个queue里的顺序,queue是典型的FIFO,天然顺序。多个queue同时消费是无法绝对保证消息的有序性的。

需要对发送消息和消息消费做如下调整:

① Rocket MQ给我们提供了MessageQueueSelector接口,可以自己重写里面的接口,实现自己的算法,保证相同唯一标识的消息都发送到同一个队列上。

② 消费者业务保证。一个线程对应一个消息队列,负责拉取消息;创建多个线程和多个内存队列,一个线程负责一个内存队列,线程从消息队列拉取消息后,根据相同消息标识进入同一个内存队列的原则,进行消息分发,这样可以提高消息处理的顺序性,也可以提高消息的处理效率。

RocketMQ发送普通消息(三种方式)

RocketMQ 发送普通消息有三种实现方式:可靠同步发送、可靠异步发送单向(Oneway)发送

注意 :顺序消息只支持可靠同步发送。

可靠同步发送

原理:同步发送是指消息发送方发出数据后,会在收到接收方发回响应之后才发下一个数据包的通讯方式。

应用场景:此种方式应用场景非常广泛,例如重要通知邮件、报名短信通知、营销短信系统等。

可靠异步发送

原理:异步发送是指发送方发出数据后,不等接收方发回响应,接着发送下个数据包的通讯方式。 消息队列 RocketMQ 的异步发送,需要用户实现异步发送回调接口(SendCallback)。

应用场景:异步发送一般用于链路耗时较长,对 RT 响应时间较为敏感的业务场景,例如批量发货等操作。

单向发送

原理:单向(Oneway)发送特点为发送方只负责发送消息,不等待服务器回应且没有回调函数触发,即只发送请求不等待应答。 此方式发送消息的过程耗时非常短,一般在微秒级别。

应用场景:适用于某些耗时非常短,但对可靠性要求并不高的场景,例如日志收集。

发送状态

当Producer发送消息的时候,会返回SendResult对象,该对象又包含了一个SendStatus对象。

package org.apache.rocketmq.client.producer;
public enum SendStatus {
    SEND_OK,
    FLUSH_DISK_TIMEOUT,
    FLUSH_SLAVE_TIMEOUT,
    SLAVE_NOT_AVAILABLE,
}

下面对这几种状态进行说明

  • SEND_OK:代表发送成功!但并不保证它是可靠的。要确保不会丢失任何消息,还应启用SYNC_MASTER或SYNC_FLUSH。
  • SLAVE_NOT_AVAILABLE:如果Broker的角色是SYNC_MASTER(同步复制)(默认为异步),但没有配置Slave Broker,将获得此状态。
  • FLUSH_DISK_TIMEOUT:如果Broker设置为 SYNC_FLUSH(同步刷盘)(默认为ASYNC_FLUSH),并且Broker的syncFlushTimeout(默认为5秒)内完成刷新磁盘,将获得此状态。
  • FLUSH_SLAVE_TIMEOUT:如果Broker的角色是SYNC_MASTER(同步复制)(默认为ASYNC_MASTER),并且从属Broker的syncFlushTimeout(默认为5秒)内完成与主服务器的同步,将获得此状态。

三种发送方式的对比

发送方式 发送 TPS 发送结果反馈 可靠性
同步发送 不丢失
异步发送 不丢失
单向发送 最快 可能丢失

说一说RocketMQ实现分布式事务原理

RocketMQ虽然之前也支持分布式事务,但并没有开源,等到RocketMQ 4.3才正式开源。

举个分布式场景

例子:假设 A 给 B 转 100块钱,同时它们不是同一个服务上。
目标:就是 A 减100块钱,B 加100块钱。

实际情况可能有四种:

1)就是A账户减100 (成功),B账户加100 (成功)
2)就是A账户减100(失败),B账户加100 (失败)
3)就是A账户减100(成功),B账户加100 (失败)
4)就是A账户减100 (失败),B账户加100 (成功)

基础概念

  • 最终一致性:RocketMQ是一种最终一致性的分布式事务,就是说它保证的是消息最终一致性,而不是像2PC、3PC、TCC那样强一致分布式事务。
  • Half Message(半消息):是指暂不能被Consumer消费的消息。Producer 已经把消息成功发送到了 Broker 端,但此消息被标记为暂不能投递状态,处于该种状态下的消息称为半消息。需要 Producer对消息的二次确认后,Consumer才能去消费它。
  • 消息回查:由于网络闪断,生产者应用重启等原因。导致 Producer 端一直没有对 Half Message(半消息) 进行二次确认。这时Broker服务器会定时扫描长期处于半消息的消息,会主动询问 Producer端该消息的最终状态(Commit或者Rollback),该消息即为 消息回查。

分布式事务交互流程

我们来说明下上面这张图:

1、A服务先发送个Half Message给Brock端,消息中携带 B服务 即将要+100元的信息。

2、当A服务知道Half Message发送成功后,那么开始第3步执行本地事务。

3、执行本地事务(会有三种情况1、执行成功。2、执行失败。3、网络等原因导致没有响应)

4.1、如果本地事务成功,那么Producer像Brock服务器发送Commit,这样B服务就可以消费该message。
4.2、如果本地事务失败,那么Producer像Brock服务器发送Rollback,那么就会直接删除上面这条半消息。
4.3、如果因为网络等原因迟迟没有返回失败还是成功,那么会执行RocketMQ的回调接口,来进行事务的回查

从上面流程可以得知 只有A服务本地事务执行成功 ,B服务才能消费该message。

为什么要先发送Half Message(半消息)

  • 可以先确认Broker服务器是否正常 ,如果半消息都发送失败了那说明Broker挂了。
  • 可以通过半消息来回查事务,如果半消息发送成功后一直没有被二次确认,那么就会回查事务状态。

什么情况会回查

  • 执行本地事务的时候,由于突然网络等原因一直没有返回执行事务的结果(commit或者rollback)导致最终返回UNKNOW,那么就会回查。
  • 本地事务执行成功后,返回Commit进行消息二次确认的时候的服务挂了,在重启服务那么这个时候在brock端,它还是个Half Message(半消息),这也会回查。

特别注意:如果回查,那么一定要先查看当前事务的执行情况,再看是否需要重新执行本地事务。

想象下如果出现第二种情况而引起的回查,如果不先查看当前事务的执行情况,而是直接执行事务,那么就相当于成功执行了两个本地事务。

为什么说MQ是最终一致性事务

在上面举例事务不一致的两种情况中,永远不会发生:A账户减100 (失败),B账户加100 (成功)

因为:如果A服务本地事务都失败了,那B服务永远不会执行任何操作,因为消息压根就不会传到B服务。

那么 A账户减100 (成功),B账户加100 (失败) 会不会可能存在的。

答案是会的。因为A服务只负责当我消息执行成功了,保证消息能够送达到B,至于B服务接到消息后最终执行结果A并不管。

那B服务失败怎么办?

如果B最终执行失败,几乎可以断定就是代码有问题所以才引起的异常,因为消费端RocketMQ有重试机制,如果不是代码问题一般重试几次就能成功。

如果是代码的原因引起多次重试失败后,也没有关系,将该异常记录下来,由人工处理,人工兜底处理后,就可以让事务达到最终的一致性。

RocketMQ如何做负载均衡?

生产者的负载均衡

从MessageQueue列表中随机选择一个(默认策略),通过自增随机数对列表大小取余获取位置信息,但获得的MessageQueue所在的集群不能是上次的失败集群。

集群超时容忍策略,先随机选择一个MessageQueue,如果因超时等异常导致发送失败,会优先选择该broker集群下其他的messeagequeue进行发送。如果没有找到则从之前发送失败broker集群中选择一个MessageQueue进行发送,如果还没有找到则使用默认策略。

消费者的负载均衡

  • 平均分配策略(默认)(AllocateMessageQueueAveragely)
  • 环形分配策略(AllocateMessageQueueAveragelyByCircle)
  • 手动配置分配策略(AllocateMessageQueueByConfig)
  • 机房分配策略(AllocateMessageQueueByMachineRoom)
  • 一致性哈希分配策略(AllocateMessageQueueConsistentHash)
  • 靠近机房策略(AllocateMachineRoomNearby)

RocketMq会有重复消费的问题吗?如何解决?

影响消息正常发送和消费的重要原因是网络的不确定性需要保证消费端处理消息的业务逻辑保持幂等性

引起重复消费的原因

  • ACK:正常情况下在consumer真正消费完消息后应该发送ack,通知broker该消息已正常消费,从queue中剔除。当ack因为网络原因无法发送到broker,broker会认为此条消息没有被消费,此后会开启消息重投机制把消息再次投递到consumer。
  • 消费模式:在CLUSTERING模式下,消息在broker中会保证相同group的consumer消费一次,但是针对不同group的consumer会推送多次。

解决方案

  • 数据库表:处理消息前,使用消息主键在表中带有约束的字段中insert。
  • 单机时可以使用map ConcurrentHashMap -> putIfAbsent
  • 使用Redis做标记。可以在redis里面设置一个标记key,例如给已经下过的订单做个下单标记,每次下单时可以检查是否存在这个key,有就代表下过单了,直接返回;没有则继续下单。

RocketMq延迟消息?如何实现的

RocketMQ 支持定时消息,但是不支持任意时间精度,仅支持特定的 level,例如定时 5s, 10s, 1m 等。其中,level=0 级表示不延时,level=1 表示 1 级延时,level=2 表示 2 级延时。默认的配置是messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h。

Message msg = new Message(topic, tags, keys, body);
msg.setDelayTimeLevel(3);

RocketMq消息积压问题如何解决?

(1)提高消费并行读

  • 同一个Consumer Group下,通过增加Consumer实例的数量来提高并行度,超过订阅队列数的Consumer实例无效。
  • 提高单个Consumer的消费并行线程,通过修改Consumer的consumerThreadMin和consumerThreadMax来设置线程数。

(2)批量方式消费

通过设置Consumer的consumerMessageBathMaxSize这个参数,默认是1,一次只消费一条消息,例如设置N,那么每次消费的消息条数小于等于N。

(3)丢弃非重要消息

当消息发生堆积时,如果消费速度跟不上生产速度,可以选择丢弃一些不重要的消息。

(4)优化消息消费的过程

对于消费消息的过程一般包括业务处理以及跟数据库的交互,可以试着通过一些其他的方法优化消费的逻辑。

临时方案:新建一个topic,写一个临时的分发数据的consumer程序,这个程序部署上去消费积压的数据,消费之后不做耗时的处理,直接均匀轮询写入临时建立好的queue中。临时用一部分机器来部署consumer,每一批consumer消费一个临时queue的数据。等快速消费完积压数据之后,得恢复原先部署架构,重新用原先的consumer机器来消费消息。

RocketMQ四种集群方式

  • 单个Master节点:负载压力非常大,如果宕机的话,数据可能会丢失
  • 多个Master阶段:分摊存储数据,但是没有Slave节点的话,宕机的情况下数据可能会丢失
  • 多Master和多Slave节点,同步形式实现主从数据同步,在生产者将消息存放到主再同步到备Broker中才返回ack确认消息投递成功
  • 多Master和多Slave节点,异步形式实现主从数据同步,在生产者将消息存放到主,返回ack确认消息投递成功,异步同步到备Broker中,效率高,但是数据可能会丢失

在Broker扩容的时候会影响到其他的Broker使用吗?

不会,因为生产者是通过NameServer中注册的节点数通过轮询来实现数据的存放,节点数没有写死。可以缩容,但是前提是Broker中的消息要被消费完。

同步刷盘 or 异步刷盘

同步刷盘和异步刷盘指的是 内存和磁盘 的关系。

RocketMQ的消息最终是是存储到磁盘上的,这样既能保证断电后恢复,又可以让存储的消息量超出内存的限制。

从客户端发送消息,一开始先写到内存,再写到磁盘上。如下图所示:

两种策略

  • 同步刷盘:当数据成功写到内存中之后立刻刷盘(同步),在保证消息写到磁盘也成功的前提下返回写成功状态。
  • 异步刷盘 :数据写入内存后,直接返回成功状态。异步将内存中的数据持久化到磁盘上。

同步刷盘和异步输盘的优劣

同步刷盘

  • 优点:保证了数据的可靠性,保证数据不会丢失
  • 缺点:同步刷盘效率较低,因为需要内存将消息写入磁盘后才返回成功状态。

异步刷盘

  • 异步刷盘可以提高系统的吞吐量。因为它仅仅是写入内存成功后,就返回成功状态。
  • 异步刷盘不能保证数据的可靠性。因为写入内存成功,但写入磁盘的时候因为某种原因写入失败,那就会丢失该条消息。

同步复制 or 异步复制

同步复制和异步复制指的是 Master节点和slave节点 的关系。

如果一个Broker组有Master和Slave,消息需要从Master复制到Slave上。

两种策略

  • 同步复制: 当数据成功写到内存中Master节点之后立刻同步到Slave中,当Slave也成功的前提下返回写成功状态。
  • 异步复制: 当数据成功写到内存中Master节点之后,直接返回成功状态,异步将Master数据存入Slave节点。

同步复制和异步复制的优劣

同步复制 : 数据安全性高,性能低一点。
异步复制 : 数据可能丢失,性能高一点。

建议 线上采用 同步复制 + 异步刷盘;

RocketMQ如何保证消息不丢失?

首先在如下三个部分都可能会出现丢失消息的情况:

  • Producer端
  • Broker端
  • Consumer端

Producer端如何保证消息不丢失

  • 采取send()同步发消息,发送结果是同步感知的。
  • 发送失败后可以重试,设置重试次数。默认3次。
producer.setRetryTimesWhenSendFailed(10);
  • 集群部署,比如发送失败了的原因可能是当前Broker宕机了,重试的时候会发送到其他Broker上。

Broker端如何保证消息不丢失

  • 修改刷盘策略为同步刷盘。默认情况下是异步刷盘的。
flushDiskType = SYNC_FLUSH
  • 集群部署,主从模式,高可用。

Consumer端如何保证消息不丢失

完全消费正常后再进行手动ack确认。

RocketMQ在分布式事务支持这块机制的底层原理?

分布式系统中的事务可以使用TCC(Try、Confirm、Cancel)、2pc来解决分布式系统中的消息原子性。

RocketMQ 4.3+提供分布事务功能,通过 RocketMQ 事务消息能达到分布式事务的最终一致。

RocketMQ实现方式:

  • Half Message:预处理消息,当broker收到此类消息后,会存储到RMQ_SYS_TRANS_HALF_TOPIC的消息消费队列中;
  • 检查事务状态:Broker会开启一个定时任务,消费RMQ_SYS_TRANS_HALF_TOPIC队列中的消息,每次执行任务会向消息发送者确认事务执行状态(提交、回滚、未知),如果是未知,Broker会定时去回调在重新检查。
  • 超时:如果超过回查次数,默认回滚消息。

也就是他并未真正进入Topic的queue,而是用了临时queue来放所谓的half message,等提交事务后才会真正的将half message转移到topic下的queue。

如果让你来动手实现一个分布式消息中间件,整体架构你会如何设计实现?

  • 需要考虑能快速扩容、天然支持集群
  • 持久化的姿势
  • 高可用性
  • 数据0丢失的考虑
  • 服务端部署简单、client端使用简单

说说RocketMQ 是如何保证数据的高容错性的?

  • 在不开启容错的情况下,轮询队列进行发送,如果失败了,重试的时候过滤失败的Broker;
  • 如果开启了容错策略,会通过RocketMQ的预测机制来预测一个Broker是否可用;
  • 如果上次失败的Broker可用那么还是会选择该Broker的队列;
  • 如果上述情况失败,则随机选择一个进行发送;
  • 在发送消息的时候会记录一下调用的时间与是否报错,根据该时间去预测broker的可用时间。

其实就是send消息的时候queue的选择。源码在如下:org.apache.rocketmq.client.latency.MQFaultStrategy#selectOneMessageQueue()

任何一台Broker突然宕机了怎么办?

Broker主从架构以及多副本策略。Master收到消息后会同步给Slave,这样一条消息就不止一份了,Master宕机了还有slave中的消息可用,保证了MQ的可靠性和高可用性。而且Rocket MQ4.5.0开始就支持了Dlegder模式,基于raft的,做到了真正意义的HA。

Broker把自己的信息注册到哪个NameServer上?

Broker会向所有的NameServer上注册自己的信息,而不是某一个,是每一个,全部!

基于RocketMQ实现分布式事务-案例

事务消息

Half Message,半消息

暂时不能被 Consumer消费的消息。Producer已经把消息发送到 Broker端,但是此消息的状态被标记为不能投递,处于这种状态下的消息称为半消息。事实上,该状态下的消息会被放在一个叫做 RMQ_SYS_TRANS_HALF_TOPIC的主题下。

当 Producer端对它二次确认后,也就是 Commit之后,Consumer端才可以消费到;那么如果是Rollback,该消息则会被删除,永远不会被消费到。

事务状态回查

我们想,可能会因为网络原因、应用问题等,导致Producer端一直没有对这个半消息进行确认,那么这时候 Broker服务器会定时扫描这些半消息,主动找Producer端查询该消息的状态。

当然,什么时候去扫描,包含扫描几次,我们都可以配置。

简而言之,RocketMQ事务消息的实现原理就是基于两阶段提交和事务状态回查,来决定消息最终是提交还是回滚的。

在本文,我们的代码就以 订单服务、积分服务 为例。整体流程如下:

在这里插入图片描述

订单服务

在订单服务中,我们接收前端的请求创建订单,保存相关数据到本地数据库。

事务日志表

在订单服务中,除了有一张订单表之外,还需要一个事务日志表。 它的定义如下:

CREATE TABLE transaction_log (
  id varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT ‘事务ID’,
  business varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT ‘业务标识’,
  foreign_key varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT ‘对应业务表中的主键’,
  PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

这张表专门作用于事务状态回查。当提交业务数据时,此表也插入一条数据,它们共处一个本地事务中。通过事务ID查询该表,如果返回记录,则证明本地事务已提交;如果未返回记录,则本地事务可能是未知状态或者是回滚状态。

TransactionMQProducer

我们知道,通过 RocketMQ发送消息,需先创建一个消息发送者。值得注意的是,如果发送事务消息,在这里我们的创建的实例必须是 TransactionMQProducer。

@Component
public class TransactionProducer {

    private String producerGroup = "order_trans_group";
    private TransactionMQProducer producer;

    //用于执行本地事务和事务状态回查的监听器
    @Autowired
    OrderTransactionListener orderTransactionListener;
    //执行任务的线程池
    ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10, 60,
                                                         TimeUnit.SECONDS, new ArrayBlockingQueue<>(50));

    @PostConstruct
    public void init(){
        producer = new TransactionMQProducer(producerGroup);
        producer.setNamesrvAddr("127.0.0.1:9876");
        producer.setSendMsgTimeout(Integer.MAX_VALUE);
        producer.setExecutorService(executor);
        producer.setTransactionListener(orderTransactionListener);
        this.start();
    }
    private void start(){
        try {
            this.producer.start();
        } catch (MQClientException e) {
            e.printStackTrace();
        }
    }
    //事务消息发送 
    public TransactionSendResult send(String data, String topic) throws MQClientException {
        Message message = new Message(topic,data.getBytes());
        return this.producer.sendMessageInTransaction(message, null);
    }

}

上面的代码中,主要就是创建事务消息的发送者。在这里,我们重点关注 OrderTransactionListener,它负责执行本地事务和事务状态回查。

OrderTransactionListener

@Component
public class OrderTransactionListener implements TransactionListener {

    @Autowired
    OrderService orderService;

    @Autowired
    TransactionLogService transactionLogService;

    Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public LocalTransactionState executeLocalTransaction(Message message, Object o) {
        logger.info("开始执行本地事务....");
        LocalTransactionState state;
        try{
            String body = new String(message.getBody());
            OrderDTO order = JSONObject.parseObject(body, OrderDTO.class);
            orderService.createOrder(order,message.getTransactionId());
            state = LocalTransactionState.COMMIT_MESSAGE;
            logger.info("本地事务已提交。{}",message.getTransactionId());
        }catch (Exception e){
            logger.info("执行本地事务失败。{}",e);
            state = LocalTransactionState.ROLLBACK_MESSAGE;
        }
        return state;
    }

    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt messageExt) {
        logger.info("开始回查本地事务状态。{}",messageExt.getTransactionId());
        LocalTransactionState state;
        String transactionId = messageExt.getTransactionId();
        if (transactionLogService.get(transactionId)>0){
            state = LocalTransactionState.COMMIT_MESSAGE;
        }else {
            state = LocalTransactionState.UNKNOW;
        }
        logger.info("结束本地事务状态查询:{}",state);
        return state;
    }

}

在通过 producer.sendMessageInTransaction发送事务消息后,如果消息发送成功,就会调用到这里的executeLocalTransaction方法,来执行本地事务。在这里,它会完成订单数据和事务日志的插入。

该方法返回值 LocalTransactionState 代表本地事务状态,它是一个枚举类。

public enum LocalTransactionState {
    //提交事务消息,消费者可以看到此消息
    COMMIT_MESSAGE,
    //回滚事务消息,消费者不会看到此消息
    ROLLBACK_MESSAGE,
    //事务未知状态,需要调用事务状态回查,确定此消息是提交还是回滚
    UNKNOW;
}

那么, checkLocalTransaction 方法就是用于事务状态查询。在这里,我们通过事务ID查询transaction_log这张表,如果可以查询到结果,就提交事务消息;如果没有查询到,就返回未知状态。

注意,这里还涉及到另外一个问题。如果是返回未知状态,RocketMQ Broker服务器会以1分钟的间隔时间不断回查,直至达到事务回查最大检测数,如果超过这个数字还未查询到事务状态,则回滚此消息。

当然,事务回查的频率和最大次数,我们都可以配置。在 Broker 端,可以通过这样来配置它:

brokerConfig.setTransactionCheckInterval(10000); //回查频率10秒一次
brokerConfig.setTransactionCheckMax(3); //最大检测次数为3

业务实现类

@Service
public class OrderServiceImpl implements OrderService {
    @Autowired
    OrderMapper orderMapper;
    @Autowired
    TransactionLogMapper transactionLogMapper;
    @Autowired
    TransactionProducer producer;

    Snowflake snowflake = new Snowflake(1,1);
    Logger logger = LoggerFactory.getLogger(this.getClass());

    //执行本地事务时调用,将订单数据和事务日志写入本地数据库
    @Transactional
    @Override
    public void createOrder(OrderDTO orderDTO,String transactionId){

        //1.创建订单
        Order order = new Order();
        BeanUtils.copyProperties(orderDTO,order);
        orderMapper.createOrder(order);

        //2.写入事务日志
        TransactionLog log = new TransactionLog();
        log.setId(transactionId);
        log.setBusiness("order");
        log.setForeignKey(String.valueOf(order.getId()));
        transactionLogMapper.insert(log);

        logger.info("订单创建完成。{}",orderDTO);
    }

    //前端调用,只用于向RocketMQ发送事务消息
    @Override
    public void createOrder(OrderDTO order) throws MQClientException {
        order.setId(snowflake.nextId());
        order.setOrderNo(snowflake.nextIdStr());
        producer.send(JSON.toJSONString(order),"order");
    }

}

在订单业务服务类中,我们有两个方法。一个用于向RocketMQ发送事务消息,一个用于真正的业务数据落库。

至于为什么这样做,其实有一些原因的,我们后面再说。

调用

@RestController
public class OrderController {

    @Autowired
    OrderService orderService;
    Logger logger = LoggerFactory.getLogger(this.getClass());

    @PostMapping("/create_order")
    public void createOrder(@RequestBody OrderDTO order) throws MQClientException {
        logger.info("接收到订单数据:{}",order.getCommodityCode());
        orderService.createOrder(order);
    }

}

总结

目前已经完成了订单服务的业务逻辑。我们总结流程如下:

考虑到异常情况,这里的要点如下:

  • 第一次调用createOrder,发送事务消息。如果发送失败,导致报错,则将异常返回,此时不会涉及到任何数据安全。
  • 如果事务消息发送成功,但在执行本地事务时发生异常,那么订单数据和事务日志都不会被保存,因为它们是一个本地事务中。
  • 如果执行完本地事务,但未能及时的返回本地事务状态或者返回了未知状态。那么,会由Broker定时回查事务状态,然后根据事务日志表,就可以判断订单是否已完成,并写入到数据库。

基于这些要素,我们可以说,已经保证了订单服务和事务消息的一致性。那么,接下来就是积分服务如何正确的消费订单数据并完成相应的业务操作。

积分服务

在积分服务中,主要就是消费订单数据,然后根据订单内容,给相应用户增加积分。

积分记录表

CREATE TABLE t_points (
    id bigint(16) NOT NULL COMMENT ‘主键’,
    user_id bigint(16) NOT NULL COMMENT ‘用户id’,
    order_no bigint(16) NOT NULL COMMENT ‘订单编号’,
    points int(4) NOT NULL COMMENT ‘积分’,
    remarks varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT ‘备注’,
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

在这里,我们重点关注order_no字段,它是实现幂等消费的一种选择。

消费者启动

@Component
public class Consumer {

    String consumerGroup = "consumer-group";
    DefaultMQPushConsumer consumer;

    @Autowired
    OrderListener orderListener;

    @PostConstruct
    public void init() throws MQClientException {
        consumer = new DefaultMQPushConsumer(consumerGroup);
        consumer.setNamesrvAddr("127.0.0.1:9876");
        consumer.subscribe("order","*");
        consumer.registerMessageListener(orderListener);
        consumer.start();
    }

}

启动一个消费者比较简单,我们指定要消费的 topic 和监听器就好了。

消费者监听器

@Component
public class OrderListener implements MessageListenerConcurrently {

    @Autowired
    PointsService pointsService;
    Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
        logger.info("消费者线程监听到消息。");
        try{
            for (MessageExt message:list) {
                logger.info("开始处理订单数据,准备增加积分....");
                OrderDTO order  = JSONObject.parseObject(message.getBody(), OrderDTO.class);
                pointsService.increasePoints(order);
            }
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }catch (Exception e){
            logger.error("处理消费者数据发生异常。{}",e);
            return ConsumeConcurrentlyStatus.RECONSUME_LATER;
        }
    }
}

监听到消息之后,调用业务服务类处理即可。处理完成则返回CONSUME_SUCCESS以提交,处理失败则返回RECONSUME_LATER来重试。

增加积分

在这里,主要就是对积分数据入库。但注意,入库之前需要先做判断,来达到幂等性消费。

@Service
public class PointsServiceImpl implements PointsService {

    @Autowired
    PointsMapper pointsMapper;

    Snowflake snowflake = new Snowflake(1,1);
    Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public void increasePoints(OrderDTO order) {

        //入库之前先查询,实现幂等
        if (pointsMapper.getByOrderNo(order.getOrderNo())>0){
            logger.info("积分添加完成,订单已处理。{}",order.getOrderNo());
        }else{
            Points points = new Points();
            points.setId(snowflake.nextId());
            points.setUserId(order.getUserId());
            points.setOrderNo(order.getOrderNo());
            Double amount = order.getAmount();
            points.setPoints(amount.intValue()*10);
            points.setRemarks("商品消费共【"+order.getAmount()+"】元,获得积分"+points.getPoints());
            pointsMapper.insert(points);
            logger.info("已为订单号码{}增加积分。",points.getOrderNo());
        }
    }

}

幂等性消费

实现幂等性消费的方式有很多种,具体怎么做,根据自己的情况来看。

比如,在本例中,我们直接将订单号和积分记录绑定在同一个表中,在增加积分之前,就可以先查询此订单是否已处理过。

或者,我们也可以额外创建一张表,来记录订单的处理情况。

再者,也可以将这些信息直接放到redis缓存里,在入库之前先查询缓存。

不管以哪种方式来做,总的思路就是在执行业务前,必须先查询该消息是否被处理过。那么这里就涉及到一个数据主键问题,在这个例子中,我们以订单号为主键,也可以用事务ID作主键,如果是普通消息的话,我们也可以创建唯一的消息ID作为主键。

消费异常

我们知道,当消费者处理失败后会返回 RECONSUME_LATER ,让消息来重试,默认最多重试16次。

那,如果真的由于特殊原因,消息一直不能被正确处理,那怎么办 ?

我们考虑两种方式来解决这个问题。

第一,在代码中设置消息重试次数,如果达到指定次数,就发邮件或者短信通知业务方人工介入处理。

@Component
public class OrderListener implements MessageListenerConcurrently {

    Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
        logger.info("消费者线程监听到消息。");
        for (MessageExt message:list) {
            if (!processor(message)){
                return ConsumeConcurrentlyStatus.RECONSUME_LATER;
            }
        }
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }

    /**
	 * 消息处理,第3次处理失败后,发送邮件通知人工介入
	 * @param message
	 * @return
	 */
    private boolean processor(MessageExt message){
        String body = new String(message.getBody());
        try {
            logger.info("消息处理....{}",body);
            int k = 1/0;
            return true;
        }catch (Exception e){
            if(message.getReconsumeTimes()>=3){
                logger.error("消息重试已达最大次数,将通知业务人员排查问题。{}",message.getMsgId());
                sendMail(message);
                return true;
            }
            return false;
        }
    }

}

第二,等待消息重试最大次数后,进入死信队列。

消息重试最大次数默认是16次,我们也可以在消费者端设置这个次数。

consumer.setMaxReconsumeTimes(3);//设置消息重试最大次数

死信队列的主题名称是 %DLQ% + 消费者组名称,比如在订单数据中,我们设置了消费者组名:

String consumerGroup = "order-consumer-group";

那么这个消费者,对应的死信队列主题名称就是%DLQ%order-consumer-group

最后就可以通过程序代码监听这个主题,来通知人工介入处理或者直接在控制台查看处理了。通过幂等性消费和对死信消息的处理,基本上就能保证消息一定会被处理。

基于RocketMQ实现分布式事务(半消息事务)

RocketMQ的分布式事务可以称为“半消息事务”。

RocketMQ分布式事务原理

RocketMQ是靠半消息机制实现分布式事务:

  • 事务消息:MQ 提供类似 X/Open XA 的分布事务功能,通过 MQ 事务消息能达到分布式事务的最终一致。
  • 半消息:暂不能投递的消息,发送方已经将消息成功发送到了 MQ 服务端,但是服务端未收到生产者对该消息的二次确认,此时该消息被标记成“暂不能投递”状态,处于该种状态下的消息即半消息。
  • 半消息回查:由于网络闪断、生产者应用重启等原因,导致某条事务消息的二次确认丢失,MQ 服务端通过扫描发现某条消息长期处于“半消息”时,需要主动向消息生产者询问该消息的最终状态(Commit 或是 Rollback),该过程即消息回查。

流程说明:

  1. 发送方向 MQ 服务端发送事务消息;
  2. MQ Server 将消息持久化成功之后,向发送方 ACK 确认消息已经发送成功,此时消息为半消息;
  3. 发送方开始执行本地事务逻辑;
  4. 发送方根据本地事务执行结果向 MQ Server 提交二次确认(Commit 或是 Rollback),MQ Server 收到 Commit 状态则将半消息标记为可投递,订阅方最终将收到该消息;MQ Server 收到 Rollback 状态则删除半消息,订阅方将不会接受该消息;
  5. 在断网或者是应用重启的特殊情况下,上述步骤4提交的二次确认最终未到达 MQ Server,经过固定时间后 MQ Server 将对该消息发起消息回查;
  6. 发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果;
  7. 发送方根据检查得到的本地事务的最终状态再次提交二次确认,MQ Server 仍按照步骤4对半消息进行操作;

是否任何情况下MQ的事务性消息都可以保证双方的最终一致性?答案是否定的。

假设:“MQ发送方在步骤(3)执行完本地事务之后commit之前异常退出”。在这种情况下如果如果MQ发送方由于运维上的失误长时间不重启MQ发送方,那么MQ在多次回查不成功之后将会丢弃该消息。最终分布式事务的双方是不能达到最终一致性了。当然这个回查的最大值可以通过修改broker的参数transactionCheckMax来调整。但是过大的transactionCheckMax参数将会导致MQ堆积过多的半包消息,从而危害MQ的稳定性,是个需要权衡的参数。

RocketMQ分布式事务使用

如上图所示,使用者只需要实现紫色+绿色模块:

  • 紫色代表业务方自定义实现,
  • 绿色代表RocketMQ定义业务需要实现的方法。

具体步骤如下:

生产者

  1. 业务方保存本地事务记录,并初始化状态。
  2. 业务方调用sendMessageInTransaction发送半消息到MQ的RMQ_SYS_TRANS_HALF_TOPIC队列。
  3. MQ执行成功,回调业务方executeLocalTransaction方法,也就是业务方的业务逻辑。
  4. 业务方返回事务状态给MQ,
    • commit:塞一条消息进REAL_TOPIC真实队列,等待消费者消费。
    • commit/rollback:添加一条消息进RMQ_SYS_TRANS_OP_HALF_TOPIC队列,代表已处理消息。
    • unknow:根据一定的频率回查业务方本地事务状态。
  5. MQ内部有定时任务,轮询比较halfoffset、opset,判定哪些未处理(无结果)消息,并回查业务方本地事务状态。
  6. MQ->业务方, 执行checkLocalTransaction方法,查询本地事务状态。返回事务状态给MQ就是步骤4。

需要业务方实现的也就3个方法

消费者

1、初始化

自定义实现CommandLineRunner接口,执行startConsumer(): spring 容器启动完毕后,执行初始化过程。

(1)XXConsumerEntry extends ConsumerEntry。init()子类实现,addConsumerAction()添加具体业务操作。指定一个tag,一个ConsumerExecutor()

(2)DefaultMQPushConsumer定义消费者,MessageModel=集群消费,指定消费群组。

(注:这里还可以设置很多参数,例如:consumeMessageBatchMaxSize:一次派发消费多少条(默认1),pullBatchSize:一次拉取多少条(默认32))

(3)指定消息监听器:使用base包提供的TracingRocketMQSingleConsumer。注册监听器TracingRocketMQSingleConsumer.SingleMessageListenerConcurrently。实际上就是封装的RocketMQ的MessageListener接口,定义了consumeMessage()接口,最终会调用步骤1定义的ConsumerAction的execute()。执行消息的消费。

2、拉取消费

消费者会从MQ长轮询并发拉取消息,并根据初始化的MessageLister接口执行业务消费逻辑。

MQ根据返回的状态,如果是RECONSUME_LATER重试,就会入SCHEDULE延迟队列、RETRY重试队列、DLQ死信队列。要注意的是:进入死信队列的消息,需要管理员手动排查问题。

需要业务方实现1个方法

其它细节

从哪里开始消费

consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);

  • CONSUME_FROM_LAST_OFFSET:一个新的订阅组第一次启动从队列的最后位置开始消费,后续再启动接着上次消费的进度开始消费
  • CONSUME_FROM_FIRST_OFFSET:一个新的订阅组第一次启动从队列的最前位置开始消费,后续再启动接着上次消费的进度开始消费
  • CONSUME_FROM_TIMESTAMP:一个新的订阅组第一次启动从指定时间点开始消费,后续再启动接着上次消费的进度开始消费

一些问题排查思路

理解了RocketMQ原理,数据流转,对排查问题可以提供思路。

(1)队列数据膨胀

RMQ_SYS_TRANS_HALF_TOPIC膨胀:可能是死循环了。定时任务反查事务状态,一直消费不完。
RMQ_SYS_TRANS_OP_HALF_TOPIC膨胀:业务量暴增,接口被刷。
RETRY重试、DLQ死信队列膨胀:可能是服务不可用。

(2)rocketMQ业务异常日志,具体判断。

(3)broker延迟可能reblance失衡。

(4)唯一消息ID

msgId  transacctionId
MessageExt extends Message :transacctionId是Message字段,msgId是MessageExt的拓展字段。
MessageExt的transactionId就是RocketMQ认为的唯一ID,消息在RocketMQn内部流转,transactionId不变,msgId 会变。

下图是生产环境rocketMQ 异常时的日志总结,注意图中newMsgId=msgId   realMsgId=transactionId

注意:这里transacctionId就是RocktMQ认定的唯一事务ID。这里是说对应一个事务,但是不一定适合做接口幂等性(消息重复消费问题)。接口幂等性是与业务耦合的,保证多次执行,同一结果。

幂等性如何实现

  • 天然幂等性:纯读接口
  • 后天校验型:状态机校验、业务key校验,等等。

 

参考:

 

posted @ 2021-12-22 23:15  残城碎梦  阅读(305)  评论(0编辑  收藏  举报