redis-----06-----redis-zset结构以及应用

2022-10-22,,

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

# 添加到键为key有序集合(sorted set)里面。
# 1)如果指定添加的成员已经是有序集合里面的成员,则会更新成员的分数(scrore)并更新到正确的排序位置。
# 2)如果key不存在,将会创建一个新的key并将分数/成员(score/member)对添加到有序集合,
# 	就像原来存在一个空的有序集合一样。如果key存在,但是类型不是有序集合,将会返回一个错误应答。
#	分数值是一个双精度的浮点型数字字符串。+inf和-inf都是有效值。
# 3)ZADD 命令在key后面分数/成员(score/member)对前面支持一些参数(>= Redis 3.0.2),他们是:
# 	3.1)XX: 仅仅更新存在的成员,不添加新成员。
# 	3.2)NX: 不更新存在的成员。只添加新成员。
# 	3.3)CH: 修改返回值为发生变化的成员总数,原始是返回新添加成员的总数 (CH 是 changed 的意思)。
#			更改的元素是新添加的成员或者是已经存在的成员更新分数。 所以在命令中指定的成员有相同的分数
#			将不被计算在内。注:在通常情况下,ZADD返回值只计算新添加成员的数量。
# 	CH其实不难,用得也不多,不过下面还是会详细介绍。
# 	3.4)INCR: 当ZADD指定这个选项时,成员的操作就等同ZINCRBY命令,对成员的分数进行递增操作。
# 4)有序集合里面的成员是不能重复的都是唯一的,但是,不同成员间有可能有相同的分数。
#	当多个成员有相同的分数时,他们将是有序的字典(ordered lexicographically)(仍由分数作为第一排序条件,然后,相同分数的成员按照字典规则相对排序)。
# 5)返回值Integer reply, 包括:
# 5.1)添加到有序集合的成员数量,不包括已经存在更新分数的成员(即不使用CH参数时)。
# 5.2)如果指定INCR参数, 返回将会变成bulk-string-reply。成员的新分数(双精度的浮点型数字)字符串。
# 6)redis >= 2.4: 接受多个成员。在Redis 2.4以前,命令只能添加或者更新一个成员。
ZADD key [NX|XX] [CH] [INCR] score member [score member ...]

# 从键为key有序集合中删除 member 的键值对。
# 1)当key存在,但是其不是有序集合类型,就返回一个错误。
# 2)返回值integer-reply, 如下的整数:
# 	返回的是从有序集合中删除的成员个数,不包括不存在的成员。
# 3)= 2.4: 接受多个元素。在2.4之前的版本中,每次只能删除一个成员。
ZREM key member [member ...]

# 返回有序集key中,成员member的score值。
# 1)如果member元素不是有序集key的成员,或key不存在,返回nil。
# 2)返回值bulk-string-reply: 返回member成员的score值(double型浮点数),以字符串形式表示。
ZSCORE key member
  • 1)演示ZADD(主要按照上面文字介绍执行),下面命令都是从上往下顺序执行的,只是我打了注释。
# 1)如果指定添加的成员已经是有序集合里面的成员,则会更新成员的分数(scrore)并更新到正确的排序位置。
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>

# 2)如果key不存在,将会创建一个新的key并将分数/成员(score/member)对添加到有序集合,
# 	就像原来存在一个空的有序集合一样。如果key存在,但是类型不是有序集合,将会返回一个错误应答。
#	分数值是一个双精度的浮点型数字字符串。+inf和-inf都是有效值。
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>

# 3)ZADD 命令在key后面分数/成员(score/member)对前面支持一些参数(>= Redis 3.0.2),他们是:
# 	3.1)XX: 仅仅更新存在的成员,不添加新成员。
# 下面看到,原本z2有one、two成员,然后下面命令想添加成员three,但是因为有XX选项,所以不会添加。
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> 

# 	3.2)NX: 不更新存在的成员。只添加新成员。
# 下面看到,因为存在NX选项,one、two的值都没有改变,只会增加成员three。
# 因为上面的命令将one的scores设为11,将two的scores设为22.想看详细点其实可以将上面测XX时的ZRANGE z2 0 -1加上WITHSCORES选项来看分数。
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> 

# 	3.3)CH: 修改返回值为发生变化的成员总数,原始是返回新添加成员的总数 (CH 是 changed 的意思)。
#			更改的元素是新添加的成员或者是已经存在的成员更新分数。 所以在命令中指定的成员有相同的分数
#			将不被计算在内。注:在通常情况下,ZADD返回值只计算新添加成员的数量。
# 	下面测试CH选项的思想很简单:
#	1. 就是想测试不带CH选项作对比,然后因为scores与member共有四种可能,以member为自变量,scores为因变量,那么有:
# 	1)member和scores都一样。2)member一样,scores不一样。3)member不一样,scores一样。4)member和scores都不一样。
#	2. 然后加上CH选项,和上面不带CH选项的方式再测一次即可。
# 先给z3集合添加两个元素,表示已有。
192.168.1.9:6379> ZADD z3  1 one 2 two
(integer) 2
# 以two成员为例子,和z3集合已有的元素进行测试。
# 1)member和scores都一样。因为没有添加新成员,所以返回0.
192.168.1.9:6379> ZADD z3 2 two
(integer) 0
# 2)member一样,scores不一样。因为没有添加新成员,所以返回0.
192.168.1.9:6379> ZADD z3 22 two
(integer) 0
# 3)member不一样,scores一样。因为添加了1个新成员,所以返回1.
192.168.1.9:6379> ZADD z3 22 three
(integer) 1
# 4)member和scores都不一样。因为添加了1个新成员,所以返回1.
192.168.1.9:6379> ZADD z3 44 four
(integer) 1

# 2. 添加了CH选项。
# 先给z4集合添加两个元素,表示已有。
192.168.1.9:6379> ZADD z4 CH 1 one 2 two
(integer) 2
# 1)member和scores都一样。因为没有添加新成员或者scores改变,所以返回0.
192.168.1.9:6379> ZADD z4 CH 2 two
(integer) 0
# 2)member一样,scores不一样。没有添加新成员但scores改变了,所以返回1.
192.168.1.9:6379> ZADD z4 CH 22 two
(integer) 1
# 3)member不一样,scores一样。因为添加了1个新成员,所以返回1.
192.168.1.9:6379> ZADD z4 CH 22 three
(integer) 1
# 4)member和scores都不一样。因为添加了1个新成员,所以返回1.
192.168.1.9:6379> ZADD z4 CH 44 four
(integer) 1
192.168.1.9:6379> 
# 总结CH选项:通过上面CH的测试,实际上CH中,我们只需要考虑member相同时的情况,这样才会与不加CH选项时的返回值有区别。
#	因为member不同时,肯定会添加新的member,所以和不加CH选项是一样的。


# 	3.4)INCR: 当ZADD指定这个选项时,成员的操作就等同ZINCRBY命令,对成员的分数进行递增操作。
# 可以看到,添加了INCR选项后,成员的操作就等同ZINCRBY命令,只会对成员的分数进行递增操作。若成员不存在初始值是0.
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> 

# 4)有序集合里面的成员是不能重复的都是唯一的,但是,不同成员间有可能有相同的分数。
#	当多个成员有相同的分数时,他们将是有序的字典(ordered lexicographically)(仍由分数作为第一排序条件,然后,相同分数的成员按照字典规则相对排序)。
# 下面看到,当多个成员的scores相同,会按照成员的首字母大写排序,若首字母相同,则比较下一个字母,以此类推。
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"

# 5)返回值Integer reply, 包括:
# 5.1)添加到有序集合的成员数量,不包括已经存在更新分数的成员(即不使用CH参数时)。
看上面3.3)不带CH选项的返回值或者看上面演示的命令中,使用到ZADD命令的返回值即可。

# 5.2)如果指定INCR参数, 返回将会变成bulk-string-reply。成员的新分数(双精度的浮点型数字)字符串。
看上面3.4)INCR的测试即可。

# 6)redis >= 2.4: 接受多个成员。在Redis 2.4以前,命令只能添加或者更新一个成员。
记住即可,不需要测试。
  • 2)演示ZREM:
# 1)当key存在,但是其不是有序集合类型,就返回一个错误。
# 字符串结构当做zset来错误使用ZREM。
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> 
# 或者hash结构当做zset来错误使用ZREM。报同样的错误。
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> 

# 2)返回值integer-reply, 如下的整数:
# 	返回的是从有序集合中删除的成员个数,不包括不存在的成员。
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
# 下面看到,six、ten不存在,所以会忽略。最终删除存在的member:one two three。
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"
  • 3)演示ZSCORE:
# 1)如果member元素不是有序集key的成员,或key不存在,返回nil。
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> 

# 2)返回值bulk-string-reply: 返回member成员的score值(double型浮点数),以字符串形式表示。
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

# 为有序集key的成员member的score值加上增量increment。
# 1)如果key存在但不存在member,就在key中添加一个member,score是increment(就好像它之前的score是0.0)。
# 2)如果key不存在,就创建一个只含有指定member成员的有序集合。当key不是有序集类型时,返回一个错误。
# 3)score值必须是字符串表示的整数值或双精度浮点数,并且能接受double精度的浮点数。也可给一个负数来减少score的值。 
#	实际上前半句话不需演示,因为有序集合的member的score肯定是整数或者double类型,否则在ZADD会报错。而对一个不存在的member进行ZINCRBY,
#	该member的score默认是0.0,所以前半句话可以不理。故只需演示给一个负数来减少score的值。 
# 4)返回值Bulk string reply: member成员的新score值,以字符串形式表示。
ZINCRBY key increment member 

# 返回key的有序集元素个数。
# 返回值integer-reply: key存在的时候,返回有序集的元素个数;否则返回0。
ZCARD key
  • 1)演示ZINCRBY:
# 1)如果key存在但不存在member,就在key中添加一个member,score是increment(就好像它之前的score是0.0)。
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"

# 2)如果key不存在,就创建一个只含有指定member成员的有序集合。当key不是有序集类型时,返回一个错误。
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> 

# 3)score值必须是字符串表示的整数值或双精度浮点数,并且能接受double精度的浮点数。也可给一个负数来减少score的值。 
#	实际上前半句话不需演示,因为有序集合的member的score肯定是整数或者double类型,否则在ZADD会报错。而对一个不存在的member进行ZINCRBY,
#	该member的score默认是0.0,所以前半句话可以不理。故只需演示给一个负数来减少score的值。 
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> 

# 4)返回值Bulk string reply: member成员的新score值,以字符串形式表示。
看上面的返回值即可。
  • 2)演示ZCARD:
# 返回值integer-reply: key存在的时候,返回有序集的元素个数;否则返回0。
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> 

# key不存在的时候。
192.168.1.9:6379> EXISTS z4
(integer) 0
192.168.1.9:6379> ZCARD z4
(integer) 0

2.3 ZRANK、ZRANGE、ZRANGEBYSCORE

# 返回有序集key中成员member的排名。
# 1)返回有序集key中成员member的排名。其中有序集成员按score值递增(从小到大)顺序排列。排名以0为底,也就是说,score值最小的成员排名为0。
#	使用ZREVRANK命令可以获得成员按score值递减(从大到小)排列的排名。
# 2)返回值:
# 	2.1)如果member是有序集key的成员,返回integer-reply:member的排名。
# 	2.2)如果member不是有序集key的成员,返回bulk-string-reply: nil。
ZRANK key member 

# 返回存储在有序集合key中的指定范围的元素 order by id limit 1,100。
# 1)返回存储在有序集合key中的指定范围的元素。 返回的元素可以认为是按照得分从最低到最高排列。 如果得分相同,将按字典排序。
# 	当你需要元素从最高分到最低分排列时,请参阅ZREVRANGE(相同的得分将使用字典倒序排序)。
# 2)参数start和stop都是基于零的索引,即0是第一个元素,1是第二个元素,以此类推。 
#	它们也可以是负数,表示从有序集合的末尾的偏移量,其中-1是有序集合的最后一个元素,-2是倒数第二个元素,等等。
# 3)start和stop都是全包含的区间,因此例如ZRANGE myzset 0 1将会返回有序集合的第一个和第二个元素。
# 4)超出范围的索引不会产生错误。 如果start参数的值大于有序集合中的最大索引,或者start > stop,将会返回一个空列表。 
# 5)如果stop的值大于有序集合的末尾,Redis会将其视为有序集合的最后一个元素。
# 6)可以传递WITHSCORES选项,以便将元素的分数与元素一起返回。这样,返回的列表将包含value1,score1,...,valueN,scoreN,而不是value1,...,valueN。 
#	客户端类库可以自由地返回更合适的数据类型(建议:具有值和得分的数组或记录)。
# 7)返回值array-reply:返回给定范围内的元素列表(如果指定了WITHSCORES选项,将同时返回它们的得分)。
ZRANGE key start stop [WITHSCORES]

# 1)返回有序集合key中,分数在min和max之间的所有元素(且包含min和max); 元素被认为是从低分到高分排序的。
#	limit 指定从第几个 开始返回多少个元素。
# 2)可选的LIMIT参数指定返回结果的数量及区间(类似SQL中SELECT LIMIT offset, count)。
#	注意,如果offset太大(因为太大你需要for遍历到这个位置),定位offset就可能遍历整个有序集合,这会增加O(N)的复杂度。
# 3)可选参数WITHSCORES会返回元素和其分数,而不只是元素。这个选项在redis2.0之后的版本都可用。
# 4)min和max可以是-inf和+inf,这样一来,你就可以在不知道有序集的最低和最高score值的情况下,使用ZRANGEBYSCORE这类命令。
# 5)默认情况下,区间的取值使用闭区间(小于等于或大于等于),你也可以通过给参数前增加(符号来使用可选的开区间(小于或大于)。
# 	例如:"ZRANGEBYSCORE zset (1 5"。	它代表返回所有符合条件1 < score <= 5的成员。
#	"ZRANGEBYSCORE zset (5 (10"。		它代表返回所有符合条件5 < score < 10 的成员。
# 6)返回值array-reply: 指定分数范围的元素列表(也可以返回他们的分数)。
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
  • 1)演示ZRANK:
# 1)返回有序集key中成员member的排名。其中有序集成员按score值递增(从小到大)顺序排列。排名以0为底,也就是说,score值最小的成员排名为0。
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

# 2)返回值:
# 	2.1)如果member是有序集key的成员,返回integer-reply:member的排名。
看上面的返回值即可。

# 	2.2)如果member不是有序集key的成员,返回bulk-string-reply: nil。
192.168.1.9:6379> ZRANK z1 five
(nil)
192.168.1.9:6379> 
  • 2)演示ZRANGE:
# 1)返回存储在有序集合key中的指定范围的元素。 返回的元素可以认为是按照得分从最低到最高排列。 如果得分相同,将按字典排序。
# 	当你需要元素从最高分到最低分排列时,请参阅ZREVRANGE(相同的得分将使用字典倒序排序)。
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
# 下面看到,score都是2,因为首字母b比t排在前面,所以成员b排序在前。
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> 

# 2)参数start和stop都是基于零的索引,即0是第一个元素,1是第二个元素,以此类推。 
#	它们也可以是负数,表示从有序集合的末尾的偏移量,其中-1是有序集合的最后一个元素,-2是倒数第二个元素,等等。
例如:ZRANGE z1 0 -1 withscores

# 3)start和stop都是全包含的区间,因此例如ZRANGE myzset 0 1将会返回有序集合的第一个和第二个元素。
192.168.1.9:6379> ZRANGE z1 0 1 withscores
1) "one"
2) "1"
3) "b"
4) "2"
192.168.1.9:6379> 

# 4)超出范围的索引不会产生错误。 如果start参数的值大于有序集合中的最大索引,或者start > stop,将会返回一个空列表。 
192.168.1.9:6379> ZRANGE z1 2 1 withscores
(empty array)
192.168.1.9:6379>

# 5)如果stop的值大于有序集合的末尾,Redis会将其视为有序集合的最后一个元素。
192.168.1.9:6379> ZRANGE z1 1 10 withscores
1) "b"
2) "2"
3) "two"
4) "2"
5) "three"
6) "3"

# 6)可以传递WITHSCORES选项,以便将元素的分数与元素一起返回。这样,返回的列表将包含value1,score1,...,valueN,scoreN,而不是value1,...,valueN。 
#	客户端类库可以自由地返回更合适的数据类型(建议:具有值和得分的数组或记录)。
看上面带withscores选项的结果即可。

# 7)返回值array-reply:返回给定范围内的元素列表(如果指定了WITHSCORES选项,将同时返回它们的得分)。
看上面ZRANGE命令执行的结果即可。
  • 3)演示ZRANGEBYSCORE:
# 1)返回有序集合key中,分数在min和max之间的所有元素(且包含min和max); 元素被认为是从低分到高分排序的。
#	limit 指定从第几个 开始返回多少个元素。
看下面执行的结果即可。

# 2)可选的LIMIT参数指定返回结果的数量及区间(类似SQL中SELECT LIMIT offset, count)。
#	注意,如果offset太大(因为太大你需要for遍历到这个位置),定位offset就可能遍历整个有序集合,这会增加O(N)的复杂度。
# 注意,offset从0下标开始。如果加上LIMIT选项,根据返回的结果来进一步筛选,那无非有四种可能:
# 以offset为自变量,count为因变量:
	# 1)返回的结果集中,offset和count都没有超过结果集的元素个数。按照LIMIT的限制正常返回。
	# 2)返回的结果集中,offset没有超过结果集的元素个数,但count超过。返回从offset开始,结果集剩余的所有元素。
	# 3)返回的结果集中,offset超过结果集的元素个数,但count没有超过。返回空数组。
	# 4)返回的结果集中,offset超过结果集的元素个数,count也超过。返回空数组。
# 先往有序集合添加元素。
192.168.1.9:6379> ZADD z1 1 one 2 two 10 ten 60 sixty 100 hundred
(integer) 5
# 测试2)的第一种可能。不加LIMIT时,因为socers在1-10区间的有one two ten,但是此时加上了LIMIT 0 2,所以会返回 one two这两个元素。
192.168.1.9:6379> ZRANGEBYSCORE z1 1 10 LIMIT 0 2 
1) "one"
2) "two"
192.168.1.9:6379>

# 测试2)的第二种可能。因为socers在1-10区间的有one two ten,但是此时加上了LIMIT 1 3,也就是从two开始返回3个元素,但是因为不够,那么就返回从offset=1剩下的所有元素。
192.168.1.9:6379> ZRANGEBYSCORE z1 1 10 LIMIT 1 3
1) "two"
2) "ten"
192.168.1.9:6379>

# 测试2)的第三种可能。因为socers在1-10区间的有one two ten,但是此时加上了LIMIT 3 2,offset直接大于结果集的最大元素个数的下标2,所以此时count没有参考价值,所以返回空数组。
192.168.1.9:6379> ZRANGEBYSCORE z1 1 10 LIMIT 3 2
(empty array)
192.168.1.9:6379>

# 测试2)的第四种可能。因为socers在1-10区间的有one two ten,但是此时加上了LIMIT 3 5,offset直接大于结果集的最大元素个数的下标2,所以此时count没有参考价值,同样所以返回空数组。
192.168.1.9:6379> ZRANGEBYSCORE z1 1 10 LIMIT 3 5
(empty array)
192.168.1.9:6379>

# 3)可选参数WITHSCORES会返回元素和其分数,而不只是元素。这个选项在redis2.0之后的版本都可用。
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> 

# 4)min和max可以是-inf和+inf,这样一来,你就可以在不知道有序集的最低和最高score值的情况下,使用ZRANGEBYSCORE这类命令。
192.168.1.9:6379> ZRANGEBYSCORE z1 -inf +inf
1) "one"
2) "two"
3) "ten"
4) "sixty"
5) "hundred"
192.168.1.9:6379> 

# 5)默认情况下,区间的取值使用闭区间(小于等于或大于等于),你也可以通过给参数前增加(符号来使用可选的开区间(小于或大于)。
# 	例如:"ZRANGEBYSCORE zset (1 5"。	它代表返回所有符合条件1 < score <= 5的成员。
#	"ZRANGEBYSCORE zset (5 (10"。		它代表返回所有符合条件5 < score < 10 的成员。
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> 

# 6)返回值array-reply: 指定分数范围的元素列表(也可以返回他们的分数)。
看上面执行的结果即可。

下面我们总结一下上面的基础命令设计到的一些关键下标,实际上只有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

# 移除有序集key中,所有score值介于min和max之间(包括等于min或max)的成员。 
#	自版本2.1.6开始,score值等于min或max的成员也可以不包括在内,语法请参见ZRANGEBYSCORE命令。
# 1)返回值integer-reply: 删除的元素的个数。
ZREMRANGEBYSCORE key min max
  • 1)演示ZREMRANGEBYSCORE:
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字段。

通过上面分析即可实现一个热搜榜。

# hot:20220226:hot代表热搜这个功能。20220226代表22年2月26号这一天的热搜。

# 1. 假设有10个热搜榜,首次阅读量都为1。用户通过点击新闻次数来改变score,从而改变热搜榜的顺序。
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> 
# 2. 然后通过用户的点击新闻行为,来改变score,从而改变热搜榜。
# 假设我们想让10001增加30点击数,10005增加20点击数,10010增加10点击数。其它不变。
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>

# 3. 获取前10个排行榜。
# 因为zrang默认返回的是从小到大的,即点击数从小到大,所以我们热搜榜需要返回点击数从大到小的,这样才是热搜榜。
# 下面看到,10001点击数最多所以排行第一,其次是10005,到10010,其余可以不理,因为我给的都是默认值。他们会按照score相同时,member的字母来排序,然后根据zrevrange倒转返回,最终显示下面的结果。
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>
# 实际上也可以使用ZRANG命令,但是需要加上REV选项。结果与zrevrange是一样的。
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服务区调到这些延时任务,以便实现统一处理。

那么如何去实现延时队列呢?看下面的伪代码解释。

# 1. delay thread。提交延时任务的线程。可能是S1、S2、S3、S4。
# delay:1 delay代表功能,1代表唯一标识id,所以delay:1可以认为是一个延时队列。
# socre使用时间戳来表示,now+5表示延时5秒执行这个任务。
# task1:可能是一个json字符串,里面可能详细记录了这个任务如何来操作。比如这个任务需要广播到S2、S3、S4。或者只转发到某一个节点例如S2进行处理。
zadd delay:1 now+5 task1 
zadd delay:1 now+10 task2

# 2. check thread。统一处理延时任务的线程。
# 注意:下面取[0,now]区间可以做到定时执行到时的延时任务。
# 例如:
#	1)上面添加任务,此时now=0s,下面check服务检测区间为[0,0],所以不会返回这个任务。
# 	2)当过了5s后,那么now=5s,下面的check服务检测区间为[0,5],所以刚好返回这个延时任务,然后从延时队列删除并执行。now+10的task2任务同理。
# 	3)这里强调一下,即使存在同样时间戳的延时队列即score相同,它也能被执行,只是执行先后顺序而已。
#		例如有两个延时任务t1,t2,score都是now+5即5s后执行,假设t1先执行,执行时间耗时1s,那么
#		下一次遍历zrangebyscore的区间就是[0,6]秒,t2同样能被返回,因为t1在执行前就从延时队列中被删除了。所以t2同样能够实现延时任务,
#		只不过延时多1秒执行,因为本来是5s后执行,因t1的耗时变成6s后执行,可以根据具体场景限制这个任务的耗时时长。
for {
	# vals最多只包含一个member,因为limit 0 1。返回的结果集不包含score。
	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次。

# 实现限流的方法有:
# 方法一:使用zset。
# 1. 分析。上面的概念有4个关键点:指定用户 user_id 的某个行为 action 在特定时间内 period 只允许发生做多的次数 max_count。即user_id、action、period、max_count。
# key limit:10001:action1的含义:limit代表限流这个功能。10001代表用户这个唯一id。action1代表用户的某个具体行为。

# 2. 使用zset,记录某个用户的某个行为的次数。其中score和member都使用now(下面会解释这样做的目的)。 
zadd limit:10001:action1 now now 

# 3. 从zset集合删除不在period时间内的的行为次数,这样zset集合剩余的元素个数就代表period内发生的行为次数。
# 因为now 时间单位为 毫秒,period传入为秒,所以period乘以1000转化成毫秒。 
zremrangebyscore limit:10001:action1 0 now - period*1000 

# 4. 获取period时间内用户的某个行为的次数。
count = zcard limit:10001:action1 
# expire limit:10001:action1 60+1

# 5. 得到次数后,就可以比较是否超过限流的次数,最终判断是否允许用户这个行为。
# 比较 count 与 max_count 。如果 count > max_count 说明超过次数;否则没有超过限定次数。

例如对上面进行例子解释。
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这种方式它的窗口是移动的。

# 方法二:使用string + expire。
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的方法是熔断(窗口不移动)的处理还是限流(窗口移动)的处理。

《redis-----06-----redis-zset结构以及应用.doc》

下载本文的Word格式文档,以方便收藏与打印。