「这是我参与2022首次更文挑战的第4天,活动详情查看:2022首次更文挑战」。
为什么引入延迟消息
我们应用想在某一时刻执行某一动作,目前的方式,我们可以创建定时任务执行动作。这种方式存在很多缺点:如果我们的执行时间是根据业务都存在差异,比如动作A需要在1s后执行,动作B需要在10s后执行,动作C需要在1h后执行…,这种情况使用定时任务肯定是不合理的。
那么,如果存在某种解决方案可以做到上述的场景,它需要提供什么功能呢?
- 支持延迟,比如可以在指定的时间到达后执行某些动作
- 支持循环,可以支持固定间隔触发某些动作,存在很多时间间隔
- 支持取消,可能根据业务场景,我们不想继续触发某些动作了,需要支持取消操作
- 支持重试,由于某些原因导致在指定的时间触发动作失败,需要支持重试机制
业界延迟消息实现方案
| 方案 | 优点 | 缺点 |
|---|---|---|
| 时间轮实现延迟消息 | 基于内存级别实现延迟,效率高 | 不支持分布式、不支持状态存储、不知道指定时间 |
| Redis实现延迟消息 | 简单实用,快速落地 | 随着数据量增大,对内存要求极高 |
| 消息队列实现延迟消息 | 分布式消息,支持各种场景延迟,高吞吐,低延迟 | 各种组件实现的版本不一致,需要根据业务场景进行取舍 |
时间轮算法
算法原理
Netty实现UML
Netty代码实现
public class WheelTimer {
@SneakyThrows
public static void main(String[] args) {
int count = 2;
CountDownLatch countDownLatch = new CountDownLatch(count);
HashedWheelTimer timer = new HashedWheelTimer();
System.out.println("开始时间: " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new Date()));
for (int i = 1; i <= count; i++) {
int finalI = i;
timer.newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
System.out.println("设置提醒: " + finalI + ", 时间: " + new SimpleDateFormat("yyyy-MM-dd hh:mm:ss").format(new Date()));
countDownLatch.countDown();
}
}, i * 10, TimeUnit.SECONDS);
}
countDownLatch.await();
timer.stop();
System.out.println("主线程结束");
}
}
缺点
- 时间轮算法只关注定时消息操作,而没有对消息状态进行存储,服务重启或者异常导致消息状态丢失
- 只适用于基于内存级别的定时,不支持跨JVM分布式定时操作
Redis实现延迟队列
延
迟队列实现原理
在Redis中,有一种有序集合(Sorted Set)的数据结构,在有序集合中,所有元素是按照其Score进行排序的,我们可以把消息被消费的预期时间戳作为Score,定时任务不断读取Score大于当前时间的元素即可
实现方案
- 调用API,传入执行时间、消息体等数据
- 生成唯一key,把消息体数据序列化后存入Redis的String结构中
- 把key和执行时间的时间戳存入Redis的有序集合结构中,有序集合中不存储具体的消息体数据,而是存储唯一的key
- 定时任务不断读取时间戳最小的消息
- 如果时间戳小于当前时间,将key放入作为队列的Redis的List结构中
- 另外一个定时任务不断从队列中读取需要消费的消息的key
- 根据key获取消息体数据,对消息进行消费
- 如果消费消息成功,删除key对应的消息体数据
- 如果消费消息失败,重新存入key和时间戳(加60秒)
RocketMQ延迟队列
延
迟队列原理
定时消息(延迟队列)是指消息发送到broker后,不会立即被消费,等待特定时间投递给真正的topic,broker有配置项messageDelayLevel,默认值为“1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h”,18个level,可以配置自定义messageDelayLevel
messageDelayLevel是broker的属性,不属于某个topic,发消息时,设置delayLevel等级即可:msg.setDelayLevel(level),level有以下三种情况: level == 0,消息为非延迟消息 1 <= level <= maxLevel,消息延迟特定时间,例如level1,延迟1s level >= maxLevel,则level maxLevel,例如level==20,延迟2h
定时消息会暂存在名为SCHEDULE_TOPIC_XXXX的topic中,并根据delayTimeLevel存入特定的queue,queueId = delayTimeLevel – 1,即一个queue只存相同延迟的消息,保证具有相同发送延迟的消息能够顺序消费,broker会调度地消费SCHEDULE_TOPIC_XXXX,将消息写入真实的topic
延迟队列Java实现
public boolean send(String topic, String tags, String keys, String body, int delayTimeLevel) {
if (StringUtils.isNotEmpty(tags) && StringUtils.isNotEmpty(tags.trim())) {
tags = tags.trim();
} else {
tags = "DEFAULT_TAG";
}
try {
Message message = new Message(topic, tags, body.getBytes(StandardCharsets.UTF_8));
if (delayTimeLevel > 0) {
message.setDelayTimeLevel(delayTimeLevel);
}
if (StringUtils.isNotEmpty(keys)) {
message.setKeys(keys);
}
SendResult sendResult = producer.getRocketProducer().send(message);
if (sendResult.getSendStatus() == SendStatus.SEND_OK) {
return true;
}
} catch (Exception e) {
return false;
}
return false;
}
对比开源版与阿里云版
| 维度/队列 | RocketMQ | RocketMQ Aliyun |
|---|---|---|
| 延迟消息 | 固定等级延迟 | 支持ms级别自定义消息延迟 |
| 定时消息 | 不支持 | 支持指定ms级别自定义时间戳消息延迟 |
| 取消消息 | 不支持 | 不支持 |
| 固定时间间隔消息 | 不支持 | 不支持 |
阿里云延迟队列实现
public boolean send(String topic, String tags, String keys, String body, long delay) {
if (StringUtils.isNotEmpty(tags) && StringUtils.isNotEmpty(tags.trim())) {
tags = tags.trim();
} else {
tags = "DEFAULT_TAG";
}
try {
TopicMessage message;
if (StringUtils.isNotEmpty(tags)) {
message = new TopicMessage(body.getBytes(), tags);
} else {
message = new TopicMessage(body.getBytes());
}
if (StringUtils.isNotEmpty(keys)) {
message.setMessageKey(keys);
}
if (delay > 0) {
message.setStartDeliverTime(delay);
}
message.getProperties().put("timestamp", String.valueOf(System.currentTimeMillis()));
TopicMessage result = producer.getAliyunProducer().publishMessage(message);
System.out.println(new Date() + " Send mq message success. Topic is:" + topic + ", msgId is: " + result.getMessageId()
+ ", bodyMD5 is: " + result.getMessageBodyMD5());
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
最佳实践
- 消息tags的合理使用:一个应用尽可能使用一个topic,消息子类型可以使用tag进行区分
- keys的使用,每个消息在业务层面的唯一标识码
- 消息生产者要注意消息丢失的情况,可根据业务场景,设置刷盘机制、重试机制,以rocketmq为例,SYNC_FLUSH(同步刷新)相比于ASYNC_FLUSH(异步处理)会损失很多性能,但是也更可靠
- 消息消费者要注意消息重复的情况,可通过redis、数据库唯一键做幂等
- 生产者速度大于消费者速度的情况,致使消息堆积,可通过提高并发度、批量消费、跳过非重要消息、优化消费链路的方式解决
小结
业界提供了多种实现方案,但是都有优缺点,需要我们根据业务进行判断与取舍