RabbitMQ使用总结-数据幂等和消费顺序

304 阅读6分钟

数据保证幂等性

数据的幂等性,主要发生在消费端,当消费者消费这条任务的时候,可以将这条任务状态改为processing处理中,如果其它消费者收到了因为故障补偿的相同任务,则先判断数据中心是否有当前任务,并且任务是处理中或者已经处理过,如果是处理中或者已经处理过,则视为任务重复投递,直接丢弃任务,从而保证了任务的幂等性。

2022-11-12-12-14-38-image.png 伪代码:

// 判断是否是幂等数据,如果不是创建状态直接丢弃任务
boolean dataIsCreatedStatus = transferDataIsThisStatus(transferData.getTaskId(), TransferDataEnums.Status.CREATEED);
if (!dataIsCreatedStatus) {
    log.info("重复数据,丢弃.....");
    return;
}
// 修改任务状态为处理中
updateDataStatus(transferData.getTaskId(), TransferDataEnums.Status.PROCESSING);
// .... 执行一系列逻辑

// 修改任务状态为处理完成
updateDataStatus(transferData.getTaskId(), TransferDataEnums.Status

保证数据顺序

在我们的业务中,大多数场景是不用保证数据顺序的,因为MQ设计出来的目的就是异步,解耦,既然异步了那么很多情况下都无法确定数据顺序,如我们IM消息就一般就允许数据顺序不一致或者给用户发送短信等任务。如果一旦强制保证了顺序,MQ的异步就发挥不出来了。

一般会发生数据顺序错误有如下几个场景:

  • 多生产者

    当用户逐条发送多条顺序记录的时候,因为应用服务采用了分布式部署,然后统一Nginx代理,导致数据发送到每个服务上的数据都是一部分,并且应用服务再投递到MQ的一个队列的时候,就会造成数据顺序错乱。

2022-11-13-10-18-06-image.png

  • MQ内部顺序

    RabbitMQ内部是通过交换机绑定到队列实现数据投递,因此如果多交换机绑定一个队列,或者一个交换机绑定多个队列的时候,发送数据也会造成顺序错乱。这种解决方式主要就是保持一个队列。

  • MQ集群部署

    当MQ集群部署的时候,即使是在生产者端发送保证了数据顺序,但是因为MQ集群到达每个broker的数据不一样,同样会造成数据错乱。

  • 多个消费者

    当多个消费者消费同一个队列的时候,每个消费者消费能力不一样,并且是同时消费的,也无法保证数据顺序。

在一个大型分布式集群中,以下图中的每个阶段的数据发送到下一个阶段都可能造成数据顺序错误:

如何保证消费顺序?

在上面可以看到,一个数据使用MQ流转,会在多个地方多种场景下造成数据执行处理顺序的问题,难道我们要在每一个地方都做相关的逻辑处理来保证数据顺序吗?可以这样做,但是太麻烦,最终我们看到只要在出口处也就是消费者处理数据的地方能够保证数据顺序就行,也就是最终数据顺序,有点类似于分布式事务里面的“最终一致性”,在消费者之前的数据流转无论它怎么来,我们只要保证了消费顺序即可。 程序保证消费顺序步骤如下:

对需要保证顺序的数据进行编组编号,如上面的data(1,2,3,4,5,6,7,8)可以进行编组为groupId=xxx,seq=数据顺序,最后每条数据指定开始和结束endSeq,startSeq。

  1. 消费者获取到数据,判断当前组是否是startSeq数据或者seq-1数据是否已经处理完成。
  2. 如果处理完成,则接受处理当前数据,并修改其状态。
  3. 如果未处理完成则重新投递到MQ,或者拒签。
  4. 处理到最后一条数据的时候视为整个任务执行完成,修改任务状态。

2022-11-13-10-40-59-image.png

场景演示:

场景模拟:我们一次性批量发送一组数据数据内容为1-10,如果在保持顺序的情况下,消费端插入到数据库应该是一个顺序的数据集合。

模拟数据如下:

[    {        "content": "数据:1"    },    {        "content": "数据:2"    },    {        "content": "数据:3"    },    {        "content": "数据:4"    },    {        "content": "数据:5"    },    {        "content": "数据:6"    },    {        "content": "数据:7"    },    {        "content": "数据:8"    },    {        "content": "数据:9"    },    {        "content": "数据:10"    }]

消费端代码如下:

private void processTransferData(TransferData transferData) {
    TestTransferData data = new TestTransferData();
    data.setContent("port【" + port + "】----" + transferData.getContent());
    testTransferDataMapper.insertTestTransferData(data);

    // 修改任务状态为处理完成
    updateDataStatus(transferData.getTaskId(), TransferDataEnums.Status.ACCEPTED);
}

当只有一个消费者的时候,插入到数据库中的数据是有序的

mysql> select * from test_transfer_data; -- id 为自增
+-----+--------------------------+
| id  | content                  |
+-----+--------------------------+
|  95 | port【8082----数据:1  |
|  96 | port【8082----数据:2  |
|  97 | port【8082----数据:3  |
|  98 | port【8082----数据:4  |
|  99 | port【8082----数据:5  |
| 100 | port【8082----数据:6  |
| 101 | port【8082----数据:7  |
| 102 | port【8082----数据:8  |
| 103 | port【8082----数据:9  |
| 104 | port【8082----数据:10 |
+-----+--------------------------+
10 rows in set (0.00 sec)

如果有多个消费者的时候,将会产生错误顺序的数据:

mysql> select * from test_transfer_data;
+-----+--------------------------+
| id  | content                  |
+-----+--------------------------+
| 105 | port【8082----数据:7  |
| 106 | port【8082----数据:9  |
| 107 | port【8081----数据:2  |
| 108 | port【8081----数据:1  |
| 109 | port【8081----数据:3  |
| 110 | port【8081----数据:4  |
| 111 | port【8081----数据:5  |
| 112 | port【8081----数据:6  |
| 113 | port【8081----数据:8  |
| 114 | port【8081----数据:10 |
+-----+--------------------------+
10 rows in set (0.00 sec)

可以看到上面的数据内容顺序发生了明显的错乱。

解决消费顺序

生产者对要求顺序数据编号编组:

    public void batchSendData(List<TransferData> transferDatas) {
        UUID uuid = UUID.fastUUID();
        String groupID = "SEQ_GROUP_" + uuid.toString(true);
        for (int i = 0; i < transferDatas.size(); i++) {
            TransferData transferData = transferDatas.get(i);
            // 编组编号
            TransferSeq transferSeq = new TransferSeq();
            transferSeq.setGroupId(groupID);
            transferSeq.setStartSeq(0);
            transferSeq.setEndSeq(transferDatas.size()-1);
            transferSeq.setSeq(i);
            // 发送数据
            transferData.setSeq(true);
            transferData.setTransferSeq(transferSeq);
            sendData(transferData);
        }
    }

消费端对数据做是否能够消费判断

    private boolean cannotProcess(Channel channel, long deliveryTag, TransferData transferData) throws IOException {
        ..... 其它判断,如幂等....
        // 如果是有顺序要求的数据
        if (transferData.isSeq() && !canProcessCurSeqData(transferData)) {
            log.info("不满足有序签收条件,拒绝签收.....");
            // 当前不满足处理条件,则拒收
            channel.basicNack(deliveryTag, false, true);
            return true;
        }
        return false;
    }   

    // 判断是否能够处理当前有序的数据
    private boolean canProcessCurSeqData(TransferData transferData) {
        TransferSeq transferSeq = transferData.getTransferSeq();
        // 如果当前是有序数据的首个数据则可以直接处理
        if (Objects.equals(transferSeq.getStartSeq(), transferSeq.getSeq())) {
            return true;
        }
        Object preSeq = redisCache.getCacheMapValue(CacheConstants.SEND_SEQ_DATA_TASK_GROUP, transferSeq.getGroupId());
        // 当前任务的上一个任务已经执行完成
        if (Objects.nonNull(preSeq) && Objects.equals(preSeq, transferSeq.getSeq() - 1)) {
            return true;
        }
        return false;
    }

业务处理完成后,修改有序数据的状态:

    public void transferDataConsumer(Message message, Channel channel) {
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        String content = new String(message.getBody(), StandardCharsets.UTF_8);
        TransferData transferData = JSONObject.parseObject(content, TransferData.class);
        try {
            log.info("收到了正文:" + content);
            // 判断是否能够处理,如果不能够处理则不做处理
            if (cannotProcess(channel, deliveryTag, transferData))
                return;
            // 修改任务状态为处理中
            updateDataStatus(transferData.getTaskId(), TransferDataEnums.Status.PROCESSING);
            // .... 执行一系列逻辑++++++++++++++++++++
            TestTransferData data = new TestTransferData();
            data.setContent("port【" + port + "】----" + transferData.getContent());
            testTransferDataMapper.insertTestTransferData(data);
            // .... 执行一系列逻辑++++++++++++++++++++

            // 修改有序数据状态
            if (transferData.isSeq()) {
                TransferSeq transferSeq = transferData.getTransferSeq();
                // 最后一个任务,直接删除缓存
                if (Objects.equals(transferSeq.getSeq(), transferSeq.getEndSeq())) {
                    redisCache.deleteCacheMapValue(CacheConstants.SEND_SEQ_DATA_TASK_GROUP, transferSeq.getGroupId());
                } else {
                    // 否则标记当前序列组执行完的最后序号    
                    redisCache.setCacheMapValue(CacheConstants.SEND_SEQ_DATA_TASK_GROUP, transferSeq.getGroupId(), transferSeq.getSeq());
                }
            }
            // 修改任务状态为处理完成
            updateDataStatus(transferData.getTaskId(), TransferDataEnums.Status.ACCEPTED);

            // 消费成功+++++++++++++++++++++++++++++++++
            // 删除失败的key
            redisCache.deleteCacheMapValue(CacheConstants.RECEIVE_DATA_TASK_FAIL_KEY, transferData.getTaskId());
            try {
                channel.basicAck(deliveryTag, false);
            } catch (IOException e) {
                e.printStackTrace();
            }
        } catch (Exception e) {  
            // 发生了错误
            e.printStackTrace();
            // 清理执行数据造成的脏数据
            rollbackTransferData(transferData);
            try {
                channel.basicAck(deliveryTag, false);
                // 尝试重新投递
                tryAgainSend(transferData);
            } catch (IOException e1) {
                e1.printStackTrace();
            }

        }
    }

最后结果查看数据库,结果正确。