6

背景

当前业务存在以下场景:在一个事务内的最后一步是发送kafka消息,消费端收到通知后读取数据并做处理。但是由于kafka几乎是即时收到消息,导致偶尔出现“在发完kafka和提交事务的间隙,消费端收到了消息并读取到了事务提交前的数据”。
这个问题可以通过延迟消息来解决。

发送端 vs 消费端

要做延迟,那么首先要考虑的是:延迟放在发送端,还是放在消费端?最终选择放在消费端:

  • 让数据先被kafka存储起来,数据更安全。
  • 想把延迟消息做成一个服务,不只是支持某一个场景/业务,在这种设计前提下,让延迟逻辑放在消费端,可以统一调整逻辑,也方便排查问题。

思路

是在整体外面包一层代理

  1. 另外创建一个延迟Topic,延迟消息都发到延迟Topic里。
  2. 有专门的服务来消费延迟Topic的消息,取到消息之后存储起来,定期检查消息是否已经延迟时间。
  3. 已到延迟时间的消息,重新发送到原先Topic。
    WX20200526-150427.png

这样做的好处是,不需要对kafka做任何改造。

存储

延迟队列消费者拉取到消息之后,要怎么存储?第三方存储,其需要满足以下几个条件:

  • 高性能:写入延迟要低,MQ的一个重要作用是削峰填谷,在选择临时存储时,写入性能必须要高,关系型数据库(如Mysql)通常不满足需求。
  • 高可靠:延迟消息写入后,不能丢失,需要进行持久化,并进行备份
  • 存储成本低:可以支持大量消息存储,(Redis存储成本太高)。
  • 支持排序: 支持按照某个字段对消息进行排序,对于延迟消息需要按照时间进行排序。普通消息通常先发送的会被先消费,延迟消息与普通消息不同,需要进行排序。例如先发一条延迟10s的消息,再发一条延迟5s的消息,那么后发送的消息需要被先消费。
  • 支持长时间保存:一些业务的延迟消息,需要延迟几个月,甚至更长,所以延迟消息必须能长时间保留。不过通常不建议延迟太长时间,存储成本比较大,且业务逻辑可能已经发生变化,已经不需要消费这些消息。

基于以上条件,选择了RocksDB来存储数据:

  • 高性能嵌入式KV存储引擎。
  • 数据持久化到磁盘。
  • 基于LMS存储,key自然排序,迭代器(Iterator)根据key顺序遍历。

代码

发送端

消息基类

public class DelayMessage<T extends DelayMessage> {
    /**
     * 事件唯一ID,用于去重检查
     */
    private String eventId = UUIDGenerator.generateString();

    /**
     * 事件时间
     */
    @JSONField(format = KafkaConstants.DATETIME_FORMAT)
    private Date eventTime = new Date();
    
    /**
     * 真实事件时间
     */
    @JSONField(format = KafkaConstants.DATETIME_FORMAT)
    private Date actualTime;

    /**
     * 真实Topic
     */
    private String actualTopic;

    public Date getActualTime() {
        return actualTime;
    }

    public T setActualTime(Date actualTime) {
        this.actualTime = actualTime;
        return (T) this;
    }

    public String getActualTopic() {
        return actualTopic;
    }

    public T setActualTopic(String actualTopic) {
        this.actualTopic = actualTopic;
        return (T) this;
    }

    public Date getEventTime() {
        return eventTime;
    }

    public T setEventTime(Date eventTime) {
        this.eventTime = eventTime;
        return (T) this;
    }
}

消息对象继承DelayMessage,将消息发送到延迟Topic。

延迟服务消费端

接收延迟消息

@KafkaListener(topics = {KafkaConstants.KAFKA_TOPIC_MESSAGE_DELAY}, containerFactory = "kafkaContainerFactory")
public boolean onMessage(String json) throws Throwable {
    try {
        DelayMessage delayMessage = deserialize(json, DelayMessage.class);
        if (!isDelay(delayMessage)) {
            // 如果接收到消息时,消息已经可以发送了,直接发送到实际的队列
            sendActualTopic(delayMessage, json);
        } else {
            // 存储
            localStorage(delayMessage, json);
        }
    } catch (Throwable e) {
        log.error("consumer kafka delay message[{}] error!", json, e);
        throw e;
    }
    return true;
}

private void sendActualTopic(DelayMessage delayMessage, String message) {
    kafkaSender.send(message, delayMessage.getActualTopic());
}

@SneakyThrows
private void localStorage(DelayMessage delayMessage, String message) {
    String key = generateRdbKey(delayMessage);
    if (rocksDb.keyMayExist(RocksDbUtils.toByte(key), null)) {
        return;
    }
    rocksDb.put(RocksDbUtils.toByte(key), RocksDbUtils.toByte(message));
}

private String generateRdbKey(DelayMessage delayMessage) {
    return delayMessage.getActualTime().getTime() + RDB_KEY_SPLITTER + delayMessage.getEventId();
}

这里要注意生成key的方法:

  • RocksDB是按key自然排序,迭代器遍历时是按key顺序遍历。
  • 按时间来生成key,遍历时遇到第一个不符合的key,即可结束遍历。
  • key里加上消息ID,用以去重。

处理存储的延迟消息

启动定时任务(ScheduledExecutorService)定时检查消息。

private void handleRdbMessage() {
    try {
        try (RocksIterator rocksIterator = rocksDb.newIterator()) {
            for (rocksIterator.seekToFirst(); rocksIterator.isValid(); rocksIterator.next()) {
                String key = "";
                String value = "";
                try {
                    byte[] keyByte = rocksIterator.key();
                    key = RocksDbUtils.toString(keyByte);
                    if (!isMessageExpired(key)) {
                        break;
                    }
                    value = RocksDbUtils.toString(rocksIterator.value());
                    DelayMessage delayMessage = JSON.parseObject(value, DelayMessage.class);
                    sendActualTopic(delayMessage, value);
                    rocksDb.delete(keyByte);
                } catch (NumberFormatException e) {
                  // 异常key
                    log.error("handler kafka rocksdb delay message[{}:{}] NumberFormatException error!", key, value, e);
                    if (StringUtils.isNotBlank(key)) {
                        rocksDb.delete(RocksDbUtils.toByte(key));
                    }
                } catch (Exception e) {
                    log.error("handler kafka rocksdb delay message[{}:{}] error!", key, value, e);
                }
            }
        }
    } catch (Exception e) {
        // 捕获异常,否则ScheduledExecutorService会停止定时任务
        log.error("handler kafka rocksdb delay message error!", e);
    }
}

private boolean isMessageExpired(String rdbKey) {
    long actualTime = Long.valueOf(rdbKey.split(RDB_KEY_SPLITTER)[0]);
    return actualTime <= System.currentTimeMillis();
}

这里sendActualTopicrocksDb.delete两个操作并不是原子性,但一般kafka消费端都会做防重复,所以也不会有问题。

其他

当前仅仅简易实现了延迟队列,还有很多需要完成完善的地方,比如:

  • 当前数据分散到不同的消费节点上,如果某一个节点服务器异常导致数据丢失,就只能人工介入,从kafka文件里获取数据;可通过部署不同的kafka group来达到数据备份,通过选主方式来决定哪一个group执行业务。
  • 一条消息被存储三份:实际队列,延迟队列,RocksDB,可以通过操作kafka CommitLog的方式,让RocksDB里仅存储CommitLog offset 相关信息,减小RocksDB占用空间。

参考:

RocketMQ教程_延迟消息)
任意时间延迟消费原理详解
如何在MQ中实现支持任意延迟的消息?

noname
314 声望49 粉丝

一只菜狗