延时队列及其实现方式

1 延时队列简介

延时队列是一种特殊的队列,它允许将消息或任务延迟一段时间后再进行处理。延时队列的作用是在某些需要延迟处理的场景中,提供一种可靠的延迟处理机制。
延时队列的原理可以通过以下步骤来解释:

  1. 将消息或任务存储在队列中:当需要延迟处理的消息或任务到达时,将其放入延时队列中。延时队列可以使用队列数据结构(如数组、链表)或者消息中间件(如RabbitMQ、Kafka)来实现。
  2. 设置延迟时间:每个消息或任务都会被设置一个延迟时间,即需要延迟处理的时间。这个延迟时间可以是固定的,也可以根据业务需求动态设置。
  3. 定时检查:延时队列会定时检查队列中的消息或任务是否到达延迟时间。这个检查可以通过定时任务、时间轮算法等方式来实现。
  4. 处理延迟消息或任务:当延迟时间到达时,延时队列会将消息或任务从队列中取出,并进行相应的处理。处理方式可以是发送消息给消费者、执行任务、更新缓存等。

延时队列的应用场景包括:

  1. 消息重试:当某个消息发送失败时,可以将该消息放入延时队列中,并设置一个延迟时间,到达延迟时间后再进行重试发送。
  2. 定时任务:需要在指定的时间点执行某个任务时,可以将任务放入延时队列中,并设置一个延迟时间,到达延迟时间后执行任务。
  3. 订单超时处理:当订单在一定时间内未支付或未完成,可以将订单放入延时队列中,并设置一个延迟时间,到达延迟时间后进行超时处理。
  4. 缓存过期处理:当缓存中的数据过期时,可以将数据放入延时队列中,并设置一个延迟时间,到达延迟时间后进行缓存更新或删除。

2 实现思路

1. Kafka中的时间轮(Time Wheel):
Kafka中的时间轮是一种基于时间槽(Time Slot)的延时队列实现方式。时间轮将时间划分为若干个槽,每个槽表示一个时间段。消息或任务会被放入对应的槽中,并设置一个延迟时间。每个槽都有一个定时器,当定时器触发时,槽中的消息或任务会被处理。时间轮的精度可以根据需求进行调整,例如每个槽表示1秒、10秒或1分钟等。
2. Redis中的跳表(Skip List):
Redis中的跳表是一种有序数据结构,可以用于实现延时队列。在Redis中,可以将消息或任务存储在跳表中,并设置一个过期时间,即延迟时间。Redis会根据过期时间自动删除过期的消息或任务。通过定时轮询跳表,可以找到到达延迟时间的消息或任务,并进行处理。Redis的跳表实现了高效的查找和删除操作,适合用于延时队列的实现。
3. DelayQueue中的优先级队列:
Java中的DelayQueue是一种基于优先级队列的延时队列实现。消息或任务会被放入DelayQueue中,并设置一个延迟时间。DelayQueue会根据延迟时间进行排序,延迟时间最短的消息或任务会被优先处理。DelayQueue使用了ReentrantLock来保证并发安全,并通过Condition实现了定时等待和唤醒机制。当延迟时间到达时,DelayQueue会将消息或任务取出并进行处理。
这三种实现思路都可以实现延时队列的功能,具体选择哪种实现方式取决于需求和技术栈。时间轮适用于高吞吐量的场景,跳表适用于需要持久化的场景,而DelayQueue适用于Java开发环境下的场景。
4.其他方式
当然延时队列的实现思路肯定不止这三种(例如还有Quartz 定时任务,RabbitMQ 延时队列等),而且这三种也不一定就是性能效率最好的,出于学习与工作的需要,本文仅对这三种延时队列的实现方式进行详细介绍。

3 实现方式

3.1 Kafka+时间轮

Kafka是一种高性能的分布式消息队列系统,最初由LinkedIn开发并开源。它被设计用于处理大规模的实时数据流,具有高吞吐量、可扩展性和持久性等特点。

Kafka的核心概念包括以下几个部分:

  1. Topic(主题):消息在Kafka中以主题的形式进行分类和组织。一个主题可以被分为多个分区,每个分区可以在不同的Kafka节点上进行复制和存储。
  2. Partition(分区):主题可以被分为多个分区,每个分区是一个有序的消息日志。分区允许数据在集群中进行并行处理和负载均衡,同时提供了高可用性和容错性。
  3. Producer(生产者):生产者是向Kafka发送消息的客户端。它将消息发布到指定的主题,可以选择指定消息的分区或者让Kafka自动选择分区。
  4. Consumer(消费者):消费者是从Kafka接收消息的客户端。它可以订阅一个或多个主题,并从指定的分区中消费消息。
  5. Broker(代理):代理是Kafka集群中的一个节点,负责存储和处理消息。多个代理组成一个Kafka集群,通过分区复制和分布式协调来提供高可用性和容错性。
  6. Offset(偏移量):偏移量是消息在分区中的唯一标识,用于表示消息的位置。消费者可以通过偏移量来控制消息的消费进度。

在Kafka中,时间轮(Time Wheel)是一种用于实现延时队列的机制。它通过将时间划分为一系列的时间槽(Time Slot),每个槽表示一个时间段,来管理延时消息的处理。

Kafka的时间轮实现原理如下:

  1. 时间轮的刻度:Kafka中的时间轮将时间划分为多个刻度,每个刻度对应一个时间槽。刻度的大小可以根据需求进行调整,例如每个刻度表示1秒、10秒或1分钟等。
  2. 时间槽的数据结构:每个时间槽可以存储一个队列,用于存放到达该时间槽的延时消息。
  3. 指针的移动:Kafka维护一个指针,指向当前的时间槽。随着时间的流逝,指针会按照固定的频率(例如每秒钟)向前移动一个刻度。
  4. 消息的处理:当指针移动到某个时间槽时,Kafka会处理该时间槽中的延时消息。处理方式可以是将消息发送给消费者或执行相应的操作。
  5. 延时消息的添加:当有新的延时消息到达时,Kafka会根据消息的延迟时间计算出它应该被放置在哪个时间槽中。然后将消息放入对应的时间槽的队列中等待处理。

通过时间轮的机制,Kafka能够高效地管理和处理延时消息。它可以根据延时时间将消息存放在合适的时间槽中,当指针移动到对应的时间槽时,即可处理消息。时间轮的精度可以根据需求进行调整,以平衡延时精度和系统性能。
如图:时间轮本身有几个重要的属性,轮子分成多少个槽,两个槽之间的时间间隔,当前的时间轮滚动在哪个索引上,还有一个动态数组模拟的轮子本身,数组里面放的是List,List里面放的是Task的地址:
时间轮原理图
类图:
设计了三个类,分别为时间轮类(TimeWheel)、时间轮管理类(TaskManager)、任务类(Task)。时间轮的使用者通过时间轮管理类来和时间轮做交互,比如创建时间轮、往时间轮添加任务、删除任务、启动线程扫描时间轮执行任务等。任务类中除了有任务的地址,还有其他的一些任务描述信息,比如延时时间、一个key对应这个task,任务是在时间轮的第几圈放着等。
时间轮类图

由类图可以看出,时间轮类(TimeWheel)与任务类(Task)之间的对应关系是1:n,时间轮类(TimeWheel)与时间轮管理类(TaskManager)之间的对应关系是1:1。

流程图:
初始化时间轮,创建线程启动TimeLoop,在时间轮上找到任务并执行,执行任务之前要查看task->cycleNum,如果大于0,表示这个任务还不执行,并把cycleNum减去1,然后再在List中查看其他的任务,list任务找完,时间轮延时一段时间之后再往下滚,如下图:
流程图

添加任务的流程图:
调用TimeWheelManager::AddTask()方法,最主要任务的延时时间可能超过时间轮的一圈,所以要有一个变量保存此任务在第几圈,在任务插入队列的时候要计算出来,如下图
添加任务的流程图

删除任务的流程图:
调用TimeWheelManager::RemoveTask()方法,根据key值查找,如下图
删除任务流程图
序列图:
序列图
参考代码:

/**********************************************************************
 * @file:   kafka_timewheel.cpp
 * @brief:  实现延时任务队列的代码,包括添加延时任务、启动时间轮处理、
 * 停止时间轮处理等功能。
 *
 * @version:    1.0
 * @author: Jacky Zou
 * @date:   2023年08月14日
 *********************************************************************/
#include <iostream>
#include <vector>
#include <queue>
#include <functional>
#include <chrono>
#include <thread>
#include <atomic>
#include <iomanip>

using namespace std;
using namespace chrono;

/**
 * @brief: 延时任务结构体
 */
struct DelayedTask {
	function<void()> task;  // 任务函数
	time_point<steady_clock> expiration;  // 任务的到期时间

	/**
	 * @function:  DelayedTask
	 * @brief:  构造函数
	 *
	 * @param:  t 任务函数
	 * @param:  exp 任务的到期时间
	 * @author: Jacky Zou
	 * @date:   2023年08月14日
	 */
	DelayedTask(const function<void()>& t, const time_point<steady_clock>& exp)
		: task(t), expiration(exp) {}

	/**
	 * @function:  operator<
	 * @brief:  重载小于运算符,用于任务的比较
	 *
	 * @param:  other 另一个延时任务
	 * @return: 当前任务是否小于另一个任务
	 * @author: Jacky Zou
	 * @date:   2023年08月14日
	 */
	bool operator<(const DelayedTask& other) const
	{
		return expiration > other.expiration;
	}
};

class DelayedQueue {
public:
	/**
	 * @function:  DelayedQueue
	 * @brief:  构造函数
	 *
	 * @author: Jacky Zou
	 * @date:   2023年08月14日
	 */
	DelayedQueue() : running(false) {}

	/**
	 * @brief 析构函数
	 * 停止时间轮的处理并清空延时任务队列
	 */
	~DelayedQueue()
	{
		stop();
		while (!tasks.empty()) {
			tasks.pop();
		}
	}

	/**
	 * @function:  addTask
	 * @brief:  添加延时任务
	 *
	 * @param:  task 任务函数
	 * @param:  delaySeconds 延时时间(秒)
	 * @author: Jacky Zou
	 * @date:   2023年08月14日
	 */
	void addTask(const function<void()>& task, int delaySeconds)
	{
		time_point<steady_clock> expiration = steady_clock::now() + seconds(delaySeconds);
		tasks.push({ task, expiration });
	}

	/**
	 * @function:  start
	 * @brief:  启动时间轮的处理
	 *
	 * @author: Jacky Zou
	 * @date:   2023年08月14日
	 */
	void start()
	{
		if (!running.exchange(true)) {
			thread t(&DelayedQueue::process, this);
			t.detach();
		}
	}

	/**
	 * @function:  stop
	 * @brief:  停止时间轮的处理
	 *
	 * @author: Jacky Zou
	 * @date:   2023年08月14日
	 */
	void stop()
	{
		running.exchange(false);
	}

private:
	priority_queue<DelayedTask> tasks;  // 延时任务队列
	atomic<bool> running;  // 时间轮运行状态

	/**
	 * @function:  process
	 * @brief:  时间轮的处理函数
	 *
	 * @details:不断循环处理延时任务队列中的任务,直到时间轮停止运行。
	 *           每次循环中,检查延时任务队列中是否有任务到期,如果有则执行任务并从队列中移除。
	 *           在执行任务之前,获取当前时间并格式化输出。
	 *           在每次循环结束后,通过休眠一段时间来避免长时间的空闲等待。
	 * @author: Jacky Zou
	 * @date:   2023年08月14日
	 */
	void process()
	{
		while (running.load()) {
			while (!tasks.empty()) {
				auto task = tasks.top();
				if (task.expiration > steady_clock::now()) {
					break;
				}
				tasks.pop();
				auto now = system_clock::now();
				time_t now_c = system_clock::to_time_t(now);
				struct tm now_tm;
				localtime_s(&now_tm, &now_c);
				char buffer[80];
				strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &now_tm);
				cout << "当前时间: " << buffer << ":";
				task.task();
				cout << "时间轮中还剩 " << tasks.size() << " 个任务。" << endl;
			}
			this_thread::sleep_for(milliseconds(100));  // 避免长时间的空闲等待
		}
	}
};

/**
 * @function:  addTasks
 * @brief:  添加延时任务到队列
 *
 * @param:  queue 延时任务队列
 * @param:  numTasks 任务数量
 * @author: Jacky Zou
 * @date:   2023年08月14日
 */
void addTasks(DelayedQueue& queue, int numTasks)
{
	for (int i = 1; i <= numTasks; i++) {
		queue.addTask([i]() {
			cout << "任务" << i << " 已被执行完毕。";
			}, i * 2 - 1);
	}
}

int main()
{
	DelayedQueue queue;

	int numTasks;
	cout << "输入任务数量: ";
	cin >> numTasks;

	addTasks(queue, numTasks); // 添加延时任务
	queue.start(); // 启动时间轮的处理
	this_thread::sleep_for(seconds(numTasks * 2)); // 等待任务执行
	queue.stop(); // 停止时间轮的处理
	return 0;
}

3.2 redis中的ZSet

Redis(Remote Dictionary Server)
是一个开源的内存数据存储系统,它提供了一个键值对的存储结构,并支持多种数据结构的操作。Redis以其高性能、简单易用和丰富的功能而受到广泛关注和使用。

Redis的特点包括:

  • 数据存储在内存中,因此读写速度非常快。
  • 支持持久化,可以将数据保存到磁盘上,以便重启后恢复数据。
  • 支持多种数据结构,如字符串、哈希表、列表、集合和有序集合等。
  • 提供了丰富的操作命令,可以对数据进行增删改查、排序、计数等操作。
  • 支持发布订阅、事务和Lua脚本等高级功能。

ZSet(Sorted Set)
Redis的数据结构Zset,同样可以实现延迟队列的效果,主要利用它的score属性,redis通过score来为集合中的成员进行从小到大的排序。
redis+ZSet实现延时队列
通过zadd命令向队列delayqueue 中添加元素,并设置score值表示元素过期的时间;向delayqueue 添加三个order1、order2、order3,分别是10秒、20秒、30秒后过期。

ZSet是Redis提供的一种数据结构,它是一个有序的、不重复的元素集合。每个元素都关联着一个分值(score),这个分值用来表示元素在有序集合中的位置。ZSet的特点是元素的排序是根据分值来确定的,而不是根据元素本身的值。
ZSet的实现原理基于跳表(Skip List)和哈希表(Hash Table)的结合。在内部,ZSet使用跳表来维护有序集合,并使用哈希表来存储元素与分值的映射关系。

ZSet的实现原理如下:

  1. 在ZSet中,每个元素都是一个有序集合节点,包含了元素的值和分值。
  2. ZSet使用跳表来维护元素的有序性。跳表中的每个节点都指向下一层级的相同节点,形成多级索引结构。
  3. 跳表的最底层是一个普通的有序链表,其中的节点按照分值从小到大排列。
  4. 在插入和删除操作时,ZSet会根据元素的分值找到对应的位置,然后进行插入或删除。同时,ZSet会更新跳表中的索引结构,以保持有序性。
  5. ZSet使用哈希表来存储元素与分值的映射关系。哈希表中的键是元素的值,值是元素的分值。这样可以通过元素的值快速找到对应的分值。
  6. 在查询操作时,ZSet可以根据元素的值快速找到对应的分值,然后根据分值在跳表中进行查找。

通过跳表和哈希表的结合,ZSet实现了高效的有序集合操作。它可以在O(log n)的时间复杂度内进行插入、删除和查找操作,非常适合实现需要排序的数据结构。
跳表(Skip List)是一种有序的数据结构,可以在O(log n)的时间复杂度内进行查找、插入和删除操作。跳表通过在原始链表上添加多级索引来加速查找操作。

跳表
基本原理如下:

  • 在原始链表的基础上,添加多个层级的索引。每个索引层级都是一个有序链表,其中的节点指向下一层级中的相同节点。
  • 最底层的索引就是原始链表本身,最高层的索引只有一个节点,指向链表的头部和尾部。
  • 在进行查找操作时,从最高层级开始,逐层向下查找。如果当前节点的下一个节点的值大于目标值,则向下一层级继续查找。直到找到目标值或者达到最底层级。
  • 在插入和删除操作时,也需要逐层查找到对应的位置,然后进行插入或删除。

跳表的优点是简单、高效,可以在有序链表上实现快速查找和插入操作。缺点是需要额外的空间来存储索引层级,占用了一定的内存。
当使用Redis中的跳表方式实现延时队列时,主要的思想是利用Redis的有序集合(Sorted Set)数据结构来存储延时队列的消息。
跳表
按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到O(log n)。但是,这种方法在新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。
skiplist为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。
时间复杂度
插入元素O(logn)
删除元素O(logn)

设计了三个类:SkipList类、TaskNode类、DelayQueue类。

类图如下:
跳表类图
工作流程:
初始化流程图,同样是启动一个线程,循环把当前时间与跳表头任务的执行时间戳作比较,当now()>task->outQueueTime,则执行队头的任务,并把跳表头的任务删除。
初始化流程图
添加任务流程图,入队时计算一下出队时间就可以。
时序图
时序图:
使用跳表序列图

3.3 DelayQueue中的优先级队列

优先级队列是一种特殊的队列,其中的元素按照优先级进行排序。在优先级队列中,元素被赋予一个优先级值,优先级值越高的元素会被排在队列的前面。当从优先级队列中取出元素时,总是取出优先级最高的元素。

优先级队列的实现原理通常使用堆(Heap)数据结构。堆是一种完全二叉树,可以分为最大堆和最小堆两种类型。在最大堆中,父节点的值大于等于其子节点的值;而在最小堆中,父节点的值小于等于其子节点的值。
优先级队列-----内部数据结构----堆-----完全二叉树------一个排好序的数据结构,每当插入数据或者删除数据(只能最顶的元素)都会调整结构,保证root是最大或者最小的元素。优先队列具有队列的所有特性,包括基本操作,只是在这基础上添加了内部的一个排序,它本质是一个堆实现的。
大顶堆:

priority_queue<int, vector<int>, less<int> > a; // 大顶堆

小顶堆:

priority_queue<int, vector<int>, greater<int> > a;  // 小顶堆

大顶堆 小顶堆
时间复杂度
插入元素O(logn)
删除元素O(logn)

基于DelayQueue的优先级队列+阻塞的方式实现延时队列的原理如下:

  1. 定义元素类:首先,我们定义一个元素类,该类包含两个属性:元素值和延时时间。延时时间可以是一个绝对时间点,也可以是一个相对时间段。
  2. 使用DelayQueue:DelayQueue是Java中的一个阻塞队列实现,它可以存储实现了Delayed接口的元素。Delayed接口中的getDelay方法用于获取元素的延时时间。DelayQueue会根据元素的延时时间进行排序,延时时间最短的元素会排在队列的前面。
  3. 创建一个处理线程:为了处理延时队列中的元素,我们需要创建一个专门的线程。该线程会不断从DelayQueue中取出延时时间已到的元素进行处理。具体的处理逻辑可以根据业务需求来定义。
  4. 线程的工作流程:
    • 线程首先从DelayQueue中取出队头元素,这是队列中延时时间最短的元素。
    • 如果队头元素的延时时间已经到达或超过当前时间,线程就可以对该元素进行处理。
    • 如果队头元素的延时时间还未到达当前时间,线程会等待一段时间,直到延时时间到达或有新的元素加入队列。
    • 当延时时间到达或有新的元素加入队列时,线程会被唤醒,继续从DelayQueue中取出延时时间已到的元素进行处理。

优先级队列默认使用vector作为其底层存储数据的容器,在vector上又使用了堆算法将vector中元素构造成堆的结构,因此priority_queue就是堆,所有需要用到堆的位置,都可以考虑使用priority_queue。注意:默认情况下priority_queue是大顶堆。通过使用DelayQueue作为优先级队列,延时队列可以保证延时时间已到的元素会被及时取出并进行处理,而不会被延时时间较长的元素阻塞。同时,通过阻塞的方式,可以实现在队列为空时的等待和唤醒操作,以及在延时时间到达或有新元素加入时的及时处理。这种实现方式能够满足延时队列的需求,并且具有较高的效率和可靠性。

类:
采用优先级队列实现的延时相比时间轮方式简单一些,STL已经实现了priority_queue,只需要在延时队列中封装一个优先级队列,添加任务就是添加在优先级队列中。Task类需要实现一个operator<操作符重载,用来放在优先级队列中时来排序,排序方式按照出队列的时间戳来排序。排在队头的任务执行时间戳最小,所以优先级队列应该初始化为小顶堆。
类图
流程图:
初始化流程图,同样时启动一个线程,循环把当前时间与队头任务的执行时间戳作比较,当now()>task->outQueueTime,则执行队头的任务,并把队头的任务删除。
流程图
添加任务流程图,入队时计算一下出队时间就可以。
在这里插入图片描述
序列图:
序列图
在Java中,DelayQueue是一个使用优先级队列实现的无界阻塞队列。它是JDK7 提供的 7 个阻塞队列之一。在JDK中,有一个DelayQueue类,它是Java.util.concurrent包下的一个实现延迟队列的API。DelayQueue是一个无界阻塞队列,它实际上是通过封装一个PriorityQueue(优先队列)来实现的。PriorityQueue使用完全二叉堆来对队列元素进行排序。当我们向DelayQueue中添加元素时,会为每个元素指定一个延迟时间,队列中的元素会按照延迟时间进行排序,最小的元素会被放在队列的首部。只有当元素的延迟时间到达后,才允许从队列中取出。DelayQueue可以存放基本数据类型或自定义实体类。对于基本数据类型,元素默认按照升序排列。对于自定义实体类,需要根据类的属性值进行比较计算。

3.4 其他方式或技术

3.4.1 Quartz 定时任务

Quartz是一个开源的Java定时任务调度框架,它可以用于实现延时队列。Quartz是一个企业级的开源的任务调度框架,Quartz内部使用TreeSet来保存Trigger,如下图。Java中的TreeSet是使用TreeMap实现,TreeMap是一个红黑树实现。红黑树的插入和删除复杂度都是logN。和最小堆相比各有千秋。最小堆插入比红黑树快,删除顶层节点比红黑树慢。
Quartz定时任务框架实现延时队列的原理如下:

  1. 创建Scheduler对象:通过SchedulerFactory创建一个Scheduler对象,它是Quartz的核心组件,负责任务的调度和执行。

  2. 创建JobDetail:创建一个JobDetail对象,用于定义要执行的具体任务。JobDetail包含了任务的属性和执行逻辑。

  3. 创建Trigger:创建一个Trigger对象,用于触发任务的执行。在延时队列的场景中,可以使用SimpleTrigger或者CronTrigger来设置任务的延时时间。

  4. 将JobDetail和Trigger绑定到Scheduler中:通过scheduler.scheduleJob(jobDetail, trigger)方法将JobDetail和Trigger对象绑定到Scheduler中,告诉Scheduler要执行哪个任务以及何时触发执行。

  5. 启动Scheduler:通过scheduler.start()方法启动Scheduler,开始执行任务。

  6. 触发任务执行:当触发时间到达或者延时时间过去后,Scheduler会根据Trigger的配置触发对应的Job执行。

  7. 执行任务逻辑:Scheduler会根据JobDetail中定义的任务逻辑,调用Job的execute方法执行具体的任务。

通过以上步骤,Quartz实现了延时队列的功能。Scheduler负责调度和执行任务,JobDetail定义任务的属性和执行逻辑,Trigger用于触发任务的执行。将JobDetail和Trigger对象绑定到Scheduler中,并设置触发时间和延时时间,可以实现任务的延时执行。一旦触发时间到达或者延时时间过去,Scheduler会调用对应的Job执行具体的任务逻辑。

小结:Quartz定时任务框架通过Scheduler、JobDetail和Trigger等组件的配合,实现了延时队列的功能。Scheduler负责任务调度和执行,JobDetail定义任务的属性和执行逻辑,Trigger用于触发任务的执行。通过将JobDetail和Trigger对象绑定到Scheduler中,并设置触发时间和延时时间,可以实现任务的延时执行。一旦触发时间到达或者延时时间过去,Scheduler会触发对应的Job执行具体的任务逻辑。

3.4.2 Redis 过期回调

Redis 提供了键过期回调事件(key expiration callback),可以用于实现延迟队列的效果。
在 Redis 中,可以通过设置键的过期时间(expire)以及设置键过期时的回调函数(expire callback)来实现延迟队列的功能。
Redis 的键过期回调事件是通过 Redis 的事件循环机制实现的。在 Redis 中,事件循环是一个基于事件驱动的单线程事件处理器,负责处理客户端请求和其他事件。
当 Redis 设置一个键的过期时间时,它会在内部创建一个定时器,用于跟踪键的过期时间。定时器会在键的过期时间到达时触发一个事件。
当键的过期时间到达时,Redis 的事件循环会检测到该事件,并将其放入待处理事件的队列中。然后,事件循环会从待处理事件队列中取出事件,并调用相应的回调函数来处理事件。
在 Redis 中,可以使用 EXPIREPEXPIRE 命令设置键的过期时间,并使用 EXPIREPEXPIREAT 命令设置键过期时的回调函数。
在 C++ 中,可以使用 hiredis 库提供的异步 API 来注册键过期回调函数。通过 redisAsyncCommand 函数发送命令,设置键的过期时间和回调函数。
当键过期时,Redis 会执行以下步骤:

  1. 检测到键过期事件。
  2. 将事件放入待处理事件队列。
  3. 事件循环从待处理事件队列中取出事件。
  4. 调用相应的回调函数来处理事件。

回调函数会根据需要执行相应的操作,例如输出日志、删除键等。
需要注意的是,Redis 的事件循环是单线程的,因此回调函数的执行是在事件循环的主线程中进行的。如果回调函数需要执行耗时的操作,可能会阻塞事件循环的执行。

总结起来,Redis 的键过期回调事件是通过 Redis 的事件循环机制实现的。当键的过期时间到达时,事件循环会检测到该事件,并调用相应的回调函数来处理事件。这种机制可以用于实现延迟队列等需要在特定时间点执行操作的场景。

3.4.3 RabbitMQ 延时队列

RabbitMQ是一个开源的消息代理,它支持消息的传递和排队。尽管RabbitMQ本身并没有直接支持延迟队列功能,但可以通过利用消息队列的TTL(Time-to-Live)和DLX(Dead-Letter Exchange)属性来实现延迟队列。

延迟队列是指消息在发送后,不会立即被消费者接收,而是在一定的延迟时间后再被消费。这在一些需要延迟处理的场景中非常有用,例如定时任务、消息重试等。

在RabbitMQ中,可以通过设置消息的TTL来实现延迟队列。TTL是消息的存活时间,一旦超过指定的时间,消息将被自动删除。通过设置消息的TTL,可以让消息在一定的延迟时间后被消费。

为了实现延迟队列,还需要使用DLX属性。DLX是一个特殊的交换机,用于接收被标记为"死信"的消息。当消息的TTL过期或被消费者拒绝时,消息会被发送到DLX。可以将DLX绑定到一个队列上,这样被发送到DLX的消息就会进入这个队列,实现延迟队列的效果。

具体实现步骤如下:

  1. 创建一个普通的交换机和队列,用于发送消息。
  2. 设置队列的TTL属性,指定消息的延迟时间。
  3. 设置队列的DLX属性,指定DLX交换机的名称。
  4. 创建DLX交换机和队列,用于接收"死信"消息。
  5. 将DLX队列绑定到一个消费者上,用于处理延迟消息。

这样,当消息的TTL过期或被消费者拒绝时,消息会被发送到DLX交换机,然后进入DLX队列,最终被消费者处理。

需要注意的是,RabbitMQ的延迟队列实现并不是真正意义上的精确延迟,而是通过设置消息的TTL来实现近似的延迟效果。因此,在使用RabbitMQ实现延迟队列时,需要根据具体的需求和场景进行调整和权衡。

总结起来,通过设置消息的TTL和DLX属性,可以间接实现延迟队列功能。这种方式在实际应用中比较常见,可以满足一定程度上的延迟需求。

4 总结

时间轮插入任务和删除任务都是O(1)的时间复杂度,但是需要一个线程一直在滚动来扫描任务列表;
跳表和优先级队列内部都是排好序的数据结构,c++中STL已经写好了priority_queue,用跳表实现延时任务的话我们需要自己实现这种数据结构;插入数据和删除数据都是O(logn)的时间复杂度。

方式优点缺点适用场景
Kafka+时间轮- 高吞吐量
- 低延迟
- 需要额外实现时间轮算法
- 对初学者较为复杂
大规模延时队列
Redis中的ZSet以及跳表- 操作简单高效- 需要手动轮询获取到期的消息
- 可能存在一定的延迟
延时要求不高的场景
基于优先级队列+阻塞- 简单易懂- 可能存在性能瓶颈
- 延时队列中消息较多时性能下降
小规模延时队列
Quartz定时任务- 成熟的定时任务框架
- 可实现延时任务的调度和执行
- 对大规模延时队列可能存在性能问题
- 需要额外配置和维护
小规模延时队列
Redis过期回调- 操作简单- 需要手动设置过期时间
- 可能存在一定的延迟
延时要求不高的场景
RabbitMQ延时队列- 结合RabbitMQ的可靠性和高可用性
- 可实现延时队列
- 实现方式相对间接
- 需要进行额外配置和维护
延时要求不高的场景

需要根据具体的需求和场景选择合适的实现方式。如果追求高吞吐量和低延迟,可以考虑Kafka+时间轮或Redis中的ZSet以及跳表;如果对性能要求不高且希望简单实现,可以考虑基于优先级队列+阻塞、Redis过期回调或RabbitMQ延时队列;如果需要更灵活的任务调度,可以选择Quartz定时任务。

5 声明

本文仅作为个人学习及分享所用。
本文部分文字性内容由AI生成,内容可能是搬运而来,如有侵权联系删除。
本文图片来自我的同事QXL,非本人绘制。
本文部分内容参考:https://segmentfault.com/a/1190000022718540 作者:程序员小富

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

比特熊猫

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值