Redis实现延迟队列方法介绍

news2024/9/23 3:11:30

延迟队列,顾名思义它是一种带有延迟功能的消息队列。那么,是在什么场景下我才需要这样的队列呢?

1. 背景

我们先看看以下业务场景:

  • 当订单一直处于未支付状态时,如何及时的关闭订单
  • 如何定期检查处于退款状态的订单是否已经退款成功
  • 在订单长时间没有收到下游系统的状态通知的时候,如何实现阶梯式的同步订单状态的策略
  • 在系统通知上游系统支付成功终态时,上游系统返回通知失败,如何进行异步通知实行分频率发送:15s 3m 10m 30m 30m 1h 2h 6h 15h

1.1 解决方案

  • 最简单的方式,定时扫表。例如对于订单支付失效要求比较高的,每2S扫表一次检查过期的订单进行主动关单操作。优点是简单缺点是每分钟全局扫表,浪费资源,如果遇到表数据订单量即将过期的订单量很大,会造成关单延迟。
  • 使用RabbitMq或者其他MQ改造实现延迟队列,优点是,开源,现成的稳定的实现方案,缺点是:MQ是一个消息中间件,如果团队技术栈本来就有MQ,那还好,如果不是,那为了延迟队列而去部署一套MQ成本有点大
  • 使用Redis的zset、list的特性,我们可以利用redis来实现一个延迟队列RedisDelayQueue

2. 设计目标

  • 实时性:允许存在一定时间的秒级误差
  • 高可用性:支持单机、支持集群
  • 支持消息删除:业务会随时删除指定消息
  • 消息可靠性:保证至少被消费一次
  • 消息持久化:基于Redis自身的持久化特性,如果Redis数据丢失,意味着延迟消息的丢失,不过可以做主备和集群保证。这个可以考虑后续优化将消息持久化到MangoDB中

3. 设计方案

设计主要包含以下几点:

  • 将整个Redis当做消息池,以KV形式存储消息
  • 使用ZSET做优先队列,按照Score维持优先级
  • 使用LIST结构,以先进先出的方式消费
  • ZSET和LIST存储消息地址(对应消息池的每个KEY)
  • 自定义路由对象,存储ZSET和LIST名称,以点对点的方式将消息从ZSET路由到正确的LIST
  • 使用定时器维护路由
  • 根据TTL规则实现消息延迟

3.1 设计图

还是基于有赞的延迟队列设计,进行优化改造及代码实现。有赞设计

3.2 数据结构

  • ZING:DELAY_QUEUE:JOB_POOL 是一个Hash_Table结构,里面存储了所有延迟队列的信息。KV结构:K=prefix+projectName field = topic+jobId V=CONENT;V由客户端传入的数据,消费的时候回传
  • ZING:DELAY_QUEUE:BUCKET 延迟队列的有序集合ZSET,存放K=ID和需要的执行时间戳,根据时间戳排序
  • ZING:DELAY_QUEUE:QUEUE LIST结构,每个Topic一个LIST,list存放的都是当前需要被消费的JOB

图片仅供参考,基本可以描述整个流程的执行过程

3.3 任务的生命周期

  1. 新增一个JOB,会在ZING:DELAY_QUEUE:JOB_POOL中插入一条数据,记录了业务方消费方。ZING:DELAY_QUEUE:BUCKET也会插入一条记录,记录执行的时间戳
  2. 搬运线程会去ZING:DELAY_QUEUE:BUCKET中查找哪些执行时间戳的RunTimeMillis比现在的时间小,将这些记录全部删除;同时会解析出每个任务的Topic是什么,然后将这些任务PUSH到TOPIC对应的列表ZING:DELAY_QUEUE:QUEUE
  3. 每个TOPIC的LIST都会有一个监听线程去批量获取LIST中的待消费数据,获取到的数据全部扔给这个TOPIC的消费线程池
  4. 消费线程池执行会去ZING:DELAY_QUEUE:JOB_POOL查找数据结构,返回给回调结构,执行回调方法。

3.4 设计要点

3.4.1 基本概念

  • JOB:需要异步处理的任务,是延迟队列里的基本单元
  • Topic:一组相同类型Job的集合(队列)。供消费者来订阅

3.4.2 消息结构

每个JOB必须包含以下几个属性

  • jobId:Job的唯一标识。用来检索和删除指定的Job信息
  • topic:Job类型。可以理解成具体的业务名称
  • delay:Job需要延迟的时间。单位:秒。(服务端会将其转换为绝对时间)
  • body:Job的内容,供消费者做具体的业务处理,以json格式存储
  • retry:失败重试次数
  • url:通知URL

3.5 设计细节

3.5.1 如何快速消费ZING:DELAY_QUEUE:QUEUE

最简单的实现方式就是使用定时器进行秒级扫描,为了保证消息执行的时效性,可以设置每1S请求Redis一次,判断队列中是否有待消费的JOB。但是这样会存在一个问题,如果queue中一直没有可消费的JOB,那频繁的扫描就失去了意义,也浪费了资源,幸好LIST中有一个BLPOP阻塞原语,如果list中有数据就会立马返回,如果没有数据就会一直阻塞在那里,直到有数据返回,可以设置阻塞的超时时间,超时会返回NULL;具体的实现方式及策略会在代码中进行具体的实现介绍

3.5.2 避免定时导致的消息重复搬运及消费

  • 使用Redis的分布式锁来控制消息的搬运,从而避免消息被重复搬运导致的问题
  • 使用分布式锁来保证定时器的执行频率

4. 核心代码实现

4.1 技术说明

技术栈:SpringBoot,Redisson,Redis,分布式锁,定时器

注意:本项目没有实现设计方案中的多Queue消费,只开启了一个QUEUE,这个待以后优化

4.2 核心实体

4.2.1 Job新增对象

/**
 * 消息结构
 *
 * @author 睁眼看世界
 * @date 2020年1月15日
 */
@Data
public class Job implements Serializable {
 
 private static final long serialVersionUID = 1L;
 
 /**
 * Job的唯一标识。用来检索和删除指定的Job信息
 */
 @NotBlank
 private String jobId;
 
 
 /**
 * Job类型。可以理解成具体的业务名称
 */
 @NotBlank
 private String topic;
 
 /**
 * Job需要延迟的时间。单位:秒。(服务端会将其转换为绝对时间)
 */
 private Long delay;
 
 /**
 * Job的内容,供消费者做具体的业务处理,以json格式存储
 */
 @NotBlank
 private String body;
 
 /**
 * 失败重试次数
 */
 private int retry = 0;
 
 /**
 * 通知URL
 */
 @NotBlank
 private String url;
}
4.2.2 Job删除对象
/**
 * 消息结构
 *
 * @author 睁眼看世界
 * @date 2020年1月15日
 */
@Data
public class JobDie implements Serializable {
 
 private static final long serialVersionUID = 1L;
 
 /**
 * Job的唯一标识。用来检索和删除指定的Job信息
 */
 @NotBlank
 private String jobId;
 
 
 /**
 * Job类型。可以理解成具体的业务名称
 */
 @NotBlank
 private String topic;
}

4.3 搬运线程

/**
 * 搬运线程
 *
 * @author 睁眼看世界
 * @date 2020年1月17日
 */
@Slf4j
@Component
public class CarryJobScheduled {
 
 @Autowired
 private RedissonClient redissonClient;
 
 /**
 * 启动定时开启搬运JOB信息
 */
 @Scheduled(cron = "*/1 * * * * *")
 public void carryJobToQueue() {
 System.out.println("carryJobToQueue --->");
 RLock lock = redissonClient.getLock(RedisQueueKey.CARRY_THREAD_LOCK);
 try {
 boolean lockFlag = lock.tryLock(LOCK_WAIT_TIME, LOCK_RELEASE_TIME, TimeUnit.SECONDS);
 if (!lockFlag) {
 throw new BusinessException(ErrorMessageEnum.ACQUIRE_LOCK_FAIL);
 }
 RScoredSortedSet<Object> bucketSet = redissonClient.getScoredSortedSet(RD_ZSET_BUCKET_PRE);
 long now = System.currentTimeMillis();
 Collection<Object> jobCollection = bucketSet.valueRange(0, false, now, true);
 List<String> jobList = jobCollection.stream().map(String::valueOf).collect(Collectors.toList());
 RList<String> readyQueue = redissonClient.getList(RD_LIST_TOPIC_PRE);
 readyQueue.addAll(jobList);
 bucketSet.removeAllAsync(jobList);
 } catch (InterruptedException e) {
 log.error("carryJobToQueue error", e);
 } finally {
 if (lock != null) {
 lock.unlock();
 }
 }
 }
}

4.4 消费线程

@Slf4j
@Component
public class ReadyQueueContext {
 
 @Autowired
 private RedissonClient redissonClient;
 
 @Autowired
 private ConsumerService consumerService;
 
 /**
 * TOPIC消费线程
 */
 @PostConstruct
 public void startTopicConsumer() {
 TaskManager.doTask(this::runTopicThreads, "开启TOPIC消费线程");
 }
 
 /**
 * 开启TOPIC消费线程
 * 将所有可能出现的异常全部catch住,确保While(true)能够不中断
 */
 @SuppressWarnings("InfiniteLoopStatement")
 private void runTopicThreads() {
 while (true) {
 RLock lock = null;
 try {
 lock = redissonClient.getLock(CONSUMER_TOPIC_LOCK);
 } catch (Exception e) {
 log.error("runTopicThreads getLock error", e);
 }
 try {
 if (lock == null) {
 continue;
 }
 // 分布式锁时间比Blpop阻塞时间多1S,避免出现释放锁的时候,锁已经超时释放,unlock报错
 boolean lockFlag = lock.tryLock(LOCK_WAIT_TIME, LOCK_RELEASE_TIME, TimeUnit.SECONDS);
 if (!lockFlag) {
 continue;
 }
 
 // 1. 获取ReadyQueue中待消费的数据
 RBlockingQueue<String> queue = redissonClient.getBlockingQueue(RD_LIST_TOPIC_PRE);
 String topicId = queue.poll(60, TimeUnit.SECONDS);
 if (StringUtils.isEmpty(topicId)) {
 continue;
 }
 
 // 2. 获取job元信息内容
 RMap<String, Job> jobPoolMap = redissonClient.getMap(JOB_POOL_KEY);
 Job job = jobPoolMap.get(topicId);
 
 // 3. 消费
 FutureTask<Boolean> taskResult = TaskManager.doFutureTask(() -> consumerService.consumerMessage(job.getUrl(), job.getBody()), job.getTopic() + "-->消费JobId-->" + job.getJobId());
 if (taskResult.get()) {
 // 3.1 消费成功,删除JobPool和DelayBucket的job信息
 jobPoolMap.remove(topicId);
 } else {
 int retrySum = job.getRetry() + 1;
 // 3.2 消费失败,则根据策略重新加入Bucket
 
 // 如果重试次数大于5,则将jobPool中的数据删除,持久化到DB
 if (retrySum > RetryStrategyEnum.RETRY_FIVE.getRetry()) {
 jobPoolMap.remove(topicId);
 continue;
 }
 job.setRetry(retrySum);
 long nextTime = job.getDelay() + RetryStrategyEnum.getDelayTime(job.getRetry()) * 1000;
 log.info("next retryTime is [{}]", DateUtil.long2Str(nextTime));
 RScoredSortedSet<Object> delayBucket = redissonClient.getScoredSortedSet(RedisQueueKey.RD_ZSET_BUCKET_PRE);
 delayBucket.add(nextTime, topicId);
 // 3.3 更新元信息失败次数
 jobPoolMap.put(topicId, job);
 }
 } catch (Exception e) {
 log.error("runTopicThreads error", e);
 } finally {
 if (lock != null) {
 try {
 lock.unlock();
 } catch (Exception e) {
 log.error("runTopicThreads unlock error", e);
 }
 }
 }
 }
 }
}

4.5 添加及删除JOB

/**
 * 提供给外部服务的操作接口
 *
 * @author why
 * @date 2020年1月15日
 */
@Slf4j
@Service
public class RedisDelayQueueServiceImpl implements RedisDelayQueueService {
 
 @Autowired
 private RedissonClient redissonClient;
 
 
 /**
 * 添加job元信息
 *
 * @param job 元信息
 */
 @Override
 public void addJob(Job job) {
 
 RLock lock = redissonClient.getLock(ADD_JOB_LOCK + job.getJobId());
 try {
 boolean lockFlag = lock.tryLock(LOCK_WAIT_TIME, LOCK_RELEASE_TIME, TimeUnit.SECONDS);
 if (!lockFlag) {
 throw new BusinessException(ErrorMessageEnum.ACQUIRE_LOCK_FAIL);
 }
 String topicId = RedisQueueKey.getTopicId(job.getTopic(), job.getJobId());
 
 // 1. 将job添加到 JobPool中
 RMap<String, Job> jobPool = redissonClient.getMap(RedisQueueKey.JOB_POOL_KEY);
 if (jobPool.get(topicId) != null) {
 throw new BusinessException(ErrorMessageEnum.JOB_ALREADY_EXIST);
 }
 
 jobPool.put(topicId, job);
 
 // 2. 将job添加到 DelayBucket中
 RScoredSortedSet<Object> delayBucket = redissonClient.getScoredSortedSet(RedisQueueKey.RD_ZSET_BUCKET_PRE);
 delayBucket.add(job.getDelay(), topicId);
 } catch (InterruptedException e) {
 log.error("addJob error", e);
 } finally {
 if (lock != null) {
 lock.unlock();
 }
 }
 }
 
 
 /**
 * 删除job信息
 *
 * @param job 元信息
 */
 @Override
 public void deleteJob(JobDie jobDie) {
 
 RLock lock = redissonClient.getLock(DELETE_JOB_LOCK + jobDie.getJobId());
 try {
 boolean lockFlag = lock.tryLock(LOCK_WAIT_TIME, LOCK_RELEASE_TIME, TimeUnit.SECONDS);
 if (!lockFlag) {
 throw new BusinessException(ErrorMessageEnum.ACQUIRE_LOCK_FAIL);
 }
 String topicId = RedisQueueKey.getTopicId(jobDie.getTopic(), jobDie.getJobId());
 
 RMap<String, Job> jobPool = redissonClient.getMap(RedisQueueKey.JOB_POOL_KEY);
 jobPool.remove(topicId);
 
 RScoredSortedSet<Object> delayBucket = redissonClient.getScoredSortedSet(RedisQueueKey.RD_ZSET_BUCKET_PRE);
 delayBucket.remove(topicId);
 } catch (InterruptedException e) {
 log.error("addJob error", e);
 } finally {
 if (lock != null) {
 lock.unlock();
 }
 }
 }
}

5. 待优化的内容

  1. 目前只有一个Queue队列存放消息,当需要消费的消息大量堆积后,会影响消息通知的时效。改进的办法是,开启多个Queue,进行消息路由,再开启多个消费线程进行消费,提供吞吐量
  2. 消息没有进行持久化,存在风险,后续会将消息持久化到MangoDB中

6. 源码

更多详细源码请在下面地址中获取

百度网盘: 百度网盘 请输入提取码

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/488850.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

神秘的IP地址8.8.8.8地址到底是什么?为什么会被用作DNS服务器地址呢?

当我们在配置网络连接或者路由器时&#xff0c;经常会遇到需要填写DNS服务器地址的情况。而在这些情况下&#xff0c;很多人都会听到一个神秘的数字地址&#xff1a;8.8.8.8。那么&#xff0c;这个地址到底是什么&#xff0c;为什么会被用作DNS服务器地址呢&#xff1f;本文将详…

分流稳压器TL431/TLV431

分流稳压器&#xff08;Shunt Regulator&#xff09;指的是依靠调节Ik电流来稳定输出电压&#xff0c;分流指的就是Ik来分流。 分流稳压器&#xff0c;也叫做并联电压稳压器&#xff0c;相对地&#xff0c;还有串联电压稳压器。 1、常见型号 TL431TLV431Vref2.5V1.24VVoutVref…

作为一个java工程师,你还记得java的基本数据类型有那些吗?他们的取值范围是多少?

Java是一种强类型编程语言&#xff0c;因此所有的变量都必须先声明后使用。Java基本数据类型指的是Java语言中最基本的数据类型&#xff0c;包括整型、浮点型、字符型、布尔型等。 Java基本数据类型一共有8种&#xff0c;它们分别是&#xff1a; byte&#xff08;字节型&#…

SpringCloud高频面试题--附答案

今天给大家分享SpringCloud高频面试题。下面是一张Spring Cloud核心组件关系图&#xff1a; 从这张图中&#xff0c;其实我们是可以获取很多信息的&#xff0c;希望大家细细品尝。 话不多说&#xff0c;直接开始面试&#xff0c;你准备好了吗&#xff1f; 1、什么是Spring Cl…

汽车电路图、原理框图、线束图、元器件布置图的识读技巧与要点

摘要&#xff1a; 想要读懂汽车电路图就必须把电的通路理清楚&#xff0c;即某条线是什么信号&#xff0c;该信号是输入信号、输出信号还是控制信号以及信号起什么作用&#xff0c;在什么条件下有信号&#xff0c;从哪里来&#xff0c;到哪里去。 一、汽车电路图的识读技巧 1.…

【Python习题集3】常用数据结构习题

数据结构 一、实验内容二、实验总结 一、实验内容 1.从键盘输入一个正整数列表&#xff0c;以-1结束&#xff0c;分别计算列表中奇数和偶数的和。 &#xff08;1&#xff09;源代码 n int(input("请输入一个正整数&#xff1a;")) list3 [] while n ! -1:list3.a…

【云原生|Kubernetes】02-Kubeadm安装部署Kubernetes集群

【云原生|Kubernetes】02-Kubeadm安装部署Kubernetes集群 文章目录 【云原生|Kubernetes】02-Kubeadm安装部署Kubernetes集群前言环境介绍安装kubernetes集群基础环境设置安装Kubernetes集群安装kubeadm&#xff0c;kubectl&#xff0c;kubeletkubeadm初始化集群(master节点操作…

C语言-学习之路-05

C语言-学习之路-05 函数函数分类函数的作用 函数的定义函数定义格式 函数名字、形参、函数体、返回值函数调用无参函数调用有参函数调用 函数的声明多文件&#xff08;分文件&#xff09;编程分文件编程 函数 函数分类 C语言程序都是从主函数main()开始执行的。从函数的定义角…

259页11万字智慧水利建设项目解决方案

本资料来源公开网络&#xff0c;仅供个人学习&#xff0c;请勿商用&#xff0c;如有侵权请联系删除。 1.1.1 项目建设任务 依托省政务云平台&#xff1b;建立水利专业数据的汇集、共享、交换和水利数据库、数据仓库、数据资源目录与大数据服务等&#xff1b;建立水利大数据分…

OpenGL(五)——变换

目录 一、前言 二、向量 三、矩阵 四、旋转的箱子 4.1 GLM库 4.2 构建 一、前言 前面了解渲染架构中uniform可以传递矩阵变换&#xff0c;如从RGBA到YUV&#xff0c;同时它也可以使物体通过矩阵变换动起来。 二、向量 向量是包含方向和大小的矢量&#xff0c;平面2D坐标可…

一文吃透Tomcat核心知识点

架构 首先&#xff0c;看一下整个架构图。最全面的Java面试网站 接下来简单解释一下。 Server&#xff1a;服务器。Tomcat 就是一个 Server 服务器。 Service&#xff1a;在服务器中可以有多个 Service&#xff0c;只不过在我们常用的这套 Catalina 容器的Tomcat 中只包含一…

在技术圈超卷的当下,学历到底是敲门砖还是枷锁?

前言 最近&#xff0c;突然之间被“孔乙己文学”刷屏了&#xff0c;短时间内“孔乙己文学”迅速走红&#xff0c;孔乙己是中国文学中的一位经典人物&#xff0c;他的长衫被认为是他的象征之一&#xff0c;孔乙己的长衫折射出很多现象&#xff0c;既有社会的&#xff0c;也有教育…

【Linux】进程信号及信号产生

文章目录 一. 生活层面的信号二. 进程信号三. 硬件中断四. 信号产生五. Term&Core1. 核心转储的意义2. 云服务器为什么关闭核心转储3. core dump标志 六. 总结结束语 一. 生活层面的信号 在学习进程信号前&#xff0c;我们不妨认识一下现实中有哪些信号。 日常生活中&#…

净利润下滑13%,帅丰电器已掉队?

近年来&#xff0c;随着市场竞争加剧&#xff0c;厨电行业加速洗牌&#xff0c;超60%杂牌或被淘汰出局&#xff0c;三类品牌全部被清退。而作为毛利最高的厨电细分市场&#xff0c;集成灶行业吸引了大批企业涌入&#xff0c;市场渗透率快速提升&#xff0c;已经超过30%&#xf…

如何入门数据分析

如何入门数据分析&#xff1f; 随着数字经济、大数据时代的发展&#xff0c;数据已然成为当下时代最重要的盈利资源&#xff0c;让企业在做决策和计划方案时更有针对性和依据&#xff0c;能提前预测市场发展方向&#xff0c;做好布局。由此而产生的数据分析岗位也逐渐被更多企业…

亚马逊、Lazada、阿里国际、eBay、Temu、Ozon好消息不断,机会来了

1. 亚马逊第一季度营收1273.58亿美元 同比扭亏为盈 亚马逊2023财年第一季度财报。亚马逊第一季度净销售额为1273.58亿美元&#xff0c;与上年同期的1164.44亿美元相比增长9%&#xff0c;不计入汇率变动的影响为同比增长11%&#xff1b;净利润为31.72亿美元&#xff0c;上年同期…

“智慧交通”转型升级+创新发展策略

随着“互联网交通”的应用创新推陈出新&#xff0c;传统轨道交通行业客户服务中心已难以满足乘客对便捷高效的客户服务需求&#xff1b;节假日人流量激增&#xff0c;客户服务人手不足&#xff0c;交通、站点堵塞、信息更新不及时等问题是常态。因此&#xff0c;“智慧城市”交…

多租户SAAS系统涉及实战解决方案—案例JeecgBoot低代码平台

JeecgBoot免费低代码平台&#xff0c;提供一键切换多租户模式机制&#xff01;快速实现全系统的saas租户方案&#xff0c;通过租户ID进行数据隔离。 租户设计思路 1、开启全系统租户隔离 开启方法 将 org.jeecg.config.mybatis.MybatisPlusSaasConfig#OPEN_SYSTEM_TENANT_CO…

为什么是三次握手和四次挥手

文章目录 为什么是三次握手为什么是四次挥手什么是TIME_WAIT状态解决TIME_WAIT状态引起的bind失败的方法 为什么是三次握手 因为这是双方都有收发的最小次数 握手的目的&#xff1a;确认网络好着没&#xff0c;对方好着没 确认双方主机是否健康&#xff08;双方&#xff09;…

美国访问学者访问J1签证和商务B1签证的区别

美国校方通常希望他国的访问学者申请访问类签证&#xff08;J1&#xff09;&#xff0c;当然也有很少一部分人以商务签证&#xff08;B1&#xff09;的身份入境&#xff0c;知识人网小编就这两种签证的区别做一下简单介绍。 交流访问学者签证&#xff08;J1&#xff09;旨在促进…