1 zset
zset(有序集合)是Redis中最常问的数据结构。这个有序集合类似C++的set容器,但是底层结构不一样,C++的底层结构是使用RB-tree(红黑树)实现的。而zset不一样,zset使用跳表实现。
zset一方面通过set来保证内部value值的唯一性,另一方面通过value的score(权重)来进行排序。这个排序的功能是通过Skip List(跳跃列表)来实现的。
利用zset的去重和有序的效果可以由很多使用场景,通常用来实现排行榜。举两个例子:
- 存储粉丝列表,value是粉丝的ID,score是关注时间戳,这样可以对粉丝关注进行排序。
- 存储学生成绩,value使学生的ID,score是学生的成绩,这样可以对学生的成绩排名。
关于跳表,可以参考我这篇文章:redis-----01-----redis介绍(redis安装下载、底层存储结构原理剖析)。
2 基础命令
2.1 ZADD、ZREM、ZSCORE
ZADD key [NX|XX] [CH] [INCR] score member [score member ...]
ZREM key member [member ...]
ZSCORE key member
- 1)演示ZADD(主要按照上面文字介绍执行),下面命令都是从上往下顺序执行的,只是我打了注释。
192.168.1.9:6379> ZADD z1 1 one
(integer) 1
192.168.1.9:6379> ZADD z1 2 one
(integer) 0
192.168.1.9:6379> ZSCORE z1 one
"2"
192.168.1.9:6379>
192.168.1.9:6379> EXISTS z2
(integer) 0
192.168.1.9:6379> ZADD z2 1 one 2 two
(integer) 2
192.168.1.9:6379> ZADD z2 hello three
(error) ERR value is not a valid float
192.168.1.9:6379>
192.168.1.9:6379> ZADD z2 XX 11 one 22 two 3 three
(integer) 0
192.168.1.9:6379> ZRANGE z2 0 -1
1) "one"
2) "two"
192.168.1.9:6379>
192.168.1.9:6379> ZADD z2 NX 111 one 222 two 3 three
(integer) 1
192.168.1.9:6379> ZRANGE z2 0 -1 WITHSCORES
1) "three"
2) "3"
3) "one"
4) "11"
5) "two"
6) "22"
192.168.1.9:6379>
192.168.1.9:6379> ZADD z3 1 one 2 two
(integer) 2
192.168.1.9:6379> ZADD z3 2 two
(integer) 0
192.168.1.9:6379> ZADD z3 22 two
(integer) 0
192.168.1.9:6379> ZADD z3 22 three
(integer) 1
192.168.1.9:6379> ZADD z3 44 four
(integer) 1
192.168.1.9:6379> ZADD z4 CH 1 one 2 two
(integer) 2
192.168.1.9:6379> ZADD z4 CH 2 two
(integer) 0
192.168.1.9:6379> ZADD z4 CH 22 two
(integer) 1
192.168.1.9:6379> ZADD z4 CH 22 three
(integer) 1
192.168.1.9:6379> ZADD z4 CH 44 four
(integer) 1
192.168.1.9:6379>
192.168.1.9:6379> ZADD z5 INCR 5 five
"5"
192.168.1.9:6379> ZADD z5 INCR 10 five
"15"
192.168.1.9:6379>
192.168.1.9:6379> ZADD z6 1 one 1 two 1 three
(integer) 3
192.168.1.9:6379> ZRANGE z6 0 -1 WITHSCORES
1) "one"
2) "1"
3) "three"
4) "1"
5) "two"
6) "1"
看上面3.3)不带CH选项的返回值或者看上面演示的命令中,使用到ZADD命令的返回值即可。
看上面3.4)INCR的测试即可。
记住即可,不需要测试。
192.168.1.9:6379> set tyy hello
OK
192.168.1.9:6379> type tyy
string
192.168.1.9:6379> ZREM tyy hello
(error) WRONGTYPE Operation against a key holding the wrong kind of value
192.168.1.9:6379>
192.168.1.9:6379> HSET role:10001 name lqq
(integer) 1
192.168.1.9:6379> ZREM role:10001 name
(error) WRONGTYPE Operation against a key holding the wrong kind of value
192.168.1.9:6379>
192.168.1.9:6379> EXISTS z1
(integer) 0
192.168.1.9:6379> ZADD z1 1 one 2 two 3 three 4 four
(integer) 4
192.168.1.9:6379> ZREM z1 one two three six ten
(integer) 3
192.168.1.9:6379> ZRANGE z1 0 -1 WITHSCORES
1) "four"
2) "4"
192.168.1.9:6379> EXISTS z1
(integer) 0
192.168.1.9:6379> ZADD z1 1 one
(integer) 1
192.168.1.9:6379> ZSCORE z1 two
(nil)
192.168.1.9:6379> EXISTS z2
(integer) 0
192.168.1.9:6379> ZSCORE z2 one
(nil)
192.168.1.9:6379>
192.168.1.9:6379> ZADD z3 1 one 2 two 3 three
(integer) 3
192.168.1.9:6379> ZSCORE z3 one
"1"
192.168.1.9:6379> ZSCORE z3 two
"2"
192.168.1.9:6379> ZSCORE z3 three
"3"
2.2 ZINCRBY、ZCARD
ZINCRBY key increment member
ZCARD key
192.168.1.9:6379> EXISTS z1
(integer) 0
192.168.1.9:6379> ZADD z1 1 one
(integer) 1
192.168.1.9:6379> ZRANGE z1 0 -1 withscores
1) "one"
2) "1"
192.168.1.9:6379> ZINCRBY z1 5 two
"5"
192.168.1.9:6379> ZRANGE z1 0 -1 withscores
1) "one"
2) "1"
3) "two"
4) "5"
192.168.1.9:6379> EXISTS z2
(integer) 0
192.168.1.9:6379> ZINCRBY z2 1 one
"1"
192.168.1.9:6379> ZRANGE z2 0 -1 withscores
1) "one"
2) "1"
192.168.1.9:6379> set hello world
OK
192.168.1.9:6379> type hello
string
192.168.1.9:6379> ZINCRBY hello 1 one
(error) WRONGTYPE Operation against a key holding the wrong kind of value
192.168.1.9:6379>
192.168.1.9:6379> ZRANGE z2 0 -1 withscores
1) "one"
2) "1"
192.168.1.9:6379> ZINCRBY z2 -11 one
"-10"
192.168.1.9:6379> ZRANGE z2 0 -1 withscores
1) "one"
2) "-10"
192.168.1.9:6379>
看上面的返回值即可。
192.168.1.9:6379> EXISTS z3
(integer) 0
192.168.1.9:6379> ZADD z3 1 one 2 two
(integer) 2
192.168.1.9:6379> ZRANGE z3 0 -1 withscores
1) "one"
2) "1"
3) "two"
4) "2"
192.168.1.9:6379> ZCARD z3
(integer) 2
192.168.1.9:6379> ZADD z3 3 three
(integer) 1
192.168.1.9:6379> ZRANGE z3 0 -1 withscores
1) "one"
2) "1"
3) "two"
4) "2"
5) "three"
6) "3"
192.168.1.9:6379> ZCARD z3
(integer) 3
192.168.1.9:6379>
192.168.1.9:6379> EXISTS z4
(integer) 0
192.168.1.9:6379> ZCARD z4
(integer) 0
2.3 ZRANK、ZRANGE、ZRANGEBYSCORE
ZRANK key member
ZRANGE key start stop [WITHSCORES]
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
192.168.1.9:6379> ZADD z1 1 one 2 two 3 three
(integer) 3
192.168.1.9:6379> ZRANGE z1 0 -1 withscores
1) "one"
2) "1"
3) "two"
4) "2"
5) "three"
6) "3"
192.168.1.9:6379> ZRANK z1 one
(integer) 0
192.168.1.9:6379> ZRANK z1 two
(integer) 1
192.168.1.9:6379> ZRANK z1 three
(integer) 2
看上面的返回值即可。
192.168.1.9:6379> ZRANK z1 five
(nil)
192.168.1.9:6379>
192.168.1.9:6379> ZRANGE z1 0 -1 withscores
1) "one"
2) "1"
3) "two"
4) "2"
5) "three"
6) "3"
192.168.1.9:6379> ZADD z1 2 b
(integer) 1
192.168.1.9:6379> ZRANGE z1 0 -1 withscores
1) "one"
2) "1"
3) "b"
4) "2"
5) "two"
6) "2"
7) "three"
8) "3"
192.168.1.9:6379>
例如:ZRANGE z1 0 -1 withscores
192.168.1.9:6379> ZRANGE z1 0 1 withscores
1) "one"
2) "1"
3) "b"
4) "2"
192.168.1.9:6379>
192.168.1.9:6379> ZRANGE z1 2 1 withscores
(empty array)
192.168.1.9:6379>
192.168.1.9:6379> ZRANGE z1 1 10 withscores
1) "b"
2) "2"
3) "two"
4) "2"
5) "three"
6) "3"
看上面带withscores选项的结果即可。
看上面ZRANGE命令执行的结果即可。
看下面执行的结果即可。
192.168.1.9:6379> ZADD z1 1 one 2 two 10 ten 60 sixty 100 hundred
(integer) 5
192.168.1.9:6379> ZRANGEBYSCORE z1 1 10 LIMIT 0 2
1) "one"
2) "two"
192.168.1.9:6379>
192.168.1.9:6379> ZRANGEBYSCORE z1 1 10 LIMIT 1 3
1) "two"
2) "ten"
192.168.1.9:6379>
192.168.1.9:6379> ZRANGEBYSCORE z1 1 10 LIMIT 3 2
(empty array)
192.168.1.9:6379>
192.168.1.9:6379> ZRANGEBYSCORE z1 1 10 LIMIT 3 5
(empty array)
192.168.1.9:6379>
192.168.1.9:6379> ZRANGEBYSCORE z1 1 10 WITHSCORES
1) "one"
2) "1"
3) "two"
4) "2"
5) "ten"
6) "10"
192.168.1.9:6379> ZRANGEBYSCORE z1 1 100 WITHSCORES
1) "one"
2) "1"
3) "two"
4) "2"
5) "ten"
6) "10"
7) "sixty"
8) "60"
9) "hundred"
10) "100"
192.168.1.9:6379>
192.168.1.9:6379> ZRANGEBYSCORE z1 -inf +inf
1) "one"
2) "two"
3) "ten"
4) "sixty"
5) "hundred"
192.168.1.9:6379>
192.168.1.9:6379> ZRANGEBYSCORE z1 1 10
1) "one"
2) "two"
3) "ten"
192.168.1.9:6379> ZRANGEBYSCORE z1 (1 10
1) "two"
2) "ten"
192.168.1.9:6379> ZRANGEBYSCORE z1 (1 10)
(error) ERR min or max is not a float
192.168.1.9:6379> ZRANGEBYSCORE z1 (1 (10
1) "two"
192.168.1.9:6379>
看上面执行的结果即可。
下面我们总结一下上面的基础命令设计到的一些关键下标,实际上只有ZRANK、ZRANGE、ZRANGEBYSCORE这三个涉及到。
- 1)ZRANK的返回排名需要注意,它的返回排名是从0开始的。例如看上面演示ZRANK的第一点例子。
- 2)ZRANGE需要注意的是,start stop的下标是从0开始的。
- 3)ZRANGEBYSCORE需要注意的是,min和max指定是区间,并非是下标。例如0 100代表取scores范围在[0, 100]内的成员。1 100代表取scores范围在[1, 100]内的成员,只与有序集合的member的score有关,与有序集合的下标无关。 并且注意,offset的取值从下标0开始,这个offset的值代表不加LIMIT时,运算得出的结果集的偏移位置。
2.4 ZREMRANGEBYSCORE
ZREMRANGEBYSCORE key min max
192.168.1.9:6379> ZADD z1 1 one 2 two 3 three 4 four 5 five
(integer) 5
192.168.1.9:6379> ZREMRANGEBYSCORE z1 1 3
(integer) 3
192.168.1.9:6379> ZRANGE z1 0 -1
1) "four"
2) "five"
192.168.1.9:6379> ZREMRANGEBYSCORE z1 4 (5
(integer) 1
192.168.1.9:6379> ZRANGE z1 0 -1
1) "five"
192.168.1.9:6379>
2.5 有序集合的排序规则排序
注意,有序集合比较规则,先通过比较 score 来确定排序,如果 score 相同则比较 membe。 member 比较规则是按照字母顺序来进行比较。上面命令介绍中也多次提到。
3 存储结构
zset是使用跳表实现的,而跳表底层实际上也是使用链表(内部会有序)实现。所以当节点数量少(128)且字符串长度小(64)时,它会使用更高效的链表,即压缩列表存储,以提供效率。 但归根到底都是使用链表进行存储。 这和我们第一节redis介绍讲的list有点类似。
但面试时,面试官问你zset底层是使用什么结构实现的,你应该说跳表,而不能直接说链表,只有问你跳表是使用什么数据结构实现的,你这时回链表结构才不会有太大的问题。
4 应用
4.1 百度热榜
相信大家平时都会有看过各大应用程序的热榜的信息,例如抖音、微博、百度等热榜。那么他们是如何实现的呢?
- 其实不难的。例如上图的百度热榜,很明显右边的多少万就是排序的一个根据,那么它就代表zset的score选项。而左边的文字就是zset的member成员,我们会先使用id来代表这个member,例如10001代表 “中央一号文件要点速读”,10002代表 “中方支持俄乌通过谈判解决问题” 。这样就能使用zset做到热搜榜的排行。
- 虽然上面的member是id,但是上图看到的这些主题文字如何保存、以及点击后该怎么显示这些内容呢,很明显,需要一个额外的hash结构来存储这些内容。其中hash的key为:id。例如hash:id,具体点可以是hot:20220226:10001。
然后这个id的热搜的hash结构可以存储本身的id、text主题、点击后展示该热搜的url等等hash字段。
通过上面分析即可实现一个热搜榜。
192.168.1.9:6379> zincrby hot:20220226 1 10001
"1"
192.168.1.9:6379> zincrby hot:20220226 1 10002
"1"
192.168.1.9:6379> zincrby hot:20220226 1 10003
"1"
192.168.1.9:6379> zincrby hot:20220226 1 10004
"1"
192.168.1.9:6379> zincrby hot:20220226 1 10005
"1"
192.168.1.9:6379> zincrby hot:20220226 1 10006
"1"
192.168.1.9:6379> zincrby hot:20220226 1 10007
"1"
192.168.1.9:6379> zincrby hot:20220226 1 10008
"1"
192.168.1.9:6379> zincrby hot:20220226 1 10009
"1"
192.168.1.9:6379> zincrby hot:20220226 1 10010
"1"
192.168.1.9:6379>
192.168.1.9:6379> zincrby hot:20220226 10 10010
"11"
192.168.1.9:6379> zincrby hot:20220226 20 10005
"21"
192.168.1.9:6379> zincrby hot:20220226 30 10001
"30"
192.168.1.9:6379>
192.168.1.9:6379> zrevrange hot:20220226 0 9 withscores
1) "10001"
2) "31"
3) "10005"
4) "21"
5) "10010"
6) "11"
7) "10009"
8) "1"
9) "10008"
10) "1"
11) "10007"
12) "1"
13) "10006"
14) "1"
15) "10004"
16) "1"
17) "10003"
18) "1"
19) "10002"
20) "1"
192.168.1.9:6379>
192.168.1.9:6379> zrange hot:20220226 0 9 withscores rev
1) "10001"
2) "31"
3) "10005"
4) "21"
5) "10010"
6) "11"
7) "10009"
8) "1"
9) "10008"
10) "1"
11) "10007"
12) "1"
13) "10006"
14) "1"
15) "10004"
16) "1"
17) "10003"
18) "1"
19) "10002"
20) "1"
192.168.1.9:6379>
4.2 延时队列
- 1)延时队列:假设在一个后端进程中有一个延时任务需要处理,这个任务需要通知其它分布式节点完成某项工作,那么就需要这个后端进程提交延迟任务到redis,这些延时任务统一交给一个check服务去分发给其它分布式节点处理。
- 2)一般zset结构的处理:将消息序列化成一个字符串作为 zset 的 member。这个消息的到期处理时间作为 score,然后用多个线程轮询 zset 获取到期的任务进行处理。
例如下面例子。解释下图:
- S1、S2、S3、S4是我们四个后端服务,他们都连接着同一个redis服务。然后还有一个check服务也与redis服务进行连接。
- 假设S1有一个延时任务提交到redis(因为这个任务肯定不能单纯的在S1简单的添加定时器进行处理,而且S2、S3、S4也会有延时任务,如果都简单用定时器实现,那么肯定不行的,因为我们要求这种任务需要具体分布式特性,使用定时器的话就没有这个特性了。所以我们要提交任务到redis并需要一个check服务同一调度这个延时任务),这个任务需要通知S2、S3、S4去处理,那么就需要一个额外的check服务区调到这些延时任务,以便实现统一处理。
那么如何去实现延时队列呢?看下面的伪代码解释。
zadd delay:1 now+5 task1
zadd delay:1 now+10 task2
for {
vals := zrangebyscore delay:1 0 now limit 0 1
val := vals[0]
zrem delay:1 val
handle(val)
以上就是对延时队列(或者叫延时任务)的伪代码解释。
4.3 分布式定时器
上面的分布式延时任务队列在只是针对于比较简单的开发场景,对于一些开发场景比较复杂、广泛的时候,就需要引入我们的分布式定时器。 下面我们简单介绍一下分布式定时器的设计原理。
- 1)生产者将定时任务 hash 到不同的 redis 实体中,为每一个 redis 实体分配一个 dispatcher 进
程,用来定时获取 redis 中超时事件并发布到不同的消费者中。 - 2)因为假设生产者提交任务后,按照延时队列的处理,只有一个redis服务和check服务,若此时它们两者有人宕机了呢,那么明显不符合分布式系统中的可靠性,所以需要分布式的定时器去处理。即生产者产生任务后按适当的情况提交到redis集群中,然后对应的dispatcher从与其对应的redis服务取任务去分配给消费者消费。
分布式的可靠性应该指的是分布式CAP原理中的P即分区容错性(因为本人对分布式不是特别了解)。分布式CAP原理可以参考分布式CAP定理,为什么不能同时满足三个特性?。
4.4 时间窗口限流
在讲时间窗口限流之前,我们先了解限流和熔断的概念。
-
1)限流:窗口是移动的。例如5秒内限定某个行为发生10次,那么它是这样计算的:假设开始是1-6秒,然后经过1秒后,它是2-7秒,再经过1秒后,它是3-8秒。即限流这里统计的行为可能是重复的,并且窗口是移动的。 例如1-6秒中,第1秒发生了1次,第2秒-第6秒发生了3次,1-6秒总共发生了1+3=4次。 那么在2-7秒的统计时,由于1-6秒的第2秒-第6秒代表2-7秒的前6秒,所以2-7秒的前6秒的发生次数为3次,假设第7秒发生2次,那么2-7秒就总共发生了3+2=5次。 同理,3-8秒的第3秒-第7秒的发生次数就是1-6(即3-6秒)与2-7(即3-7秒)时发生的次数(因为这里没假设具体的秒数,所以没法算出具体的发生次数),然后加上第8秒发生的次数,就是3-8秒发生的总次数。 -
2)熔断:窗口是固定的。同样例如5秒内限定某个行为发生10次,比上面的容易理解,那么它是这样计算的:假设开始是1-6秒,那么若这5秒内某个行为没有发生10次,就会清零;然后7-12秒继续统计5秒内有没有发生某个行为10次,没有继续清零。以此类推。即之前发生的行为次数不会影响到下一次的统计,并且窗口是固定的。 -
3)时间窗口限流的概念:系统限定用户的某个行为在指定的时间范围内(动态)只能发生N次。
zadd limit:10001:action1 now now
zremrangebyscore limit:10001:action1 0 now - period*1000
count = zcard limit:10001:action1
例如对上面进行例子解释。
1)假设系统设置的period=2s,max_count=5次。再假设zadd时,now=5s,经过1s后,zremrangebyscore开始执行,那么now=5+1=6s,
所以zremrangebyscore截断的区间为:[0, 4],最终删除了0-4s的行为次数,这样有序集合中剩余的元素就是最近period内的次数,即第5秒和第6秒。这也解释了为什么zadd时,score和member都使用now。
同理再经过1s,区间为[0,5],剩余的为第6秒和第7秒。这样通过zcard后,即可得到period内的行为次数。然后与max_count进行比较。
2)并且看到,使用zset这种方式它的窗口是移动的。
val = incr limit:10001:action1
val = incr limit:10001:action1
expire limit:10001:action1 60+1
例如对上面进行例子解释。
1)具体看https://blog.csdn.net/liyunlong41/article/details/89854808即可。
关于这篇文章讲的incr和expire并不是原子操作的处理方法,建议使用lua脚本,他说的ttl方法毕竟不是百分百安全的。
2)这个string + expire的方法是熔断(窗口不移动)的处理还是限流(窗口移动)目前本人还不是很清楚。
等后续有时间再看看这个string + expire的实现。毕竟其实只理解原理,还远远不够在实际开发中灵活运用。
即string+expire例子本人还有两个疑问:
1)string+expire命令调用的具体执行顺序。
2)这个string + expire的方法是熔断(窗口不移动)的处理还是限流(窗口移动)的处理。
|