大数据最全MQ高可用相关设置(2),2024年最新大数据开发面试超详细知识点

img
img

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!


生产者设置同步发送 :

 // 异步发送 默认
 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,只有当所有同步副本全部收到消息时,生产者才会收到一个来自 服务器的成功响应。

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的,它会将一个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

消费类型重试间隔最大重试次数
顺序消费间隔时间可通过自定义设置,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));


![img](https://img-blog.csdnimg.cn/img_convert/18d4b261ba1c530b4192b29bd784f987.png)
![img](https://img-blog.csdnimg.cn/img_convert/01491a3a6b00b647d3694c4358315734.png)

**网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。**

**[需要这份系统化资料的朋友,可以戳这里获取](https://bbs.csdn.net/forums/4f45ff00ff254613a03fab5e56a57acb)**


**一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!**

er.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));


[外链图片转存中...(img-n3o2ibBq-1715421504831)]
[外链图片转存中...(img-j9yFSnqx-1715421504831)]

**网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。**

**[需要这份系统化资料的朋友,可以戳这里获取](https://bbs.csdn.net/forums/4f45ff00ff254613a03fab5e56a57acb)**


**一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!**

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值