如何用 Redis
打造一个延迟队列、广播(设计概述)
前言:
何为延迟队列?这个话题我相信在阅读本文的时候就已经有很明确的答案了,那么想要实现一个延迟队列,应该具备哪些条件,如何做到更加灵活,且拥有高扩展能力?下面开始一一分析
1. Redis
数据结构的选择
在此,大多数人都知道使用 Zset
可以做延迟队列,也有很多博客当中描述了相关的使用和设计,在这里我也会做详细的解释:
1.1. Zset
的使用(超时列表)
## 添加元素
Zadd key score member
# Zadd zyred 1 test
## 统计元素个数
ZCard key
# ZCard zyred -> 1
## 通过 score 返回有序集合指定区间内的成员
ZRangeByScore key min max
# ZRangeByScore zyred 0 1 -> 0到1之间的所有元素
## 获取元素的 score 值
ZScore key member
# ZScore zyred test -> 1
## 删除元素
ZRem key member
# ZRem zyred test
在上面介绍了 Zset
集合的命令,score
可以使用时间戳,而 member
则需要设计一下。如何设计一个合理的 member
则需要接下来的分析:
目前市面上的延迟队列都是使用 TOPIC
来表示消费者,那么在我们的延迟队列中 member
也可以做成 TOPIC
,但是这样并不完美,为了更好的区分每一条消息,于是在消息体内设置了一个 messageId
的属性,那么只要将 TOPIC
与 messageId
组合在一起,用一个特殊符号隔开,就能完美的设计出 member
number -> TOPIC:messageId
score -> System.currentTimeMillis() + delay(消息多少ms过期)
1.2 Hash
的使用(元数据列表)
## 添加消息体到 hash
HSet key filed value
# HSet my_key zyred 1
## 获取消息体
HGet key field
# HGet my_key zyred
## 删除消息体
HDel key field
# HDel my_key zyred
定义: 为什么要使用 hash
(首先 hash
的 key
是不能重复的,这个特性我们必须要清楚), 使用 hash
, 主要的目的是为了存储元数据,所谓的元数据就是用户提交的消息体内容。然而为什么要这样设计,为什么不直接将元数据放入到 zset
的 member
中,这样做的目的是为了让 zset
更好的维护,显得没有那么杂乱无章。
从上述中,能了解到 hash
的 value
字段保存的是消息体,那么 key
应该如何设计。在 zset
中的 member
我们设计为 TOPIC:messageId
的格式,那么当 zset
中的消息过期后,是不是应该拿着这个 member
去找到对应的消息,如何更快的找到 hash
中的 value
交给用过去消费,无非就是使用 TOPIC:messageId
来保证 hash key
的唯一性
hash field -> TOPIC:messageId
hash value -> {json}
1.3 Set
集合的使用 (待消费列表)
## 添加一个元素
SAdd key member
# SAdd set_key zyred
## 批量获取元素
SMembers key
# SMembers set_key
## 删除单个
SRem key member
# SRem set_key zyred
Set
集合主要是针对消费者,当 set
集合中有消息待消费的情况,消费线程会主动从 set
集合中拿去消息来消费, member
的设计与 hash
的 filed
保持一致
set member -> TOPIC:messageId
1.4 数据结构使用总结
添加一条消息到延迟队列,此时会构建一个 field
为 TOPIC:messageId
, 并且将消息序列化为 json
保存到 hash
中,hash
内保存完毕后,将 field
与消息过期时间传入到 zset
内,如下图所示:
hash
zset
- 当 zset 里的 field 过期后,会通过某种方式将 field 放入到 set 集合中并且通知消费者线程进行消费消息,最终消费者会从 hash 中拿到消息体从而进行消费。
2. 线程设计
通过 Redis
数据结构的选择 章节中能够大致的了解到数据在各个数据结构之间的扭转,那么清楚了底层的扭转逻辑就针对这个逻辑进行设计系统
2.1 线程模型
2.1.1 搬运线程
定义: 搬运线程即从 zset
中转移超时的消息到 set
集合中提供给消费线程使用,这个线程极为重要。
为什么要将搬运的动作设计为单线程?
如果将搬运做成了多线程,那么会出现一个问题,会不会出现重复搬运的情况? 这是必然会出现的,所以为了避免 field
被重复搬运,那么这里就做单线程搬运,从根源上杜绝了重复消费的问题
2.1.2 消费线程
定义: 消费线程与消费者个数绑定,有多少个消费者就会创建多少个线程,当然消费者成千上万个的时候,肯定不太适合使用 redis
来做延迟队列。
2.2 线程通讯
线程通讯主要是当搬运线程成功搬运一个或多个 filed
的时候,来唤醒指定 topic
的消费线程进行消费消息,这个比较抽象,不太好画图,索性通过一个场景来描述这个抽象的问题
一个班级有N个学生,一个班主任,此时学生都在睡午觉,而班主任会定时巡查是否有家长来找学生,突然班主任发现小明的妈妈来学校找小明,于是班主任将小明妈妈带到办公室,然后就跑到教室轻轻的唤醒小明(不会打扰其他学生),并告诉小明他的妈妈来找他。
在以上的场景中,学生是消费线程,班主任是搬运线程,而家长则是待消费的消息,班主任把家长带到办公室的动作就是搬运超时的消息,唤醒小明不打扰其他同学则是指定线程唤醒后消费消息,当小明妈妈与小明见面完毕后,小明会继续回到位置上睡午觉(别杠,这里只是一个场景)
3. 总结
通过本文对 Redis 做延迟队列的底层核心逻辑的剖析,能够知道采用的数据结构与线程,接下来的逻辑将会在下一篇文章中继续描述