> 文章列表 > Chapter3-用适合的方式发送和接收消息

Chapter3-用适合的方式发送和接收消息

Chapter3-用适合的方式发送和接收消息

3.1 不同类型的消费者

        消费者可分为两种类型。 一个是DefaultMQPushConsumer ,由系统控制读取操作,收到消息后自动调用传人的处理方法来处理;另 一个是 DefaultMlConsumer ,读取操作中的大部分功能由使用者自主控制 。

3.1.1 DefaultMQPushConsumer 的使用

        使用 DefaultMQPushConsumer 主要是设置好各种参数和传人处理消息的函数。 系统收到消息后自动调用处理函数来处理消息,自动保存 Offset ,而且加入新的 DefaultMQPushConsumer 后会自动做负载均衡。 
Consumer.javahttps://github.com/apache/rocketmq/blob/develop/example/src/main/java/org/apache/rocketmq/example/quickstart/Consumer.java

        DefaultMQPushConsumer 需要设置三个参数 :GroupName 、NameServer、Topic

1 ) Consumer 的 GroupName 用 于把 多个 Consumer 组织到一起 , 提高并发处理能力,GroupName 需要和消息模式 ( MessageModel )配合使用 。

        RocketMQ 支持两种消息模式 :Clustering 和 Broadcasting 。

  • 在 Clustering 模式下,同一个 ConsumerGroup ( GroupName 相同 ) 里的每个 Consumer 只消费所订阅消息的一部分内容, 同一个 ConsumerGroup里所有的 Consumer 消 费的内容合起来才是所订阅 Topic 内容的整体 ,从而达到负载均衡的目的 。
  •  在 Broadcasting 模式下,同一个 ConsumerGroup 里的每个 Consumer 都能消费到所订阅 Topic 的全部消息,也就是一个消息会被多次分发,被多个 Consumer 消费 。

2) NameServer 的地址和端口号,可以填写多个 ,用分号隔开,达到消除单点故障的目的 , 比如 “ ip 1 :port;ip2:port;ip3 :port ” 。 

 3 ) Topic 名称用来标识消息类型 , 需要提前创建。(在启动broker 传入入参数也可以 autoCreateTopicEnable=true) 如果不需要消费某个 Topic 下的所有消息,可以通过指定消息的 Tag 进行消息过滤,比如:Consumer. subscribe ("TopicTest", "tag1 || tag2 || tag3"), 表示这个 Consumer 要消费“ TopicTest ”下带有 tagl 或 tag2 或 tag3 的消息( Tag 是在发送消息时设置 的标签) 。 在填写 Tag 参数的位置,用 null 或者 "*" 表示要消费这个 Topic的所有消息 。

3.1.2 DefaultMQPushConsumer 的处理流程 

 DefaultMQPushConsumer 主要功能实现在 DefaultMQPushConsumerlmpl类中,消息的处理逻辑是在 pullMessage 这个函数里的 PullCallBack 中 。 在PullCallBack 函数里有个 switch 语句,根据从 Broker 返回的消息类型做相应的处理:

         DefaultMQPushConsuer 的源码中有很多 PullRequest 语句,比如

Default­MQPushConsumerlmpl.this.executePullRequestlmmediately(pullRequest)。 为什么“PushConsumer ”中使用“ PullRequest ”呢? 这是通过“长轮询”方式达到Push 效果的方法,长轮询方式既有 Pull 的优点,又兼具 Push 方式的实时性。

        Push 方式是 Server 端接收到消息后,主动把消息推送给 Client 端,实时性高。 对于一个提供队列服务的 Server 来说,用 Push 方式主动推送有很多弊端:首先是加大 Server 端的工作量,进而影响 Server 的性能;其次, Client 的处理能力各不相同, Client 的状态不受 Server 控制,如果 Client 不能及时处理Server 推送过来的消息,会造成各种潜在问题

        Pull 方式是 Client 端循环地从 Server 端拉取消息,主动权在 Client 手里,自己拉取到一定量消息后,处理妥当了再接着取。 Pull 方式的问题是循环拉取消息的间隔不好设定,间隔太短就处在一个 “ 忙等”的状态,浪费资源;每个Pull 的时间间隔太长 Server 端有消息到来时 有可能没有被及时处理。

        “长轮询”方式通过 Client 端和 Server 端的配合,达到既拥有 Pull 的优点,又能达到保证实时性的目的 。 

         源码中有这一行设置语句 requestHeader.setSuspendTimeoutMillis (brokerSus-
pendMaxTimeMillis ),作用是设置 Broker 最长阻塞时间 ,默认设置是 15 秒,注意是 Broker 在没有新消息的时候才阻塞,有消息会立刻返回 。

         从 Broker 的源码中可以看 出,服务端接到新消息请求后, 如果队列里没有新消息,并不急于返回,通过一个循环不断查看状态,每次 waitForRunning一段 时间 (默认是 5 秒 ) , 然后后 再 Check 。默认情况下当 Broker 一直没有新消息, 第三次 Check 的时候, 等待时间超过Request 里面的 Broker­SuspendMaxTimeMillis , 就返回空结果。 在等待的过程中, Broker 收到了新的消息后会直接调用 notifyMessageArriving 函数返回请求结果 。 “长轮询”的核心 是, Broker 端 HOLD 住客户端过来的请求一小段时间,在这个时间内有新消息到达,就利用现有的连接立刻返回消息给 Consumer 。 “长 轮询”的 主动权还是掌握在 Consumer 手中, Broker 即使有大量消息积压 ,也不会主动推送给Consumer 。

        长轮询方式的局限性,是在 HOLD 住 Consumer 请求的时候需要占用资源,它适合用在消息队列这种客户端连接数可控的场景中

3.1.3 DefaultMQPushConsumer 的流量控制 

        PushConsumer 的核心还是 Pull方式

         上二张图片“PullMessages.pullMessages(request)”是在run方法中运行的(新的线程)

        Pull 获得的消息,如果直接提交到线程池里执行,很难监控和控制 ,比如,如何得知当前消息堆积的数量?如何重复处理某些消息? 如何延迟处理某些消息? RocketMQ 定义了一个快照类 Process Queue 来解决这些问题,在Push Consumer 运行的时候, 每个 Message Queue 都会有个对应的 Proces s Queue 对象,保存了这个 Message Queue 消息处理状态的快照 。

        ProcessQueue 对象里主要的内容是一个 TreeMap 和一个读写锁。 TreeMap里以 Message Queue 的 Offset 作为 Key ,以消息内容的引用为 Value ,保存了所有从 MessageQueue 获取到,但是还未被处理的消息; 读写锁控制着多个线程对 TreeMap 对象的并发访问 。

        有了 ProcessQueue 对象,流量控制就方便和灵活多了,客 户 端在每次 Pull请求前会做下面三个判断来控制流量。

         从代码中可以看出,PushConsumer 会判断获取但还未处理的消息个数 、消息总大小、Offset 的跨度,任何一个值超过设定的大小就隔一段时间再拉取消息,从而达到流量控制的目的。 此外 ProcessQueue 还可以辅助实现顺序消费的逻辑 。

 3.1.4 DefaultMQPullConsumer

        使用 DefaultMQPullConsumer 像使用 DefaultMQPushConsumer 一样需要设置各种参数,写处理消息的函数,同时还需要做额外的事情 。  

PullConsumer.javahttps://github.com/apache/rocketmq/blob/develop/example/src/main/java/org/apache/rocketmq/example/simple/PullConsumer.java

( 1 )获取 Message Queue 并遍历

        一 个 Topic 包括多个 MessageQueue ,如果这个 Consumer 需要获取 Topic下所有的消息,就要遍历多有的 MessageQueue 。 如果有特殊情况,也可以选择某些特定的 Message Queue 来读取消息 。

( 2 )维护 Offsetstore

        从一个 Message Queue 里拉取消息的时候,要传入 Offset 参数( long 类型的值),随着不断读取消息 , Offset 会不断增长 。 这个时候由用户负责把 Offset存储下来,根据具体情况可以存到内存里、写到磁盘或者数据库里等。

 ( 3 )根据不同的消息状态做不同的处理

        拉取消息的请求发出后,会返回: FOUND 、 NO_MATCHED_MSG 、 NO_NEW_MSG 、 OFFSET_ILLEGAL 四种状态,需要根据每个状态做不同的处理。比较重要的两个状态是 FOUNT 和 NO_NEW_MSG ,分别表示获取到消息和没有新的消息 。

         实际情况中可以把 while (true )放到外层,达到无限循环的目的 。 因为 Pull Consumer 需要用户自己处理遍历 Message Queue 、保存 Offset ,所以Pull Consumer 有更多的自主性和灵活性。

 3.1.5 Consumer 的启动、关闭流程

         Consumer 分为 Push 和 Pull 两种方式,对于 PullConsumer 来说,使用者主动权很高,可以根据实际需要暂停、停止、启动消费过程。 需要注意的是Offset 的保存,要在程序的异常处理部分增加把 Offset 写入磁盘方面的处理,记准了每个 MessageQueue 的 Offset ,才能保证消息消费的准确性 。

        DefaultMQPushConsumer 的退出, 要调用 shutdown() 函数, 以便释放资源、保存 Offset 等 。 这个调用要加到 Consumer 所在应用的退出逻辑中 。PushConsumer 在启动的时候 ,会做各种配置检查,然后连接 NameServer获取 Topic 信息,启动时如果遇到异常,比如无法连接NameServer,程序仍然可以正常启动不报错(日志里有 WARN 信息 ) 。 在单机环境下可以测试这种情况,启动 DefaultMQPushConsumer 时故意 把 NameServer 地址填错,程序仍然可以正常启动,但是不会收到消息

         为什么 DefaultMQPushConsumer 在无法连接 NameServer 时不直接报错退出呢? 这和分布式系统的设计有关, RocketMQ 集群可以有多个 NameServer 、Broker ,某个机器出异常后整体服务依然可用 。 所以 DefaultMQPushConsumer被设计成当发现某个连接异常时不立刻退出,而是不断尝试重新连接。 可以进行这样一个测试,在 DefaultMQPushConsumer 正常运行的时候,手动 kill 掉Broker 或 NameServer ,过一会儿再启动 。 会发现 DefaultMQPushConsumer 不会出错退出,在服务恢复后正常运行,在服务不可用的这段时间 ,仅仅会在日志里报异常信息 。

        如果需要在 DefaultMQPushConsumer 启动的时候,及时暴露配置问题,该如何操作呢? 可以 在 Consumer.start()语句后调用: Consumer .fetchSubscribeMessageQueues ("TopicName") ,这时如果配置信息写得不准确,或者当前服务不可用,这个语句会报MQClient-Exception 异常 。

 3.2 不同类型的生产者

       生产者向消息队列里写人消息,不 同的业务场景需要生产者采用不同的写入策略 。 比如同步发送、异步发送、 延迟发送、 发送事务消息等

3.2.1 DefaultMQProducer 
Producer.javahttps://github.com/apache/rocketmq/blob/develop/example/src/main/java/org/apache/rocketmq/example/quickstart/Producer.java

         发送消息要经过五个步骤 :

1 )设置 Producer 的 GroupName 。

 2 )设置 lnstanceName ,当一个 Jvm 需要启动多个 Producer 的时候,通过设置不同的 InstanceName 来区分,不设置的话系统使用默认名称“DEFAULT ” 。

3 )设置发送失败重试次数,当网络出现异常的时候,这个次数影响消息的重复投递次数。 想保证不丢消息,可以设置多重试几次。

        producer.setRetryTimesWhenSendFailed(3);

4 )设置 NameServer 地址。

5 )组装消息并发送 。

        消息的发送有同步和异步两种方式,上面的代码使用的是异步方式。 在第 2 章的例子中用的是同步方式。 消息发送的返回状态有如下四种 : FLUSHDISK_TIMEOUT 、 FLUSH_SLAVE_ TIMEOUT 、 SLAVE_NOT_AVAILABLE 、SEND_OK ,不同状态在不同的刷盘策略和同步策略的配置下含义是不同的 。

  • FLUSH_DISK_TIMEOUT : 表示没有在规定时间内完成刷盘(需要Broker 的刷盘策设置成 SYNC_FLUSH 才会报这个错误) 。
  • FLUSH_SLAVE_TIMEOUT :表示在主备方式下,并且 Broker 被设置成 SYNC_MASTER 方式,没有在设定时间内完成主从同步。
  • SLAVE_NOT_AVAILABLE : 这个状态产生的场景和 FLUSH_SLAVE _TIMEOUT 类似, 表示在主备方式下,并且 Broker 被设置成 SYNC_MASTER ,但是没有找到被配置成 Slave 的 Broker 。
  • SEND_OK :表示发送成功,发送成功的具体含义,比如消息是否已经被存储到融盘?消息是否被同步到了 Slave 上?消息在 Slave 上是否被写人磁盘?需要结合所配置的刷盘策略、主从策略来定。 这个状态还可以简单理解为,没有发生上面列出的三个问题状态就是 SEND_ OK。

        写一个高质量的生产者程序,重点在于对发送结果的处理,要充分考虑各种异常,写清对应的处理逻辑。

3.2.2 发送延迟消息 

        延迟消息的使用方法是在创建 Message 对象时,调用 setDelayTimeLevel ( int level ) 方法设置延迟时间, 然后再把这个消息发送 出 去 。 目前延迟的时间不支持任意设置,仅支持预设值的时间长度 ( 1s/5s/10s/30s/1m/2m/3m/4m/5m/6m/7m/8m/9m/10m/20m/30m/1h/2h ) 。 比如 setDelayTimeLevel(3 ) 表示延迟 10s 。 

 3.2.3 自定义消息发送规则

        一个 Topic 会有多个 MessageQueue ,如 果使用 Producer 的 默认配置 ,这个 Producer 会轮流向各个 MessageQueue 发送消息 。 Consumer 在消费消息的时候,会根据负载均衡策略,消费被分配到的 MessageQueue ,如果不经过特定的设置,某条消息被发l哪个 MessageQueue ,被哪个 Consumer 消费是未知的 。

        如果业务需 要我们把消息发送到指定的 MessageQueue 里,比如把同一类型的消息都发相同的 MessageQueue , 该怎么办呢?可以用 Message­QueueSelector 

        发送消息的时候,把 MessageQueueSelector 的对象作为参数,使用 public SendResult send ( Message msg, MessageQueueSelector selector, Object arg )函数发送消息即可 。 在 MessageQueueSelector 的实现中,根据传入的 Object 参数,或者根据 Message 消息内容确定把消息发往那个 Message Queue ,返回被选中的 MessageQueue 。 

 3.2.4 对事务的支持

        RocketMQ 的事务消息,是指发送消息事件和其他事件需要同时成功或同时失败。 比如银行转账, A 银行的某账户要转一万元到 B 银行的某账户 。 A 银行发送“B 银行账户增加一万元” 这个消息,要和“从 A 银行账户扣除一万元”这个操作同时成功或者同时失败 。

        RocketMQ 采用两阶段提交的方式实现事务消息TransactionMQProducer处理上面情况的流程是,先发一个“准备从 B 银行账户增加一万元”的消息,发送成功后做从 A 银行账户扣除一万元的操作 ,根据操作结果是否成功,确定之前的“准备从 B 银行账户增加一万元”的消息是做 commit 还是 rollback ,具体流程如下:

在这里插入图片描述

事务消息发送及提交

         1 )发送方向 RocketMQ 发送“待确认”消息 。

        2) RocketMQ 将收到的“待确认” 消息持久化成功后, 向发送方回复消息已经发送成功,此时第一阶段消息发送完成。

        3 )发送方开始执行本地事件逻辑。

        4 )发送方根据本地事件执行结果向 RocketMQ 发送二次确认( Commit 或是 Rollback ) 消息 , RocketMQ 收到 Commit 状态则将第一阶段消息标记为可投递,订阅方将能够收到该消息;收到 Rollback 状态则删除第一阶段的消息,订阅方接收不到该消息 。

事务补偿

         5 )如果出现异常情况,步骤 4 )提交的二次确认最终未到达 RocketMQ,服务器在经过固定时间段后将对“待确认”消息、发起回查请求 。

        6 )发送方收到消息回查请求后(如果发送一阶段消息的 Producer 不能工作,回查请求将被发送到和 Producer 在同一个 Group 里的其他 Producer ),通过检查对应消息的本地事件执行结果返回 Commit 或 Roolback 状态 。

        7) RocketMQ 收到回查请求后,按照步骤 4 ) 的逻辑处理。

事务消息状态

事务消息共有三种状态,提交状态、回滚状态、中间状态:

  • TransactionStatus.CommitTransaction: 提交事务,它允许消费者消费此消息。

  • TransactionStatus.RollbackTransaction: 回滚事务,它代表该消息将被删除,不允许被消费。

  • TransactionStatus.Unknown: 中间状态,它代表需要检查消息队列来确定状态。

        上面的逻辑似乎很好地实现了 事务消息功能 ,它也是 RocketMQ 之前的版本实现事务消息 的逻辑。 但是因为 RocketMQ 依赖将数据顺序写到磁盘这个特征来提高性能,步骤 4 )却需要更改第一阶段消息的状态,这样会造成磁盘Catch 的脏页过多, 降低系统的性能。 所以 RocketMQ 在 4.x 的版本中将这部分功能去除。 系统中的一些上层 Class 都还在,用户可以根据实际需求实现自己的事务功能 。

        客户端支持用户实现事务消息有,LocalTransactionExecuter(过期) 、Transaction-CheckListener(过期)、 TransactionMQProduce与TransactionListenertransactionhttps://github.com/apache/rocketmq/tree/develop/example/src/main/java/org/apache/rocketmq/example/transaction
事务消息发送 | RocketMQ (apache.org)https://rocketmq.apache.org/zh/docs/4.x/producer/06message5

 3.3  如何存储队列位置信息

         RocketMQ 中, 一种类型的消息会放到一个 Topic 里,为了能够并行, 一般一个 Topic 会有多个 Message Queue (也可以设置成一个), Offset 是指某个 Topic 下的一条消息在某个 Message Queue 里的位置,通过 Offset 的值可以定位到这条消息,或者指示 Consumer 从这条消息开始向后继续处理

         Offset 的类结构,主要分为本地文件类型Broker 代存的类型两种 。 对于 DefaultMQPushConsurner 来说,默认是 CLUSTERING 模式,也就是同一个 Consumer group 里的多个消费者每人消费一部分,各自收到的消息内容不一样。 这种情况下,由 Broker 端存储和控制 Offset 的值,使用RemoteBrokerOffsetStore 结构 org.apache.rocketmq.client.consumer.store.RemoteBrokerOffsetStore

                在 DefaultMQPushConsumer 里的 BROADCASTING 模式下,每个 Consumer都收到这个 Topic 的全部消息,各个 Consumer 间相互没有干扰, RocketMQ 使用 LocalfileOffsetStore ,把 Offset 存到本地。 org.apache.rocketmq.client.consumer.store.LocalFileOffsetStore

 {

        "OffsetTable":[

                {"brokerNarne":"localhost","QueueId":1,"Topic":"broker1"},

                {"brokerNarne":"localhost","QueueId":2,"Topic":"broker1"},

                {"brokerNarne":"localhost","QueueId":0,"Topic":"broker1"}

        ]

}


import org.apache.rocketmq.client.consumer.store.OffsetSerializeWrapper;
import org.apache.rocketmq.client.consumer.store.ReadOffsetType;
import org.apache.rocketmq.common.MixAll;
import org.apache.rocketmq.common.message.MessageQueue;import java.io.IOException;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong;/*** 参照 org.apache.rocketmq.client.consumer.store.LocalFileOffsetStore* @author Jay*/
public class LocalFileOffsetStore {private final String groupName;private final String storePath;private ConcurrentHashMap<MessageQueue, AtomicLong> offsetTable = new ConcurrentHashMap<MessageQueue, AtomicLong>();public LocalFileOffsetStore(String groupName, String storePath) {this.groupName = groupName;this.storePath = storePath;}/*** Load*/void load() {OffsetSerializeWrapper offsetSerializeWrapper = this.readLocalOffset();if (offsetSerializeWrapper == null || offsetSerializeWrapper.getOffsetTable() == null)return;ConcurrentMap<MessageQueue, AtomicLong> currentOffsetTable = offsetSerializeWrapper.getOffsetTable();this.offsetTable.putAll(currentOffsetTable);for (MessageQueue mq : currentOffsetTable.keySet()) {long l = currentOffsetTable.get(mq).get();System.out.printf("load Consumer's Offset, %s %s %d \\n", this.groupName, mq, l);}}/*** 更新偏移量,将其存储在内存中** @param mq           MessageQueue* @param offset       偏移量* @param increaseOnly*/void updateOffset(final MessageQueue mq, final long offset, final boolean increaseOnly) {if (mq == null)return;AtomicLong offsetOld = this.offsetTable.get(mq);if (offsetOld == null) {this.offsetTable.putIfAbsent(mq, new AtomicLong(offset));} else {offsetOld.set(offset);}}/*** 从本地存储获取偏移量** @param mq   MessageQueue集合* @param type* @return 获取的偏移量*/long readOffset(final MessageQueue mq, final ReadOffsetType type) {if (mq == null || this.offsetTable.get(mq) == null)return 0;return this.offsetTable.get(mq).get();}/*** 在本地存储保留所有偏移量** @param mqs MessageQueue集合*/void persistAll(final Set<MessageQueue> mqs) {if (mqs == null || mqs.isEmpty())return;OffsetSerializeWrapper offsetSerializeWrapper = new OffsetSerializeWrapper();for (Map.Entry<MessageQueue, AtomicLong> entry : this.offsetTable.entrySet()) {if (mqs.contains(entry.getKey())) {AtomicLong offset = entry.getValue();offsetSerializeWrapper.getOffsetTable().put(entry.getKey(), offset);}}String jsonString = offsetSerializeWrapper.toJson(true);if (jsonString == null)return;try {MixAll.string2File(jsonString, this.storePath);} catch (IOException e) {e.printStackTrace();}}/*** 从文件中加载 offsetTable 信息** @return offsetTable*/private OffsetSerializeWrapper readLocalOffset() {String content = null;try {content = MixAll.file2String(this.storePath);} catch (IOException e) {e.printStackTrace();}if (content == null || content.length() == 0)return null;OffsetSerializeWrapper offsetSerializeWrapper = null;try {offsetSerializeWrapper =OffsetSerializeWrapper.fromJson(content, OffsetSerializeWrapper.class);} catch (Exception e) {e.printStackTrace();}return offsetSerializeWrapper;}}

        DefaultMQPushConsumer 类里有个函数用来设置从哪儿开始消费消息:

         注意设置读取位置不是每次都有效,它的优先级默认在 Offset Store 后面 ,比如 在 DefaultMQPushConsumer 的 BROADCASTING 方式下 ,默认是 从Broker 里读取某个 Topic 对应 ConsumerGroup 的 Offset , 当读取不到 Offset 的时候, ConsumeFromWhere 的设置才生效 。 大部分情况下这个设置在 ConsumerGroup 初次启动时有效。 如果 Consumer 正常运行后被停止, 然后再启动, 会接着上次的 Offset 开始消费, ConsumeFromWhere 的设置无效 。

 3.4 自定义日志输出

          RocketMQ 的 默认 Log 存储位置是:$ {user.home }/Logs/rocketmqLogs, Lo g 配置文件的设置可以通过 JVM启动参数、 环境变量、 代码中的设置语句这三种方式来配置 。

        可以在程序,中使用 System . setProperty(”rocketmq.Client.Log.loadconfig ”,” false ”) 语 句, 或者在 JVM 启动时使用 - D 参数来设置。然后把 Logback.xml 放到 maven 项目的
resources 文件夹下 。 在 Logback .xml 示例配置里,在原有 RocketMQ 日志的基础上,增加了 STDOUT 输出,这样可以把 RocketMQ 的日志输出到应用系统console 中,便于调试时发现问题

第 3 章:配置 (qos.ch)https://logback.qos.ch/manual/configuration.html