MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

RocketMQ消息去重与防重复消费

2022-03-075.4k 阅读

RocketMQ消息去重原理

在分布式系统中,消息重复是一个常见的问题。RocketMQ通过多种机制来实现消息去重,以确保消息在传递过程中的准确性和一致性。

RocketMQ的消息去重主要基于生产者端和消费者端两个层面。在生产者端,RocketMQ通过唯一的消息ID来标识每一条消息。当生产者发送消息时,RocketMQ会为每条消息生成一个全局唯一的ID,这个ID在整个RocketMQ集群中是唯一的。例如,在发送消息时,生产者代码中可以获取到这个唯一ID:

DefaultMQProducer producer = new DefaultMQProducer("producerGroup");
producer.setNamesrvAddr("localhost:9876");
producer.start();
Message msg = new Message("TopicTest" ,
    "TagA" ,
    "OrderID188" ,
    "Hello world".getBytes(RemotingHelper.DEFAULT_CHARSET));
SendResult sendResult = producer.send(msg);
System.out.println("Message ID: " + sendResult.getMsgId());
producer.shutdown();

上述代码中,sendResult.getMsgId()获取到的就是RocketMQ为这条消息生成的唯一ID。

在消费者端,RocketMQ利用这个唯一ID来进行去重。当消费者从Broker拉取消息时,RocketMQ会保证同一个Consumer Group内的消息按顺序消费。消费者在处理消息之前,会先检查该消息的ID是否已经被处理过。如果已经处理过,则直接丢弃该消息,不再进行重复处理。

生产者端消息去重实现

生产者端的消息去重主要依赖于RocketMQ生成的唯一消息ID。在实际应用中,生产者通常需要确保消息的幂等性。所谓幂等性,是指对同一操作多次执行所产生的影响均与一次执行的影响相同。

以电商系统中的订单创建为例,假设生产者向RocketMQ发送创建订单的消息。如果由于网络问题等原因,消息发送出现重复,那么订单创建操作不应该重复执行,否则会导致重复下单的问题。

在生产者代码中,我们可以通过以下方式来保证幂等性:

public class OrderProducer {
    private static final Logger logger = LoggerFactory.getLogger(OrderProducer.class);
    private DefaultMQProducer producer;

    public OrderProducer() throws MQClientException {
        producer = new DefaultMQProducer("order_producer_group");
        producer.setNamesrvAddr("localhost:9876");
        producer.start();
    }

    public void sendOrderMessage(Order order) {
        try {
            Message msg = new Message("OrderTopic",
                    "CreateOrder",
                    order.getOrderId().getBytes(RemotingHelper.DEFAULT_CHARSET),
                    order.toString().getBytes(RemotingHelper.DEFAULT_CHARSET));
            SendResult sendResult = producer.send(msg);
            logger.info("Send message success, msgId: {}, orderId: {}", sendResult.getMsgId(), order.getOrderId());
        } catch (Exception e) {
            logger.error("Send message failed", e);
        }
    }

    public void shutdown() {
        producer.shutdown();
    }
}

在上述代码中,Order类包含订单的相关信息,order.getOrderId()可以作为业务层面的唯一标识。生产者在发送消息时,通过RocketMQ生成的msgId和业务层面的orderId双重保障,在一定程度上避免重复消息带来的问题。

消费者端消息去重实现

消费者端的消息去重是保证消息准确消费的关键环节。RocketMQ消费者在消费消息时,会维护一个已消费消息ID的集合。当接收到新消息时,首先检查该消息的ID是否在集合中。

以Java代码为例,消费者端可以通过以下方式实现去重:

public class OrderConsumer {
    private static final Logger logger = LoggerFactory.getLogger(OrderConsumer.class);
    private static final Set<String> consumedMsgIds = Collections.synchronizedSet(new HashSet<>());
    private DefaultMQPushConsumer consumer;

    public OrderConsumer() throws MQClientException {
        consumer = new DefaultMQPushConsumer("order_consumer_group");
        consumer.setNamesrvAddr("localhost:9876");
        consumer.subscribe("OrderTopic", "CreateOrder");
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                for (MessageExt msg : msgs) {
                    String msgId = msg.getMsgId();
                    if (consumedMsgIds.contains(msgId)) {
                        logger.info("Duplicate message, msgId: {}", msgId);
                        continue;
                    }
                    consumedMsgIds.add(msgId);
                    try {
                        String orderInfo = new String(msg.getBody(), RemotingHelper.DEFAULT_CHARSET);
                        logger.info("Consume message success, msgId: {}, orderInfo: {}", msgId, orderInfo);
                    } catch (UnsupportedEncodingException e) {
                        logger.error("Decode message failed", e);
                        return ConsumeConcurrentlyStatus.RECONSUME_LATER;
                    }
                }
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        consumer.start();
    }

    public void shutdown() {
        consumer.shutdown();
    }
}

在上述代码中,consumedMsgIds是一个线程安全的集合,用于存储已消费消息的ID。当消费者接收到消息时,首先检查消息ID是否在集合中,如果存在则认为是重复消息,直接跳过;如果不存在,则将消息ID添加到集合中,并处理消息。

集群环境下的消息去重

在集群环境下,RocketMQ的消息去重机制面临更多挑战。由于多个消费者实例可能同时处理消息,需要确保每个实例的去重逻辑是一致的。

RocketMQ通过以下方式来解决集群环境下的去重问题:

  1. 全局唯一ID:RocketMQ生成的消息ID在整个集群中是唯一的,这为去重提供了基础。
  2. Consumer Group:同一个Consumer Group内的消费者实例共享消费进度和去重信息。当一个消费者实例消费了一条消息并将其ID记录下来后,其他实例在处理相同消息时,会通过共享的去重信息判断该消息是否已经被消费。

例如,在一个包含多个消费者实例的集群中,假设实例A消费了一条消息并将其ID记录到共享的去重信息中。当实例B接收到相同消息时,会首先检查共享的去重信息,发现该消息已被消费,从而直接丢弃该消息。

消息去重与性能优化

虽然消息去重是保证消息准确性的重要手段,但在一定程度上会对系统性能产生影响。例如,消费者端维护已消费消息ID的集合需要占用内存空间,并且每次消费消息时都需要进行ID检查,这会增加处理时间。

为了在保证消息去重的同时优化性能,可以采取以下措施:

  1. 定期清理去重集合:消费者端可以定期清理已消费消息ID的集合,以减少内存占用。例如,可以设置一个定时器,每隔一段时间清理一次集合中过期的消息ID。
  2. 使用高效的数据结构:选择高效的数据结构来存储已消费消息ID,如布隆过滤器(Bloom Filter)。布隆过滤器可以在占用较少内存的情况下,快速判断一个元素是否存在,虽然存在一定的误判率,但在消息去重场景下可以接受。

防重复消费的场景分析

  1. 网络抖动场景:在网络不稳定的情况下,生产者可能会重复发送消息,消费者也可能重复接收消息。例如,生产者发送消息后,由于网络延迟,没有及时收到Broker的确认响应,生产者可能会认为消息发送失败而重新发送。此时,RocketMQ的去重机制就需要发挥作用,确保消费者不会重复处理这些消息。
  2. 系统故障恢复场景:当系统发生故障并恢复后,可能会出现消息重复消费的情况。例如,消费者在处理消息过程中突然崩溃,重启后从Broker拉取消息时,可能会再次拉取到之前未处理完成的消息。RocketMQ通过消息重试和去重机制,保证消息在故障恢复后能够正确处理,避免重复消费。
  3. 负载均衡场景:在负载均衡环境下,消息可能会被分配到不同的消费者实例上。如果负载均衡算法不合理,可能会导致同一消息被多个消费者实例处理。RocketMQ通过Consumer Group的机制,确保同一个Consumer Group内的消费者实例对消息的消费是一致的,避免重复消费。

防重复消费的高级策略

  1. 基于数据库的去重:除了RocketMQ自身的去重机制外,还可以结合数据库来实现更严格的去重。例如,在处理消息时,先将消息的唯一标识(如业务ID)插入到数据库的一张去重表中。如果插入成功,则处理消息;如果插入失败(表示该消息已处理过),则直接丢弃消息。
public class DatabaseBasedDeduplication {
    private static final Logger logger = LoggerFactory.getLogger(DatabaseBasedDeduplication.class);
    private DataSource dataSource;

    public DatabaseBasedDeduplication(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public boolean isDuplicate(String businessId) {
        String sql = "SELECT COUNT(*) FROM deduplication_table WHERE business_id =?";
        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {
            pstmt.setString(1, businessId);
            try (ResultSet rs = pstmt.executeQuery()) {
                if (rs.next()) {
                    return rs.getInt(1) > 0;
                }
            }
        } catch (SQLException e) {
            logger.error("Check duplicate failed", e);
        }
        return false;
    }

    public void markAsProcessed(String businessId) {
        String sql = "INSERT INTO deduplication_table (business_id) VALUES (?)";
        try (Connection conn = dataSource.getConnection();
             PreparedStatement pstmt = conn.prepareStatement(sql)) {
            pstmt.setString(1, businessId);
            pstmt.executeUpdate();
        } catch (SQLException e) {
            logger.error("Mark as processed failed", e);
        }
    }
}

在上述代码中,isDuplicate方法用于检查消息是否已处理过,markAsProcessed方法用于将消息标记为已处理。

  1. 幂等性服务设计:从业务层面设计幂等性服务也是防重复消费的重要策略。例如,在电商系统的订单支付接口中,无论调用多少次,只要订单状态是已支付,就不再重复执行支付操作。这样即使消息重复消费,也不会对业务产生不良影响。

消息去重与事务消息

RocketMQ的事务消息机制为消息去重提供了更强大的保障。事务消息可以保证消息发送与本地事务的一致性。

以银行转账为例,假设需要从账户A向账户B转账。首先发送一条事务消息,消息内容包含转账信息。当消息发送成功后,执行本地转账事务(从账户A扣除金额,向账户B增加金额)。如果本地事务执行成功,则提交事务消息;如果本地事务执行失败,则回滚事务消息。

在这个过程中,RocketMQ会保证事务消息要么全部成功,要么全部失败。如果由于某些原因导致消息重复发送,由于本地事务的幂等性,不会出现重复转账的问题。

public class TransactionProducer {
    private static final Logger logger = LoggerFactory.getLogger(TransactionProducer.class);
    private TransactionMQProducer producer;

    public TransactionProducer() throws MQClientException {
        producer = new TransactionMQProducer("transaction_producer_group");
        producer.setNamesrvAddr("localhost:9876");
        producer.setTransactionListener(new TransactionListener() {
            @Override
            public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
                // 执行本地事务
                try {
                    // 例如:转账操作
                    boolean result = transfer((TransferInfo) arg);
                    if (result) {
                        return LocalTransactionState.COMMIT_MESSAGE;
                    } else {
                        return LocalTransactionState.ROLLBACK_MESSAGE;
                    }
                } catch (Exception e) {
                    logger.error("Execute local transaction failed", e);
                    return LocalTransactionState.ROLLBACK_MESSAGE;
                }
            }

            @Override
            public LocalTransactionState checkLocalTransaction(MessageExt msg) {
                // 检查本地事务状态
                String transactionId = msg.getTransactionId();
                boolean result = checkTransactionStatus(transactionId);
                if (result) {
                    return LocalTransactionState.COMMIT_MESSAGE;
                } else {
                    return LocalTransactionState.ROLLBACK_MESSAGE;
                }
            }
        });
        producer.start();
    }

    public void sendTransferMessage(TransferInfo transferInfo) {
        try {
            Message msg = new Message("TransferTopic",
                    "Transfer",
                    transferInfo.getTransactionId().getBytes(RemotingHelper.DEFAULT_CHARSET),
                    transferInfo.toString().getBytes(RemotingHelper.DEFAULT_CHARSET));
            SendResult sendResult = producer.sendMessageInTransaction(msg, transferInfo);
            logger.info("Send transaction message success, msgId: {}", sendResult.getMsgId());
        } catch (Exception e) {
            logger.error("Send transaction message failed", e);
        }
    }

    public void shutdown() {
        producer.shutdown();
    }

    private boolean transfer(TransferInfo transferInfo) {
        // 实际的转账逻辑
        return true;
    }

    private boolean checkTransactionStatus(String transactionId) {
        // 检查转账事务状态的逻辑
        return true;
    }
}

在上述代码中,TransactionListener接口定义了本地事务的执行和检查方法。通过事务消息机制,RocketMQ在保证消息一致性的同时,也进一步增强了消息去重的能力。

消息去重与消息顺序性

RocketMQ在保证消息去重的同时,也支持消息的顺序性。在某些场景下,如订单处理流程,消息的顺序性非常重要。例如,订单创建消息需要在订单支付消息之前处理,否则可能会出现支付成功但订单不存在的情况。

RocketMQ通过Message Queue来保证消息的顺序性。同一个Message Queue中的消息会按照发送顺序依次被消费。消费者在消费消息时,会按照顺序依次处理。结合消息去重机制,RocketMQ可以确保顺序消费的消息不会被重复处理。

public class OrderedConsumer {
    private static final Logger logger = LoggerFactory.getLogger(OrderedConsumer.class);
    private DefaultMQPushConsumer consumer;

    public OrderedConsumer() throws MQClientException {
        consumer = new DefaultMQPushConsumer("ordered_consumer_group");
        consumer.setNamesrvAddr("localhost:9876");
        consumer.subscribe("OrderTopic", "CreateOrder || PayOrder");
        consumer.registerMessageListener(new MessageListenerOrderly() {
            @Override
            public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
                for (MessageExt msg : msgs) {
                    String msgId = msg.getMsgId();
                    // 去重逻辑
                    if (isDuplicate(msgId)) {
                        logger.info("Duplicate message, msgId: {}", msgId);
                        continue;
                    }
                    try {
                        String orderInfo = new String(msg.getBody(), RemotingHelper.DEFAULT_CHARSET);
                        logger.info("Consume message success, msgId: {}, orderInfo: {}", msgId, orderInfo);
                    } catch (UnsupportedEncodingException e) {
                        logger.error("Decode message failed", e);
                        return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
                    }
                }
                return ConsumeOrderlyStatus.SUCCESS;
            }
        });
        consumer.start();
    }

    private boolean isDuplicate(String msgId) {
        // 去重判断逻辑
        return false;
    }

    public void shutdown() {
        consumer.shutdown();
    }
}

在上述代码中,MessageListenerOrderly接口确保消息按顺序消费,同时结合去重逻辑,保证消息不会被重复处理。

消息去重与高可用

RocketMQ的高可用架构也对消息去重起到了重要作用。RocketMQ采用Master - Slave架构,Master节点负责处理消息的读写,Slave节点作为备份。当Master节点出现故障时,Slave节点可以切换为Master节点继续提供服务。

在这个过程中,消息去重机制需要保证在节点切换后仍然有效。RocketMQ通过复制机制将消息和相关的去重信息同步到Slave节点,确保在节点切换后,新的Master节点能够正确处理消息去重。

例如,当Master节点接收到一条消息并将其ID记录到去重信息中后,会将这条消息和去重信息同步到Slave节点。如果Master节点故障,Slave节点切换为Master节点后,在处理消息时可以根据同步过来的去重信息判断消息是否已被处理。

消息去重与监控

为了确保消息去重机制的正常运行,需要对消息去重相关指标进行监控。监控可以帮助我们及时发现消息重复的异常情况,并采取相应的措施。

RocketMQ提供了一些内置的监控指标,如消息发送成功率、消息消费成功率等。我们可以在此基础上,增加消息去重相关的指标监控,如重复消息数量、去重成功率等。

通过监控系统,我们可以实时查看这些指标的变化情况。如果发现重复消息数量突然增加,可能表示去重机制出现了问题,需要及时排查原因。例如,可以通过分析消息ID、消费者实例状态等信息,找出导致消息重复的原因,并进行修复。

消息去重与业务逻辑的融合

在实际应用中,消息去重需要与业务逻辑紧密融合。不同的业务场景对消息去重的要求可能不同。例如,在实时数据分析场景中,对消息的准确性要求极高,即使少量的消息重复也可能导致分析结果的偏差,因此需要严格的去重机制。而在一些非关键业务场景中,对消息重复的容忍度可能相对较高。

在设计消息去重方案时,需要充分考虑业务逻辑的特点。例如,在电商系统中,订单创建和支付消息的去重需要结合订单状态的管理。如果订单已经处于已支付状态,再次接收到支付消息时,应该直接忽略,而不是重复处理支付逻辑。

同时,业务逻辑也可以为消息去重提供更多的辅助信息。例如,在物流系统中,包裹的唯一标识可以作为消息去重的重要依据,结合RocketMQ生成的消息ID,进一步提高消息去重的准确性。

总结

RocketMQ的消息去重与防重复消费机制是分布式系统中保证消息准确传递和处理的重要手段。通过生产者端生成唯一消息ID、消费者端维护去重信息以及结合多种高级策略,RocketMQ能够在各种复杂场景下有效地避免消息重复消费的问题。

在实际应用中,我们需要根据业务场景的特点,合理选择和配置消息去重方案,同时结合监控和性能优化措施,确保系统的高效稳定运行。无论是简单的单实例应用,还是复杂的集群环境,RocketMQ的消息去重机制都能够为我们提供可靠的消息处理保障。