Redis 有序集合
有序集合编码有ziplist
或者skiplist
。
ziplist
编码的有序集合使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个元素则保存元素的分值。
压缩列表内的集合元素按分值从小到大进行排序, 分值较小的元素被放置在靠近表头的方向, 而分值较大的元素则被放置在靠近表尾的方向。
举个例子, 如果我们执行以下 ZADD 命令, 那么服务器将创建一个有序集合对象作为 price
键的值:
redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3
而skiplist
编码的有序集合对象使用zset
作为底层实现,一个zset
结构同时包含一个字典和一个跳跃表:
typedef struct zset{
zskiplist * zsl;
dict * dict;
}zset;
下面介绍跳表结构
Redis跳跃表的实现
Redis 的跳跃表由 redis.h/zskiplistNode
和 redis.h/zskiplist
两个结构定义, 其中 zskiplistNode
结构用于表示跳跃表节点, 而 zskiplist
结构则用于保存跳跃表节点的相关信息, 比如节点的数量, 以及指向表头节点和表尾节点的指针。
上图为跳跃表示意图。位于图片最左边的是zskiplist
结构,该结构包括以下属性:
header
指向跳跃表的表头节点tail
指向跳跃表的表尾节点level
记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)length
记录跳跃表的长度,也就是跳跃表目前包含节点的数量。
位于 zskiplist
结构右方的是四个 zskiplistNode
结构, 该结构包含以下属性:
- 层(level):节点中用
L1
、L2
、L3
等字样标记节点的各个层,L1
代表第一层,L2
代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。 - 后退(backward)指针:节点中用
BW
字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。 - 分值(score):各个节点中的
1.0
、2.0
和3.0
是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。 - 成员对象(obj):各个节点中的
o1
、o2
和o3
是节点所保存的成员对象。
跳跃表节点的实现
typedef struct zskiplistNode{
//后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj;
//层
struct zskiplistLevel{
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
} level[];
}zskiplistNode;
层
跳跃表节点的 level
数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度, 一般来说, 层的数量越多, 访问其他节点的速度就越快。
每次创建一个新跳跃表节点的时候, 程序都根据幂次定律随机生成一个介于 1
和 32
之间的值作为 level
数组的大小, 这个大小就是层的“高度”。
前进指针
每个层都有一个指向表尾方向的前进指针(level[i].forward
属性), 用于从表头向表尾方向访问节点。
下图用虚线表示出了程序从表头向表尾方向, 遍历跳跃表中所有节点的路径:
- 迭代程序首先访问跳跃表的第一个节点(表头), 然后从第四层的前进指针移动到表中的第二个节点。
- 在第二个节点时, 程序沿着第二层的前进指针移动到表中的第三个节点。
- 在第三个节点时, 程序同样沿着第二层的前进指针移动到表中的第四个节点。
- 当程序再次沿着第四个节点的前进指针移动时, 它碰到一个
NULL
, 程序知道这时已经到达了跳跃表的表尾, 于是结束这次遍历。
跨度
层的跨度(level[i].span
属性)用于记录两个节点之间的距离:
- 两个节点之间的跨度越大, 它们相距得就越远。
- 指向
NULL
的所有前进指针的跨度都为0
, 因为它们没有连向任何节点。
初看上去, 很容易以为跨度和遍历操作有关, 但实际上并不是这样 —— 遍历操作只使用前进指针就可以完成了, 跨度实际上是用来计算排位(rank)的: 在查找某个节点的过程中, 将沿途访问过的所有层的跨度累计起来, 得到的结果就是目标节点在跳跃表中的排位。
Redis skiplist编码实现zset
skiplist
编码的有序集合对象使用zset
作为底层实现,一个zset
结构同时包含一个字典和一个跳跃表:
typedef struct zset{
zskiplist * zsl;
dict * dict;
}zset;
zset
结构中的 zsl
跳跃表按分值从小到大保存了所有集合元素, 每个跳跃表节点都保存了一个集合元素: 跳跃表节点的 object
属性保存了元素的成员, 而跳跃表节点的 score
属性则保存了元素的分值。 通过这个跳跃表, 程序可以对有序集合进行范围型操作, 比如 ZRANK 、ZRANGE 等命令就是基于跳跃表 API 来实现的。
除此之外, zset
结构中的 dict
字典为有序集合创建了一个从成员到分值的映射, 字典中的每个键值对都保存了一个集合元素: 字典的键保存了元素的成员, 而字典的值则保存了元素的分值。 通过这个字典, 程序可以用 O(1)
复杂度查找给定成员的分值, ZSCORE 命令就是根据这一特性实现的, 而很多其他有序集合命令都在实现的内部用到了这一特性。
在理论上来说, 有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现, 但无论单独使用字典还是跳跃表, 在性能上对比起同时使用字典和跳跃表都会有所降低。举个例子, 如果我们只使用字典来实现有序集合, 那么虽然以O(N)
内存空间 (因为要创建一个数组来保存排序后的元素)。另一方面, 如果我们只使用跳跃表来实现有序集合, 那么跳跃表执行范围型操作的所有优点都会被保留, 但因为没有了字典, 所以根据成员查找分值这一操作的复杂度将从 O(logn)
。
因为以上原因, 为了让有序集合的查找和范围型操作都尽可能快地执行, Redis 选择了同时使用字典和跳跃表两种数据结构来实现有序集合。
下图展示了skiplist
编码的有序集合对象
其中,跳跃表和字典会共享元素的成员和分值,所以并不会造成任何数据重复。
编码的转换
当有序集合对象可以同时满足以下两个条件时, 对象使用 ziplist
编码:
- 有序集合保存的元素数量小于
128
个; - 有序集合保存的所有元素成员的长度都小于
64
字节;
不能满足以上两个条件的有序集合对象将使用 skiplist
编码。
注意
以上两个条件的上限值是可以修改的, 具体请看配置文件中关于 zset-max-ziplist-entries
选项和 zset-max-ziplist-value
选项的说明。
对于使用 ziplist
编码的有序集合对象来说, 当使用 ziplist
编码所需的两个条件中的任意一个不能被满足时, 程序就会执行编码转换操作, 将原本储存在压缩列表里面的所有集合元素转移到 zset
结构里面, 并将对象的编码从 ziplist
改为 skiplist
。