前言
在没有真正认识 Redis 之前,你可能都低估了它
一开始对于 Redis 我们的认识都是一个 key:value
的缓存,当然用的最多的也就是这个作用。但随着 Redis 的不断发展,慢慢的我就发现它有的功能越来越多,它可能在一定程度上帮我们快速简化一些高并发场景下的开发。我觉得它其中最重要的设计是它的 数据结构 。通过几个基础的数据结构的组合,就能实现一些高性能的结构。比如我们今天要讨论的 Sorted Set 就是这样一个结构。由于 Redis 中称为 zset 所以后文中为了简化直接也叫 zset。
什么是 Sorted Set
我觉得可能很多同学还没有用过,其实非常容易理解,就是一个有序的集合,无论你以什么顺序添加元素,最终都会根据分数排成一个有序的集合。通过它我们可以快速获得一个组数据的最高的几个值。
猜测实现
堆?没错,我的第一反应也是这个,要实现一个这样的结构最先想到的就是堆或者说是优先队列的实现,完美匹配。
但,不对,我们知道,对于堆,我们只能快速得到最大或最小值。而对于 zset 其中有一个方法是 ZRANGE key start stop
也就是可以获取一个范围,比如获取排序后的第 3 位开始后的 5 个元素。而且它可以快速获取到一个某个元素的位置 ZRANK key
,也就是快速查询到某个元素的排名。
所以,这显然不能简单的用“堆”搞定了。
前置知识
- ziplist 压缩列表 redis 中为了压缩数据大小来实现的一种数据结构,通过数据长度来定位下一个数据和前一个数据
- skiplist 跳表 一种 “堆叠”(这个概念是我脑补的)的数据结构,通过不同层级的的标志能快速进行元素位置的定位,思路上有点类似二分
这两个结构我在这里不具体展开了,不然篇幅太长。如有需要,请查看参考链接:
具体实现
废话不多说,直接开代码最直接。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
所以 zset 的结构就是一个字典(hash 表)+ 一个 skiplist(跳表)就完事了呗?简单,走了走了。别急~ 其实并不是这样。
数据结构的变化
我来看一下元素的 add 方法就明白了里面的玄机
int zsetAdd(robj *zobj, double score, sds ele, int in_flags, int *out_flags, double *newscore) {
// 重点 1
if (zobj->encoding == OBJ_ENCODING_LISTPACK) {
unsigned char *eptr;
if ((eptr = zzlFind(zobj->ptr,ele,&curscore)) != NULL) {
...................
} else if (!xx) {
/* check if the element is too large or the list
* becomes too long *before* executing zzlInsert. */
if (zzlLength(zobj->ptr)+1 > server.zset_max_listpack_entries ||
sdslen(ele) > server.zset_max_listpack_value ||
!lpSafeToAdd(zobj->ptr, sdslen(ele)))
{
// 重点 2
zsetConvertAndExpand(zobj, OBJ_ENCODING_SKIPLIST, zsetLength(zobj) + 1);
} else {
zobj->ptr = zzlInsert(zobj->ptr,ele,score);
if (newscore) *newscore = score;
*out_flags |= ZADD_OUT_ADDED;
return 1;
}
} else {
*out_flags |= ZADD_OUT_NOP;
return 1;
}
}
/* Note that the above block handling listpack would have either returned or
* converted the key to skiplist. */
// 重点 3
if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
...............
} else {
serverPanic("Unknown sorted set encoding");
}
return 0; /* Never reached. */
}
- 根据当前 encoding 不同,有两种不同的实现,分别是:
ziplist
和我们上面提到的 `dict + skiplist - 触发数据结构变化的条件是元素数量超过
zset_max_listpack_entries
默认 128,还有一个条件是,有序集合保存的所有元素成员的长度都小于zset_max_listpack_value(64)
字节 - 我们可以通过
OBJECT ENCODING key
命令查看对象的编码方式(数据结构)
127.0.0.1:6379> OBJECT ENCODING linkinstar
"listpack"
127.0.0.1:6379> OBJECT ENCODING linkinstars
"skiplist"
为什么需要 dict
这是非常常见的一种查询优化策略,就是用空间换时间,为了实现快速用 key 查询该元素的分数和位置就能用到 dict(ZRANK)
long zsetRank(robj *zobj, sds ele, int reverse, double *output_score) {
// ......
if (zobj->encoding == OBJ_ENCODING_LISTPACK) {
// 这里只能遍历了
// ....
} else if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
// 这里查字典就好了
de = dictFind(zs->dict,ele);
if (de != NULL) {
score = *(double*)dictGetVal(de);
rank = zslGetRank(zsl,score,ele);
/* Existing elements always have a rank. */
serverAssert(rank != 0);
if (output_score)
*output_score = score;
if (reverse)
return llen-rank;
else
return rank-1;
} else {
return -1;
}
} else {
serverPanic("Unknown sorted set encoding");
}
}
可以看到,如果是 SKIPLIST 的 encoding 也就是有 dict 的数据结构的时候,就直接 dictFind
了,非常快。而且作为一个 dict 非常好维护,添加和删除元素的时候,同时操作一次 dict 就可以了。
注意点
Most sorted set operations are O(log(n)), where _n_ is the number of members.
Exercise some caution when running theZRANGE
command with large returns values (e.g., in the tens of thousands or more). This command's time complexity is O(log(n) + m), where _m_ is the number of results returned.
绝大多数 zset 的操作都是 O(log(n))
但 ZRANGE
需要注意。当实现为跳表的时候,需要进行 ZRANGE
查询的时候,需要查询一个范围值,查询第一个目标肯定是 O(log(n))
然后向后找 m 个,所以显然 m 太大会影响性能。
应用场景
排行榜
第一个能想到的应用场景肯定是这个,毕竟它的特性太像了,将需要排序的人和分数扔进去,一下就能搞定一个高性能的排行榜。就不多说了。
限流
这个是我一开始也没想到的。对于限流我们常用的肯定是 token 或漏桶什么的,当然也有窗口,由于固定窗口有边界问题。滑动窗口就可以解决很大一部分问题,而如何滑动就很关键了。此时 zset 就能帮助我们来实现这个滑动窗口,我们可以通过将用户访问的时间戳作为分数扔进去,每次访问的时候可以丢弃掉过期的分数,而在 zset 的中的数量就是限流的大小了,超过数量就拒绝了。
具体可以参考:https://engineering.classdojo.com/blog/2015/02/06/rolling-rat...
所有其他利用堆的场景
由于 zset 它本质就是个堆,那其实这个特性还可以被用在例如:撮合交易、抽奖等等需要堆的场景。
总结
Redis Sorted Set 给我们带来的思考可能有下面这些:
- 在不同数据量的时候使用不同的数据结构能优化存储和性能
- 通过不同数据结构的组合来优化不同场景下的性能以提供更多的操作
- 只要底层数据结构实现的好,上面能扩展的功能也会很简单