20210329 3. RocketMQ 高级实战 - 拉勾教育

RocketMQ 高级实战

生产者

Tags 的使用

一个应用尽可能用一个 Topic ,而消息子类型则可以用 tags 来标识。 tags 可以由应用自由设置,只有生产者在发送消息设置了 tags ,消费方在订阅消息时才可以利用 tags 通过 Broker 做消息过滤: message.setTags("TagA")

Keys 的使用

每个消息在业务层面的唯一标识码要设置到 keys 字段,方便将来定位消息丢失问题。服务器会为每个消息创建索引(哈希索引),应用可以通过 Topic 、 key 来查询这条消息内容,以及消息被谁消费。由于是哈希索引,请务必保证 key 尽可能唯一,这样可以避免潜在的哈希冲突。

// 订单Id
String orderId = "20034568923546";
message.setKeys(orderId);

日志的打印

消息发送成功或者失败要打印消息日志,务必要打印 SendResultkey 字段。 send 方法只要不抛异常,就代表发送成功。发送成功会有多个状态,在 org.apache.rocketmq.client.producer.SendStatus 里定义。

以下对每个状态进行说明:

状态 描述
SEND_OK 消息发送成功。要注意的是消息发送成功也不意味着它是可靠的。要确保不会丢失任何消息,还应启用同步 Master 服务器或同步刷盘,即 SYNC_MASTERSYNC_FLUSH
FLUSH_DISK_TIMEOUT 消息发送成功但是服务器刷盘超时。此时消息已经进入服务器队列(内存),只有服务器宕机,消息才会丢失。消息存储配置参数中可以设置刷盘方式和同步刷盘时间长度。
如果 Broker 服务器设置了刷盘方式为同步刷盘,即 FlushDiskType = SYNC_FLUSH (默认为异步刷盘方式),当 Broker 服务器未在同步刷盘时间内(默认为 5s )完成刷盘,则将返回该状态——刷盘超时。
FLUSH_SLAVE_TIMEOUT 消息发送成功,但是服务器同步到 Slave 时超时。此时消息已经进入服务器队列,只有服务器宕机,消息才会丢失。
如果 Broker 服务器的角色是同步 Master ,即 SYNC_MASTER (默认是异步 Master 即 ASYNC_MASTER ),并且从 Broker 服务器未在同步刷盘时间(默认为 5 秒)内完成与主服务器的同步,则将返回该状态——数据同步到 Slave 服务器超时。
SLAVE_NOT_AVAILABLE 消息发送成功,但是此时 Slave 不可用。
如果 Broker 服务器的角色是同步 Master ,即 SYNC_MASTER (默认是异步 Master 服务器即 ASYNC_MASTER ),但没有配置 Slave Broker 服务器,则将返回该状态——无 Slave 服务器可用。

消息发送失败处理方式

Producer 的 send 方法本身支持内部重试,重试逻辑如下:

  • 至多重试 2 次(同步发送为 2 次,异步发送为 0 次)。
  • 如果发送失败,则轮转到下一个 Broker 。这个方法的总耗时时间不超过 sendMsgTimeout 设置的值,默认 10s
  • 如果本身向 Broker 发送消息产生超时异常,就不会再重试。

以上策略也是在一定程度上保证了消息可以发送成功。如果业务对消息可靠性要求比较高,建议应用增加相应的重试逻辑:比如调用 send 同步方法发送失败时,则尝试将消息存储到 db ,然后由后台线程定时重试,确保消息一定到达 Broker 。

上述 db 重试方式为什么没有集成到 MQ 客户端内部做,而是要求应用自己去完成,主要基于以下几点考虑:

  1. MQ 的客户端设计为无状态模式,方便任意的水平扩展,且对机器资源的消耗仅仅是 CPU 、内存、网络。
  2. 如果 MQ 客户端内部集成一个 KV 存储模块,那么数据只有同步落盘才能较可靠,而同步落盘本身性能开销较大,所以通常会采用异步落盘,又由于应用关闭过程不受 MQ 运维人员控制,可能经常会发生 kill -9 这样暴力方式关闭,造成数据没有及时落盘而丢失。
  3. Producer 所在机器的可靠性较低,一般为虚拟机,不适合存储重要数据。

综上,建议重试过程交由应用来控制。

选择 oneway 形式发送

通常消息的发送是这样一个过程:

  • 客户端发送请求到服务器
  • 服务器处理请求
  • 服务器向客户端返回应答

所以,一次消息发送的耗时时间是上述三个步骤的总和,而某些场景要求耗时非常短,但是对可靠性要求并不高,例如日志收集类应用,此类应用可以采用 oneway 形式调用, oneway 形式只发送请求不等待应答,而发送请求在客户端实现层面仅仅是一个操作系统系统调用的开销,即将数据写入客户端的 socket 缓冲区,此过程耗时通常在微秒级。

API 实例

// 生产者设置 keys , tags

public class MyProducer {
    public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException {
        DefaultMQProducer producer = new DefaultMQProducer("producer_grp_14_01");

        producer.setNamesrvAddr("node1:9876");

        producer.start();

        Message message = new Message("tp_demo_14", "hello lagou".getBytes());
        // tag用于标记一类消息
        message.setTags("tag1");

        // keys用于建立索引的时候,hash取模将消息的索引放到SlotTable的一个Slot链表中
        message.setKeys("oid_2020-12-30_567890765");

        producer.send(message, new SendCallback() {
            @Override
            public void onSuccess(SendResult sendResult) {

            }

            @Override
            public void onException(Throwable e) {

            }
        });

        producer.shutdown();
    }
}

消费者

消费过程幂等

RocketMQ 无法避免消息重复( Exactly-Once ),所以如果业务对消费重复非常敏感,务必要在业务层面进行去重处理。

可以借助关系数据库进行去重。首先需要确定消息的唯一键,可以是 msgId ,也可以是消息内容中的唯一标识字段,例如订单 Id 等。

在消费之前判断唯一键是否在关系数据库中存在。如果不存在则插入,并消费,否则跳过。(实际过程要考虑原子性问题,判断是否存在可以尝试插入,如果报主键冲突,则插入失败,直接跳过)

msgId 一定是全局唯一标识符,但是实际使用中,可能会存在相同的消息有两个不同 msgId 的情况(消费者主动重发、因客户端重投机制导致的重复等),这种情况就需要使业务字段进行重复消费。

消费速度慢的处理方式

提高消费并行度

绝大部分消息消费行为都属于 IO 密集型,即可能是操作数据库,或者调用 RPC,这类消费行为的消费速度在于后端数据库或者外系统的吞吐量。

通过增加消费并行度,可以提高总的消费吞吐量,但是 并行度增加到一定程度,反而会下降。所以,应用必须要设置合理的并行度。 如下有几种修改消费并行度的方法:

  • 同一个 ConsumerGroup 下,通过增加 Consumer 实例数量来提高并行度(需要注意的是超过订阅队列数的 Consumer 实例无效)。可以通过加机器,或者在已有机器启动多个进程的方式。
  • 提高单个 Consumer 的消费并行线程,通过修改参数 consumeThreadMinconsumeThreadMax 实现。
  • 丢弃部分不重要的消息
批量方式消费

某些业务流程如果支持批量方式消费,则可以很大程度上提高消费吞吐量。

例如订单扣款类应用,一次处理一个订单耗时 1s,一次处理 10 个订单可能也只耗时 2s,这样即可大幅度提高消费的吞吐量,通过设置 Consumer的 consumeMessageBatchMaxSize 这个参数,默认是 1,即一次只消费一条消息,例如设置为 N,那么每次消费的消息数小于等于 N。

跳过非重要消息

发生消息堆积时,如果消费速度一直追不上发送速度,如果业务对数据要求不高的话,可以选择丢弃不重要的消息。

例如,当某个队列的消息数堆积到 100000 条以上,则尝试丢弃部分或全部消息,这样就可以快速追上发送消息的速度。示例代码如下:

public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
    long offset = msgs.get(0).getQueueOffset();
    String maxOffset = msgs.get(0).getProperty(MessageConst.PROPERTY_MAX_OFFSET);
    long diff = Long.parseLong(maxOffset) - offset;
    if (diff > 100000) {
        // TODO 消息堆积情况的特殊处理
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
    // TODO 正常消费过程
    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}

优化每条消息消费过程

举例如下,某条消息的消费过程如下:

  • 根据消息从 DB 查询【数据 1】
  • 根据消息从 DB 查询【数据 2】
  • 复杂的业务计算
  • 向 DB 插入【数据 3】
  • 向 DB 插入【数据 4】

这条消息的消费过程中有 4 次与 DB 的 交互,如果按照每次 5ms 计算,那么总共耗时 20ms ,假设业务计算耗时 5ms ,那么总过耗时 25ms ,所以如果能把 4 次 DB 交互优化为 2 次,那么总耗时就可以优化到 15ms ,即总体性能提高了 40% 。所以应用如果对时延敏感的话,可以把 DB 部署在 SSD 硬盘,相比于 SCSI 磁盘,前者的 RT 会小很多。

消费打印日志

如果消息量较少,建议在消费入口方法打印消息,消费耗时等,方便后续排查问题。

public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
    log.info("RECEIVE_MSG_BEGIN: " + msgs.toString());
    // TODO 正常消费过程
    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}

如果能打印每条消息消费耗时,那么在排查消费慢等线上问题时,会更方便。

其他消费建议

  • 关于消费者和订阅

    第一件需要注意的事情是,不同的消费组可以独立的消费一些 topic ,并且每个消费组都有自己的消费偏移量。

    确保同一组内的每个消费者订阅信息保持一致。

  • 关于有序消息

    消费者将锁定每个消息队列,以确保他们被逐个消费,虽然这将会导致性能下降,但是当你关心消息顺序的时候会很有用。

    我们不建议抛出异常,你可以返回 ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT 作为替代。

  • 关于并发消费

    顾名思义,消费者将并发消费这些消息,建议你使用它来获得良好性能,我们不建议抛出异常,你可以返回 ConsumeConcurrentlyStatus.RECONSUME_LATER 作为替代。

  • 关于消费状态 Consume Status

    对于并发的消费监听器,你可以返回 RECONSUME_LATER 来通知消费者现在不能消费这条消息,并且希望可以稍后重新消费它。然后,你可以继续消费其他消息。对于有序的消息监听器,因为你关心它的顺序,所以不能跳过消息,但是你可以返回 SUSPEND_CURRENT_QUEUE_A_MOMENT 告诉消费者等待片刻。

  • 关于 Blocking

    不建议阻塞监听器,因为它会阻塞线程池,并最终可能会终止消费进程

  • 关于线程数设置

    消费者使用 ThreadPoolExecutor 在内部对消息进行消费,所以你可以通过设置 setConsumeThreadMinsetConsumeThreadMax 来改变它。

  • 关于消费位点

    当建立一个新的消费组时,需要决定是否需要消费已经存在于 Broker 中的历史消息。

    • CONSUME_FROM_LAST_OFFSET 将会忽略历史消息,并消费之后生成的任何消息。(默认
    • CONSUME_FROM_FIRST_OFFSET 将会消费每个存在于 Broker 中的信息。
    • 也可以使用 CONSUME_FROM_TIMESTAMP 来消费在指定时间戳后产生的消息。
    public static void main(String[] args) throws MQClientException {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("consumer_grp_15_01");
        consumer.setNamesrvAddr("node1:9886");
        consumer.subscribe("tp_demo_15", "*");
    
        // 以下三个选一个使用,如果是根据时间戳进行消费,则需要设置时间戳
        // 从第一个消息开始消费,从头开始
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
        // 从最后一个消息开始消费,不消费历史消息
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
    
        // 从指定的时间戳开始消费
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_TIMESTAMP);
        // 指定时间戳的值
        consumer.setConsumeTimestamp("");
    
        consumer.setMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                // TODO 处理消息的业务逻辑
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                // return ConsumeConcurrentlyStatus.RECONSUME_LATER;
            }
        });
        consumer.start();
    }
    

Broker

Broker 角色

Broker 角色分为 ASYNC_MASTER (异步主机,默认)、 SYNC_MASTER (同步主机)以及 Slave (从机)。如果对消息的可靠性要求比较严格,可以采用 SYNC_MASTERSlave 的部署方式。如果对消息可靠性要求不高,可以采用 ASYNC_MASTERSlave 的部署方式。如果只是测试方便,则可以选择仅 ASYNC_MASTER 或仅 SYNC_MASTER 的部署方式。

FlushDiskType

SYNC_FLUSH(同步刷新)相比于ASYNC_FLUSH(异步处理)会损失很多性能,但是也更可靠,所以需要根据实际的业务场景做好权衡。

Broker 配置

参数名 默认值 说明
listenPort 10911 接受客户端连接的监听端口
namesrvAddr null nameServer 地址
brokerIP1 网卡的 InetAddress 当前 broker 监听的 IP
brokerIP2 跟 brokerIP1 一样 存在主从 broker 时,如果在 broker 主节点上配置 了 brokerIP2 属性,broker 从节点会连接主节点配 置的 brokerIP2 进行同步
brokerName null broker 的名称
brokerClusterName DefaultCluster 本 broker 所属的 Cluser 名称
brokerId 0 broker id, 0 表示 master, 其他的正整数表示 slave
storePathCommitLog $HOME/store/commitlog/ 存储 commit log 的路径
storePathConsumerQueue $HOME/store/consumequeue/ 存储 consume queue 的路径
mappedFileSizeCommitLog 1024 * 1024 * 1024(1G) commit log 的映射文件大小
deleteWhen 04 在每天的什么时间删除已经超过文件保留时间的 commit log
fileReservedTime 72 以小时计算的文件保留时间
brokerRole ASYNC_MASTER SYNC_MASTER/ASYNC_MASTER/SLAVE
flushDiskType ASYNC_FLUSH SYNC_FLUSH/ASYNC_FLUSH
SYNC_FLUSH 模式 下的 broker 保证在收到确认生产者之前将消息刷 盘。
ASYNC_FLUSH 模式下的 broker 则利用刷盘 一组消息的模式,可以取得更好的性能。

NameServer

NameServer 的设计:

  1. NameServer 互相独立,彼此没有通信关系,单台 NameServer 挂掉,不影响其他 NameServer 。

  2. NameServer 不去连接别的机器,不主动推消息。

  3. 单个 Broker ( Master 、 Slave )与所有 NameServer 进行定时注册,以便告知 NameServer 自己还活着。

    Broker 每隔 30 秒向所有 NameServer 发送心跳,心跳包含了自身的 Topic 配置信息。 NameServer 每隔 10 秒,扫描所有还存活的 Broker 连接,如果某个连接的最后更新时间与当前时间差值超过 2 分钟,则断开此连接, NameServer 也会断开此 Broker 下所有与 Slave 的连接。同时更新 Topic 与队列的对应关系,但不通知生产者和消费者。

    Broker Slave 同步或者异步从Broker Master 上拷贝数据。

  4. Consumer 随机与一个 NameServer 建立长连接,如果该 NameServer 断开,则从 NameServer 列表中查找下一个进行连接。

    Consumer 主要从 NameServer 中根据 Topic 查询 Broker 的地址,查到就会缓存到客户端,并向提供 Topic 服务的 Master 、 Slave 建立长连接,且定时向 Master 、 Slave 发送心跳。

    如果 Broker 宕机,则 NameServer 会将其剔除,而 Consumer 端的定时任务 MQClientInstance.this.updateTopicRouteInfoFromNameServer 每 30 秒执行一次,将 Topic 对应的 Broker 地址拉取下来,此地址只有 Slave 地址了,此时 Consumer 从 Slave 上消费。

    消费者与 Master 和 Slave 都建有连接,在不同场景有不同的消费规则。

  5. Producer 随机与一个 NameServer 建立长连接,每隔 30 秒(此处时间可配置)从 NameServer 获取 Topic 的最新队列情况,如果某个 Broker Master 宕机, Producer 最多 30 秒才能感知,在这个期间,发往该 Broker Master 的消息失败。 Producer 向提供 Topic 服务的 Master 建立长连接,且定时向 Master 发送心跳。

    生产者与所有的 Master 连接,但不能向 Slave 写入。

    客户端是先从 NameServer 寻址的,得到可用 Broker 的 IP 和端口信息,然后据此信息连接 Broker 。

综上所述, NameServer 在 RocketMQ 中的作用:

  • NameServer 用来保存活跃的 Broker 列表,包括 Master 和 Slave 。
  • NameServer 用来保存所有 topic 和该 topic 所有队列的列表。
  • NameServer 用来保存所有 Broker 的 Filter 列表。
  • 命名服务器为客户端,包括生产者,消费者和命令行客户端提供最新的路由信息。

RocketMQ 为什么不使用 ZooKeeper 而自己开发 NameServer ?

在服务发现领域,ZooKeeper 根本就不能算是最佳的选择。

  1. 注册中心是 CP 还是 AP 系统?

在分布式系统中,即使是对等部署的服务,因为请求到达的时间,硬件的状态,操作系统的调度,虚拟机的 GC 等,任何一个时间点,这些对等部署的节点状态也不可能完全一致,而流量不一致的情况下,只要注册中心在 A 承诺的时间内(例如 1s 内)将数据收敛到一致状态(即满足最终一致),流量将很快趋于统计学意义上的一致,所以注册中心以最终一致的模型设计在生产实践中完全可以接受。

  1. 分区容忍及可用性需求分析,实践中,注册中心不能因为自身的任何原因破坏服务之间本身的可连通性,这是注册中心设计应该遵循的铁律!

    在 CAP 的权衡中,注册中心的可用性比数据强一致性更宝贵,所以整体设计更应该偏向 AP ,而非 CP ,数据不一致在可接受范围,而 P 下舍弃 A 却完全违反了注册中心不能因为自身的任何原因破坏服务本身的可连通性的原则。

  2. 服务规模、容量、服务联通性

    当数据中心服务规模超过一定数量,作为注册中心的 ZooKeeper 性能堪忧。

    在服务发现和健康监测场景下,随着服务规模的增大,无论是应用频繁发布时的服务注册带来的写请求,还是刷毫秒级的服务健康状态带来的写请求,还是恨不能整个数据中心的机器或者容器皆与注册中心有长连接带来的连接压力上, ZooKeeper 很快就会力不从心,而 ZooKeeper 的写并不是可扩展的,不可以通过加节点解决水平扩展性问题

  3. 注册中心需要持久存储和事务日志么? 需要,也不需要。

    在服务发现场景中,其最核心的数据——实时的健康的服务的地址列表,真的需要数据持久化么?不需要

    在服务发现中,服务调用发起方更关注的是其要调用的服务的实时的地址列表和实时健康状态,每次发起调用时,并不关心要调用的服务的历史服务地址列表、过去的健康状态。

    但是一个完整的生产可用的注册中心,除了服务的实时地址列表以及实时的健康状态之外,还会存储一些服务的元数据信息,例如服务的版本,分组,所在的数据中心,权重,鉴权策略信息,服务标签等元数据,这些数据需要持久化存储,并且注册中心应该提供对这些元数据的检索的能力。

  4. 服务健康检查

    使用 ZooKeeper 作为服务注册中心时,服务的健康检测绑定在了 ZooKeeper 对于 Session 的健康监测上,或者说绑定在 TCP 长链接活性探测上了。

    ZK 与服务提供者机器之间的 TCP 长链接活性探测正常的时候,该服务就是健康的么?答案当然是否定的!注册中心应该提供更丰富的健康监测方案,服务的健康与否的逻辑应该开放给服务提供方自己定义,而不是一刀切搞成了 TCP 活性检测!

    健康检测的一大基本设计原则就是尽可能真实的反馈服务本身的真实健康状态,否则一个不敢被服务调用者相信的健康状态判定结果还不如没有健康检测。

  5. 注册中心的容灾考虑

    如果注册中心(Registry)本身完全宕机了,服务调用链路应该受到影响么?不应该受到影响。

    服务调用(请求响应流)链路应该是弱依赖注册中心,必须仅在服务发布,机器上下线,服务扩缩容等必要时才依赖注册中心。

    这需要注册中心仔细的设计自己提供的客户端,客户端中应该有针对注册中心服务完全不可用时做容灾的手段,例如设计客户端缓存数据机制就是行之有效的手段。另外,注册中心的健康检查机制也要仔细设计以便在这种情况不会出现诸如推空等情况的出现。

    ZooKeeper 的原生客户端并没有这种能力,所以利用 ZooKeeper 实现注册中心的时候我们一定要问自己,如果把 ZooKeeper 所有节点全干掉,你生产上的所有服务调用链路能不受任何影响么?

  6. 你有没有 ZooKeeper 的专家可依靠?

    1. 难以掌握的 Client/Session 状态机
    2. 难以承受的异常处理

    阿里巴巴是不是完全没有使用 ZooKeeper?并不是。

    熟悉阿里巴巴技术体系的都知道,其实阿里巴巴维护了目前国内最大规模的 ZooKeeper 集群,整体规模有近千台的 ZooKeeper 服务节点。

    在粗粒度分布式锁,分布式选主,主备高可用切换等不需要高TPS支持的场景下有不可替代的作用,而这些需求往往多集中在大数据、离线任务等相关的业务领域,因为大数据领域,讲究分割数据集,并且大部分时间分任务多进程/线程并行处理这些数据集,但是总是有一些点上需要将这些任务和进程统一协调,这时候就是 ZooKeeper 发挥巨大作用的用武之地。

    但是在交易场景交易链路上,在主业务数据存取,大规模服务发现、大规模健康监测等方面有天然的短板,应该竭力避免在这些场景下引入 ZooKeeper ,在阿里巴巴的生产实践中,应用对 ZooKeeper 申请使用的时候要进行严格的场景、容量、SLA需求的评估。

对于 ZooKeeper ,大数据使用,服务发现不用。

客户端配置

相对于 RocketMQ 的 Broker 集群,生产者和消费者都是客户端。

本节主要描述生产者和消费者公共的行为配置。

DefaultMQProducerTransactionMQProducerDefaultMQPushConsumerDefaultMQPullConsumer 都继承于 ClientConfig 类, ClientConfig 为客户端的公共配置类。

客户端的配置都是 get 、 set 形式,每个参数都可以用 Spring 来配置,也可以在代码中配置。

例如 namesrvAddr 这个参数可以这样配置,producer.setNamesrvAddr("192.168.0.1:9876") ,其他参数同理。

客户端寻址方式

RocketMQ可以令客户端找到Name Server, 然后通过Name Server再找到Broker。如下所示有多
种配置方式,优先级由高到低,高优先级会覆盖低优先级。

  • 代码中指定 NameServer 地址,多个 NameServer 地址之间用分号分割

    producer.setNamesrvAddr("192.168.0.1:9876;192.168.0.2:9876");
    consumer.setNamesrvAddr("192.168.0.1:9876;192.168.0.2:9876");
    
  • JVM 启动参数中指定 NameServer 地址

    -Drocketmq.namesrv.addr=192.168.0.1:9876;192.168.0.2:9876
    
  • 环境变量指定 NameServer 地址

    export NAMESRV_ADDR=192.168.0.1:9876;192.168.0.2:9876  
    

    需要重启 IDEA ,一定是关闭之后再启动,让它加载该环境变量

  • HTTP 静态服务器寻址(默认

    该静态地址,客户端第一次会 10s 后调用,然后每隔 2 分钟调用一次。

    客户端启动后,会定时访问一个静态 HTTP 服务器,默认地址如下:http://jmenv.tbsite.net:8080/rocketmq/nsaddr,可以通过改变系统属性进行调整,源码参考:org.apache.rocketmq.common.MixAll

    System.setProperty("rocketmq.namesrv.domain", "localhost");
    
    // 这样可以修改为 http://localhost:8080/rocketmq/nsaddr
    

    在本地启动一个简单服务:

    @RequestMapping("/rocketmq/nsaddr")
    public String getNameServerAddr() {
        return "node1:9876";
    }
    

    也可以通过修改 hosts 信息,将地址重定向到指定服务

推荐使用 HTTP 静态服务器寻址方式,好处是客户端部署简单,且 NameServer 集群可以热升级。因为只需要修改域名解析,客户端不需要重启。

客户端的公共配置

参数名 默认值 说明
namesrvAddr Name Server 地址列表,多个 NameServer 地址用分号隔开
clientIP 本机IP 客户端本机IP地址,某些机器会发生无法识别 客户端IP地址情况,需要应用在代码中强制指 定
instanceName DEFAULT 客户端实例名称,客户端创建的多个 Producer、Consumer实际是共用一个内部实 例(这个实例包含网络连接、线程资源等)
clientCallbackExecutorThreads 4 通信层异步回调线程数
pollNameServerInteval 30000 轮询Name Server间隔时间,单位毫秒
heartbeatBrokerInterval 30000 向Broker发送心跳间隔时间,单位毫秒
persistConsumerOffsetInterval 5000 持久化Consumer消费进度间隔时间,单位毫秒

Producer 配置

参数名 默认值 说明
producerGroup DEFAULT_PRODUCER Producer组名,多个Producer如 果属于一个应用,发送同样的消 息,则应该将它们归为同一组
createTopicKey TBW102 在发送消息时,自动创建服务器不 存在的topic,需要指定Key,该 Key可用于配置发送消息所在topic 的默认路由。
defaultTopicQueueNums 4 在发送消息,自动创建服务器不存 在的topic时,默认创建的队列数
sendMsgTimeout 10000 发送消息超时时间,单位毫秒
compressMsgBodyOverHowmuch 4096 消息Body超过多大开始压缩 (Consumer收到消息会自动解压 缩),单位字节
retryAnotherBrokerWhenNotStoreOK FALSE 如果发送消息返回sendResult,但 是sendStatus!=SEND_OK,是否重 试发送
retryTimesWhenSendFailed 2 如果消息发送失败,最大重试次 数,该参数只对同步发送模式起作 用
maxMessageSize 4MB 客户端限制的消息大小,超过报错,同时服务端也会限制,所以需要跟服务端配合使用。
transactionCheckListener 事务消息回查监听器,如果发送事务消息,必须设置
checkThreadPoolMinSize 1 Broker回查Producer事务状态时, 线程池最小线程数
checkThreadPoolMaxSize 1 Broker回查Producer事务状态时, 线程池最大线程数
checkRequestHoldMax 2000 Broker回查Producer事务状态时, Producer本地缓冲请求队列大小
RPCHook null 该参数是在Producer创建时传入 的,包含消息发送前的预处理和消 息响应后的处理两个接口,用户可 以在第一个接口中做一些安全控制 或者其他操作。

PushConsumer 配置

参数名 默认值 说明
consumerGroup DEFAULT_CONSUMER Consumer组名,多个Consumer如果属于 一个应用,订阅同样的消息,且消费逻辑 一致,则应该将它们归为同一组
messageModel CLUSTERING 消费模型支持集群消费和广播消费两种
consumeFromWhere CONSUME_FROM_LAST_OFFSET Consumer启动后,默认从上次消费的位置 开始消费,这包含两种情况:一种是上次 消费的位置未过期,则消费从上次中止的 位置进行;一种是上次消费位置已经过 期,则从当前队列第一条消息开始消费
consumeTimestamp 半个小时前 只有当consumeFromWhere值为 CONSUME_FROM_TIMESTAMP时才起作 用。
allocateMessageQueueStrategy AllocateMessageQueueAveragely Rebalance算法实现策略
subscription 订阅关系
messageListener 消息监听器
offsetStore 消费进度存储
consumeThreadMin 10 消费线程池最小线程数
consumeThreadMax 20 消费线程池最大线程数
consumeConcurrentlyMaxSpan 2000 单队列并行消费允许的最大跨度
pullThresholdForQueue 1000 拉消息本地队列缓存消息最大数
pullInterval 0 拉消息间隔,由于是长轮询,所以为0,但 是如果应用为了流控,也可以设置大于0的 值,单位毫秒
consumeMessageBatchMaxSize 1 批量消费,一次消费多少条消息
pullBatchSize 32 批量拉消息,一次最多拉多少条

PullConsumer 配置

参数名 默认值 说明
consumerGroup DEFAULT_CONSUMER Consumer组名,多个Consumer如果属于一个应用,订阅同样的消息,且消 费逻辑一致,则应该将它们归为同一 组
brokerSuspendMaxTimeMillis 20000 长轮询,Consumer 拉消息请求在 Broker挂起最长时 间,单位毫秒
consumerTimeoutMillisWhenSuspend 30000 长轮询,Consumer 拉消息请求在 Broker挂起超过指 定时间,客户端认 为超时,单位毫秒
consumerPullTimeoutMillis 10000 非长轮询,拉消息 超时时间,单位毫 秒
messageModel BROADCASTING 消息支持两种模 式:集群消费和广 播消费
messageQueueListener 监听队列变化
offsetStore 消费进度存储
registerTopics 注册的topic集合
allocateMessageQueueStrategy AllocateMessageQueueAveragely Rebalance算法实现 策略

Message 数据结构

字段名 默认 值 说明
Topic null 必填,消息所属topic的名称
Body null 必填,消息体
Tags null 选填,消息标签,方便服务器过滤使用。目前只支持每个消息设 置一个tag
Keys null 选填,代表这条消息的业务关键词,服务器会根据keys创建哈希 索引,设置后,可以在Console系统根据Topic、Keys来查询消息,由于是哈希索引,请尽可能保证key唯一,例如订单号,商品 Id等。
Flag 0 选填,完全由应用来设置,RocketMQ不做干预
DelayTimeLevel 0 选填,消息延时级别,0表示不延时,大于0会延时特定的时间才 会被消费
WaitStoreMsgOK TRUE 选填,表示消息是否在服务器落盘后才返回应答。

系统配置

JVM 选项

设置 Xms 和 Xmx 一样大,防止 JVM 重新调整堆空间大小影响性能。

-server -Xms8g -Xmx8g -Xmn4g

设置 DirectByteBuffer 内存大小。当 DirectByteBuffer 占用达到这个值,就会触发 Full GC 。

-XX:MaxDirectMemorySize=15g

如果不太关心 RocketMQ 的启动时间,可以设置 pre-touch ,这样在 JVM 启动的时候就会分配完整的页空间。

-XX:+AlwaysPreTouch

禁用偏向锁可能减少 JVM 的停顿,因为偏向锁在线程需要获取锁之前会判断当前线程是否拥有锁,如果拥有,就不用再去获取锁了。在并发小的时候使用偏向锁有利于提升 JVM 效率,在高并发场合禁用掉。

-XX:-UseBiasedLocking

推荐使用 JDK1.8 的 G1 垃圾回收器:

当在 G1 的 GC 日志中看到 to-space overflow 或者 to-space exhausted 的时候,表示 G1 没有足够的内存使用的(可能是 survivor 区不够了,可能是老年代不够了,也可能是两者都不够了),这时候表示 Java 堆占用大小已经达到了最大值。比如:

924.897: [GC pause (G1 Evacuation Pause)(mixed) (to-space exhausted), 0.1957310 secs]
924.897: [GC pause (G1 EvacuationPause) (mixed) (to-space overflow), 0.1957310 secs] 

为了解决这个问题,请尝试做以下调整:

  1. 增加预留内存:增大参数 -XX:G1ReservePercent 的值(相应的增加堆内存)来增加预留内存;
  2. 更早的开始标记周期:减小 -XX:InitiatingHeapOccupancyPercent 参数的值,以更早的开始标记周期;
  3. 增加并发收集线程数:增大 -XX:ConcGCThreads 参数值,以增加并行标记线程数。

对 G1 而言,大小超过 region 大小 50% 的对象将被认为是大对象,这种大对象将直接被分配到老年代的 humongous regions 中, humongous regions 是连续的 region 集合, StartsHumongous 标记集合从哪里开始, ContinuesHumongous 标记连续集合。

在分配大对象之前,将会检查标记阈值,如果有必要的话,还会启动并发周期。

死亡的大对象会在标记周期的清理阶段和发生 Full GC 的时候被清理。

为了减少复制开销,任何转移阶段都不包含大对象的复制。在 Full GC 时,G1 在原地压缩大对象。

因为每个独立的 humongous regions 只包含一个大对象,因此从大对象的结尾到它占用的最后一个 region 的结尾的那部分空间时没有被使用的,对于那些大小略大于 region 整数倍的对象,这些没有被使用的内存将导致内存碎片化。

如果你看到因为大对象的分配导致不断的启动并发收集,并且这种分配使得老年代碎片化不断加剧,那么请增加 -XX:G1HeapRegionSize 参数的值,这样的话,大对象将不再被G1认为是大对象,它会走普通对象的分配流程。

# G1回收器将堆空间划分为1024个region,此选项指定堆空间region的大小
-XX:+UseG1GC -XX:G1HeapRegionSize=16m
-XX:G1ReservePercent=25
-XX:InitiatingHeapOccupancyPercent=30

上述设置可能有点儿激进,但是对于生产环境,性能很好。

-XX:MaxGCPauseMillis 不要设置的太小,否则JVM会使用小的年轻代空间以达到此设置的值,同时引起很频繁的 minor GC 。

推荐使用 GC log 文件:

-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=30m

如果写 GC 文件增加了 Broker 的延迟,可以考虑将 GC log 文件写到内存文件系统:

-Xloggc:/dev/shm/mq_gc_%p.log123

Linux 内核参数

os.sh 脚本在 bin 文件夹中列出了许多内核参数,可以进行微小的更改然后用于生产用途。下面的参数需要注意,更多细节请参考 /proc/sys/vm/*文档

# 获取内核参数值
sysctl vm.extra_free_kbytes
# 设置内核参数值
sudo sysctl -w vm.overcommit_memory=1
  • vm.extra_free_kbytes ,告诉 VM 在后台回收( kswapd )启动的阈值与直接回收(通过分配进程)的阈值之间保留额外的可用内存。 RocketMQ 使用此参数来避免内存分配中的长延迟。(与具体内核版本相关)

  • vm.min_free_kbytes ,如果将其设置为低于 1024KB ,将会巧妙的将系统破坏,并且系统在高负载下容易出现死锁。

  • vm.max_map_count ,限制一个进程可能具有的最大内存映射区域数。 RocketMQ 将使用 mmap 加载 CommitLog 和 ConsumeQueue ,因此建议将为此参数设置较大的值。

  • vm.swappiness ,定义内核交换内存页面的积极程度。较高的值会增加攻击性,较低的值会减少交换量。建议将值设置为 10 来避免交换延迟。

  • File descriptor limits , RocketMQ 需要为文件( CommitLog 和 ConsumeQueue )和网络连接打开文件描述符。我们建议设置文件描述符的值为 655350 。

    echo '* hard nofile 655350' >> /etc/security/limits.conf
    
  • Disk scheduler,RocketMQ 建议使用 I/O 截止时间调度器,它试图为请求提供有保证的延迟。

动态扩缩容

动态增减 Namesrv 机器

NameServer 是 RocketMQ 集群的协调者,集群的各个组件是通过 NameServer 获取各种属性和地址信息的。

主要功能包括两部分:

  1. 一个是各个 Broker 定期上报自己的状态信息到 NameServer ;
  2. 另一个是各个客户端,包括 Producer 、 Consumer ,以及命令行工具,通过 NameServer 获取最新的状态信息。

所以,在启动 Broker 、生产者和消费者之前,必须告诉它们 NameServer 的地址,为了提高可靠性,建议启动多个 NameServer 。 NameServer 占用资源不多,可以和 Broker 部署在同一台机器。有多个 NameServer 后,减少某个 NameServer 不会对其他组件产生影响。

有四种种方式可设置 NameServer 的地址,下面按优先级由高到低依次介绍:

  • 通过代码设置,比如在 Producer 中,通过 Producer.setNamesrvAddr("name-server1-ip:port;name-server2-ip:port") 来设置。

    mqadmin 命令行工具中,是通过 -n name-server-ip1:port;name-server-ip2:port 参数来设
    置的,如果自定义了命令行工具,也可以通过 defaultMQAdminExt.setNamesrvAddr("nameserver1-ip:port;name-server2-ip:port") 来设置。

  • 使用 Java 启动参数设置,对应的 option 是 rocketmq.namesrv.addr

  • 通过 Linux 环境变量设置,在启动前设置变量: NAMESRV_ADDR

  • 通过 HTTP 服务来设置,当上述方法都没有使用,程序会向一个 HTTP 地址发送请求来获取 NameServer 地址,默认的 URL 是 http://jmenv.tbsite.net:8080/rocketmq/nsaddr (淘宝的测试地址),通过 rocketmq.namesrv.domain 参数来覆盖 jmenv.tbsite.net ;通过 rocketmq.namesrv.domain.subgroup 参数来覆盖 nsaddr

第 4 种方式看似繁琐,但它是唯一支持动态增加 NameServer ,无须重启其他组件的方式。使用这种方式后其他组件会每隔 2 分钟请求一次该 URL ,获取最新的 NameServer 地址。

动态增减 Broker 机器

由于业务增长,需要对集群进行扩容的时候,可以动态增加 Broker 角色的机器。只增加 Broker 不会对原有的 Topic 产生影响,原来创建好的 Topic 中数据的读写依然在原来的那些 Broker 上进行。

集群扩容后,一是可以把新建的 Topic 指定到新的 Broker 机器上,均衡利用资源;另一种方式是通
updateTopic 命令更改现有的 Topic 配置,在新加的 Broker 上创建新的队列。比如 TestTopic 是现有的一个 Topic ,因为数据量增大需要扩容,新增的一个 Broker 机器地址是 192.168.0.1:10911,这个时候
执行下面的命令:mqadmin updateTopic-b 192.168.0.1:10911 -t TestTopic -n 192.168.0.100:9876,结果是在新增的 Broker 机器上,为 TestTopic 新创建了 8 个读写队列。

mqadmin updateTopic -b <arg> | -c <arg> [-h] [-n <arg>] [-o <arg>] [-p <arg>] [-r <arg>] [-s <arg>] -t <arg> [-u <arg>] [-w <arg>]  
[root@node1 ~]# mqadmin topicStatus -n localhost:9876 -t tp_demo_07
[root@node1 ~]# mqadmin updateTopic -b node2:10911 -t tp_demo_07 -n 'node1:9876;node2:9876' -w 8 -r 8
[root@node1 ~]# mqadmin topicStatus -n localhost:9876 -t tp_demo_07

如果因为业务变动或者置换机器需要减少 Broker ,此时该如何操作呢?减少 Broker 要看是否有持续运行的 Producer ,当一个 Topic 只有一个 Master Broker ,停掉这个 Broker 后,消息的发送肯定会受到影响,需要在停止这个 Broker 前,停止发送消息。

当某个 Topic 有多个 Master Broker ,停了其中一个,这时候是否会丢失消息呢?答案和 Producer 使用的发送消息的方式有关,如果使用同步方式 send(msg) 发送,在 DefaultMQProducer 内部有个自动重试逻辑,其中一个 Broker 停了,会自动向另一个 Broker 发消息,不会发生丢消息现象。如果使用异步方式发送 send(msg , callback) ,或者用 sendOneWay 方式,会丢失切换过程中的消息。因为在异步和 sendOneWay 这两种发送方式下, Producer.setRetryTimesWhenSendFailed 设置不起作用,发送失败不会重试。 DefaultMQProducer 默认每 30 秒到 NameServer 请求最新的路由消息, Producer 如果获取不到已停止的 Broker 下的队列信息,后续就自动不再向这些队列发送消息。

如果 Producer 程序能够暂停,在有一个 Master 和一个 Slave 的情况下也可以顺利切换。可以关闭 Producer 后关闭 Master Broker ,这个时候所有的读取都会被定向到 Slave 机器,消费消息不受影响。把 Master Broker 机器置换完后,基于原来的数据启动这个 Master Broker ,然后再启动 Producer 程序正常发送消息。

用 Linux 的 kill pid 命令就可以正确地关闭 Broker , BrokerController 下有个 shutdown 函数,这个函数被加到了 ShutdownHook 里,当用 Linux 的 kill 命令时(不能用 kill -9 ), shutdown 函数会先被执行。也可以通过 RocketMQ 提供的工具( mqshutdown Broker )来关闭 Broker ,它们的原理是一样的。

各种故障对消息的影响

我们期望消息队列集群一直可靠稳定地运行,但有时候故障是难免的,本节我们列出可能的故障情况,看看如何处理:

  1. Broker 正常关闭,启动;
  2. Broker 异常 Crash,然后启动;
  3. OS Crash,重启;
  4. 机器断电,但能马上恢复供电;
  5. 磁盘损坏;
  6. CPU、主板、内存等关键设备损坏。

假设现有的 RocketMQ 集群,每个 Topic 都配有多 Master 角色的 Broker 供写入,并且每个 Master 都至少有一个 Slave 机器(用两台物理机就可以实现上述配置),我们来看看在上述情况下消息的可靠性情况。

第 1 种情况属于可控的软件问题,内存中的数据不会丢失。如果重启过程中有持续运行的 Consumer , Master 机器出故障后, Consumer 会自动重连到对应的 Slave 机器,不会有消息丢失和偏差。当 Master 角色的机器重启以后, Consumer 又会重新连接到 Master 机器(注意在启动 Master 机器的时候,如果 Consumer 正在从 Slave 消费消息,不要停止 Consumer 。假如此时先停止 Consumer 后再启动 Master 机器,然后再启动 Consumer ,这个时候 Consumer 就会去读 Master 机器上已经滞后的 offset 值,造成消息大量重复)。

如果第 1 种情况出现时有持续运行的 Producer ,一台 Master 出故障后, Producer 只能向 Topic 下其他的 Master 机器发送消息,如果 Producer 采用同步发送方式,不会有消息丢失。

第 2 、 3 、 4 种情况属于软件故障,内存的数据可能丢失,所以刷盘策略不同,造成的影响也不同,如果 Master 、 Slave 都配置成 SYNC_FLUSH ,可以达到和第 1 种情况相同的效果。

第 5 、 6 种情况属于硬件故障,发生第 5 、 6 种情况的故障,原有机器的磁盘数据可能会丢失。如果 Master 和 Slave 机器间配置成同步复制方式,某一台机器发生 5 或 6 的故障,也可以达到消息不丢失的效果。如果 Master 和 Slave 机器间是异步复制,两次 Sync 间的消息会丢失。

总的来说,当设置成:

  1. 多 Master ,每个 Master 带有 Slave ;
  2. 主从之间设置成 SYNC_MASTER
  3. Producer 用同步方式写;
  4. 刷盘策略设置成 SYNC_FLUSH

就可以消除单点依赖,即使某台机器出现极端故障也不会丢消息。

posted @ 2021-03-29 16:57  流星<。)#)))≦  阅读(205)  评论(0编辑  收藏  举报