IT学习笔记--RocketMQ

部署架构:

  • NameServer是一个几乎无状态节点,可集群部署,节点之间无任何信息同步。
  • Broker部署相对复杂,Broker分为Master与Slave,一个Master可以对应多个Slave,但是一个Slave只能对应一个Master,Master与Slave的对应关系通过指定相同的BrokerName,不同的BrokerId来定义,BrokerId为0表示Master,非0表示Slave。Master也可以部署多个。每个Broker与NameServer集群中的所有节点建立长连接,定时注册Topic信息到所有NameServer。注意:当前RocketMQ版本在部署架构上支持一Master多Slave,但只有BrokerId=1的从服务器才会参与消息的读负载。
  • Producer与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer获取Topic路由信息,并向提供Topic 服务的Master建立长连接,且定时向Master发送心跳。Producer完全无状态,可集群部署。
  • Consumer与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer获取Topic路由信息,并向提供Topic服务的Master、Slave建立长连接,且定时向Master、Slave发送心跳。Consumer既可以从Master订阅消息,也可以从Slave订阅消息,消费者在向Master拉取消息时,Master服务器会根据拉取偏移量与最大偏移量的距离(判断是否读老消息,产生读I/O),以及从服务器是否可读等因素建议下一次是从Master还是Slave拉取。

执行流程:

  1. 启动NameServer,NameServer起来后监听端口,等待Broker、Producer、Consumer连上来,相当于一个路由控制中心。
  2. Broker启动,跟所有的NameServer保持长连接,定时发送心跳包。心跳包中包含当前Broker信息(IP+端口等)以及存储所有Topic信息。注册成功后,NameServer集群中就有Topic跟Broker的映射关系。
  3. 收发消息前,先创建Topic,创建Topic时需要指定该Topic要存储在哪些Broker上,也可以在发送消息时自动创建Topic。
  4. Producer发送消息,启动时先跟NameServer集群中的其中一台建立长连接,并从NameServer中获取当前发送的Topic存在哪些Broker上,轮询从队列列表中选择一个队列,然后与队列所在的Broker建立长连接从而向Broker发消息。
  5. Consumer跟Producer类似,跟其中一台NameServer建立长连接,获取当前订阅Topic存在哪些Broker上,然后直接跟Broker建立连接通道,开始消费消息。

基本概念:

消息模型(Message Model):

RocketMQ主要由 Producer、Broker、Consumer 三部分组成,其中Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息。Broker 在实际部署过程中对应一台服务器,每个 Broker 可以存储多个Topic的消息,每个Topic的消息也可以分片存储于不同的 Broker。Message Queue 用于存储消息的物理地址,每个Topic中的消息地址存储于多个 Message Queue 中。ConsumerGroup 由多个Consumer 实例构成。

消息生产者(Producer):

负责生产消息,一般由业务系统负责生产消息。一个消息生产者会把业务应用系统里产生的消息发送到broker服务器。RocketMQ提供多种发送方式,同步发送、异步发送、顺序发送、单向发送。同步和异步方式均需要Broker返回确认信息,单向发送不需要。

消息消费者(Consumer):

负责消费消息,一般是后台系统负责异步消费。一个消息消费者会从Broker服务器拉取消息、并将其提供给应用程序。从用户应用的角度而言提供了两种消费形式:拉取式消费、推动式消费。

主题(Topic):

表示一类消息的集合,每个主题包含若干条消息,每条消息只能属于一个主题,是RocketMQ进行消息订阅的基本单位。

代理服务器(Broker Server):

消息中转角色,负责存储消息、转发消息。代理服务器在RocketMQ系统中负责接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备。代理服务器也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等。

 名字服务(Name Server):

名称服务充当路由消息的提供者。生产者或消费者能够通过名字服务查找各主题相应的Broker IP列表。多个Namesrv实例组成集群,但相互独立,没有信息交换。

生产者组(Producer Group):

同一类Producer的集合,这类Producer发送同一类消息且发送逻辑一致。如果发送的是事务消息且原始生产者在发送之后崩溃,则Broker服务器会联系同一生产者组的其他生产者实例以提交或回溯消费。

 消费者组(Consumer Group):

一个组中的客户端会基本保证只消费一次。

同一类Consumer的集合,这类Consumer通常消费同一类消息且消费逻辑一致。消费者组使得在消息消费方面,实现负载均衡和容错的目标变得非常容易。要注意的是,消费者组的消费者实例必须订阅完全相同的Topic。RocketMQ 支持两种消息模式:集群消费(Clustering)和广播消费(Broadcasting)。

 集群消费(Clustering):

集群消费模式下,相同Consumer Group的每个Consumer实例平均分摊消息。

广播消费(Broadcasting):

广播消费模式下,相同Consumer Group的每个Consumer实例都接收全量的消息。

 普通顺序消息(Normal Ordered Message):

普通顺序消费模式下,消费者通过同一个消费队列收到的消息是有顺序的,不同消息队列收到的消息则可能是无顺序的。

严格顺序消息(Strictly Ordered Message):

严格顺序消息模式下,消费者收到的所有消息均是有顺序的。

消息(Message):

消息系统所传输信息的物理载体,生产和消费数据的最小单位,每条消息必须属于一个主题。RocketMQ中每个消息拥有唯一的Message ID,且可以携带具有业务标识的Key。系统提供了通过Message ID和Key查询消息的功能。

标签(Tag):

为消息设置的标志,用于同一主题下区分不同类型的消息。来自同一业务单元的消息,可以根据不同业务目的在同一主题下设置不同标签。标签能够有效地保持代码的清晰度和连贯性,并优化RocketMQ提供的查询系统。消费者可以根据Tag实现对不同子主题的不同消费逻辑,实现更好的扩展性。

Half Message:

指的是暂不能投递的消息,发送方已经将消息成功发送到了 MQ 服务端,但是服务端未收到生产者对该消息的二次确认,此时该消息被标记成“暂不能投递”状态,处于该种状态下的消息即半消息。

Message Status Check:

由于网络闪断、生产者应用重启等原因,导致某条事务消息的二次确认丢失,MQ 服务端通过扫描发现某条消息长期处于“半消息”时,需要主动向消息生产者询问该消息的最终状态(Commit 或是 Rollback),该过程即消息回查。

消息过滤:

RocketMQ的消费者可以根据Tag进行消息过滤,也支持自定义属性过滤。消息过滤目前是在Broker端实现的,优点是减少了对于Consumer无用消息的网络传输,缺点是增加了Broker的负担、而且实现相对复杂。

消息可靠性:

RocketMQ支持消息的高可靠,影响消息可靠性的几种情况:

  1. Broker非正常关闭

  2. Broker异常Crash

  3. OS Crash

  4. 机器掉电,但是能立即恢复供电情况

  5. 机器无法开机(可能是cpu、主板、内存等关键设备损坏)

  6. 磁盘设备损坏

1、2、3、4 四种情况都属于硬件资源可立即恢复情况,RocketMQ在这四种情况下能保证消息不丢,或者丢失少量数据(依赖刷盘方式是同步还是异步)。

5、6属于单点故障,且无法恢复,一旦发生,在此单点上的消息全部丢失。

RocketMQ在这两种情况下,通过异步复制,可保证99%的消息不丢,但是仍然会有极少量的消息可能丢失。通过同步双写技术可以完全避免单点,同步双写势必会影响性能,适合对消息可靠性要求极高的场合,例如与Money相关的应用。注:RocketMQ从3.0版本开始支持同步双写。

 定时消息:

定时消息(延迟队列)是指消息发送到broker后,不会立即被消费,等待特定时间投递给真正的topic。broker有配置项messageDelayLevel,默认值为“1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h”,18个level。

messageDelayLevel是broker的属性,不属于某个topic。发消息时,设置delayLevel等级即可:msg.setDelayLevel(level)。level有以下三种情况:

  • level == 0,消息为非延迟消息

  • 1 <= level <= maxLevel,消息延迟特定时间,例如level == 1,延迟1s

  • level > maxLevel,则level == maxLevel,例如level == 20,延迟2h

定时消息会暂存在名为SCHEDULE_TOPIC_XXXX的topic中,并根据delayTimeLevel存入特定的queue,queueId = delayTimeLevel – 1,即一个queue只存相同延迟的消息,保证具有相同发送延迟的消息能够顺序消费。broker会调度地消费SCHEDULE_TOPIC_XXXX,将消息写入真实的topic。

需要注意的是,定时消息会在第一次写入和调度写入真实topic时都会计数,因此发送数量、tps都会变高。

消息重投:

生产者在发送消息时:

  • 同步消息失败会重投
  • 异步消息有重试
  • oneway没有任何保证。

消息重投保证消息尽可能发送成功、不丢失,但可能会造成消息重复,消息重复在RocketMQ中是无法避免的问题。消息重复在一般情况下不会发生,当出现消息量大、网络抖动,消息重复就会是大概率事件。另外,生产者主动重发、consumer负载变化也会导致重复消息。

如下方法可以设置消息重试策略:

  1. retryTimesWhenSendFailed:同步发送失败重投次数,默认为2,因此生产者会最多尝试发送retryTimesWhenSendFailed + 1次。不会选择上次失败的broker,尝试向其他broker发送,最大程度保证消息不丢失。超过重投次数,抛异常,由客户端保证消息不丢失。当出现RemotingException、MQClientException和部分MQBrokerException时会重投。
  2. retryTimesWhenSendAsyncFailed:异步发送失败重试次数,异步重试不会选择其他broker,仅在同一个broker上做重试,不保证消息不丢。
  3. retryAnotherBrokerWhenNotStoreOK:消息刷盘(主或备)超时或slave不可用(返回状态非SEND_OK),是否尝试发送到其他broker,默认false。十分重要消息可以开启。

死信队列:

死信队列用于处理无法被正常消费的消息。当一条消息初次消费失败,消息队列会自动进行消息重试;达到最大重试次数后,若消费依然失败,则表明消费者在正常情况下无法正确地消费该消息,此时,消息队列不会立刻将消息丢弃,而是将其发送到该消费者对应的特殊队列中。

RocketMQ将这种正常情况下无法被消费的消息称为死信消息(Dead-Letter Message),将存储死信消息的特殊队列称为死信队列(Dead-Letter Queue)。

在RocketMQ中,可以通过使用console控制台对死信队列中的消息进行重发来使得消费者实例再次进行消费。

消费模式Push or Pull:

RocketMQ消息订阅有两种模式,一种是Push模式(MQPushConsumer),即MQServer主动向消费端推送;另外一种是Pull模式(MQPullConsumer),即消费端在需要时,主动到MQ Server拉取。但在具体实现时,Push和Pull模式本质都是采用消费端主动拉取的方式,即consumer轮询从broker拉取消息。

  • Push模式特点:

    好处就是实时性高。不好处在于消费端的处理能力有限,当瞬间推送很多消息给消费端时,容易造成消费端的消息积压,严重时会压垮客户端。

  • Pull模式特点:

    好处就是主动权掌握在消费端自己手中,根据自己的处理能力量力而行。缺点就是如何控制Pull的频率。定时间隔太久担心影响时效性,间隔太短担心做太多“无用功”浪费资源。比较折中的办法就是长轮询。

  • Push模式与Pull模式的区别:

    Push方式里,consumer把长轮询的动作封装了,并注册MessageListener监听器,取到消息后,唤醒MessageListener的consumeMessage()来消费,对用户而言,感觉消息是被推送过来的。

    Pull方式里,取消息的过程需要用户自己主动调用,首先通过打算消费的Topic拿到MessageQueue的集合,遍历MessageQueue集合,然后针对每个MessageQueue批量取消息,一次取完后,记录该队列下一次要取的开始offset,直到取完了,再换另一个MessageQueue。

    RocketMQ使用长轮询机制来模拟Push效果,算是兼顾了二者的优点。

 

核心API:

同步发送基本消息:

//指明生产者组名称
DefaultMQProducer producer = new DefaultMQProducer("TEST_GROUP");
// Name Server 地址
producer.setNamesrvAddr(HOST);
producer.setSendMsgTimeout(600);
producer.start();
//创建消息
Message msg = new Message("TopicTest", ("Hello RocketMQ " +i).getBytes(RemotingHelper.DEFAULT_CHARSET));
//发送消息
SendResult sendResult = producer.send(msg);
System.out.printf("%s%n", sendResult);
producer.shutdown();

 异步发送基本消息:

DefaultMQProducer producer = new DefaultMQProducer("TEST_GROUP");
producer.setNamesrvAddr(HOST);
producer.start();
Message message = new Message("TopicTest", "测试消息".getBytes("UTF-8"));
producer.send(message, new SendCallback() {
    @Override
    public void onSuccess(SendResult sendResult) {
        System.out.println(sendResult);
    }

    @Override
    public void onException(Throwable e) {

    }
});
producer.shutdown();

 推动式消费(订阅模式):

该模式下Broker收到数据后会主动推送给消费端,该消费模式一般实时性较高。

DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(GROUP);
consumer.setNamesrvAddr(HOST);
consumer.subscribe(TOPIC, "*");
consumer.registerMessageListener(new MessageListenerConcurrently() {
    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
        for (MessageExt msg : msgs) {
            String s = new String(msg.getBody());
            System.out.println(s);
        }
        return null;
    }
});
consumer.start();

拉取式消费:

 应用通常主动调用Consumer的拉消息方法从Broker服务器拉消息、主动权由应用控制。一旦获取了批量消息,应用就会启动消费过程。

DefaultMQProducer producer = new DefaultMQProducer(GROUP);
producer.setNamesrvAddr(HOST);
producer.start();
for (int i = 0; i < 100; i++) {
    Message message = new Message("TopicTest", ("测试消息" + i).getBytes("UTF-8"));
    SendResult result = producer.send(message);
    System.out.println(result);
}
producer.shutdown();

发送顺序消息:

在RocketMQ中,一个Topic由多个MessageQueue组成,常规的消息发送会将消息随机发送给任意一个MessageQueue中,这样客户端在消费不同的MessageQueue的消息时就会出现消息无序的情况。

发送顺序消息的关键就是客户端将消息发送个一个特定的MeesageQueue,这样消费者在消费时就能保证消息的获取的顺序性了。

public class OrderDTO {
    private Integer orderId;
    private String name;
    private String step;
}

public static List<OrderDTO> buildOrderList() {
    List<OrderDTO> list = new ArrayList<>();
    list.add(new OrderDTO(3001, "张三", "创建订单"));
    list.add(new OrderDTO(3002, "李四", "创建订单"));
    list.add(new OrderDTO(3001, "张三", "发送通知"));
    list.add(new OrderDTO(3001, "张三", "完成"));
    list.add(new OrderDTO(3002, "李四", "发送通知"));
    list.add(new OrderDTO(3002, "李四", "完成"));
    return list;
}

private static final String HOST = "192.168.0.10:9876";
private static final String GROUP = "ROCKET_TEST";
private static final String TOPIC = "TopicTest";

public static void main(String[] args) throws MQClientException, UnsupportedEncodingException, RemotingException, InterruptedException, MQBrokerException {
    List<OrderDTO> orders = buildOrderList();
    DefaultMQProducer producer = new DefaultMQProducer(GROUP);
    producer.setNamesrvAddr(HOST);
    producer.start();
    for (OrderDTO order : orders) {
        Message msg = new Message(TOPIC, JSONObject.toJSONString(order).getBytes(RemotingHelper.DEFAULT_CHARSET));
        /**
         * 参数一:消息对象
         * 参数二:MessageQueue选择器
         * 参数三:选择MessageQueue的业务标识
         */
        SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
            /**
             * @param mqs MessageQueue集合
             * @param msg 消息对象
             * @param arg 业务表示参数
             * @return
             */
            @Override
            public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
                Integer orderId = (Integer) arg;
                int index = orderId % mqs.size();
                return mqs.get(index);
            }
        }, order.getOrderId());
        System.out.println(sendResult);
    }
}

消费顺序消息:

private static final String HOST = "192.168.0.10:9876";
private static final String GROUP = "ROCKET_TEST";
private static final String TOPIC = "TopicTest";

public static void main(String[] args) throws MQClientException, UnsupportedEncodingException, RemotingException, InterruptedException, MQBrokerException {
    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(GROUP);
    consumer.subscribe(TOPIC, "*");
    consumer.setNamesrvAddr(HOST);

    consumer.registerMessageListener(new MessageListenerOrderly() {
        @Override
        public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs,
                                                   ConsumeOrderlyContext context) {
            for (MessageExt msg : msgs) {
                System.out.println(Thread.currentThread().getName() + "消费消息:" + new String(msg.getBody()));
            }
            return ConsumeOrderlyStatus.SUCCESS;
        }
    });
    consumer.start();
    System.out.printf("Consumer Started.%n");
}

 生产者事务消息:

生产者:

 

private static final String GROUP = "group";
private static final String HOST = "192.168.0.10:9876";
private static final String TOPIC = "TransactionTopic";

public static void main(String[] args) throws MQClientException, InterruptedException {
    TransactionListener transactionListener = new TransactionListenerImpl();
    TransactionMQProducer producer = new TransactionMQProducer(GROUP);
    producer.setNamesrvAddr(HOST);
    ExecutorService executorService = new ThreadPoolExecutor(2, 5, 100, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(2000), new ThreadFactory() {
        @Override
        public Thread newThread(Runnable r) {
            Thread thread = new Thread(r);
            thread.setName("client-transaction-msg-check-thread");
            return thread;
        }
    });

    producer.setExecutorService(executorService);
    producer.setTransactionListener(transactionListener);
    producer.start();

    String[] tags = new String[] {"TagA", "TagB", "TagC", "TagD", "TagE"};
    for (int i = 0; i < 10; i++) {
        try {
            Message msg = new Message(TOPIC, tags[i % tags.length], "KEY" + i, ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
            SendResult sendResult = producer.sendMessageInTransaction(msg, null);
            System.out.printf("%s%n", sendResult);

            Thread.sleep(10);
        } catch (MQClientException | UnsupportedEncodingException e) {
            e.printStackTrace();
        }
    }

    for (int i = 0; i < 100000; i++) {
        Thread.sleep(1000);
    }
    producer.shutdown();
}

 事务监听:

public class TransactionListenerImpl implements TransactionListener {
    private AtomicInteger transactionIndex = new AtomicInteger(0);

    private ConcurrentHashMap<String, Integer> localTrans = new ConcurrentHashMap<>();

    /**
     * 执行本地事务,并返回给MQ事务状态
     * @param msg
     * @param arg
     * @return
     */
    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        int value = transactionIndex.getAndIncrement();
        int status = value % 3;
        localTrans.put(msg.getTransactionId(), status);
        return LocalTransactionState.UNKNOW;
    }

    /**
     * 若长时间未提交事务状态,会进行事务回查
     * @param msg
     * @return
     */
    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        Integer status = localTrans.get(msg.getTransactionId());
        if (null != status) {
            switch (status) {
                case 0:
                    return LocalTransactionState.UNKNOW;
                case 1:
                    return LocalTransactionState.COMMIT_MESSAGE;
                case 2:
                    return LocalTransactionState.ROLLBACK_MESSAGE;
            }
        }
        return LocalTransactionState.COMMIT_MESSAGE;
    }
}

RocketMQ消息发送流程:

 NameServer 全部都是处于相同状态的,保存的都是相同的信息。在 Broker 启动的时候,其会将自己在本地存储的话题配置文件 (默认位于 $HOME/store/config/topics.json 目录) 中的所有话题加载到内存中去,然后会将这些所有的话题全部同步到所有的 NameServer 中。与此同时,Broker 也会启动一个定时任务,默认每隔 30 秒来执行一次话题全同步。

NameServer信息同步:

NameServer作为无状态节点,本身不持久化任何信息,它只会将Broker通过心跳上报的Topic信息存储在内存中,不同的NameServer之间是没有通讯连接的,每一个NameServer中数据理论上来说都是最终一致的。

Broker在启动时,会将自身的信息全部注册至NameServer,然后每隔30s会向NameServer通过心跳更新自身的Topic信息。NameServer 每隔10s 会扫描 brokerLiveTable,检测表中上次收到心跳包的时间,比较当前时间与上一次时间,如果超过120s,则会认为broker不可用,移除路由表中该broker相关的所有信息。

寻找Topic路由信息:

当客户端发送消息的时候,其首先会尝试寻找Topic路由信息。即这条消息应该被发送到哪个地方去。

客户端在内存中维护了一份和Topic相关的路由信息表 topicPublishInfoTable,当发送消息的时候,会首先尝试从此表中获取信息。如果此表不存在这条话题的话,那么便会从 Name 服务器获取路由消息。

发送消息时的容错机制:

 在RocketMQ Producer发送失败后,Producer默认会再重试两次(retryTimesWhenSendFailed)。若上一次发送失败,则在重试选择队列时,会尽量跳过上一次失败的broker,去选择不是上一次失败的broker上的队列。

但是此种容错只会在当前这一次发送中生效,RocketMQ 提供了 sendLatencyFaultEnable 参数开启 broker故障延迟机制,故障延迟机制由 MQFaultStrategy 实现,MQFaultStrategy 使用了装饰器模式,对基础的容错机制进行了增强。

是否启用Broker故障延迟机制,开启与不开启sendLatencyFaultEnable机制在消息发送时都能规避故障的Broker,那么这两种机制有何区别呢?

开启所谓的故障延迟机制,即设置sendLatencyFaultEnable为true,其实是一种较为悲观的做法。当消息发送者遇到一次消息发送失败 后,就会悲观地认为Broker不可用,在接下来的一段时间内就不再向其发送消息,直接避开该Broker。而不开启延迟规避机制,就只会在本次消息发送的重试过程中规避该Broker,下一次消息发送还是会继续尝试。 

发送消息的整体流程:

  • Broker启动时,向NameServer注册信息

  • 客户端调用producer发送消息时,会先从NameServer获取该topic的路由信息。消息头code为GET_ROUTEINFO_BY_TOPIC

  • 从NameServer返回的路由信息,包括topic包含的队列列表和broker列表

  • Producer端根据查询策略,选出其中一个队列,用于后续存储消息

  • 每条消息会生成一个唯一id,添加到消息的属性中。属性的key为UNIQ_KEY

  • 对消息做一些特殊处理,比如:超过4M会对消息进行压缩

  • producer向Broker发送rpc请求,将消息保存到broker端。消息头的code为SEND_MESSAGE或SEND_MESSAGE_V2(配置文件设置了特殊标志)

RocketMQ消息存储流程 :

RocketMQ存储的文件主要包括CommitLog文件、ConsumeQueue文件、Index文件。RocketMQ将所有topic的消息存储在同一个文件中,确保消息发送时按顺序写文件,尽最大的能力确保消息发送的高性能与高吞吐量。因为消息中间件一般是基于消息主题的订阅机制,所以给按照消息主题检索消息带来了极大的不便。为了提高消息消费的效率,RocketMQ引入了ConsumeQueue消息消费队列文件,每个topic包含多个消息消费队列,每一个消息队列有一个消息文件。Index索引文件的设计理念是为了加速消息的检索性能,根据消息的属性从CommitLog文件中快速检索消息。

  • CommitLog :消息存储文件,所有topic的消息都存储在CommitLog 文件中。

  • ConsumeQueue :消息消费队列,消息到达CommitLog 文件后,将异步转发到消息消费队列,供消息消费者消费。

  • IndexFile :消息索引文件,主要存储消息Key 与Offset 的对应关系。

CommitLog:

RocketMQ 在消息写入过程中追求极致的磁盘顺序写,所有topic的消息全部写入一个文件,即 CommitLog 文件。所有消息按抵达顺序依次追加到 CommitLog 文件中,消息一旦写入,不支持修改。CommitLog 文件默认创建的大小为 1GB。

CommitLog 每个文件的命名是按照总的字节偏移量来命名的。例如第一个文件偏移量为 0,那么它的名字为 00000000000000000000;当前这 1G 文件被存储满了之后,就会创建下一个文件,下一个文件的偏移量则为 1GB,那么它的名字为 00000000001073741824,以此类推。默认情况下这些消息文件位于 $HOME/store/commitlog 目录下。这样做的好处是给出任意一个消息的物理偏移量(即消息存储在文件的起始位置),可以通过二分法进行查找,快速定位这个文件的位置,然后用消息物理偏移量减去所在文件的名称,得到的差值就是在该文件中的绝对地址。

 ConsumeQueue:

消息消费模型是基于主题订阅机制的,即一个消费组是消费特定主题的消息。根据主题从CommitlLog文件中检索消息,这绝不是一个好主意,这样只能从文件的第一条消息逐条检索,其性能可想而知,为了解决基于topic的消息检索问题,ConsumeQueue文件结构入下图:

ConsumeQueue文件是消息消费队列文件,是 CommitLog 文件基于topic的索引文件,主要用于消费者根据 topic 消费消息,其组织方式为 /topic/queue,同一个队列中存在多个消息文件。ConsumeQueue 的设计极具技巧,每个条目长度固定(8字节CommitLog物理偏移量、4字节消息长度、8字节tag哈希码)。这里不是存储tag的原始字符串,而是存储哈希码,目的是确保每个条目的长度固定,可以使用访问类似数组下标的方式快速定位条目,极大地提高了ConsumeQueue文件的读取性能。消息消费者根据topic、消息消费进度(ConsumeQueue逻辑偏移量),即第几个ConsumeQueue条目,这样的消费进度去访问消息,通过逻辑偏移量logicOffset×20,即可找到该条目的起始偏移量(ConsumeQueue文件中的偏移量),然后读取该偏移量后20个字节即可得到一个条目,无须遍历ConsumeQueue文件。

ConsumeQueue文件可以看作基于topic维度的CommitLog索引文件,故ConsumeQueue文件夹的组织方式为topic/queue/file三层组织结构,文件存储在 $HOME/store/consumequeue/{topic}/{queueId}/{fileName},单个文件由30万个条目组成,每个文件大小约5.72MB。同样的单个ConsumeQueue文件写满后,会继续写入下一个文件中。

Index:

RocketMQ与Kafka相比具有一个强大的优势,就是支持按消息属性检索消息,引入ConsumeQueue文件解决了基于topic查找消息的问题,但如果想基于消息的某一个属性进行查找,ConsumeQueue文件就无能为力了。故RocketMQ又引入了Index索引文件,实现基于文件的哈希索引。Index文件的存储结构如下图所示。

Index文件基于物理磁盘文件实现哈希索引。Index文件由40字节的文件头、500万个哈希槽、2000万个Index条目组成,每个哈希槽4字节、每个Index条目含有20个字节,分别为4字节索引key的哈希码、8字节消息物理偏移量、4字节时间戳、4字节的前一个Index条目(哈希冲突的链表结构)。

内存映射:

虽然基于磁盘的顺序写消息可以极大提高I/O的写效率,但如果基于文件的存储采用常规的Java文件操作API,例如FileOutputStream等,将性能提升会很有限,故RocketMQ又引入了内存映射,将磁盘文件映射到内存中,以操作内存的方式操作磁盘,将性能又提升了一个档次。 在Java中可通过FileChannel的map方法创建内存映射文件。在Linux服务器中由该方法创建的文件使用的就是操作系统的页缓存(pagecache)。Linux操作系统中内存使用策略时会尽可能地利用机器的物理内存,并常驻内存中,即页缓存。在操作系统的内存不够的情况下,采用缓存置换算法,例如LRU将不常用的页缓存回收,即操作系统会自动管理这部分内存。

如果RocketMQ Broker进程异常退出,存储在页缓存中的数据并不会丢失,操作系统会定时将页缓存中的数据持久化到磁盘,实现数据安全可靠。不过如果是机器断电等异常情况,存储在页缓存中的数据也有可能丢失。

刷盘策略:

有了顺序写和内存映射的加持,RocketMQ的写入性能得到了极大的保证,但凡事都有利弊,引入了内存映射和页缓存机制,消息会先写入页缓存,此时消息并没有真正持久化到磁盘。那么Broker收到客户端的消息后,是存储到页缓存中就直接返回成功,还是要持久化到磁盘中才返回成功呢?

这是一个“艰难”的选择,是在性能与消息可靠性方面进行权衡。为此,RocketMQ提供了三种策略:同步刷盘、异步刷盘、异步刷盘+缓冲区。

类型描述
SYNC_FLUSH同步刷盘
ASYNC_FLUSH && transientStorePoolEnable=false(默认为false)异步刷盘
ASYNC_FLUSH && transientStorePoolEnable=true异步刷盘+缓冲区
  • 同步刷盘时,只有消息被真正持久化到磁盘才会响应ACK,可靠性非常高,但是性能会受到较大影响,适用于金融业务。
  • 异步刷盘时,消息写入PageCache就会响应ACK,然后由后台线程异步将PageCache里的内容持久化到磁盘,降低了读写延迟,提高了性能和吞吐量。服务宕机消息不丢失(操作系统会完成内存映射区域的刷盘),机器断电少量消息丢失。
  • 异步刷盘+缓冲区,消息先写入直接内存缓冲区,然后由后台线程异步将缓冲区里的内容持久化到磁盘,性能最好。但是最不可靠,服务宕机和机器断电都会丢失消息。

 文件恢复机制:

在RocketMQ中有broker异常停止恢复和正常停止恢复两种场景。这两种场景的区别是定位从哪个文件开始恢复的逻辑不一样,大致思路如下。

  1. 尝试恢复ConsumeQueue文件,根据文件的存储格式(8字节物理偏移量、4字节长度、8字节tag哈希码),找到最后一条完整的消息格式所对应的物理偏移量,用maxPhysical OfConsumequeue表示。
  2. 尝试恢复CommitLog文件,先通过文件的魔数判断该文件是否为ComitLog文件,然后按照消息的存储格式寻找最后一条合格的消息,拿到其物理偏移量,如果CommitLog文件的有效偏移量小于ConsumeQueue文件存储的最大物理偏移量,将会删除ConsumeQueue中多余的内容,如果大于,说明ConsuemQueue文件存储的内容少于CommitLog文件,则会重推数据。

那么如何定位要恢复的文件呢?

正常停止刷盘的情况下,先从倒数第三个文件开始进行恢复,然后按照消息的存储格式进行查找,如果该文件中所有的消息都符合消息存储格式,则继续查找下一个文件,直到找到最后一条消息所在的位置。

异常停止刷盘的情况下,RocketMQ会借助检查点文件,即存储的刷盘点,定位恢复的文件。刷盘点记录的是CommitLog、ConsuemQueue、Index文件最后的刷盘时间戳,但并不是只认为该时间戳之前的消息是有效的,超过这个时间戳之后的消息就是不可靠的。

异常停止刷盘时,从最后一个文件开始寻找,在寻找时读取该文件第一条消息的存储时间,如果这个存储时间小于检查点文件中的刷盘时间,就可以从这个文件开始恢复,如果这个文件中第一条消息的存储时间大于刷盘点,说明不能从这个文件开始恢复,需要寻找上一个文件,因为检查点文件中的刷盘点代表的是100%可靠的消息。

 RocketMQ延迟消息:

存储延迟消息:

  1. 将原始topic替换为延迟消息固定的topic:SCHEDULE_TOPIC_XXXX(所有的延时消息共用这一个topic)。

  2. 将原始queueid替换为(延迟级别-1),也就是所有相同延迟级别的延迟消息都会发送至同一个queue中。

  3. 备份原始topic/queueid,保存到原始消息的properties属性中。目的是为了等到延迟时间到了,将延迟消息发送至原始topic时使用。

  4. 不过在消息分发(构建消息索引)时,将索引单元的的tag hashcode 替换为消息的投递时间

 

延迟消息的投递:

上面broker将延迟消息写到了commitlog中,由于broker替换了我们的原始topic,所以订阅该topic的消费者此时还无法消费该消息,只有当时间到了消费者才可以消费。

处理延迟消息:

Broker中同一等级的所有延时消息会被写入到consumequeue 目录中SCHEDULE_TOPIC_XXXX目录下相同Queue中。即一个Queue中消息投递时间的延迟等级时间是相同的。那么投递时间就取决于消息存储时间了。即按照消息被发送到Broker的时间进行排序的。

每一个延迟级别对应一个DeliverDelayedMessageTimerTask,所以相同延迟级别的消息共用同一个线程。过程如下:

  1. 根据延迟topic和延迟queueid获取consumequeue,并从队列中读取索引单元

  2. 计算消息的投递时间。从索引单元中取出消息的保存时间(延迟消息的索引单元会将tag hashcode 替换为消息的存储时间),然后根据延迟等级获取出延迟时间,然后二者相加就是消息的投递时间。

  3. 如果投递时间到了。则根据索引单元中的commitlog offset 和 msg size 将该条消息A从commitlog中读取出来。然后将读取出来的消息属性复制到一个新的消息对象体B中,将A中备份的原始topic、queueid 读取出来重新设置到B中,并清除延迟属性,使其成为一条普通消息。最后调用CommitLog#putMessage(msg)方法,再次将消息B写入到commitlog中。这样消费者就可以消费到订阅了该topic的消息。

  4. 如果投递时间没到。则计算剩余投递时间countdown(投递时间-当前时间),然后开启一个JDK的Timer延迟任务,延迟时间就是countdown,继续执行DeliverDelayedMessageTimerTask的逻辑。

  5. 更新延迟消息队列的消费进度(后面持久化也就是指的它)

这里简单说下:同一个Queue(delayLevel - 1)中消息投递时间的延迟等级是相同的。那么投递时间就取决于消息存储时间了。即按照消息被发送到Broker的时间进行排序的。

持久化:

持久化其实也非常的简单,就是通过定时任务,每隔10s将延迟队列的消费进度offset写到文件中。

文件默认路径:$user.home/store/config/delayOffset.json。key 就是延迟等级,value 就是对应的消费进度offset。

 RocketMQ顺序消息:

 RocketMQ顺序消息类型:

全局顺序消息:

对于指定的一个Topic,所有消息按照严格的先入先出(FIFO)的顺序来发布和消费(单生产者单线程,单消费者单线程)

适用场景:适用于性能要求不高,所有的消息严格按照FIFO原则来发布和消费的场景。

分区顺序消息:

对于指定的一个Topic,所有消息根据Sharding Key进行划分到不同队列中,同一个队列内的消息按照严格的先进先出(FIFO)原则进行发布和消费。同一队列内同一Sharding Key的消息保证顺序,不同队列之间的消息顺序不做要求。

适用场景:适用于性能要求高,以Sharding Key作为划分字段,在同一个区块中严格地按照先进先出(FIFO)原则进行消息发布和消费的场景。

 RocketMQ顺序消息原理:

RocketMQ为了保证消息的顺序性,分别从ProducerConsumer都有着相应的设计:

Producer方面:

为保证顺序消息,可自定义MessageQueueSelector来选择队列。例: orderId % msgQueueSize,从而可保证同一个orderId的相关消息,会被发送到同一个队列里。

Consumer方面:

在整体设计上用了三把锁,来保证消息的顺序消费:

  • broker分布式锁:保证只有一个消费者进程能够拉取到消息。
  • processQueue锁:保证在拉取到消息的进程中,只有一个线程能够消费这些消息。
  • 消费锁:保证消费线程在消费途中,重平衡导致队列被分配到别的实力上时,不会立即将broker分布式锁解锁,而是等待消费者消费完成或者等待下一次重平衡在解锁。这样就能保证在重平衡场景下不会出现两个进程内的线程消费同一个队列的情况。

RocketMQ事务消息:

RocketMQ提供了事务消息的功能,采用2PC(两阶段协议)+补偿机制(事务回查)的分布式事务功能,通过这种方式能达到分布式事务的最终一致。

  • 半事务消息:指的是发送至broker但是还没被commit的消息,在半事务消息被确认之前都是无法被消费者消费的。
  • 消息回查:由于网络闪断、生产者应用重启等原因,导致某条事务消息的二次确认丢失,broker 通过扫描发现某条消息长期处于“半事务消息”时,需要主动向消息生产者询问该消息的最终状态(commit 或是 rollback),该询问过程即消息回查。

事务消息发送步骤:

  1. 发送方将半事务消息发送至broker。

  2. broker将消息持久化成功之后,向发送方返回 Ack,确认消息已经发送成功,此时消息为半事务消息。

  3. 发送方开始执行本地事务逻辑。

  4. 发送方根据本地事务执行结果向服务端提交二次确认(commit 或是 rollback),服务端收到 commit 状态则将半事务消息标记为可投递,订阅方最终将收到该消息;服务端收到 rollback 状态则“删除”半事务消息,订阅方将不会接受该消息。

  5. 在断网或者是应用重启的特殊情况下,上述步骤 4 提交的二次确认最终未到达服务端,经过固定时间后服务端将对该消息发起消息回查。

  6. 发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。

  7. 发送方根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤 4 对半事务消息进行操作。

  • 8
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值