头图

目录

  • 前言
  • 延迟队列定义
  • 应用场景
  • 实现方案

    • Redis zset
    • TimeWheel
    • 时间轮结构
    • 时间轮运行逻辑
  • 总结
原文地址:https://mp.weixin.qq.com/s/jL8_23pjYWV74rsjoWNPWg

前言

延迟队列是我们日常开发中,较为频繁接触的一种技术方案。顾名思义,延迟队列就是具有延迟功能的消息队列。比如往该队列里投递了一个延时为60s的信息,那么60s后就能收到该信息。自己在网上搜索资料整理,学习一下,为此进行了一次总结并且把知识分享出来。

延迟队列定义

首先,大家对队列数据结构一定不陌生了,它是一种先进先出的数据结构。普通队列中的元素是有序的,它们遵循着先进先出的规则,也就是说,先入队的任务优先被执行,最后入队的任务最后执行。

延迟队列与普通队列的最大区别在于其延迟的属性上,普通队列是先入队列的任务优先被执行,而延迟队列在入队时会指定一个延迟时间,表示希望经过该指定时间后处理。比如你在某个购物app上下单,而你却没支付,待支付界面上就会提醒你15分钟后自动取消,这就是延迟队列的典型范例。

应用场景

在我们现实生活中,延迟队列的使用是比较多的,比如说以下几个场景:

  • 订单超时未支付,该订单会自动取消
  • 用户外卖下单后,距离超时时间还有15分钟时,系统会提醒外卖小哥及时送餐,以免超时
  • 商品收货后未作出评价,系统默认7天后给出5星好评

......

延迟队列的使用场景是无处不在的。在以上场景中,如果不使用延迟队列,则需要业务方每秒轮训数据库,比较现在时间是否符合设定的时间,每个业务方都需要一样的重复逻辑。因此,我们可以将其抽象提取出来,作为公共组件,为此,今天的主角-延迟队列至此诞生了。

有了延迟队列,每个业务方只需要把任务添加到延迟队列中,并设置延迟时间即可,到了指定时间,任务就会被自动触发,调用对应的逻辑方法进行处理。

延迟队列为我们提供了解决大量需要延迟执行的任务提供了一个合理的解决方案。接下来,我们一起来看看延迟队列究竟是如何实现的。

实现方案

Redis zset

我们把客户端需要延迟执行的消息称为一个延迟任务,那么我们就可以使用 Redis ZSet 数据结构进行存储,延迟任务的ID作为key值,value值就是整个任务详情,score值为该延迟执行的消息延迟时间。

那么我们可以通过以下几个步骤使用Redis的ZSet数据结构来实现一个延迟队列:

  • 使用 ZADD key score value 语法进行入队操作,把延迟任务的ID作为key值,整个任务详情作为value值,该任务需要延时的时间作为score。
  • 启动一个线程(每隔一秒执行)通过 ZRANGEBYSCORE KEY -inf +inf limit 0 1 WITHSCORES 方法查询ZSet中的任务是否可执行。其中会有两种情况:
  • 如果查询出来的分数小于当前时间戳,说明这个任务已经可以执行了,则去异步执行
  • 如果查询出来的分数大于当前时间戳,说明该队列中没有需要啊执行的任务,则休眠一秒后再次轮训

从实现步骤来看,通过Redis zset 实现延迟队列是一种容易理解并实现相对简单的实现方式。并且我们可以依赖Redis 自身的持久化来实现持久化,使用Redis集群来支持高并发和高可用,是一种不错的延迟队列的实现方案。

TimeWheel

TimeWheel时间轮算法,也是一种实现延迟队列的方案之一。其应用场景丰富,在 Netty、Akka、Quartz、ZooKeeper 、Kafka等组件中都存在时间轮的踪影。

时间轮结构

在这里插入图片描述

如上面所示,时间轮是一个存储延迟任务的环形队列,底层采用数组实现,数组中的每个元素可以存放一个延迟任务列表(HashedWheelBucket),HashedWheelBucket是一个环形的双向链表(图中红色处),链表中的每一项表示的都是延迟任务项,其中封装链真正的延迟任务。

时间轮是由多个时间格组成的,每个时间格表示当前时间的基本跨度。并且时间格个数是固定的。

时间轮还有一个表盘指针,用来表示时间轮当前所指时间,随着时间的迁移,不断处理时间格中对应的延迟任务。

时间轮运行逻辑

时间轮在启动时候会记录当前启动的时间赋值给startTime。时间轮在添加延迟任务时首先会计算出一个延迟时间,比如一个任务的延迟时间为30s,那么会将当前时间+30s-时间轮启动时间,计算出一个时间戳(延迟时间)。然后将延迟任务加入到对应时间格的链表中,等待执行。

然后需要计算出几个参数值:

  1. 延迟任务总共延迟的次数:将每个任务的延迟时间/时间格计算出tick需要跳动的次数
  2. 计算时间轮round次数:根据计算的需要走的(总次数-当前tick数量)/时间格个数,比如我们现在需要添加一个延时为24秒的延迟任务,如果当前tick为0,那么轮数=(24-0)/20=1,那么指针每运行一圈就会将round取出来减一,所以需要转动到第二轮之后才可以将轮数round减为0之后才会运行
  3. 计算出该任务需要放置到时间轮(wheel)的槽位,然后加入到槽位链表最后

将timeouts中的数据放置到时间轮wheel中之后,计算出当前时针走到的槽位的位置,并取出槽位中的链表数据,将deadline和当前的时间做对比,运行过期的数xx据。

使用时间轮实现的延迟队列,能够支持大量任务的高效触发。在Kafka的时间轮训算法的实现方案中,引入了DelayQueue,使用DelayQueue来推送时间轮滚动,而延迟任务的添加与删除操作都放在时间轮中,这样的设计大幅度提升了整个延迟队列的执行效率。

总结

延迟队列在我们日常开发中应用非常广泛,在本文中分别介绍了使用Redis zset和TimeWheel时间轮两种方式实现延迟队列。从实现过程中,可以发现使用Redis zset实现延迟队列理解起来最为简单,能够快速落地,但Redis毕竟是基于内存的,虽然有持久化机制,但还是有数据丢失的可能性。而使用TimeWheel时间轮算法,是一个非常巧妙的方案,但同时也是最为难理解的方案。到这里,文章也基本结束了,希望本文对你们实现延迟队列提供一点思路。

文章也会持续更新,可以微信搜索「 迈莫coding 」第一时间阅读。每天分享优质文章、大厂经验、大厂面经,助力面试,是每个程序员值得关注的平台。

IT界学习笔记
37 声望3 粉丝