分布式延时消息的另外一种选择 Redisson (推荐使用)
mhr18 2024-12-11 11:20 16 浏览 0 评论
来源:https://blog.csdn.net/m0_73311735/article/details/127070042
因为工作中需要用到分布式的延时队列,调研了一段时间,选择使用 Redisson DelayedQueue,为了搞清楚内部运行流程,特记录下来。
总体流程大概是图中的这个样子,初看一眼有点不知从何下手,接下来我会通过以下几点来分析流程,相信看完本文你能了解整个运行流程。
- 基本使用
- 内部数据结构介绍
- 基本流程
- 发送延时消息
- 获取延时消息
- 初始化延时队列
图片
基本使用
发送延迟消息代码如下,发送了一条延迟时间为 5s 的消息。
public void produce() {
String queuename = "delay-queue";
RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue(queuename);
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
delayedQueue.offer("测试延迟消息", 5, TimeUnit.SECONDS);
}
接收消息代码如下,可以看到 delayedQueue 是没有用到的,那么为什么要加这一行呢,这个后面总结部分回答。
public void consume() throws InterruptedException {
String queuename = "delay-queue";
RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue(queuename);
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
String msg = blockingQueue.take();
//收到消息进行处理...
}
这两段代码可以写在两个不同的 Java 工程里,只要连接的是同一个 Redis 就行。
调用 comsume() 之后,如果队列里没有消息,会阻塞等待队列里有消息并且取到了才会返回。之所以这么说是因为可能有别的 Java 进程也在跟你一样取同一个队列里的消息,如果消息被另一个抢完了,那这时就还得阻塞等待。
这时看上去的原理是这样的:
生产者调用 offer() 后,自己内部开启一个定时器,等到了时间在发送到 redis 的 list 里。
图片
如果是这样设计的话,相信大家都能看出来一个很简单的问题,要是延时时间还没到,生产者自己挂了,那样消息就丢了。所以,还是让我们接着往下看。
内部数据结构介绍
redisson 源码里一共创建了三个队列:【消息延时队列】、【消息顺序队列】、【消息目标队列】。
图片
假设在同一时间按照 msg1、msg2、msg3 的顺序发消息到延时队列,这三条消息就会被保存在【消息延时队列】和【消息顺序队列】。
可以看到【消息延时队列】的顺序是按照到期时间升序排列的,而不是像【消息顺序队列】按照插入顺序排。
消息到期后会将消息从前两个队列移除(怎么移?谁来移?),插入【消息目标队列】,也就是图中第三个队列。
消费者也是阻塞在【消息目标队列】上取消息。
这时可以简单说明下每个队列的作用:
- 【消息延时队列】利用按照到期时间排序的特性,可以很快找到下一个要到期的消息,客户端内部自己定时到【消息目标队列】取
- 【消息顺序队列】这个队列对分析的流程关联不大,可以忽略
- 【消息目标队列】存放到期的消息,供消费端取
其实【消息延时队列】队列里存的时间(也就是 zet 的 score)是到期的时间戳,为了画图方便,图里就画的是延迟的时间,不过不影响理解。
理解好这几个队列的名字和作用,后面还会一直用到,如果忘了可以翻回来回顾下。
因为书写理解方便和【消息顺序队列】在本文没涉及到,后面部分好几次提到的内容:把到期的消息从【消息延时队列】移到【消息目标队列】里,这句话实际的代码逻辑是这样:把【消息延时队列】和【消息顺序队列】里的到期消息移除,把它们插入到【消息目标队列】。
基本流程
知道了内部所使用到的数据结构后,这里可以简单说下整体的基本流程。
先说发送延迟消息,发送的延迟消息会先存在【消息延时队列】和【消息顺序队列】,如果【消息延时队列】原本是空的,会发布订阅信息提醒有新的消息。
获取延迟消息只需要从【消息目标队列】阻塞的取就行了,因为里面都是到期数据。
那么问题就只剩下怎么样判断时间到了,把【消息延时队列】里的消息移动到【消息目标队列】里呢?
这部分工作交给了初始化延时队列来处理。
这里面会定时从【消息延时队列】查询最新到期时间,定时去把【消息延时队列】里的消息移动到【消息目标队列】里。
如果【消息延时队列】是空的,就不会再定时查,而是等待发布订阅信息提醒,再定时把【消息延时队列】里的消息移动到【消息目标队列】里。
刚开始看可能有点抽象,可以看完底下一节内容之后,再回头来看这里对应的流程总结,可能会比较清晰。
发送延时消息
发送延时消息的逻辑比较简单,先看下发送的代码。
public void produce() {
String queuename = "delay-queue";
RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue(queuename);
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
delayedQueue.offer("测试延迟消息", 5, TimeUnit.SECONDS);
}
从 delayedQueue.offer 方法开始,最终会执行到 RedissonDelayedQueue 的 offerAsync 方法里。
offerAsync 方法的作用就是发送一段脚本给 redis 执行,脚本内容是:
- 将消息和到期时间插入【消息延时队列】和【消息顺序队列】
- 如果最近到期的消息是刚刚插入的消息,则对指定主题发布到期时间,目的是为了让客户端定时去把【消息延时队列】里的到期数据移动到【消息目标队列】
@Override
public RFuture<Void> offerAsync(V e, long delay, TimeUnit timeUnit) {
if (delay < 0) {
throw new IllegalArgumentException("Delay can't be negative");
}
long delayInMs = timeUnit.toMillis(delay);
long timeout = System.currentTimeMillis() + delayInMs;
long randomId = ThreadLocalRandom.current().nextLong();
return commandExecutor.evalWriteNoRetryAsync(getRawName(), codec, RedisCommands.EVAL_VOID,
"local value = struct.pack('dLc0', tonumber(ARGV[2]), string.len(ARGV[3]), ARGV[3]);"
+ "redis.call('zadd', KEYS[2], ARGV[1], value);"
+ "redis.call('rpush', KEYS[3], value);"
// if new object added to queue head when publish its startTime
// to all scheduler workers
+ "local v = redis.call('zrange', KEYS[2], 0, 0); "
+ "if v[1] == value then "
+ "redis.call('publish', KEYS[4], ARGV[1]); "
+ "end;",
Arrays.<Object>asList(getRawName(), timeoutSetName, queueName, channelName),
timeout, randomId, encode(e));
}
获取延时消息
获取延时消息是本文最简单的一部分。
public void consume() throws InterruptedException {
String queuename = "delay-queue";
RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue(queuename);
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
String msg = blockingQueue.take();
//收到消息进行处理...
}
blockingQueue.take() 方法其实只是对【消息目标队列】执行 blpop 阻塞的获取到期消息
初始化延时队列
看一下初始化的代码。
public void init() {
String queuename = "delay-queue";
RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue(queuename);
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
}
入口就是在 redissonClient.getDelayedQueue(blockingQueue) 中,创建了 RedissonDelayedQueue 对象,并执行了构造方法里的逻辑。
那么这里面主要做了什么事呢?
主要是调用了 QueueTransferTask 的 start() 方法。
public void start() {
RTopic schedulerTopic = getTopic();
statusListenerId = schedulerTopic.addListener(new BaseStatusListener() {
@Override
public void onSubscribe(String channel) {
pushTask();
}
});
messageListenerId = schedulerTopic.addListener(Long.class, new MessageListener<Long>() {
@Override
public void onMessage(CharSequence channel, Long startTime) {
scheduleTask(startTime);
}
});
}
这段代码主要是设置了指定主题(主题名:redisson_delay_queue_channel:{queuename})两个发布订阅的监听器。
- 当指定主题有新订阅时调用 pushTask() 方法,里面又会调用 pushTaskAsync() 方法
- 当指定主题有新消息时调用 scheduleTask(startTime) 方法
需要注意的是,这里会先订阅指定主题,然后触发执行 onSubscribe() 方法。
所以我们主要搞懂这三个方法都是做什么的,那么整个初始化流程就明白了。
因为这三个方法是相互调用的,只看文字的话容易云里雾里,这里有个流程图,看方法解释文字的时候可以对照着流程图看比较有印象。
图片
三个方法调用流程图.drawio.png
- scheduleTask()这个方法看起来多,但核心内容就是根据方法参数指定的时间调用 pushTask()。private void scheduleTask(final Long startTime) {
TimeoutTask oldTimeout = lastTimeout.get();
if (startTime == null) {
return;
}
if (oldTimeout != null) {
oldTimeout.getTask().cancel();
}
long delay = startTime - System.currentTimeMillis();
if (delay > 10) {
Timeout timeout = connectionManager.newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
pushTask();
TimeoutTask currentTimeout = lastTimeout.get();
if (currentTimeout.getTask() == timeout) {
lastTimeout.compareAndSet(currentTimeout, null);
}
}
}, delay, TimeUnit.MILLISECONDS);
if (!lastTimeout.compareAndSet(oldTimeout, new TimeoutTask(startTime, timeout))) {
timeout.cancel();
}
} else {
pushTask();
}
} - pushTaskAsync()这个方法是抽象方法,在创建 RedissonDelayedQueue 对象的时候传进来的,代码如下:@Override
protected RFuture<Long> pushTaskAsync() {
return commandExecutor.evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
"local expiredValues = redis.call('zrangebyscore', KEYS[2], 0, ARGV[1], 'limit', 0, ARGV[2]); "
+ "if #expiredValues > 0 then "
+ "for i, v in ipairs(expiredValues) do "
+ "local randomId, value = struct.unpack('dLc0', v);"
+ "redis.call('rpush', KEYS[1], value);"
+ "redis.call('lrem', KEYS[3], 1, v);"
+ "end; "
+ "redis.call('zrem', KEYS[2], unpack(expiredValues));"
+ "end; "
// get startTime from scheduler queue head task
+ "local v = redis.call('zrange', KEYS[2], 0, 0, 'WITHSCORES'); "
+ "if v[1] ~= nil then "
+ "return v[2]; "
+ "end "
+ "return nil;",
Arrays.<Object>asList(getRawName(), timeoutSetName, queueName),
System.currentTimeMillis(), 100);
}
看不懂也不要紧,听我解释下就明白了。这里发送了一段脚本给 redis 执行:我的理解就是初始化的时候1是为了处理旧的消息,比如生产者1发送了消息,然后时间没到自己下线了,这时如果没有其他客户端在线,就没有人能把数据从【消息目标队列】移到【消息目标队列】了。2是返回的这个时间戳,会拿这个定时,等时间到了去【消息目标队列】拉去到期的消息。简单总结就是这个方法是把到期消息从【消息延时队列】放到【消息目标队列】里,并且返回了最近要到期消息的时间戳。 - 从【消息延时队列】取出前一百条到期的消息,如果有的话,添加到【消息目标队列】里,并将这些消息从【消息延时队列】和【消息顺序队列】中移除
- 从【消息延时队列】取出下一条要到期的消息,返回它的到期时间戳(如果队列里没消息返回空)。
- pushTask()private void pushTask() {
RFuture<Long> startTimeFuture = pushTaskAsync();
startTimeFuture.whenComplete((res, e) -> {
if (e != null) {
if (e instanceof RedissonShutdownException) {
return;
}
log.error(e.getMessage(), e);
scheduleTask(System.currentTimeMillis() + 5 * 1000L);
return;
}
if (res != null) {
scheduleTask(res);
}
});
}
这个代码看起来就比较简单,调用了 pushTaskAsync() 获取最近要到期消息的时间戳(异步封装了一下)。有异常的话就调用 scheduleTask() 五秒后再执行一次 pushTask()。没有异常的话如果有最近要到期消息的时间戳(说明【消息延时队列】里还有未到期消息),用这个最新到期时间调用 scheduleTask(),在这个指定的时间调用 pushTask()。这个方法简单总结就是决定了要不要调用、什么时候再调用 pushTask(),主要操作逻辑都在 pushTaskAsync() 里(把到期的消息从【消息延时队列】移到【消息目标队列】供消费端消费)。
了解了上面几个方法的流程和含义,还记得一开头提到的添加了两个发布订阅的监听器吗?
1.当指定主题有新订阅时调用 pushTask() 方法,里面又会调用 pushTaskAsync() 方法
2.当指定主题有新消息时调用 scheduleTask(startTime) 方法
需要注意的是,这里会先订阅指定主题,然后触发执行 onSubscribe() 方法
- 在初始化延时队列刚启动的时候,处理到期旧数据:把到期的消息从【消息延时队列】移到【消息目标队列】供消费端消费;处理新数据:获取下次到期时间决定下次调用 pushTask() 的时间。上面讲的这种情况是站在当前客户端的视角,但毕竟这是监听订阅信息,如果启动不止一个客户端的话(就算是1个生产者1个消费者,也算两个客户端),总有一个客户端的订阅信息回调函数,会不会有问题?仔细想想是没有的,处理到期旧数据:之前启动的客户端已经处理完了;处理新数据:获取最近到期时间,在 scheduleTask() 里,如果之前有正在定时的任务,会把原来正在定时的任务取消掉。这个被取消的任务,时间要么就是当前这个时间,要嘛是之后的时间,取消掉不会影响逻辑。
- 为了应对原本【消息延时队列】里没消息了这种情况,流程结束了,重启定时去调用 pushTask() ,把到期的消息从【消息延时队列】移到【消息目标队列】供消费端消费。
总结
再放一下开头的图总体流程图:
图片
- 初始化延时队列时会把【消息延时队列】里的到期数据移动到【消息目标队列】,没有也有可能;然后是找最近要到期的消息时间,定时去拉,这个刚启动也是可能没有的,不过不要紧,这两步是为了处理滞留在【消息延时队列】的旧数据(在发送了延时消息后,还没到期时所有客户端都下线了,这样就没人能把【消息延时队列】里的到期数据移动到【消息目标队列】里,就会出现这种情况);最主要的还是设置了发布订阅监听器,当有人发送延时消息的时候能收到通知,定时去将【消息延时队列】里的到期数据移动到【消息目标队列】。
- 发送延时消息会先发送到【消息延时队列】和【消息顺序队列】,如果【消息延时队列】里没有数据,则将刚发送的到期时间发布到指定主题,提醒其他客户端有新消息。
- 初始化延时队列时设置的发布订阅监听器把【消息延时队列】里的到期数据移动到【消息目标队列】里。
- 获取延迟消息只需要执行 blpop 阻塞的获取【消息目标队列】的消息就可以了。
这里回答开头部分说的问题,到这看完了本文,你可以试着自己想一想这个问题的答案。
接收消息代码如下,可以看到 delayedQueue 是没有用到的,那么为什么要加这一行呢,这个后面总结部分回答。
public void consume() throws InterruptedException {
String queuename = "delay-queue";
RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue(queuename);
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
String msg = blockingQueue.take();
//收到消息进行处理...
}
其实这个问题也是我开发过程中遇到的一个奇怪的地方,接收方代码没有初始化延时队列。
首先再啰嗦一句,初始化延时队列的作用是会定时去把【消息延时队列】里的到期数据移动到【消息目标队列】。
如果只有发送方初始化延时队列:
- 发送方发送了延迟消息,在到期之前下线了(它就不能把【消息延时队列】里的到期数据移动到【消息目标队列】),而且没有其他发送方。
- 接收方不管有多少个,都没人能把【消息延时队列】里的到期数据移动到【消息目标队列】。
所以接收方代码里也初始化延时队列能够避免一部分数据丢失问题。
相关推荐
- 一文读懂Prometheus架构监控(prometheus监控哪些指标)
-
介绍Prometheus是一个系统监控和警报工具包。它是用Go编写的,由Soundcloud构建,并于2016年作为继Kubernetes之后的第二个托管项目加入云原生计算基金会(C...
- Spring Boot 3.x 新特性详解:从基础到高级实战
-
1.SpringBoot3.x简介与核心特性1.1SpringBoot3.x新特性概览SpringBoot3.x是建立在SpringFramework6.0基础上的重大版...
- 「技术分享」猪八戒基于Quartz分布式调度平台实践
-
点击原文:【技术分享】猪八戒基于Quartz分布式调度平台实践点击关注“八戒技术团队”,阅读更多技术干货1.背景介绍1.1业务场景调度任务是我们日常开发中非常经典的一个场景,我们时常会需要用到一些不...
- 14. 常用框架与工具(使用的框架)
-
本章深入解析Go生态中的核心开发框架与工具链,结合性能调优与工程化实践,提供高效开发方案。14.1Web框架(Gin,Echo)14.1.1Gin高性能实践//中间件链优化router:=...
- SpringBoot整合MyBatis-Plus:从入门到精通
-
一、MyBatis-Plus基础介绍1.1MyBatis-Plus核心概念MyBatis-Plus(简称MP)是一个MyBatis的增强工具,在MyBatis的基础上只做增强不做改变,为简化开发、提...
- Seata源码—5.全局事务的创建与返回处理
-
大纲1.Seata开启分布式事务的流程总结2.Seata生成全局事务ID的雪花算法源码3.生成xid以及对全局事务会话进行持久化的源码4.全局事务会话数据持久化的实现源码5.SeataServer创...
- Java开发200+个学习知识路线-史上最全(框架篇)
-
1.Spring框架深入SpringIOC容器:BeanFactory与ApplicationContextBean生命周期:实例化、属性填充、初始化、销毁依赖注入方式:构造器注入、Setter注...
- OpenResty 入门指南:从基础到动态路由实战
-
一、引言1.1OpenResty简介OpenResty是一款基于Nginx的高性能Web平台,通过集成Lua脚本和丰富的模块,将Nginx从静态反向代理转变为可动态编程的应用平台...
- 你还在为 Spring Boot3 分布式锁实现发愁?一文教你轻松搞定!
-
作为互联网大厂后端开发人员,在项目开发过程中,你有没有遇到过这样的问题:多个服务实例同时访问共享资源,导致数据不一致、业务逻辑混乱?没错,这就是分布式环境下常见的并发问题,而分布式锁就是解决这类问题的...
- 近2万字详解JAVA NIO2文件操作,过瘾
-
原创:小姐姐味道(微信公众号ID:xjjdog),欢迎分享,转载请保留出处。从classpath中读取过文件的人,都知道需要写一些读取流的方法,很是繁琐。最近使用IDEA在打出.这个符号的时候,一行代...
- 学习MVC之租房网站(十二)-缓存和静态页面
-
在上一篇<学习MVC之租房网站(十一)-定时任务和云存储>学习了Quartz的使用、发邮件,并将通过UEditor上传的图片保存到云存储。在项目的最后,再学习优化网站性能的一些技术:缓存和...
- Linux系统下运行c++程序(linux怎么运行c++文件)
-
引言为什么要在Linux下写程序?需要更多关于Linux下c++开发的资料请后台私信【架构】获取分享资料包括:C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,fastdf...
- 2022正确的java学习顺序(文末送java福利)
-
对于刚学习java的人来说,可能最大的问题是不知道学习方向,每天学了什么第二天就忘了,而课堂的讲解也是很片面的。今天我结合我的学习路线为大家讲解下最基础的学习路线,真心希望能帮到迷茫的小伙伴。(有很多...
- 一个 3 年 Java 程序员 5 家大厂的面试总结(已拿Offer)
-
前言15年毕业到现在也近三年了,最近面试了阿里集团(菜鸟网络,蚂蚁金服),网易,滴滴,点我达,最终收到点我达,网易offer,蚂蚁金服二面挂掉,菜鸟网络一个月了还在流程中...最终有幸去了网易。但是要...
- 多商户商城系统开发全流程解析(多商户商城源码免费下载)
-
在数字化商业浪潮中,多商户商城系统成为众多企业拓展电商业务的关键选择。这类系统允许众多商家在同一平台销售商品,不仅丰富了商品种类,还为消费者带来更多样的购物体验。不过,开发一个多商户商城系统是个复杂的...
你 发表评论:
欢迎- 一周热门
-
-
Redis客户端 Jedis 与 Lettuce
-
高并发架构系列:Redis并发竞争key的解决方案详解
-
redis如何防止并发(redis如何防止高并发)
-
开源推荐:如何实现的一个高性能 Redis 服务器
-
redis安装与调优部署文档(WinServer)
-
Redis 入门 - 安装最全讲解(Windows、Linux、Docker)
-
一文带你了解 Redis 的发布与订阅的底层原理
-
Redis如何应对并发访问(redis控制并发量)
-
oracle数据库查询Sql语句是否使用索引及常见的索引失效的情况
-
Java SE Development Kit 8u441下载地址【windows版本】
-
- 最近发表
- 标签列表
-
- oracle位图索引 (63)
- oracle批量插入数据 (62)
- oracle事务隔离级别 (53)
- oracle 空为0 (50)
- oracle主从同步 (55)
- oracle 乐观锁 (51)
- redis 命令 (78)
- php redis (88)
- redis 存储 (66)
- redis 锁 (69)
- 启动 redis (66)
- redis 时间 (56)
- redis 删除 (67)
- redis内存 (57)
- redis并发 (52)
- redis 主从 (69)
- redis 订阅 (51)
- redis 登录 (54)
- redis 面试 (58)
- 阿里 redis (59)
- redis 搭建 (53)
- redis的缓存 (55)
- lua redis (58)
- redis 连接池 (61)
- redis 限流 (51)