MQ高可用相关设置

前言

MQ作用:消峰,异步,解耦, 数据收集【收集日志】、大数据处理等。

使用MQ的好处

  • 提高系统响应速度【异步】
  • 提高系统稳定性【消峰】
  • 排序保证先进先出FIFO

下表是对Kafka与RabbitMQ功能的总结性对比及补充说明。

功能项Kafka(1.1.0版本)RabbitMQ(3.6.10版本)
优先级队列不支持支持。建议优先级大小设置在0-10之间。
延迟队列不支持支持
死信队列不支持支持
重试队列不支持不支持。RabbitMQ中可以参考延迟队列实现一个重试队列,二次封装比较简单。如果要在Kafka中实现重试队列,首先得实现延迟队列的功能,相对比较复杂。
消费模式拉模式推模式+拉模式
广播消费支持。Kafka对于广播消费的支持相对而言更加正统。支持,但力度较Kafka弱。
消息回溯支持。Kafka支持按照offset和timestamp两种维度进行消息回溯。不支持。RabbitMQ中消息一旦被确认消费就会被标记删除。
消息堆积支持支持。一般情况下,内存堆积达到特定阈值时会影响其性能,但这不是绝对的。如果考虑到吞吐这因素,Kafka的堆积效率比RabbitMQ总体上要高很多。
持久化支持支持
消息追踪不支持。消息追踪可以通过外部系统来支持,但是支持粒度没有内置的细腻。支持。RabbitMQ中可以采用Firehose或者rabbitmq_tracing插件实现。不过开启rabbitmq_tracing插件件会大幅影响性能,不建议生产环境开启,反倒是可以使用Firehose与外部链路系统结合提供高细腻度的消息追踪支持。
消息过滤客户端级别的支持不支持。但是二次封装一下也非常简单。
多租户不支持支持
多协议支持只支持定义协议,目前几个主流版本间存在兼容性问题。RabbitMQ本身就是AMQP协议的实现,同时支持MQTT、STOMP等协议。
跨语言支持采用Scala和Java编写,支持多种语言的客户端。采用Erlang编写,支持多种语言的客户端。
流量控制支持client和user级别,通过主动设置可将流控作用于生产者或消费者。RabbitMQ的流控基于Credit-Based算法,是内部被动触发的保护机制,作用于生产者层面。
消息顺序性支持单分区(partition)级别的顺序性。顺序性的条件比较苛刻,需要单线程发送、单线程消费并且不采用延迟队列、优先级队列等一些高级功能,从某种意义上来说不算支持顺序性。
安全机制(TLS/SSL、SASL)身份认证和(读写)权限控制与Kafka相似
幂等性支持单个生产者单分区单会话的幂等性。不支持
事务性消息支持支持

四种MQ对比介绍

MQ如何保证消息不丢失

消息丢失的三种情况

  1. 消息在传入服务过程中丢失
  2. MQ收到消息,暂存内存中,还没消费,自己挂掉了,内存中的数据搞丢
  3. 消费者消费到了这个消息,但还没来得及处理,就挂了,MQ以为消息已经被处理

也就是生产者丢失消息、消息列表丢失消息、消费者丢失消息;

RabbitMQ

一、生产者

开启RabbitMQ事务
生产者发送数据之前开启 RabbitMQ 事务channel.txSelect,然后发送消息,如果消息没有成功被 RabbitMQ 接收到,那么生产者会收到异常报错,此时就可以回滚事务channel.txRollback,然后重试发送消息;如果收到了消息,那么可以提交事务channel.txCommit。

// 开启事务
channel.txSelect
try {
      // 这里发送消息
} catch (Exception e) {
      channel.txRollback
 
// 这里再次重发这条消息
 
}
 
// 提交事务
channel.txCommit

设置Confirm模式:

同步确认:

//开启发布确认
channel.confirmSelect();
String message = i + "";
channel.basicPublish("",queueName,null,message.getBytes());
//服务端返回 false 或超时时间内未返回,生产者可以消息重发
boolean flag = channel.waitForConfirms();
if(flag){
    System.out.println("消息发送成功");
}

异步确认 :

服务端 :

消息持久化,必须满足以下三个条件,缺一不可。

  • Exchange 设置持久化

  • Queue 设置持久化

  • Message持久化发送:发送消息设置发送模式deliveryMode=2,代表持久化消息

发送消息时设置delivery_mode属性为2,使消息被持久化保存到磁盘,即使RabbitMQ服务器宕机也能保证消息不丢失。同时,创建队列时设置durable属性为True,以确保队列也被持久化保存。

// 声明队列,并将队列设置为持久化
channel.queueDeclare(QUEUE_NAME, true, false, false, null);
String message = "Hello, RabbitMQ!";
// 发送消息时将消息设置为持久化
channel.basicPublish("", QUEUE_NAME, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());
AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder()
        .deliveryMode(2)
        .build();
channel.basicPublish("", "myQueue", properties, "Hello, RabbitMQ".getBytes());

设置备份交换机:

Map<String, Object> arguments = new HashMap<>();
arguments.put("alternate-exchange", "myAlternateExchange");
channel.exchangeDeclare("myExchange", BuiltinExchangeType.DIRECT, true, false, arguments);
channel.exchangeDeclare("myAlternateExchange", BuiltinExchangeType.FANOUT, true, false, null);

二 :服务端设置集群镜像模式

  • 单节点模式: 最简单的情况,非集群模式,节点挂了,消息就不能用了。业务可能瘫痪,只能等待。

  • 普通模式: 消息只会存在与当前节点中,并不会同步到其他节点,当前节点宕机,有影响的业务会瘫痪,只能等待节点恢复重启可用(必须持久化消息情况下)。

  • 镜像模式: 消息会同步到其他节点上,可以设置同步的节点个数,但吞吐量会下降。属于RabbitMQ的HA方案

消费方开启消息确认机制 :

// 开启消息确认机制
channel.basicConsume(QUEUE_NAME, false, new DefaultConsumer(channel) {
    @Override
    public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
        String message = new String(body, "UTF-8");
        System.out.println("Received message: " + message);
        // 手动发送消息确认
        channel.basicAck(envelope.getDeliveryTag(), false);
    }
});

手动确认 :

codechannel.basicConsume("myQueue", false, (consumerTag, delivery) -> {
    // 处理消息
    channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
}, consumerTag -> {});

RocketMQ

一、生产者提供SYNC的发送消息方式,等待broker处理结果。

RocketMQ生产者提供了3种发送消息方式,分别是:

同步发送:Producer 向 broker 发送消息,阻塞当前线程等待 broker 响应发送结果。

Message msg = new Message("TopicTest",
        "TagA","OrderID188",
        "Hello world".getBytes(RemotingHelper.DEFAULT_CHARSET));
//同步传递消息,消息会发给集群中的⼀个Broker节点。
SendResult sendResult = producer.send(msg);

异步发送:Producer 首先构建一个向 broker 发送消息的任务,把该任务提交给线程池,等执行完该任务时,回调用户自定义的回调函数,执行处理结果。

Message msg = new Message("TopicTest","TagA","OrderID188",
        "Hello world".getBytes(RemotingHelper.DEFAULT_CHARSET));
        
producer.send(msg, new SendCallback() {
    @Override
    public void onSuccess(SendResult sendResult) {
        countDownLatch.countDown();
        System.out.printf("%-10d OK %s %n", index, sendResult.getMsgId());
    }

    @Override
    public void onException(Throwable e) {
        countDownLatch.countDown();
        System.out.printf("%-10d Exception %s %n", index, e);
        e.printStackTrace();
    }
});

Oneway发送:Oneway 方式只负责发送请求,不等待应答,Producer只负责把请求发出去,而不处理响应结果。

Message msg = new Message("TopicTest" /* Topic */,
        "TagA" /* Tag */,
        ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */
);
//Call send message to deliver message to one of brokers.
//核⼼:发送消息。没有返回值,发完消息就不管了,不知道有没有发送消息成功
producer.sendOneway(msg);

生产者使用重试机制 :
当发送消息发生异常或超时的时候重新循环发送。默认重试3次,重试次数可以通过setRetryTimesWhenSendFailed指定。

// 同步发送消息,如果5秒内没有发送成功,则重试3次
DefaultMQProducer producer = new DefaultMQProducer("DefaultProducer");
producer.setRetryTimesWhenSendFailed(3);
producer.send(msg, 5000L);

SendResult定义说明(来自RocketMQ官方)

  • SEND_OK
    消息发送成功。要注意的是消息发送成功也不意味着它是可靠的。要确保不会丢失任何消息,还应启用同步Master服务器或同步刷盘,即SYNC_MASTER或SYNC_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方法时,不指定回调方法,则默认采用同步发送消息的方式,这也是丢失几率最小的一种发送方式(但是效率比较低)。

二、Borker 方面 : 设置成同步刷盘及同步复制;开启集群模式,集群同步;

1)异步刷盘

默认。消息写入 CommitLog 时,并不会直接写入磁盘,而是先写入 PageCache 缓存后返回成功,然后用后台线程异步把消息刷入磁盘。异步刷盘提高了消息吞吐量,但是可能会有消息丢失的情况,比如断点导致机器停机,PageCache 中没来得及刷盘的消息就会丢失。

2)同步刷盘

消息写入内存后,立刻请求刷盘线程进行刷盘,如果消息未在约定的时间内(默认 5 s)刷盘成功,就返回 FLUSH_DISK_TIMEOUT,Producer 收到这个响应后,可以进行重试。同步刷盘策略保证了消息的可靠性,同时降低了吞吐量,增加了延迟。要开启同步刷盘,需要增加下面配置:

flushDiskType=SYNC_FLUSH

同步复制后,消息复制流程如下:

  • slave 初始化后,跟 master 建立连接并向 master 发送自己的 offset;

  • master 收到 slave 发送的 offset 后,将 offset 后面的消息批量发送给 slave;

  • slave 把收到的消息写入 commitLog 文件,并给 master 发送新的 offset;

  • master 收到新的 offset 后,如果 offset >= producer 发送消息后的 offset,给 Producer 返回 SEND_OK。

三、消费者

RocketMQ消费失败后的消费重试机制

手动提交消息偏移量

consumer.registerMessageListener(new MessageListenerConcurrently() {
    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
        ConsumeConcurrentlyContext context) {
        System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
});
public enum ConsumeConcurrentlyStatus {
    //业务方消费成功
    CONSUME_SUCCESS,
    //业务方消费失败,之后进行重新尝试消费
    RECONSUME_LATER;
}

RECONSUME_LATER “%RETRY%+ConsumeGroupName”—重试队列的主题

消息不丢失

RocketMQ怎么保证消息不丢失

Kafka

解决方案:
1、生产者调用异步回调消息。伪代码如下: producer.send(msg,callback);
2、生产者增加消息确认机制,设置生产者参数:acks = all。指定partition的leader副本接收到消息,等待所有的follower副本都同步到了消息之后,才认为本次生产者发送消息成功了;
3、生产者设置重试次数。比如:retries>=3,增加重试次数以保证消息的不丢失;
4、定义本地消息日志表,定时任务扫描这个表自动补偿,做好监控告警。
5、后台提供一个补偿消息的工具,可以手工补偿。
6、消费者Rebalance的时候


生产者设置同步发送 :

 // 异步发送 默认
 kafkaProducer.send(new ProducerRecord<>("first","kafka" + i));
 // 同步发送
 RecordMetadata first = kafkaProducer.send(new ProducerRecord<>("first", "kafka" + i)).get(); 

生产者设置发送ack :

// 1. 创建 kafka 生产者的配置对象
Properties properties = new Properties();
// 设置 acks
properties.put(ProducerConfig.ACKS_CONFIG, "all");
// 3. 创建 kafka 生产者对象
KafkaProducer<String, String> kafkaProducer = new KafkaProducer<String, String>(properties);
            kafkaProducer.send(new ProducerRecord<>("first","atguigu " + i));

生产者设置重试次数。比如:retries>=3,增加重试次数以保证消息的不丢失;

// 1. 创建 kafka 生产者的配置对象
Properties properties = new Properties();
// 设置 acks
properties.put(ProducerConfig.ACKS_CONFIG, "all");
// 重试次数 retries,默认是 int 最大值,2147483647
properties.put(ProducerConfig.RETRIES_CONFIG, 3);
// 3. 创建 kafka 生产者对象
KafkaProducer<String, String> kafkaProducer = new KafkaProducer<String, String>(properties);

三、消费者:

通过在Consumer端设置“enable.auto.commit”属性为false后,
在代码中手动调用KafkaConsumer实例的commitSync()方法提交,

这里指的是同步阻塞commit消费的偏移量,等待Broker端的返回响应,需要注意Broker端在对commit请求做出响应之前,消费端会处于阻塞状态,从而限制消息的处理性能和整体吞吐量以确保消息能够正常被消费。

如果在消费过程中,消费端突然Crash,这时候消费偏移量没有commit,等正常恢复后依然还会处理刚刚未commit的消息。

生产者acks参数指定了必须要有多少个分区副本收到消息,生产者才会认为消息写入是成功的。这个参数对消息丢失的可能性有重要影响。

1)ack=0,生产者在成功写入悄息之前不会等待任何来自服务器的响应。

2)ack=1,只要集群的首领副本收到消息,生产者就会收到一个来自服务器的成功响应。这是默认值,提供了基本的持久性保证。

3)ack=all,只有当所有同步副本全部收到消息时,生产者才会收到一个来自 服务器的成功响应。

选择哪个值取决于你的业务需求和对可靠性和延迟的要求。一般来说,如果需要较高的可靠性,可以选择all;如果对延迟更敏感,可以选择1

MQ如何保证顺序消息

严格顺序消费的注意事项

  • 生产者不能异步发送,异步发送在发送失败的情况下,就没办法保证消息顺序
    比如你连续发了1,2,3。 过了一会,返回结果1失败,2, 3成功。你把1再重新发送1遍,这个时候顺序就乱掉了。

  • 应用中应确保业务中添加事务锁,防止并发处理同一对象。
    比如修改业务员的手机号,操作员A和操作员B同时修改业务员张三的手机号,如两人的填入手机号相同无影响,如不同,操作员A输入正确,操作员B输入错误,可能造成消费顺序乱掉,手机号修改错误。

  • 对于消费端,不能并行消费,生产者顺序发送,消费端必须顺序消费。

在实现顺序消息前提下,必须保证消息不丢失。

RabbitMQ

消息单个消费者单线程消费。

RocketMQ

RocketMQ 提供了两种顺序消息模式:全局顺序消息和分区顺序消息。

  • 全局顺序消息:适用于性能要求不高的场景,所有消息按照严格的先入先出(FIFO)的顺序来发布和消费。全局顺序消息实际上是一种特殊的分区顺序消息,即Topic中只有一个分区,因此全局顺序和分区顺序的实现原理相同。由于分区顺序消息有多个分区,所以分区顺序消息比全局顺序消息的并发度和性能更高。
  • 分区顺序消息:适用于性能要求高的场景,所有消息根据Sharding Key进行区块分区,同一个分区内的消息按照严格的先进先出(FIFO)原则进行发布和消费。同一分区内的消息保证顺序,不同分区之间的消息顺序不做要求。对于指定的一个Topic,所有消息按照严格的先入先出(FIFO)的顺序来发布和消费。分区顺序消息比全局顺序消息的并发度和性能更高。

通常情况下,RocketMQ 默认为每个 Topic 分配4个队列。队列数量是在broker的配置文件中设置的,具体来说是在broker.conf文件中通过defaultTopicQueueNums属性来设置。
如果你想为特定的Topic设置队列数量,可以使用管理工具或者控制台来完成。

mqadmin updateTopic -b brokerIP:10911 -n nameserverIP:9876 -t YourTopicName --queue-nums 8

-b 参数后面是一个broker的IP和端口,-n 参数后面是nameserver的IP和端口,-t 参数后面是你要更新的Topic名称,–queue-nums 参数后面是你想要设置的队列数量。

请注意,更改队列数量是一个影响较大的操作,因为它需要重新分配broker上的主题资源。在生产环境中,通常需要在维护窗口内进行,并确保有足够的备份和监控系统以应对可能出现的问题。


发送方使用MessageQueueSelector选择队列 :

Message msg = new Message("OrderTopicTest", "order_"+orderId,
        "KEY" + orderId,("order_"+orderId+" step " + j).getBytes(RemotingHelper.DEFAULT_CHARSET));
//消息队列的选择器
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
    //第一个参数:所有的消息,第二个参数:发送的消息,第三个参数:根据什么发送,这里面传的是orderId
    @Override
    public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
        Integer id = (Integer) arg;
        int index = id % mqs.size();
        return mqs.get(index);
    }
    //同一个订单id可以放到同一个队列里面去
}, orderId);

消费方使用MessageListenerOrderly选择队列 :

consumer.registerMessageListener(new MessageListenerOrderly() {
    @Override
    public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
        //自动提交
        context.setAutoCommit(true);
        for(MessageExt msg:msgs){
            System.out.println("收到消息内容 "+new String(msg.getBody()));
        }
        return ConsumeOrderlyStatus.SUCCESS;
    }
});

Kafka

Kafka是分布式多partition的(默认1个),它会将一个topic中的消息尽可能均匀的分发到每个partition上。那么问题就来了,这样怎么保证同一个topic消息的顺序呢?

kafka可以通过partitionKey,将某类消息写入同一个partition,一个partition只能对应一个消费线程,以保证数据有序。
也就是说生产者在写消息的时候,可以指定一个 key,比如说我们指定了某个订单 id 作为 key,那么这个订单相关的数据,一定会被分发到同一个 partition 中去,而且这个 partition 中的数据一定是有顺序的。

先后两条消息发送时,前一条消息发送失败,后一条消息发送成功,然后失败的消息重试后发送成功,造成乱序。为了解决重试机制引起的消息乱序为实现Producer的幂等性,Kafka引入了Producer ID(即PID)和Sequence Number。

对于每个PID,该Producer发送消息的每个<Topic, Partition>都对应一个单调递增的Sequence Number。
同样,Broker端也会为每个<PID, Topic, Partition>维护一个序号,并且每Commit一条消息时将其对应序号递增。
对于接收的每条消息,如果其序号比Broker维护的序号大一,则Broker会接受它,否则将其丢弃
如果消息序号比Broker维护的序号差值比一大,说明中间有数据尚未写入,即乱序,此时Broker拒绝该消息
如果消息序号小于等于Broker维护的序号,说明该消息已被保存,即为重复消息,Broker直接丢弃该消息
发送失败后会重试,这样可以保证每个消息都被发送到broker

消费者从 partition 中取出来数据的时候,也一定是有顺序的。到这里,顺序还是 ok 的,没有错乱。

指定发送partition的分区:

//没有指明 partition 值但有 key 的情况下,将 key 的 hash 值与 topic 的 partition 数进行取余得到 partition 值
// 依次指定 key 值为 a,b,f ,数据 key 的 hash 值与 3 个分区求余,
//kafkaProducer.send(new ProducerRecord<>("first","a","atguigu " + i), new Callback() {}
kafkaProducer.send(new ProducerRecord("first", 0, "", "atguigu" + i)
        , new Callback() {
            @Override
            public void onCompletion(RecordMetadata metadata, Exception e) {
                if (e == null) {
                    System.out.println(" 主题: " +
                            metadata.topic() + "->" + "分区:" + metadata.partition()
                    );
                } else {
                    e.printStackTrace();
                }
            }
        });

当部分Broker宕机,会触发Reblance,导致同一Topic下的分区数量有变化,此时生产端有可能会把顺序消息发送到不同的分区,这时会发生短暂消息顺序不一致的现象,如果生产端指定分区发送,则该分区所在的Broker宕机后将直接不可用;

广播消息&集群消息

RabbitMQ

广播消息:创建一个fanout类型的交换机,并绑定多个队列,就是广播消息

集群消息:direct和topic类型的交换机就是集群模式

RocketMQ

RocketMQ主要提供了两种消费模式:集群消费以及广播消费。我们只需要在定义消费者的时候通过setMessageModel(MessageModel.XXX)方法就可以指定是集群还是广播式消费,默认是集群消费模式,即每个Consumer Group中的Consumer均摊所有的消息。

生产者:

public class MQProducer {
    public static void main(String[] args) throws MQClientException, UnsupportedEncodingException, RemotingException, InterruptedException, MQBrokerException {
        // 创建DefaultMQProducer类并设定生产者名称
        DefaultMQProducer mqProducer = new DefaultMQProducer("producer-group-test");
        // 设置NameServer地址,如果是集群的话,使用分号;分隔开
        mqProducer.setNamesrvAddr("10.0.91.71:9876");
        // 消息最大长度 默认4M
        mqProducer.setMaxMessageSize(4096);
        // 发送消息超时时间,默认3000
        mqProducer.setSendMsgTimeout(3000);
        // 发送消息失败重试次数,默认2
        mqProducer.setRetryTimesWhenSendAsyncFailed(2);
        // 启动消息生产者
        mqProducer.start();
        // 循环十次,发送十条消息
        for (int i = 1; i <= 10; i++) {
            String msg = "hello, 这是第" + i + "条同步消息";
            // 创建消息,并指定Topic(主题),Tag(标签)和消息内容
            Message message = new Message("CLUSTERING_TOPIC", "", msg.getBytes(RemotingHelper.DEFAULT_CHARSET));
            // 发送同步消息到一个Broker,可以通过sendResult返回消息是否成功送达
            SendResult sendResult = mqProducer.send(message);
            System.out.println(sendResult);
        }
        // 如果不再发送消息,关闭Producer实例
        mqProducer.shutdown();
    }
}

消费者:

public class MQConsumerB {
    public static void main(String[] args) throws MQClientException {
        // 创建DefaultMQPushConsumer类并设定消费者名称
        DefaultMQPushConsumer mqPushConsumer = new DefaultMQPushConsumer("consumer-group-test");
        // 设置NameServer地址,如果是集群的话,使用分号;分隔开
        mqPushConsumer.setNamesrvAddr("10.0.91.71:9876");
        // 设置Consumer第一次启动是从队列头部开始消费还是队列尾部开始消费
        // 如果不是第一次启动,那么按照上次消费的位置继续消费
        mqPushConsumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
        // 设置消费模型,集群还是广播,默认为集群
        mqPushConsumer.setMessageModel(MessageModel.CLUSTERING);
        // 消费者最小线程量
        mqPushConsumer.setConsumeThreadMin(5);
        // 消费者最大线程量
        mqPushConsumer.setConsumeThreadMax(10);
        // 设置一次消费消息的条数,默认是1
        mqPushConsumer.setConsumeMessageBatchMaxSize(1);
        // 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息,如果订阅该主题下的所有tag,则使用*
        mqPushConsumer.subscribe("CLUSTERING_TOPIC", "*");
        // 注册回调实现类来处理从broker拉取回来的消息
        mqPushConsumer.registerMessageListener(new MessageListenerConcurrently() {
            // 监听类实现MessageListenerConcurrently接口即可,重写consumeMessage方法接收数据
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgList, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
                MessageExt messageExt = msgList.get(0);
                String body = new String(messageExt.getBody(), StandardCharsets.UTF_8);
                System.out.println("消费者接收到消息: " + messageExt.toString() + "---消息内容为:" + body);
                // 标记该消息已经被成功消费
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        // 启动消费者实例
        mqPushConsumer.start();
    }
}
setMessageModel(MessageModel.CLUSTERING);//设置集群消息
setMessageModel(MessageModel.BROADCASTING); //设置广播消息

1、在Rocket集群消费模式下,(订阅)同一个主题(Topic)下的消息,对于不同的消费者组是一种“广播形式”,即每个消费者组的都会消费消息。

2、在Rocket集群消费模式下,(订阅)同一个主题(Topic)下的消息,对于相同的消费者组的消费者而言是一种集群模式,即同一个消费者组内的所有消费者均分消息并消费。

集群消费模式下,相同Consumer Group的每个Consumer实例平均分摊消息。
⼴播消费模式下,相同Consumer Group的每个Consumer实例都接收全量的消息。

案例博客

测试示例博客

Kafka

实现与RocketMQ大致相同

针对Kafka同⼀条消息只能被同⼀个消费组下的某⼀个消费者消费的特性,要实现多播只要保证这些消费者属于不同的消费组即可。我们再增加⼀个消费者,该消费者属于testGroup-2消费组,结果两个客户端都能收到消息。

消息重试

重试带来的副作用
不停的重试看起来很美好,但也是有副作用的,主要包括两方面:消息重复,服务端压力增大

  • 远程调用的不确定性,因请求超时触发消息发送重试流程,此时客户端无法感知服务端的处理结果;客户端进行的消息发送重试可能会导致消费方重复消费,应该按照用户ID、业务主键等信息幂等处理消息。
  • 较多的重试次数也会增大服务端的处理压力。

RabbitMQ

消费者默认是自动提交,如果消费时出现了RuntimException,会导致消息直接重新入队,再次投递(进入队首),进入死循环,继而导致后面的消息被阻塞。消费失败的时候,消息队列会一直重复的发送消息,导致程序死循环!

消息阻塞带来的后果是:后边的消息无法被消费;RabbitMQ服务端继续接收消息,占内存和磁盘越来越多。

重试机制有2种情况

  • 消息是自动确认时,如果抛出了异常导致多次重试都失败,消息被自动确认,消息就丢失了
  • 消息是手动确认时,如果抛出了异常导致多次重试都失败,消息没被确认,也无法nack,就一直是unacked状态,导致消息积压。

重试配置 :

消费者设置手动确认提交,当不确认消息,并且设置重新放回队列的时候,这时候消息会无线的重试!

// RabbitMQ的ack机制中,第二个参数返回true,表示需要将这条消息投递给其他的消费者重新消费;
// 第三个参数true,表示这个消息会重新进入队列
//channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false);

在手动确认的条件下,使用如上配置消息在重试三次之后,就会放入死信队列,事实上手动提交的时候,basicNack的最后一个参数requeue = true时(设置重新入队列),消息会被无限次的放入消费队列重新消费,直至回送ACK。但是当requeue = false 的时候,此时消息达到重试指定次数就会后立马进入到死信队列。通过给队列绑定死信交换机DLX(basic.reject / basic.nack且requeue=false消息进入绑定的DLX),保存并处理异常或多次重试的信息。

Spring-RabbitMQ中重试实现:

spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: manual # 消费方手动应答
      direct:
        retry:
          enabled: true    # 开启消费重试机制
          max-attempts: 3  # 最大重试机制,默认为3
          initial-interval: 1000  # 重试间隔,单位毫秒,默认1000

注意:max-attempts 指的是尝试次数,就是说最开始消费的那一次也是计算在内的,那么 max-attempts: 3 便是重试两次,另外一次是正常消费,消息重试了3次,之后会抛出ListenerExecutionFailedException的异常。后面附带着Retry Policy Exhausted,提示我们重试次数已经用尽了。消息重试次数用尽后,消息就会被抛弃。

@Component
@Slf4j
public class MessageReceiver {
 
    @Autowired
    private MsgService msgService;
 
    @Autowired
    private MsgLogService msgLogService;
	
	@RabbitListener(queues = RabbitConstants.TEST_FANOUT_QUEUE_A)
    public void testMsgReceiver(Message message, Channel channel) throws IOException, InterruptedException {
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        Msg msg = JSON.parseObject(message.getBody(), Msg.class);
        try {
            int a = 1 / 0;
            channel.basicAck(deliveryTag, false);
        } catch (Exception e) {
            Map<String, Object> headers = message.getMessageProperties().getHeaders();
            //重试次数
            Integer retryCount;
            String mapKey = "retry-count";
            if (!headers.containsKey(mapKey)) {
                retryCount = 0;
            } else {
                retryCount = (Integer) headers.get(mapKey);
            }
            if (retryCount++ < RETRY) {
                log.info("已经重试 " + retryCount + " 次");
                headers.put("retry-count", retryCount);
                //当消息回滚到消息队列时,这条消息不会回到队列尾部,而是仍是在队列头部。
                //这时消费者会立马又接收到这条消息进行处理,接着抛出异常,进行 回滚,如此反复进行
                //而比较理想的方式是,出现异常时,消息到达消息队列尾部,这样既保证消息不回丢失,又保证了正常业务的进行。
                //因此我们采取的解决方案是,将消息进行应答。
                //这时消息队列会删除该消息,同时我们再次发送该消息 到消息队列,这时就实现了错误消息进行消息队列尾部的方案
                //1.应答
                channel.basicAck(deliveryTag, false);
                //2.重新发送到MQ中
                AMQP.BasicProperties basicProperties = new AMQP.BasicProperties().builder().contentType("application/json").headers(headers).build();
                channel.basicPublish(message.getMessageProperties().getReceivedExchange(),
                        message.getMessageProperties().getReceivedRoutingKey(), basicProperties,
                        message.getBody());
            } else {
 
                log.info("现在重试次数为:" + retryCount);
                /**
                 * 重要的操作 存盘
                 * 手动ack
                 * channel.basicAck(deliveryTag,false);
                 * 通知人工处理
                 * log.error("重试三次异常,快来人工处理");
                 */
				//消息存盘
                MsgLog msgLog = new MsgLog();
                msgLog.setMsgId(msg.getMsgId());
                msgLog.setMsg(new String(message.getBody(),"utf-8"));
                msgLog.setExchange(message.getMessageProperties().getReceivedExchange());
                msgLog.setRoutingKey(message.getMessageProperties().getReceivedRoutingKey());
                msgLog.setTryCount(retryCount);
                msgLog.setStatus(MsgLogStatusEnum.FAIL.getStatus());
                msgLogService.save(msgLog);
 
                /**
                 * 不重要的操作放入 死信队列
                 * 消息异常处理:消费出现异常后,延时几秒,然后从新入队列消费,直到达到ttl超时时间,再转到死信,证明这个信息有问题需要人工干预
                 */
                //休眠2s 延迟写入队列,触发转入死信队列
                //Thread.sleep(2000);
                //channel.basicNack(deliveryTag, false, true);
            }
        }
    }
    
	@RabbitListener(queues = RabbitConstants.TEST_DDL_QUEUE_A)
    public void deadTestReceiver(Message message, Channel channel) throws IOException {
        log.info("消息将放入死信队列", new String(message.getBody(), "UTF-8"));
        String str = new String(message.getBody());
        //转换为消息实体
        Msg msg = JSON.parseObject(str, Msg.class);
        log.info("收到的消息为{}", msg);
    }
}

处理unacked消息: consumer前面加了一个缓冲容器,容器能容纳最大的消息数量就是PrefetchCount。如果容器没有满RabbitMQ就会将消息投递到容器内,如果满了就不投递了。当consumer对消息进行ack以后就会将此消息移除,从而放入新的消息。

重试机制

Spring-Rabbitmq配置重试
Spring-Rabbitmq重试机制

RockeMq

Producer端重试:
消息发送时,默认情况下会进行2次重试。如果重试次数达到上限,消息将不会被再次发送。

重试配置 :

DefaultMQProducer producer = new DefaultMQProducer("ordermessage");
producer.setRetryTimesWhenSendFailed(x)同步,参数默认值是2
producer.setRetryTimesWhenSendAsyncFailed()异步,参数默认值是2

Consumer端重试:

  • 当 Consumer 端遇到异常时,消息通常会重复重试16次,重试的时间间隔包括10秒、30秒、1分钟、2分钟、3分钟等。
  • 如果 Consumer 端没有返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS 或 ConsumeConcurrentlyStatus.RECONSUME_LATER,且消息没有消费成功,MQ 会无限制地发送给消费端,直到达到最大重试次数。
  • 在集群模式下,如果消费业务逻辑代码返回Action.ReconsumerLater、NULL 或抛出异常,消息最多会重试16次。如果重试16次后消息仍然失败,则会被丢弃。
  • 消息队列 RocketMQ 默认允许每条消息最多重试16次,每次重试的间隔时间根据配置的间隔而变化。如果消息在16次重试后仍然失败,则不再投递该消息。理论上,如果消息持续失败,最长可能需要4小时46分钟内完成这16次重试。

Consumer 消费失败,这里有 3 种情况,消费者重试示例 :

public class MessageListenerImpl implements MessageListener {
	@Override
	public Action consume(Message message, ConsumeContext context) {
		//处理消息
		doConsumeMessage(message);
		//方式1:返回 Action.ReconsumeLater,消息将重试
		return Action.ReconsumeLater;
		//方式2:返回 null,消息将重试
		return null;
		//方式3:直接抛出异常, 消息将重试
		throw new RuntimeException("Consumer Message exceotion");
	}
}

如果希望消费失败后不重试,可以直接返回Action.CommitMessage

若达到最大重试次数后消息还没有成功被消费,消息将不会被再次发送,则消息将被投递至死信队列。

消息重试只针对集群消费模式生效;广播消费模式不提供失败重试特性,即消费失败后,失败消息不再重试,继续消费新的消息。在老版本的RocketMQ中,一条消息无论重试多少次,这些重试消息的MessageId始终都是一样的。但是在4.7.1版本中,每次重试MessageId都会重建。

通过consumer.setMaxReconsumeTimes(20);将重试次数设定为20次。当定制的重试次数超过16次后,消息的重试时间间隔均为2小时。

Properties properties = new Properties();
//  配置对应 Group ID的最大消息重试次数为 20 次
properties.put(PropertyKeyConst.MaxReconsumeTimes, "20");
Consumer consumer =ONSFactory.createConsumer(properties);

或者:

最大重试次数:消息消费失败后,可被重复投递的最大次数。

consumer.setMaxReconsumeTimes(10);

重试间隔:消息消费失败后再次被投递给Consumer消费的间隔时间,只在顺序消费中起作用。

consumer.setSuspendCurrentQueueTimeMillis(5000);

顺序消费和并发消费的重试机制并不相同,顺序消费消费失败后会先在客户端本地重试直到最大重试次数,这样可以避免消费失败的消息被跳过,消费下一条消息而打乱顺序消费的顺序,而并发消费消费失败后会将消费失败的消息重新投递回服务端(在一个特色的队列中保存),再等待服务端重新投递回来,在这期间会正常消费队列后面的消息。

并发消费失败后并不是投递回原Topic,而是投递到一个特殊Topic,其命名为%RETRY%$ConsumerGroupName,$ConsumerGroupName为消费组的名称,集群模式下并发消费每一个ConsumerGroup会对应一个特殊Topic,并会默认订阅该Topic

并发模式:则最多重试16次,依次按照【10s、30s、1min、2min、3min、4min、5min、6min、7min、8min、9min、10min、20min、30min、1h、2h】的时间间隔进行重试,最多重试16次,如果16次还未成功,则进入死信队列;
顺序模式:则最多重试Integer.MAX_VALUE次(顺序模式下,如果不设置的话,会重试这么多次!)

消费类型重试间隔最大重试次数
顺序消费间隔时间可通过自定义设置,SuspendCurrentQueueTimeMillis最大重试次数可通过自定义参数MaxReconsumeTimes取值进行配置。该参数取值无最大限制。若未设置参数值,默认最大重试次数为Integer.MAX
并发消费间隔时间根据重试次数阶梯变化,取值范围:1秒~2小时。不支持自定义指定时间配置最大重试次数可通过自定义参数MaxReconsumeTimes取值进行配置。默认值为16次,该参数取值无最大限制,建议使用默认值

(1)重试队列:如果Consumer端因为各种类型异常导致本次消费失败,为防止该消息丢失而需要将其重新回发给Broker端保存,保存这种因为异常无法正常消费而回发给MQ的消息队列称之为重试队列。
考虑到异常恢复需要一些时间,RocketMQ会为重试队列设置多个重试级别,每个重试级别都有与之对应的重新投递延时,重试次数越多投递延时就越大。
RocketMQ会为每个消费组都设置一个Topic名称为“%RETRY%+consumerGroup”的重试队列(这里需要注意的是,这个Topic的重试队列是针对消费组,而不是针对每个Topic设置的),用于暂时保存因为各种异常而导致Consumer端无法消费的消息。
考虑到异常恢复起来需要一些时间,会为重试队列设置多个重试级别,每个重试级别都有与之对应的重新投递延时,重试次数越多投递延时就越大。RocketMQ对于重试消息的处理是先保存至Topic名称为“SCHEDULE_TOPIC_XXXX”的延迟队列中,后台定时任务按照对应的时间进行Delay后重新保存至“%RETRY%+consumerGroup”的重试队列中。

(2)死信队列:由于有些原因导致Consumer端长时间的无法正常消费从Broker端Pull过来的业务消息,为了确保消息不会被无故的丢弃,那么超过配置的“最大重试消费次数”后就会移入到这个死信队列中。在RocketMQ中,SubscriptionGroupConfig配置常量默认地设置了两个参数,一个是retryQueueNums为1(重试队列数量为1个),另外一个是retryMaxTimes为16(最大重试消费的次数为16次)。Broker端通过校验判断,如果超过了最大重试消费次数则会将消息移至这里所说的死信队列。这里,RocketMQ会为每个消费组都设置一个Topic命名为“%DLQ%+consumerGroup"的死信队列。一般在实际应用中,移入至死信队列的消息,需要人工干预处理;

重试队列&死信队列

官方文档

中文示例文档

重试配置

Kafka

生产者重试:

生产者设置重试次数。比如:retries>=3,增加重试次数以保证消息的不丢失;如果设置了重试,还想保证消息的有序性,需要设置MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION=1,否则在重试此失败消息的时候,其他的消息可能发送成功了。

// 1. 创建 kafka 生产者的配置对象
Properties properties = new Properties();
// 设置 acks
properties.put(ProducerConfig.ACKS_CONFIG, "all");
// 重试次数 retries,默认是 int 最大值,2147483647
properties.put(ProducerConfig.RETRIES_CONFIG, 3);
// 3. 创建 kafka 生产者对象
KafkaProducer<String, String> kafkaProducer = new KafkaProducer<String, String>(properties);

kafka消费者重试需要自己实现:

kafka消费者手动提交offset的方法有两种:分别是commitSync(同步提交)和commitAsync(异步提交)。

  • commitSync(同步提交):必须等待offset提交完毕,再去消费下一批数据。
  • commitAsync(异步提交) :发送完提交offset请求后,就开始消费下一批数据了。

两者的相同点是,都会将本次提交的一批数据最高的偏移量提交;不同点是,同步提交阻塞当前线程,一直到提交成
功,并且会自动失败重试(由不可控因素导致,也会出现提交失败);而异步提交则没有失败重试机制,故有可能提交失败。

kafka消费者重试后选择操作:

  • kafka消费者重试会导致的问题:问题是若是这条消息(通过目前的代码)可能永远不能消费成功,导致消费者不会继续处理后续的任何问题,导致消费者阻塞;
  • 跳过,跳过这条没有消费的消息;这个其实是不合理的,可能会造成数据不一致性;

例如设置为同步提交并try…catch

// 是否自动提交 offset
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
//3. 创建 kafka 消费者
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);
try {
    consumer.commitSync();// 处理成功,同步提交 offset ;会阻塞当前线程
} catch (Exception e) {
	// 处理失败,不提交offset,消息会重试
    e.printStackTrace();
    // 判断当前重试次数,超过一定次数放入指定重试队列
}finally {
    consumer.close();
}
// 异步提交 offset
//consumer.commitAsync();

消费者重试解决方案:

需要建立一个专门用于重试的topic(retry topic);当消费者没有正确消费这条消息的时候,则将这条消息转发(发布)到重试主题(retry topic)上,然后提交消息的偏移量,以便继续处理下一个消息;
注意:这个时候,这个没有正确消费的消息,其实对于这个消费者来说,也算是消费完成了,因为也正常提交了偏移量,只不过是业务没有正确处理,而且这个消息被发布到另一个topic中了(retry topic);顺序消息慎用

之后再创建一个重试消费者,用于订阅这个重试主题,只不过这个重试消费者,跟之前那个消费者处理相同的业务,两个逻辑是一样的;
如果这个重试消费者,也无法小得这条消息,那就把这个消息发布到另一个重试主题上,并提交该消息的偏移量;
一直循环,递归发送到不同的重试队列,最后,当创建了很多重试消费者的时候,在最终重试消费者无法处理某条消息后,把该消息发布到一个死信队列。

优缺点:
优点:可以重试,可能在重试中,就正常消费了;
缺点:可能一直重试,都不会正常消费,一直到死信队列中;可能我们就是消费的是一个错误的消息,比如说缺字段,或者数据库中根本就没有这个业务的id;或者消息中包含特殊字段,导致无法消费;这种是消息本身的错误,导致无法消费,除非你去解决消息,不然是永远不会成功的;

重试示例原文


在Spring-Kafka中,消费失败的重试次数可以通过配置来实现。默认情况下,当使用Spring-Kafka时,如果Consumer消费失败,会尝试重新消费最多10次,直到达到配置的重试次数。

Spring-Kafka3.0 版本默认失败重试次数为10次,准确讲应该是1次正常调用+9次重试,这个在这个类可以看到org.springframework.kafka.listener.SeekUtils

在Spring Boot的application.properties或application.yml文件中添加以下配置:

spring.kafka.consumer.retries: 10

这将把重试次数设置为10次。

@KafkaListener(topics = "your_topic", retryTemplate = @RetryTemplate(maxRetries = 10))  
public void consumeMessage(String message) {  
    // 处理消息的逻辑  
}

要实现失败重试10次,可以考虑以下方案:

使用Kafka的自动提交模式,并设置auto.commit.interval.ms为适当的值,以便在每次消费消息后自动提交偏移量。
在代码中手动控制消费流程,每次消费消息后手动提交偏移量。
使用Kafka的消费者API提供的commitSync方法手动提交偏移量,并捕获可能抛出的异常,以便在失败时进行重试。
在代码中设置重试机制,例如使用循环语句实现重试10次的功能。
需要注意的是,在实现失败重试时,需要确保重试不会导致消息被重复消费或产生死循环等问题。因此,建议在重试时设置适当的间隔时间、限制重试次数或在重试前先检查消息的状态等措施。

Kafka中实现重试的主要方式是使用生产者-消费者模型。

生产者负责将消息发送到Kafka,如果发送失败,生产者会根据错误类型判断是否可以重试。如果可以重试,它将重新发送消息到Kafka。

消费者从Kafka中读取消息并处理,如果处理失败,消费者可以选择将消息放回队列(即进行重试)。消费者可以选择将消息放回队列中的某一个位置(例如队尾、队首或其他位置),以便在重试时能够按照不同的策略处理失败的消息。

值得注意的是,如果消费者重试时仍然失败,可能需要采取其他措施,例如将消息发送到另一个Kafka主题(topic)中进行处理,或者将消息写入到其他存储系统中等等。

Kafka重试使用示例场景

Spring-Kafka消费端消费重试和死信队列

Spring-Kafka 3.0 消费者消费失败处理方案

kafka自定义消息重试

所有MQ消息重试注意 :
虽然增加重试次数可以提高消息的可靠性,但过度的重试可能会导致消息处理的延迟和资源的浪费。因此,需要根据实际情况和业务需求进行权衡和调整。

回退队列

延时消息

RabbitMQ

死信和延迟使用示例

Rabbitmq 插件实现延迟队列

如果使用在消息属性上设置 TTL 的方式,消息可能并不会按时“死亡“,因为 RabbitMQ 只会检查第一个消息是否过期,如果过期则丢到死信队列,如果第一个消息的延时时长很长,而第二个消息的延时时长很短,第二个消息并不会优先得到执行。

官网上下载,下载rabbitmq_delayed_message_exchange 插件,然后解压放置到 RabbitMQ 的插件目录。
进入 RabbitMQ 的安装目录下的 plgins 目录,执行下面命令让该插件生效,然后重启 RabbitMQ

/usr/lib/rabbitmq/lib/rabbitmq_server-3.8.8/plugins rabbitmq-plugins enable rabbitmq_delayed_message_exchang

RocketMQ

定时消息(延迟队列)是指消息发送到broker后,不会立即被消费,等待特定时间投递给真正的topic。RocketMQ 支持发送延迟消息,但不支持任意时间的延迟消息的设置,仅支持内置预设值的延迟时间间隔的延迟消息;预设值的延迟时间间隔。
发消息时,设置delayLevel等级即可:msg.setDelayLevel(level)。

messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h

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

Message msg = new Message("TopicTest","TagA" ,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */
);
//messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
msg.setDelayTimeLevel(3);
SendResult sendResult = producer.send(msg);

Kafka

Kafka没有直接的延迟消息设置
Kafka延迟发送的解决思路:利用Redis的ZSet集合,实现Redis缓存队列

生产者在调用延迟发送方法时,消息并不会立刻被投递到Topic中,转而发送到延迟队列

将当前时间戳与延迟时间进行相加,将结果作为ZSet的score进行设置。

除此之外,延迟队列有包含线程池、分布式锁。每5s循环一次,对比当前时间戳与ZSet的score。拉取缓存队列中到期的消息,将消息重新组装,投递到Topic并进行消费。

Kafka延迟消息实现代码

死信队列

RabbitMQ

死信的来源

  • 消息 TTL 过期
  • 队列达到最大长度(队列满了,无法再添加数据到 mq 中)
  • 消息被拒绝(basic.reject 或 basic.nack)并且 requeue=false(不再重新入队)

需要给交换机设置绑定死信队列,并为死信队列设置专门的消费者。

Channel channel = RabbitMqUtils.getChannel();

//声明死信和普通交换机 类型为 direct
channel.exchangeDeclare(NORMAL_EXCHANGE, BuiltinExchangeType.DIRECT);
channel.exchangeDeclare(DEAD_EXCHANGE,BuiltinExchangeType.DIRECT);

//声明死信队列
String deadQueue = "dead-queue";
//死信队列绑定死信交换机与 routingkey
channel.queueBind(deadQueue,DEAD_EXCHANGE,"lisi");
//正常队列绑定死信队列信息
Map<String, Object> params = new HashMap<>();
//正常队列设置死信交换机 参数 key 是固定值
params.put("x-dead-letter-exchange", DEAD_EXCHANGE);
//正常队列设置死信 routing-key 参数 key 是固定值
params.put("x-dead-letter-routing-key", "lisi");
channel.queueDeclare(queuelame, true, false, false,agruments);

使用示例

死信和延迟使用示例

RocketMQ

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

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

RocketMQ死信队列具有如下特征:

  • 死信队列中的消息不会再被消费者正常消费,即DLQ对于消费者是不可见的
  • 死信存储有效期与正常消息相同,均为3天,3 天后会被自动删除。因此,请在死信消息产生后的 3 天内及时处理。(commitLog文件的过期时间)
  • 死信队列就是一个特殊的Topic,名称为%DLQ%consumerGroup,即每个消费者组都有一个死信队列
  • 如果一个消费者组未产生死信消息,则不会为其创建对应的死信队列

死信队列:

  • 一个死信队列对应一个 Group ID, 而不是对应单个消费者实例。
  • 如果一个 Group ID 未产生死信消息,消息队列 RocketMQ 不会为其创建相应的死信队列。
  • 一个死信队列包含了对应 Group ID 产生的所有死信消息,不论该消息属于哪个 Topic。
  • 死信队列是一个特殊的Topic,名称为%DLQ%consumerGroup

默认创建出来的死信队列,他里面的消息是无法读取的,在控制台和消费者中都无法读取。这是因为这些默认的死信队列,他们的权限perm被设置成了2:禁读(这个权限有三种 2:禁读,4:禁写,6:可读可写)。需要手动将死信队列的权限配置成6,才能被消费(可以通过mqadmin指定或者web控制台)。

一条消息进入死信队列,意味着某些因素导致消费者无法正常消费该消息,因此,通常需要您对其进行特殊处理。排查可疑因素并解决问题后,可以在消息队列 RocketMQ 控制台重新发送该消息,让消费者重新消费一次。

使用示例伪代码:

// 设置死信队列的名称
String dlqTopic = "YourDeadLetterQueueTopic";
 
// 创建消息生产者
DefaultMQProducer producer = new DefaultMQProducer("YourProducerGroup");
producer.setNamesrvAddr("YourNameServerAddress");
producer.start();
 
// 创建消息,并设置死信队列
Message msg = new Message("YourTopic", "YourTag", "YourMessageBody".getBytes(RemotingHelper.DEFAULT_CHARSET));
msg.setDelayTimeLevel(3); // 设置延时级别,可选
msg.setWaitStoreMsgOK(false); // 不等待消息存储确认,可选
msg.setReconsumeTimes(3); // 设置重试次数,超过后将进入死信队列
msg.setDeadLetterTopic(dlqTopic); // 设置死信队列的topic
 
// 发送消息
producer.send(msg);
 
producer.shutdown();

消费者 :

// 创建消费者来处理死信
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("YourConsumerGroup");
consumer.setNamesrvAddr("YourNameServerAddress");
consumer.subscribe(dlqTopic, "*"); // 订阅死信队列
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> {
    // 处理死信消息
    for (MessageExt msg : msgs) {
        // 你的死信处理逻辑
        System.out.println("Dead message: " + new String(msg.getBody()));
    }
    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
 
consumer.start();

RocketMQ死信队列

Kafka

kafka没有死信队列,需要自己在重试基础上实现。

详情看上面的重试队列

消息过期时间

RabbitMQ

消息在队列中的生存时间一旦超过设置的TTL值时,就会变成死信,消费者将无法再收到该消息
如果不设置 TTL ,则表示此消息不会过期
如果TTL设置为 0,则表示除非此时可以直接将消息投递到消费者,否则该消息会被立即丢弃

设置方式:

  • 通过创建队列时设置属性x-message-ttl(单位毫秒)设置,队列中所有消息都有相同的过期时间
  • 对消息本身进行单独设置,每条消息的TTL可以不同
  • 若两种方式一起使用,则消息的TTL以两者之间较小的那个数值为准

在定义队列时(调用channel.queueDeclare()时),在arguments中添加x-message-ttl参数,单位是毫秒:

 // 创建Queue
 Map<String , Object> arguments = new HashMap<>();
 arguments.put("x-message-ttl" , 10000);
 channel.queueDeclare(queueName , true , false , false , arguments);

在发送消息时(调用channel.basicPublish()时),在properties中设置expiration属性,单位也是毫秒:

// 4 发送消息
for (int i = 0; i < 5; i++) {

    AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder()
            .deliveryMode(2)
            .contentEncoding("UTF-8")
            .expiration("10000")
            .build();

    String msg = "RabbitMQ: TTL message" + i;
    channel.basicPublish(exchange , routingKey , properties , msg.getBytes());
}

示例代码

RocketMQ

API学习过程中并没有过期消息这以说法,以下为网上博客中的整理,是否可行待测试

RocketMQ支持消息设置过期时间,过期的消息会被自动删除。消息过期时间的处理机制主要有以下几点:

  • 消息发送时可以设置消息的过期时间,单位为毫秒。如果不设置,默认为0,即永不过期。
  • RocketMQ服务器会启动一个定时任务,每隔一定时间检测一次消息过期时间。
  • 消息存储在commitlog文件中,每个消息都有对应的物理偏移量。RocketMQ会记录每个topic-queue对应的最大偏移量值。
  • 检测消息过期时,会从最小的偏移量开始查找,直到找到第一个未过期的消息。之间的所有过期消息都会被标记为过期。
  • 过期消息在消费时会被过滤掉,不会被消费者消费到。生产者也无法再发送该消息。
  • 过期消息会在定期进行消息清理时被删除。
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.common.RemotingHelper;
 
public class MessageExpirationExample {
    public static void main(String[] args) {
        // 创建一个Message实例
        Message msg = new Message("YourTopic", "YourTag", "YourMessageBody".getBytes(RemotingHelper.DEFAULT_CHARSET));
 
        // 设置消息过期时间,例如30秒
        long timeDelay = 30 * 1000;
        long now = System.currentTimeMillis();
        long deliverTime = now + timeDelay;
 
        // 设置消息的延迟级别,这里用于设置消息过期时间
        msg.setSysFlag(msg.getSysFlag() | MessageSysFlag.DELAY);
 
        // 设置消息的存活时间,即过期时间
        msg.setDelayTimeLevel(String.valueOf(timeDelay));
 
        // 发送消息
        // producer.send(msg); // 此处应该替换为你的生产者发送代码
    }
}
// 发送消息,设置过期时间为3秒
Message msg = new Message("TopicTest", "TagA", "OrderID001", "Hello".getBytes());
msg.setDelayTimeLevel(3);   // 3秒过期

SendResult sendResult = producer.send(msg);  

// 3秒后,消息变为过期状态,不可消费
PullResult pullResult = pullConsumer.pull(pullRequest);  
List<MessageExt> msgList = pullResult.getMsgFoundList();  

Kafka

Kafka 和 RabbitMQ 相同,Kafka的消息并没有TTL这一概念,因此想要实现消息的过期功能,需要作额外的处理

这里提供一种实现方案:将消息的 TTL 的设定值以键值对的形式保存在消息的 header 字段中,在消费者端配置拦截器,消费者在消费消息时判断此条消息是否超时

Kafka TTL实现示例

消息去重/幂等性

  • 生产时消息重复
    由于生产者发送消息给MQ,在MQ确认的时候出现了网络波动,生产者没有收到确认,实际上MQ已经接收到了消息。这时候生产者就会重新发送一遍这条消息。
    生产者中如果消息未被确认,或确认失败,我们可以使用定时任务+(redis/db)来进行消息重试

  • 消费时消息重复
    消费者消费成功后,再给MQ确认的时候出现了网络波动,MQ没有接收到确认,为了保证消息被消费,MQ就会继续给消费者投递之前的消息。这时候消费者就接收到了两条一样的消息。
    由于重复消息是由于网络原因造成的,因此不可避免重复消息。但是我们需要保证消息的幂等性。

需要从生产者和消费端同时保证
生产者生产消息判断是否已经发送过。
消费端保证消息幂等性,即多次处理相同消息的效果与处理一次相同。

RabbitMQ

RabbitMQ 中消息重复消费的问题可以通过以下几种方式解决:

使用消息去重:在生产者发送消息时,为每条消息生成一个唯一标识符,并将其存储到数据库或缓存中。消费者在接收到消息后,先查询该标识符是否存在,如果存在则说明该消息已被处理过,直接跳过;否则进行业务处理,并将该标识符存储到数据库或缓存中。

使用幂等性操作:即使同一条消息被消费多次,也不会对业务造成影响。例如,在更新操作时使用乐观锁或悲观锁机制来避免并发更新问题。

使用 TTL(Time To Live)特性:设置每条消息的生命周期,超过指定时间后自动删除。如果消费者在该时间内未能成功处理该消息,则可以认为该消息已经丢失。

使用 RabbitMQ 提供的 ACK 机制:当消费者成功处理一条消息时,发送 ACK 确认信号给 RabbitMQ 服务器。服务器收到 ACK 后才会将该条消息从队列中删除。如果消费者处理失败,则发送 NACK 信号给 RabbitMQ 服务器,并设置重新入队(requeue)参数为 true,在下次重新投递时再次尝试消费。

在生产者端设置 IDempotent(幂等)属性:确保相同 ID 的请求只执行一次。这样就可以避免重复发送消息,从而避免了消息的重复消费。

RocketMQ

生产者生产时候设置一个唯一keyid

public void testRepeatProducer() throws Exception {
	// 创建默认的生产者
	DefaultMQProducer producer = new DefaultMQProducer("test-group");
	// 设置nameServer地址
	producer.setNamesrvAddr("localhost:9876");
	// 启动实例
	producer.start();
	// 我们可以使用自定义key当做唯一标识
	String keyId = UUID.randomUUID().toString();
	System.out.println(keyId);
	Message msg = new Message("TopicTest", "tagA", keyId, "我是一个测试消息".getBytes());
	SendResult send = producer.send(msg);
	System.out.println(send);
	// 关闭实例
	producer.shutdown();
}
/**
 * 在boot项目中可以使用@Bean在整个容器中放置一个单利对象
 */
public static BitMapBloomFilter bloomFilter = new BitMapBloomFilter(100); // m数组长度
 
@Test
public void testRepeatConsumer() throws Exception {
 
	DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("repeat-consumer-group");
	consumer.setMessageModel(MessageModel.BROADCASTING);
	consumer.setNamesrvAddr(MyConstant.NAME_SRV_ADDR);
	consumer.subscribe("repeatTestTopic", "*");
	// 注册一个消费监听 MessageListenerConcurrently是并发消费
	consumer.registerMessageListener(new MessageListenerConcurrently() {
	    @Override
	    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
	                                                    ConsumeConcurrentlyContext context) {
	        // 拿到消息的key
	        MessageExt messageExt = msgs.get(0);
	        String keys = messageExt.getKeys();
	        // 判断是否存在布隆过滤器中
	        if (bloomFilter.contains(keys)) {
	            // 直接返回了 不往下处理业务
	            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
	        }
	        // 这个处理业务,然后放入过滤器中
	        // do sth...
	        bloomFilter.add(keys);
	        System.out.println("keys:" + keys);
	        System.out.println(new String(messageExt.getBody()));
	        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
	    }
	});
	consumer.start();
	System.in.read();
}

Kafka

生产端:

Producer 的幂等性指的是当发送同一条消息时,数据在 Server 端只会被持久化一次,数据不丟不重,Kafka为了实现幂等性,底层设计架构中引入了ProducerID和SequenceNumbe。enable.idempotence 是否开启幂等性,默认 true,开启幂等性。

重复数据的判断标准:具有<PID, Partition, SeqNumber>相同主键的消息提交时,Broker只会持久化一条。其
中PID是Kafka每次重启都会分配一个新的;Partition 表示分区号;Sequence Number是单调自增的。
所以幂等性只能保证的是在单分区单会话内不重复。

kafka在0.11.0版本后,给每个Producer端分配了一个唯一的ID,每条消息中也会携带一个序列号,这样服务端便可以对消息进行去重,但是如果是两个Producer生产了两条相同的消息,那么kafka无法对消息进行去重,所以我们可以在消息头中自定义一个唯一的消息ID然后在consumer端对消息进行手动去重。

当Producer发送消息(x2,y2)给Broker时,Broker接收到消息并将其追加到消息流中。此时,Broker返回Ack信号给Producer时,发生异常导致Producer接收Ack信号失败。对于Producer来说,会触发重试机制,将消息(x2,y2)再次发送,但是,由于引入了幂等性,在每条消息中附带了PID(ProducerID)和SequenceNumber。相同的PID和SequenceNumber发送给Broker,而之前Broker缓存过之前发送的相同的消息,那么在消息流中的消息就只有一条(x2,y2),不会出现重复发送的情况。

缺点:Kafka 的 Exactly Once 幂等性只能保证单次会话内的精准一次性,不能解决跨会话和跨分区的问题;

消费端:

由于为了保证不少消费消息,配置了手动提交,由于处理消息期间,其他consumer的加入,进行了重平衡,或者consumer提交消息失败,进而导致接收到了重复的消息。
我们可以通过自定义唯一消息ID对消息进行过滤去重重复的消息。

事务消息

RabbitMQ

RabbitMQ中与事务机制有关的方法有三个:txSelect(), txCommit()以及txRollback();
txSelect用于将当前channel设置成transaction模式,txCommit用于提交事务,txRollback用于回滚事务,在通过txSelect开启事务之后,我们便可以发布消息给broker代理服务器了。
如果txCommit提交成功了,则消息一定到达了broker了,如果在txCommit执行之前broker异常崩溃或者由于其他原因抛出异常,这个时候我们便可以捕获异常通过txRollback回滚事务了。

// 开启事务
try (Connection connection = factory.newConnection();
             Channel channel = connection.createChannel()) {

     channel.txSelect();
     try {
         // 发送消息
         channel.basicPublish("", QUEUE_NAME, MessageProperties.PERSISTENT_TEXT_PLAIN, MESSAGE.getBytes());
         System.out.println("消息发送成功");

         // 提交事务
         channel.txCommit();
     } catch (IOException e) {
         // 回滚事务
         channel.txRollback();
         System.out.println("消息发送失败,事务回滚");
     }
 } catch (IOException | TimeoutException e) {
     e.printStackTrace();
 }

RocketMQ

RocketMQ支持事务消息,整体流程如下图:

在这里插入图片描述

1、Producer向broker发送半消息。
2、Producer端收到响应,消息发送成功,此时消息是半消息,标记为“不可投递”状态,Consumer消费不了。
3、Producer端执行本地事务。
4、正常情况本地事务执行完成,Producer向Broker发送Commit/Rollback,如果是Commit,Broker端将半消息标记为正常消息,Consumer可以消费,如果是Rollback,Broker丢弃此消息。
5、异常情况,Broker端迟迟等不到二次确认。在一定时间后,会查询所有的半消息,然后到Producer端查询半消息的执行情况。
6、Producer端查询本地事务的状态。
7、根据事务的状态提交commit/rollback到broker端。(5,6,7是消息回查)

如果MQServer没有接收到producer执行本地事务后发送的commit或者rollback指令,MQServer会向producer调用事务发送方法让producer进行检查,通过返回commit或者rollback来告知MQServer,producer中的本地事务是否执行成功。

事务的三种状态:

  • TransactionStatus.CommitTransaction:提交事务消息,消费者可以消费此消息

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

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

/**
 * 事务消息生产者
 */
public class TransactionMessageProducer {
    /**
     * 事务消息监听实现
     */
    private final static TransactionListener transactionListenerImpl = new TransactionListener() {

        /**
         * 在发送消息成功时执行本地事务
         * @param msg
         * @param arg producer.sendMessageInTransaction的第二个参数
         * @return 返回事务状态
         * LocalTransactionState.COMMIT_MESSAGE:提交事务,提交后broker才允许消费者使用
         * LocalTransactionState.RollbackTransaction:回滚事务,回滚后消息将被删除,并且不允许别消费
         * LocalTransactionState.Unknown:中间状态,表示MQ需要核对,以确定状态
         */
        @Override
        public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
            // TODO 开启本地事务(实际就是我们的jdbc操作)

            // TODO 执行业务代码(插入订单数据库表)
            // int i = orderDatabaseService.insert(....)
            // TODO 提交或回滚本地事务(如果用spring事务注解,这些都不需要我们手工去操作)

            // 模拟一个处理结果
            int index = 8;
            /**
             * 模拟返回事务状态
             */
            switch (index) {
                case 3:
                    System.out.printf("本地事务回滚,回滚消息,id:%s%n", msg.getKeys());
                    return LocalTransactionState.ROLLBACK_MESSAGE;
                case 5:
                case 8:
                    return LocalTransactionState.UNKNOW;
                default:
                    System.out.println("事务提交,消息正常处理");
                    return LocalTransactionState.COMMIT_MESSAGE;
            }
        }

        /**
         * Broker端对未确定状态的消息发起回查,将消息发送到对应的Producer端(同一个Group的Producer),
         * 由Producer根据消息来检查本地事务的状态,进而执行Commit或者Rollback
         * @param msg
         * @return 返回事务状态
         */
        @Override
        public LocalTransactionState checkLocalTransaction(MessageExt msg) {
            // 根据业务,正确处理: 订单场景,只要数据库有了这条记录,消息应该被commit
            String transactionId = msg.getTransactionId();
            String key = msg.getKeys();
            System.out.printf("回查事务状态 key:%-5s msgId:%-10s transactionId:%-10s %n", key, msg.getMsgId(), transactionId);

            if ("id_5".equals(key)) { // 刚刚测试的10条消息, 把id_5这条消息提交,其他的全部回滚。
                System.out.printf("回查到本地事务已提交,提交消息,id:%s%n", msg.getKeys());
                return LocalTransactionState.COMMIT_MESSAGE;
            } else {
                System.out.printf("未查到本地事务状态,回滚消息,id:%s%n", msg.getKeys());
                return LocalTransactionState.ROLLBACK_MESSAGE;
            }
        }
    };

    public static void main(String[] args) throws MQClientException, IOException {
        // 1. 创建事务生产者对象
        // 和普通消息生产者有所区别,这里使用的是TransactionMQProducer
        TransactionMQProducer producer = new TransactionMQProducer("GROUP_TEST");

        // 2. 设置NameServer的地址,如果设置了环境变量NAMESRV_ADDR,可以省略此步
        producer.setNamesrvAddr("192.168.100.242:9876");

        // 3. 设置事务监听器
        producer.setTransactionListener(transactionListenerImpl);

        // 4. 启动生产者
        producer.start();

        for (int i = 0; i < 10; i++) {
            String content = "Hello transaction message " + i;
            Message message = new Message("TopicTest", "TagA", "id_" + i, content.getBytes(RemotingHelper.DEFAULT_CHARSET));

            // 5. 发送消息(发送一条新订单生成的通知)
            SendResult result = producer.sendMessageInTransaction(message, i);

            System.out.printf("发送结果:%s%n", result);
        }

        System.in.read();
        // 6. 停止生产者
        producer.shutdown();
    }
}

rocketmq相关

Kafka

在使用Kafka事务前,需要开启幂等特性,将 enable.idempotence 设置为 true

Kafka 0.11.0 版本开始引入了事务性功能。实现事务性消息的过程涉及到生产者(Producer)和消费者(Consumer)两个方面:

一、生产者事务: 生产者可以通过事务将一批消息发送到 Kafka,并保证这批消息要么全部发送成功,要么全部发送失败,实现消息的原子性。

1)在使用事务之前,生产者需要先初始化一个事务,即调用 initTransactions() 方法。这样会将生产者切换到事务模式。

2)然后,生产者开始事务,将待发送的消息放入事务中,而不是直接发送到 Kafka。

3)在事务中,可以将多条消息添加到一个或多个主题的不同分区中。在事务中,如果发送消息成功,则会将消息暂存于事务缓冲区,不会立即发送到 Kafka。

4)在所有消息都添加到事务中后,可以调用 commitTransaction() 方法提交事务。如果所有消息提交成功,则整个事务提交成功,所有消息会被一起发送到 Kafka。

5)如果在事务过程中出现错误或者某条消息发送失败,可以调用 abortTransaction() 方法回滚事务,之前添加到事务中的消息不会发送到 Kafka。

Properties properties = new Properties();
properties.put(org.apache.kafka.clients.producer.ProducerConfig.TRANSACTIONAL_ID_CONFIG, transactionId);

KafkaProducer<String, String> producer = new KafkaProducer<String, String>(properties);

// 初始化事务
producer.initTransactions();
// 开启事务
producer.beginTransaction();

try {
     // 处理业务逻辑
     ProducerRecord<String, String> record1 = new ProducerRecord<String, String>(topic, "msg1");
     producer.send(record1);
     ProducerRecord<String, String> record2 = new ProducerRecord<String, String>(topic, "msg2");
     producer.send(record2);
     ProducerRecord<String, String> record3 = new ProducerRecord<String, String>(topic, "msg3");
     producer.send(record3);
     // 处理其他业务逻辑
     // 提交事务
     producer.commitTransaction();
} catch (ProducerFencedException e) {
	 // 中止事务,类似于事务回滚
     producer.abortTransaction();
}
producer.close();

事务手动提交
在一个事务中如果需要手动提交消息,需要先将 enable.auto.commit 参数设置为 false,然后调用 sendOffsetsToTransaction(Map<TopicPartition, OffsetAndMetadata> offsets, String consumerGroupId) 方法进行手动提交,该方式特别适用于 消费-转换-生产模式的状况

producer.initTransactions();
     while (true){
         org.apache.kafka.clients.consumer.ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
         if (!records.isEmpty()){
             Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
             producer.beginTransaction();
             try {
                 for (TopicPartition partition: records.partitions()){
                     List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
                     for (ConsumerRecord<String, String> record : partitionRecords) {
                         ProducerRecord<String, String> producerRecord = new ProducerRecord<>("topic-sink", record.key(), record.value());
                         producer.send(producerRecord);
                     }
                     long lastConsumedOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
                     offsets.put(partition, new OffsetAndMetadata(lastConsumedOffset + 1));
                 }
                 // 手动提交事务
                 producer.sendOffsetsToTransaction(offsets, "groupId");
                 producer.commitTransaction();
             }catch (ProducerFencedException e){
                 // log the exception
                 producer.abortTransaction();
             }
         }
    }

二、消费者事务: 消费者可以通过事务来保证消息的读取和消息处理的原子性。

1)消费者可以将消息的偏移量(Offset)保存在事务中,并在处理完消息后将偏移量提交到事务中。

2)当事务成功提交后,偏移量会被记录到消费者组中,表示这批消息已经被成功消费。

3)如果事务失败或回滚,偏移量不会被提交,下次消费者启动时会从上次提交的偏移量处继续消费。

通过使用事务,生产者和消费者都可以实现对消息的原子性处理,保证消息的可靠传输和处理。这对于一些需要强一致性的应用场景非常重要,例如金融交易系统或者订单处理系统等。但需要注意,使用事务会增加一定的系统开销,因此在实现事务时需要权衡性能和可靠性。

为了实现事务,Kafka引入了事务协调器(TransactionCoodinator)负责事务的处理,所有的事务逻辑包括分派PID等都是由TransactionCoodinator 负责实施的。

broker节点有一个专门管理事务的内部主题 __transaction_state,TransactionCoodinator 会将事务状态持久化到该主题中。

事务消息流程:

  1. 查找 TransactionCoordinator:生产者会先向某个broker发送 FindCoordinator 请求,找到 TransactionCoordinator 所在的 broker节点.
  2. 获取PID:生产者会向 TransactionCoordinator 申请获取 PID,TransactionCoordinator 收到请求后,会把 transactionalId 和对应的 PID 以消息的形式保存到主题 __transaction_state 中,保证 <transaction_Id,PID>的对应关系被持久化,即使宕机该对应关系也不会丢失
  3. 开启事务:调用 beginTransaction()后,生产者本地会标记开启了一个新事务
  4. 发送消息:生产者向用户主题发送消息,过程跟普通消息相同,但第一次发送请求前会先发送请求给TransactionCoordinator 将 transactionalId 和 TopicPartition 的对应关系存储在 __transaction_state 中
  5. 提交或中止事务:Kafka除了普通消息,还有专门的控制消息(ControlBatch)来标志一个事务的结束,控制消息有两种类型,分别用来表征事务的提交和中止
    该阶段本质就是一个两阶段提交过程:
    1. 将 PREPARE_COMMIT 或 PREPARE_ABORT 消息写入主题 __transaction_state
    2. 将COMMIT 或 ABORT 信息写入用户所使用的普通主题和 __consumer_offsets
    3. 将 COMPLETE_COMMIT 或 COMPLETE_COMMIT_ABORT 消息写入主题 __transaction_state

学习博客

消息积压

消息积压的常见原因:

  • 生产者速度快于消费者:如果消息生产者(产生消息的系统或组件)的速度比消息消费者(处理消息的系统或组件)的速度快,就会导致消息积压。
  • 消费者故障:如果消息消费者遇到故障或处理速度变慢,未能及时处理消息,也会导致积压。
  • 高峰负载:系统在高峰时段接收到大量的消息,超过了正常处理速度。
  • 消息处理失败:如果某些消息由于错误或异常而无法被正常处理,它们可能会在队列中积压。
  • 配置不合理:如果消息队列的容量设置过小或者消费者的线程数设置过少,都可能导致消息积压。

消息积压的常见表现:

  • 系统资源使用率上升:消息积压可能导致系统资源使用率上升,例如CPU利用率、内存占用、磁盘活动等。这是因为积压的消息需要占用系统资源来存储和管理。
  • 消息丢失或过期:如果消息队列没有足够的容量来处理新消息,可能会导致旧消息被丢弃或过期,从而导致数据丢失。
  • 系统警报和异常:一些监控系统会检测到消息积压问题,并发出警报或异常通知,以提醒运维团队或开发人员。
  • 队列故障或堵塞:在极端情况下,消息积压可能导致消息队列系统的故障或堵塞,导致无法正常工作。

消息积压可能导致的后果:

  • 延迟:积压的消息会导致正常消息的处理延迟,从而影响了系统的响应时间。

  • 系统负载:积压消息可能导致消息队列系统和消息处理组件的负载过高,甚至可能引发系统故障。

  • 数据丢失:消息积压导致消息久久不被消费,有可能导致超时被丢弃掉,从而导致数据丢失。

  • 不一致性:如果消息处理不及时,可能会导致系统数据的不一致性问题。

解决方案 :

  1. 增加消费者数量
    如果消息消费者的处理速度无法满足消息产生的速度,可以通过增加消费者的数量来提高消费能力。这样可以将负载分散到多个消费者上,加快消息处理速度,减少积压。
  2. 增加消息队列的容量
    如果消息队列的容量设置过小,可能会导致消息积压。可以通过增加消息队列的容量来缓解积压问题。但需要注意,过大的消息队列容量可能会增加消息处理的延迟。
  3. 优化消息消费的逻辑
    检查消息消费逻辑是否存在性能瓶颈或者不必要的复杂计算。优化消息消费的逻辑可以提高消费速度,减少消息积压。
  4. 设置消息消费失败的处理机制
    当消息消费失败时,可以根据业务需求选择合适的处理方式。可以将失败的消息记录下来,后续再次消费;或者将失败的消息发送到死信队列进行处理。
  5. 监控和报警机制
    建立监控和报警机制,及时发现消息积压的情况并采取相应的措施。可以通过监控指标、日志或者专业的监控工具来实现。

具体实现方案 :

  • 消息积压处理办法:临时紧急扩容。
  • 先修复 consumer 的问题,确保其恢复消费速度,然后将现有 cnosumer 都停掉。新建一个 topic,partition 是原来的 10 倍,临时建立好原先 10 倍的 queue 数量。然后写一个临时的分发数据的 consumer 程序,这个程序部署上去消费积压的数据,消费之后不做耗时的处理,直接均匀轮询写入临时建立好的 10 倍数量的 queue。接着临时征用 10 倍的机器来部署 consumer,每一批 consumer 消费一个临时 queue 的数据。这种做法相当于是临时将 queue 资源和 consumer 资源扩大 10 倍,以正常的 10 倍速度来消费数据。等快速消费完积压数据之后,得恢复原先部署的架构,重新用原先的 consumer 机器来消费消息。
  • MQ中消息失效:假设你用的是 RabbitMQ,RabbtiMQ 是可以设置过期时间的,也就是 TTL。如果消息在 queue 中积压超过一定的时间就会被 RabbitMQ 给清理掉,这个数据就没了。那这就是第二个坑了。这就不是说数据会大量积压在 mq 里,而是大量的数据会直接搞丢。我们可以采取一个方案,就是批量重导,这个我们之前线上也有类似的场景干过。就是大量积压的时候,我们当时就直接丢弃数据了,然后等过了高峰期以后,将丢失的那批数据,写个临时程序,一点一点的查出来,然后重新灌入 mq 里面去,把丢的数据给他补回来。也只能是这样了。假设 1 万个订单积压在 mq 里面,没有处理,其中 1000 个订单都丢了,你只能手动写程序把那 1000 个订单给查出来,手动发到 mq 里去再补一次。
  • mq消息队列块满了:如果消息积压在 mq 里,你很长时间都没有处理掉,此时导致 mq 都快写满了,咋办?这个还有别的办法吗?没有,谁让你第一个方案执行的太慢了,你临时写程序,接入数据来消费,消费一个丢弃一个,都不要了,快速消费掉所有的消息。然后走第二个方案,到了晚上再补数据吧。

针对mq消息积压其实存在一套成熟的打法,一共分三步:

  • 事前处理机制:jmeter测压
  • 事中处理机制:扩容,然后快速消费完积压的消息
  • 事后处理机制
    • 提高消费并行度
    • 跳过非重要的消费
    • 优化每条消息的消费过程。

示例博客

MQ集群架构

RabbitMQ

RabbitMQ 集群有两种模式:

  • 普通集群
  • 镜像集群

普通集群模式,就是将 RabbitMQ 部署到多台服务器上,每台服务器启动一个 RabbitMQ 实例,多个实例之间进行消息通信。

此时我们创建的队列 Queue,它的元数据(主要就是 Queue 的一些配置信息)会在所有的 RabbitMQ 实例中进行同步,但是队列中的消息只会存在于一个 RabbitMQ 实例上,而不会同步到其他队列。

当我们消费消息的时候,如果连接到了另外一个实例,那么那个实例会通过元数据定位到 Queue 所在的位置,然后访问 Queue 所在的实例,拉取数据过来发送给消费者。

这种集群可以提高 RabbitMQ 的消息吞吐能力,但是无法保证高可用,因为一旦一个 RabbitMQ 实例挂了,消息就没法访问了,如果消息队列做了持久化,那么等 RabbitMQ 实例恢复后,就可以继续访问了;如果消息没做持久化,那么消息就丢了。

它和普通集群最大的区别在于 Queue 数据和元数据不再是单独存储在一台机器上,而是同时存储在多台机器上。也就是说每个 RabbitMQ 实例都有一份镜像数据(副本数据)。每次写入消息的时候都会自动把数据同步到多台实例上去,这样一旦其中一台机器发生故障,其他机器还有一份副本数据可以继续提供服务,也就实现了高可用。

RocketMQ

单Master模式

这种方式风险较大,一旦Broker重启或者宕机时,会导致整个服务不可用。不建议线上环境使用,可以用于本地测试。

多Master模式

一个集群无Slave,全是Master,例如2个Master或者3个Master,这种模式的优缺点如下

  • 优点:配置简单,单个Master宕机或重启维护对应用无影响,在磁盘配置为RAID10时,即使机器宕机不可恢复情况下,由于RAID10磁盘非常可靠,消息也不会丢(异步刷盘丢失少量消息,同步刷盘一条不丢),性能最高;
  • 缺点:单台机器宕机期间,这台机器上未被消费的消息在机器恢复之前不可订阅,消息实时性会受到影响。

多Master多Slave模式-异步复制

每个Master配置一个Slave,有多对Master-Slave,HA采用异步复制方式,主备有短暂消息延迟(毫秒级),这种模式的优缺点如下

  • 优点:即使磁盘损坏,消息丢失的非常少,且消息实时性不会受影响,同时Master宕机后,消费者仍然可以从Slave消费,而且此过程对应用透明,不需要人工干预,性能同多Master模式几乎一样;
  • 缺点:Master宕机,磁盘损坏情况下会丢失少量消息(非同步刷盘的情况下)

多Master多Slave模式-同步双写

每个Master配置一个Slave,有多对Master-Slave,HA采用同步双写方式,即只有主备都写成功,才向应用返回成功,这种模式的优缺点如下:

  • 优点:数据与服务都无单点故障,Master宕机情况下,消息无延迟,服务可用性与数据可用性都非常高;
  • 缺点:性能比异步复制模式略低(大约低10%左右),发送单个消息的RT会略高,且目前版本在主节点宕机后,备机不能自动切换为主机
    如果是想不存在消息丢失的情况,那么在多Master的情况下要配置消息同步刷盘,而在 多Master多Slave模式-同步双写 的情况下配置同步刷盘。

以上主从结构是只做数据备份,没有容灾功能的。也就是说当⼀个master节点挂了后,slave节点是⽆法切换成master节点继续提供服务的。注意这个集群⾄少要是3台,允许少于⼀半的节点发⽣故障。这个Dledger需要在RocketMQ4.5以后的版本才⽀持

RocketMQ有一个集群模式叫做:Dleger模式。当主节点失活时,能够自动重新触发选举。

DLedger集群节点状态leader、candidate、follower。(Raft算法)

  • leader:接受客户端请求,本地写入日志数据,并将数据复制给follower;定期发送心跳数据给follower维护leader状态

  • candidate:master故障后节点的中间状态,只有处于candidate状态的节点才会发送投票选举请求,master选举完成后,节点状态为leader或者follower

  • follower:负责同步leader的日志数据;接受leader心跳数据,重置倒计时器保持follower状态,并将心跳响应返回给leader

springboot使用中指定多个nameServer地址以连接集群:

rocketmq.config.namesrvAddr=worker1:9876;worker2:9876;worker3:9876

集群架构原博客

Kafka

集群搭建

RabbitMQ

集群介绍及搭建
集群搭建

其他搭建示例

SpringBoot中的集群配置餐宿

RocketMQ

#Broker 的角色
#- ASYNC_MASTER 异步复制Master
#- SYNC_MASTER 同步双写Master
#- SLAVE
brokerRole=SYNC_MASTER
#刷盘方式
#- ASYNC_FLUSH 异步刷盘
#- SYNC_FLUSH 同步刷盘
flushDiskType=SYNC_FLUSH

搭建示例博客

集群搭建

RocketMq-主从集群搭建

主从同步双写集群搭建

SpringBoot整合RocketMQ搭建集群及使用

DLedger集群

DLedger是基于Raft协议实现的一个轻量级的Java类库,RocketMQ引入其来实现Leader选举,commitLog同步等一致性操作。

RocketMQ-on-DLedger Group 指的是需要实现自动容灾切换的一组RocketMq服务器,该群组至少要有三个节点。

使用时只需要增加DLedger相关配置即可,具体配置如下:

在这里插入图片描述
Dledger主要配置:

master01集群第一台机 :

#多副本自动主从选举Dledger相关
enableDLegerCommitLog = true
dLegerGroup = master01
dLegerSelfId = n0
dLegerPeers = n0-192.168.60.101:10911;n1-192.168.60.102:10911;n2-192.168.60.103:10911

master01集群第二台机 :

#多副本自动主从选举Dledger相关
enableDLegerCommitLog = true
dLegerGroup = master01
dLegerSelfId = n1
dLegerPeers = n0-192.168.60.101:10911;n1-192.168.60.102:10911;n2-192.168.60.103:10911

DLedger集群

DLedger集群搭建2

Kafka

装好JDK环境,下载并安装好ZooKeeper ,下载好Kafka安装包,并解压,这里示例为/opt/module/kafka目录下。

Linux下配置环境变量

vim /etc/profile

然后加入配置KAFKA_HOME=opt/module/ kafka
每台集群机器都要配置并刷新环境:source /etc/profile

在安装目录/opt/module/kafka下创建配置Kafka日志目录:mkdir logs

配置Kafka服务的配置

vim server.properties

配置以下:

broker.id=1
log.dirs=/opt/module/kafka/logs
zookeeper.connect=hadoop101:2181,hadoop102:2181,hadoop103:2181

broker.id:全局唯一编号,Kafka集群中的此编号不能重复。
log.dirs:设置 Kafka运行日志存放的路径
zookeeper.connect:配置连接Zookeeper集群地址(Kafka也依赖于zk 来管理集群)。

hadoop101和hadoop102、hadoop103三台服务都得配置。

启动集群

先启动三台服务器的Zookeeper,在 Kafka 安装目录下,使用 bin/zookeeper-server-start.sh 命令启动 ZooKeeper 服务,并指定配置文件 config/zookeeper.properties

zk start

依次在hadoop101、hadoop102、hadoop103节点上启动kafka,在kafka安装目录下的config目录下执行:

kafka-server-start.sh -daemon ./server.properties

**关闭集群:**依次在hadoop101、hadoop102、hadoop103节点上关闭kafka

kafka-server-stop.sh stop

搭建原文

MQ刷盘机制/集群同步

RabbitMQ

普通消息都是保存在内存中,只有配置交换机、对列、消息都为持久化时才会刷到磁盘。
当RabbitMQ的内存紧张时、或生产者发消息指定了消息持久化时,消息会写入磁盘

写入文件前会有一个Buffer,大小为1M(1048576),数据在写入文件时,首先会写入到这个Buffer,如果Buffert已满,则会将Buffer写入到文件(未必刷到磁盘):
有个固定的刷盘时间:25ms,也就是不管Buffer满不满,每隔25ms,Buffer里的数据及未刷新到磁盘的文件内容必定会刷到磁盘;

RocketMQ

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

RocketMQ为了提高性能,会尽可能地保证磁盘的顺序写。消息在通过Producer写入RocketMQ的时候,有两种写磁盘方式:

1)异步刷盘方式:在返回写成功状态时,消息可能只是被写入了内存的PAGECACHE,立刻返回发送端发送成功,有单独的线程执行刷盘;写操作的返回快,吞吐量大;当内存里的消息量积累到一定程度时,统一触发写磁盘操作,快速写入。

2)同步刷盘方式:在返回写成功状态时,消息已经被写入磁盘。同步调用MappedByteBuffer的force()方法,同步等待刷盘结果,进行刷盘结果返回告知发送端。具体流程是,消息写入内存的PAGECACHE后,立刻通知刷盘线程刷盘,然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,返回消息写成功的状态。

同步刷盘还是异步刷盘,是通过Broker配置文件里的flushDiskType参数设置的,这个参数被设置成SYNC_FLUSH、ASYNC_FLUSH中的一个

消息存储时,先将消息存储到内存,再根据不同的刷盘策略进行刷盘

同步刷盘:

异步刷盘:

刷盘源码

同步复制、异步复制
  如果一个broker组有Master和Slave,消息需要从Master复制到Slave上,有同步和异步两种复制方式。

  • 同步复制是等Master和Slave均写成功后才反馈给客户端写成功状态;
  • 异步复制方式是只要Master写成功即可反馈给客户端写成功状态

这两种复制方式各有优劣:

  • 在异步复制方式下,系统拥有较低的延迟和较高的吞吐量,但是如果Master出了故障,有些数据因为没有被写入Slave,有可能会丢失;
  • 在同步复制方式下,如果Master出故障,Slave上有全部的备份数据,容易恢复,但是同步复制会增大数据写入延迟,降低系统吞吐量。

同步复制和异步复制是通过Broker配置文件里的brokerRole参数进行设置的,这个参数可以被设置成ASYNC_MASTER、SYNC_MASTER、SLAVE三个值中的一个。

实际应用中要结合业务场景,合理设置刷盘方式和主从复制方式,尤其是SYNC_FLUSH方式,由于频繁的触发写磁盘动作,会明显降低性能。

通常情况下,应该把Master和Slave设置成ASYNC_FLUSH的刷盘方式,

主从之间配置成SYNC_MASTER的复制方式,这样即使有一台机器出故障,仍然可以保证数据不丢。

Kafka

Broker针对每个分区会创建一个分区目录,分区目录下面存放的是日志文件(.log)和索引文件(.index)

Kafka的刷盘策略主要有两种:同步刷盘(sync flush)和异步刷盘(async flush)。

同步异步刷盘的区别在于,消息存储在内存(memory)中以后,是否会等待执行完刷盘动作再返回,即是否会等待将消息中的消息写入磁盘中。kafka可以通过配置flush.messageflush.ms来设置刷盘策略,如果flush.message设置为5,表示每5条消息进行一次刷盘,如果flush.message设置为1,表示每一条消息都进行一次刷盘。如果flush.ms设置为1000,表示每过1000ms进行一次刷盘,如果flush.ms设置为5000,表示每过5000ms进行一次刷盘。

  • 同步刷盘:每条消息被写入磁盘前,必须等待操作系统完成该消息的磁盘写入操作。这种方式可以确保数据不丢失,但由于每次消息都要等待磁盘I/O完成,因此会影响性能。在Kafka中,默认使用的是异步刷盘策略,因为它结合了多副本和基于日志的存储机制,通过复制和重放来保障数据的高可用性。异步刷盘的目的是为了提高吞吐量和适应高性能应用场景。不过,这种方法增加了数据丢失的风险,尤其是在系统发生故障的情况下。
  • 异步刷盘:这是一种更轻量级的刷盘方式,它允许消息先被写入内存中的页缓存,然后在空闲时由操作系统异步地刷入磁盘。这样可以减少对性能的影响,尤其是当处理大量消息时。然而,由于没有立即将数据刷入磁盘,所以存在一定的数据丢失风险。Kafka提供了配置项log.flush.interval.messageslog.flush.interval.ms来控制何时触发强制的刷盘操作。如果没有设置这些参数,那么Kafka会根据log.flush.scheduler.interval.ms(默认值为3000毫秒)的时间间隔进行检查,以确定是否需要刷新所有日志到磁盘。需要注意的是,尽管可以通过这些参数来实现一定程度的控制,但是官方并不推荐依赖它们来强制刷盘,而是强调通过副本机制来保证数据的一致性和可靠性。

存储

RocketMQ

RocketMQ存储的文件主要包括CommitLog文件ConsumeQueue文件Index文件

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

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

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

在这里插入图片描述

CommitLog:

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

一个文件为 1GB 大小,也即 1024 * 1024 * 1024 = 1073741824 字节,CommitLog 每个文件的命名是按照总的字节偏移量来命名的。例如第一个文件偏移量为 0,那么它的名字为 00000000000000000000;当前这 1G 文件被存储满了之后,就会创建下一个文件,下一个文件的偏移量则为 1GB,那么它的名字为 00000000001073741824,以此类推。

在这里插入图片描述

在这里插入图片描述

默认情况下这些消息文件位于 $HOME/store/commitlog 目录下

在这里插入图片描述

以上设计对数据的读写非常方便,那么一条一条消息存入CommitLog文件后,该如何查找呢?

正如关系型数据库会为每条数据引入一个ID字段,基于文件编程也会为每条消息引入一个身份标志:消息物理偏移量,即消息存储在文件的起始位置。

正是有了物理偏移量的概念,CommitLog文件的命名方式也是极具技巧性,使用存储在该文件的第一条消息在整个CommitLog文件组中的偏移量来命名,例如第一个CommitLog文件为0000000000000000000,第二个CommitLog文件为00000000001073741824,依次类推。

这样做的好处是给出任意一个消息的物理偏移量,可以通过二分法进行查找,快速定位这个文件的位置,然后用消息物理偏移量减去所在文件的名称,得到的差值就是在该文件中的绝对地址。

ConsumeQueue:

CommitlLog文件的设计理念是追求极致的消息存储性能,但我们知道消息消费模型是基于主题订阅机制的,即一个消费组是消费特定主题的消息。根据主题从CommitlLog文件中检索消息,这绝不是一个好主意,这样只能从文件的第一条消息逐条检索,其性能可想而知,为了解决基于topic的消息检索问题,RocketMQ引入了ConsumeQueue文件。

ConsumeQueue文件是消息消费队列文件,是 CommitLog 文件基于topic的索引文件,主要用于消费者根据 topic 消费消息,其组织方式为 /topic/queue,同一个队列中存在多个消息文件。ConsumeQueue文件结构入下图:

在这里插入图片描述

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文件写满后,会继续写入下一个文件中。

生产者端的消息是顺序写入CommitLog,消费者端是顺序读取ConsumeQueue。但是根据ConsumeQueue的起始物理位置偏移量offset读取消息真实内容,实际是随机读取CommitLog。实现了 消息生产与消息消费、数据存储和数据索引 相互分离。

Index:

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

在这里插入图片描述

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

rocketmq indexfile解析

【RocketMQ工作原理】indexFile

RocketMQ使用内存映射、TransientStorePool机制来提升写文件速度

RocketMQ消息存储原理及源码解析


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

RocketMQ的存储以Broker为单位。它的存储也是分为消息文件和索引文件,但是在RocketMQ中,每个Broker只有一组commitLog文件,它把在这个 Broker上的所有主题的消息都存在这一组文件中。索引文件和Kafka一样,是按照主题和队列分别建立的,每个队列对应一组索引文件,这组索引文件在RocketMQ中称为ConsumerQueue。

ConsumeQueue是RocketMQ用来存储消息的物理offset、size和tagscode的数据结构。

RocketMQ引入Hash索引机制为消息建立定长稠密索引,它为每一条消息都建立索引,每个索引的长度(注意不是消息长度)是固定的20个字节。

写入消息的时候,Broker上所有主题、所有队列的消息按照自然顺序追加写入到同一个消息文件中,一个文件写满了再写下一个文件。查找消息的时候,可以直接根据队列的消息序号,计算出索引的全局位置(索引序号 x 索引固定长度20),然后直接读取这条索引,再根据索引中记录的消息的全局位置,找到消息。可以看到,这里两次寻址都是绝对位置寻址,比Kafka的查找是要快的。

RocketMQ集群下的消息存储方法

RocketMQ作为一个分布式消息中间件,其消息存储采用了分布式存储的方式。在集群模式下,消息会被分散存储在多个Broker节点上,每个Broker节点负责一部分消息的存储。具体来说,RocketMQ的消息存储主要依赖于以下几个部分:

  • 文件存储:RocketMQ将消息存储在磁盘上的文件中,这些文件按照一定的规则组织成多个目录和文件。

  • 主题和队列:RocketMQ支持多主题(Topic)和多队列(Queue)的模型。每个主题下可以包含多个队列,消息根据特定的规则被分发到不同的队列中。

  • Broker角色:在集群模式下,有多个Broker节点,每个Broker负责一部分消息的存储和转发。通过复制和备份机制,确保消息的可靠性和持久性。

索引学习

RocketMQ系列— 如何存储消息

Kafka

Topic是逻辑上的概念,而partition是物理上的概念,Kafka的存储以Partition为单位,每个partition对应于一个log文件,该log文件中存储的就是Producer生产的数据。Producer生产的数据会被不断追加到该log文件末端,为防止log文件过大导致数据定位效率低下,Kafka采取了分片和索引机制,将每个partition分为多个segment。每个segment包括:“.index”文件“.log”文件.timeindex等文件。这些文件位于一个文件夹下,该文件夹的命名规则为:topic名称+分区序号,例如:first-0。

在这里插入图片描述

.log 日志文件
.index 偏移量索引文件
.timeindex 时间戳索引文件

index和log文件以当前segment的第一条消息的offset命名。

每个Partition包含一组消息文件(Segment file/Log)和一组索引文件(Index),并且消息文件和索引文件一一对应,具有相同的文件名(但文件扩展名不一样),文件名就是这个文件中第一条消息的索引序号。为了防止Log过大,Kafka又引入了日志分段(LogSegment)的概念,将Log切分为多个LogSegment ,相当于一个巨型文件被平分为多个相对较小的文件,便于消息的维护和清理,并为每个Log创建对应的一组索引文件(Index)

每个索引中保存索引序号(也就是这条消息是这个分区中的第几条消息)和对应的消息在消息文件中的绝对位置。在索引的设计上,Kafka采用的是稀疏索引,为了节省存储空间,它不会为每一条消息都创建索引,而是每隔几条消息创建一条索引(4K)。

写入消息的时候非常简单,就是在消息文件尾部连续追加写入,一个文件写满了再写下一个文件。查找消息时,首先根据文件名找到所在的索引文件,然后用二分法遍历索引文件内的索引,在里面找到离目标消息最近的索引,再去消息文件中,找到这条最近的索引指向的消息位置,从这个位置开始顺序遍历消息文件,找到目标消息。

一旦找到消息位置后,就可以批量顺序读取,不必每条消息都要进行一次寻址。

Message结构:

我们在producer往kafka写入的也是一条一条的message,那存储在log中的message是什么样子的呢?消息主要包含消息体、消息大小、offset、压缩类型等,我们重点需要知道的是下面三个:

  • offset:offset是一个占8byte的有序id号,它可以唯一确定每条消息在parition内的位置!
  • 消息大小:消息大小占用4byte,用于描述消息的大小。
  • 消息体:消息体存放的是实际的消息数据(被压缩过),占用的空间根据具体的消息而不一样。

日志分段:

虽然一个Log被切分为多个分段,但只有最后一个LogSegment(当前活跃的日志分段)才能执行写入操作,在此之前所有的LogSegment都不能写入数据。当满足以下任一条件时会创建新的LogSegment:

  • 当前日志分段文件的大小超过了Broker端参数log.segment.bytes设置的值,默认1GB。
  • 当前日志分段中消息的最大时间戳与当前系统的时间戳的差值大于参数log.roll.m或log.roll.hours设置的值,默认为7天。
  • 偏移量索引文件或时间戳索引文件的大小达到Broker端参数log.index.size .max.bytes设置的值,默认为10M。
  • 追加消息的偏移量与当前日志分段的偏移量之间的差值大于Integr.MAX_VALUE。

Log文件在切分时,Kafka会关闭当前正在写入的LogSegment文件并置为只读模式,同时以可读写的模式创建新的LogSegment文件,文件大小默认为1GB。当下次Log切分时才会设置为LogSegment文件的实际大小。即:旧LogSegment文件大小为文件的实际大小,活跃LogSegment大小为默认的1GB。

在这里插入图片描述

日志索引:

Kafka采用稀疏索引(Sparse Index)的方式构造消息的索引,它并不保证每个消息在索引文件中都有对应的索引项。而是当写入一定量(由Broker端参数log.index. interval.bytes指定,默认为4KB)的消息后,索引文件才会增加一个索引项。

在这里插入图片描述

Kafka 中默认的日志保存时间为 7 天,可以通过调整如下参数修改保存时间。

  • log.retention.hours,最低优先级小时,默认 7 天。
  • log.retention.minutes,分钟。
  • log.retention.ms,最高优先级毫秒。
  • log.retention.check.interval.ms,负责设置检查周期,默认 5 分钟。

那么日志一旦超过了设置的时间,怎么处理呢?Kafka 中提供的日志清理策略有 delete 和 compact 两种。

delete 日志删除:将过期数据删除
启用删除策略: log.cleanup.policy = delete

  • 基于时间:默认打开。以 segment 中所有记录中的最大时间戳作为该文件时间戳。
  • 基于大小:默认关闭。超过设置的所有日志总大小,删除最早的 segment。使用log.retention.bytes控制,默认等于-1,表示无穷大。

compact日志压缩:对于相同key的不同value值,只保留最后一个版本。
使用启用压缩策略: log.cleanup.policy = compact

在这里插入图片描述

压缩后的offset可能是不连续的,比如上图中没有6,当从这些offset消费消息时,将会拿到比这个offset大
的offset对应的消息,实际上会拿到offset为7的消息,并从这个位置开始消费。
这种策略只适合特殊场景,比如消息的key是用户ID,value是用户的资料,通过这种压缩策略,整个消息集里就保存了所有用户最新的资料。

Kafka之所以能够实现高速的数据处理,主要归功于以下几个关键特性:

  • 零拷贝技术。Kafka 使用了零拷贝技术,这种技术可以在读写数据时避免将数据在内核空间和用户空间之间进行不必要的拷贝,直接在内核空间进行数据传输。这样做可以极大地提高 I/O 效率,降低 CPU 和内存的消耗。
    磁盘顺序读写。Kafka 在写入数据时采用磁盘顺序写的方式,即将数据仅追加到文件的末尾,而不是随机修改文件中的数据。这种方式避免了频繁的磁头寻址和旋转盘片的开销,从而提高了磁盘的读写效率。
  • 页缓存技术(page cache)。Kafka 利用操作系统的页缓存(也称为 OS Cache)来实现文件的写入。这意味着数据实际上是被写入内存中的页缓存,而不是直接写入磁盘。这样,磁盘文件的写入性能得到了提升,因为实际上是在写内存,而不是磁盘。
  • 顺序写: kafka 写数据的时候,是以磁盘顺序写的方式来写的,也就是说仅仅将数据追加到文件的末尾,不是在文件的随机位置来修改数据。
  • 分区分段存储。Kafka 的消息是按照 topic 分类存储的,每个 topic 的数据又是按照 partition 分段存储到不同的 broker 节点。每个 partition 对应操作系统上的一个文件夹,并且按照 segment 分段存储。这种存储方式有助于提高数据的处理效率。
  • 批量压缩。为了提高处理效率,Kafka 会对数据进行批量压缩。虽然这会增加 CPU 的消耗,但相对于减少的网络 IO 来说,这种 CPU 的消耗是值得的。
  • 利用 Partition 实现并行处理。Kafka 的每个 Topic 可以包含一个或多个 Partition,不同 Partition 可以位于不同的节点上。这样,可以通过集群中的不同机器实现并行处理,提高处理速度。
  • 索引机制

Kafka为什么快

Kafka日志及索引机制

Kafka存储结构以及原理

Kafka核心原理之消息存储机制-南秋同学

Kafka存储结构

设计MQ思路

其他知识点

三种MQ对比

在这里插入图片描述

博客记录

mq中常见问题

rocketmq 相关问题

rocketmq队列及消息的知识点*

rocketmq相关配置*

Kafka及RocketMQ对比及相关设置

Kafka其他配置及死信配置
Spring Boot 整合-kafka消费模式AckMode以及手动消费

面试知识点

RocketMQ知识点

Kafka

20道常见的kafka面试题以及答案

Kafka常见面试题

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值