跳表,又称跳跃表,在 Redis 中表现为 skiplist,是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
在正式介绍跳表前,先来看看 Redis 中的有序集合。
zadd class 87.5 alice 87.5 fred 65.5 charles 94.5 emily
向 class 有序集合里插入 4 条数据,查看下底层编码实现。
127.0.0.1:6379> object encoding class
"ziplist"
为压缩列表,在上一讲里详细介绍过,是由一系列特殊编码的连续内存块组成的顺序型数据结构。
// redis.conf
# Similarly to hashes and lists, sorted sets are also specially encoded in
# order to save a lot of space. This encoding is only used when the length and
# elements of a sorted set are below the following limits:
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
在 Redis 配置文件中,一旦有序集合里元素不超过 128 个或元素里的值最大长度不超过 64 时就用 ziplist。在 ziplist 中有序集合是数值在前,score 在后,数据和 score 是一一对应的,因此不超过 128 个,在 ziplist 中就是不超过 128*2 = 256 个,其内存布局大致如下。
为了进入到今天的主题,这里把配置文件中的 zset-max-ziplist-entries 调整为 1,也就是超过了 1 就是用 skiplist 实现。
127.0.0.1:6379> zadd class 87.5 alice 87.5 fred 65.5 charles 94.5 emily
(integer) 4
127.0.0.1:6379> object encoding class
"skiplist"
这时编码就显示为 skiplist 。
// server.h
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
Redis 中使用的是 zset 结构体来表存储有序集合,里面包含了字典和跳表两种结构。字典用来存储数值(键)和 score(值),从而实现 O(1) 复杂度获取数值对应的 score;跳跃表用来处理区间查询的相关操作。
字典在前面已经详细介绍过,那么接下来就看看 skiplist 的数据结构。
// server.h
#define ZSKIPLIST_MAXLEVEL 64 /* Should be enough for 2^64 elements */
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
sds ele; /* 数值 */
double score; /* 分值,排序用 */
struct zskiplistNode *backward; /* 向前指针 */
struct zskiplistLevel {
struct zskiplistNode *forward; /* 本层下一个节点 */
unsigned long span; /* 本层下一个节点与当前节点之间的元素个数 */
} level[]; /* 柔性数组,存储节点层级相关数据,最大层高 64,可以存储 2^64 个节点数 */
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail; /* 跳表的表头和表尾,头节点是个特殊的节点,层高 64,在初始化时生成 */
unsigned long length; /* 跳表节点长度,除了表头节点之外的总数 */
int level; /* 跳表的高度,除了表头节点以外的最大高度 */
} zskiplist;
和字典类似,有个 zskiplistNode 结构来存放数值和分数,顺序是从小到大,如果分值一致时,则按照数值的字典序排序;有个方便操作的 zskiplist 结构,比如 O(1) 时间复杂度的获取跳表中节点的个数,依据表头或表尾来实现正向或逆向遍历的 header 和 tail 指针 。
如图所示,zskiplist 是个 zskiplistNode 节点的概括和统筹。记录了有几个节点,最高的层级是多少,表头节点指向哪(方便正向遍历),表尾节点指向(方便反向遍历)。
zskiplistNode 的头节点和第一个存放数组分数的节点的 backward 皆为 NULL,同时头结点默认有 64 层,是为了避免后续高度增加时重新分配内存,其他属性都为默认值。层级箭头上面的数字就是 span ,是为了计算排名(rank)的,在 Redis 中排名是从 0 开始的。需要注意的是,这里 score 是按照由小到大排序的,比如要计算 charles 的排名,就是在查找路径中,把 span 累加起来再减一,1 - 1 为 0(这里减一就如前面提到的,在 Redis 中排名是从 0 开始的);要计算排名日常生活中的排名(由大到小),则需要总长度减去经过的 span 节点数,比如 emily 排名为 4-4 = 0。
127.0.0.1:6379> zrank class charles
(integer) 0
127.0.0.1:6379> zrevrank class emily
(integer) 0
关于 Redis 的跳表基础就介绍到这,接下来说说初始化及常用的 API。
// t_zset.c
/* Create a new skiplist. */
zskiplist *zslCreate(void) {
int j;
zskiplist *zsl;
zsl = zmalloc(sizeof(*zsl));
zsl->level = 1;
zsl->length = 0;
zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
zsl->header->level[j].forward = NULL;
zsl->header->level[j].span = 0;
}
zsl->header->backward = NULL;
zsl->tail = NULL;
return zsl;
}
/* Create a skiplist node with the specified number of levels.
* The SDS string 'ele' is referenced by the node after the call. */
zskiplistNode *zslCreateNode(int level, double score, sds ele) {
zskiplistNode *zn =
zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));
zn->score = score;
zn->ele = ele;
return zn;
}
初始化 skiplist 的两个函数的逻辑一目了然。先计算 zskiplist 结构占的内存然后申请对应的内存,默认级别为 1,节点数为 0,尽管后面申请了头部节点,但没算在内, 跳表的表尾节点指向 NULL。跳表的表头节点指向动态生成 zskiplistNode 节点,向前指针为 NULL,数值为 NULL,分值为 0,默认创建 64 层,每次都指向 NULL,跨度 为 0。初始化后内存布局如下。
为了加深理解,这里详细介绍下给跳表添加节点。节点添加的顺序就如同一开始 zadd 命令添加顺序,节点的层级如同上上图中假设的来。
// t_zset.c
/* Insert a new node in the skiplist. Assumes the element does not already
* exist (up to the caller to enforce that). The skiplist takes ownership
* of the passed SDS string 'ele'. */
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x; /* udpate 存储搜索路径 */
unsigned int rank[ZSKIPLIST_MAXLEVEL]; /* 存储跨度 */
int i, level;
serverAssert(!isnan(score));
x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
/* store rank that is crossed to reach the insert position */
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele,ele) < 0)))
{
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
update[i] = x;
}
/* we assume the element is not already inside, since we allow duplicated
* scores, reinserting the same element should never happen since the
* caller of zslInsert() should test in the hash table if the element is
* already inside or not. */
level = zslRandomLevel();
if (level > zsl->level) {
for (i = zsl->level; i < level; i++) {
rank[i] = 0;
update[i] = zsl->header;
update[i]->level[i].span = zsl->length;
}
zsl->level = level;
}
x = zslCreateNode(level,score,ele);
for (i = 0; i < level; i++) {
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;
/* update span covered by update[i] as x is inserted here */
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
/* increment span for untouched levels */
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}
x->backward = (update[0] == zsl->header) ? NULL : update[0];
if (x->level[0].forward)
x->level[0].forward->backward = x;
else
zsl->tail = x;
zsl->length++;
return x;
}
/* Returns a random level for the new skiplist node we are going to create.
* The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
* (both inclusive), with a powerlaw-alike distribution where higher
* levels are less likely to be returned. */
int zslRandomLevel(void) { /* 随机返回 1~64 的层级 */
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
首先添加 87.5 alice
x = zsl->header; /* 表头赋给 x */
for (i = zsl->level-1; i >= 0; i--) { /* 遍历头节点的每个层级,从下标最大层减 1 到 0。由于是首次写入, zsl->level 为 1,那么 i 的值为 1-1=0 */
/* store rank that is crossed to reach the insert position */
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1]; /* 和上面分析一样, rank[0] = 0 */
while (x->level[i].forward && /* 由于是首次写入,头节点 x 的 forward 节点都指向 NULL, 退出循环 */
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele,ele) < 0)))
{
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
update[i] = x; /* update[0] = x */
}
第一步,查找要插入的位置。由于是首次插入节点,update[0] 指向 header 节点(update 数组存放搜索路径),rank[0] 为 0(rank 数组存放 update 对应节点到待插入节点的 span 值)。
/* we assume the element is not already inside, since we allow duplicated
* scores, reinserting the same element should never happen since the
* caller of zslInsert() should test in the hash table if the element is
* already inside or not. */
level = zslRandomLevel(); /* 获取层级,范围在 1~64,假设返回 2 */
if (level > zsl->level) { /* 主要是存储超过表头层级的排名及节点 */
for (i = zsl->level; i < level; i++) { /* 1 < 2 满足 for 循环 */
rank[i] = 0; /* rank[1] = 0 */
update[i] = zsl->header; /* udpate[1] 存储头节点 */
update[i]->level[i].span = zsl->length; /* 更新头节点的 span 值为当前节点个数,刨除头部节点 */
}
zsl->level = level; /* 更新 zskiplist 的层级为最新的 2 */
}
第二步,调整跳表高度。如果要插入节点的高度大于跳表的高度,那么就分别用 rank 和 update 存储高出的那部分层级的节点信息。
x = zslCreateNode(level,score,ele); /* 创建 87.5 alice 节点 */
for (i = 0; i < level; i++) { /* 这里 level 为 2 */
x->level[i].forward = update[i]->level[i].forward; /* x->level[0].forward = NULL,因为 update[0] 为头结点,而这又是首次写入 */
update[i]->level[i].forward = x; /* 这里很巧妙,更新头结点的后置指针 */
/* update span covered by update[i] as x is inserted here */
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]); /* 这里为 0 */
update[i]->level[i].span = (rank[0] - rank[i]) + 1; /* 这里为 1 */
}
/* increment span for untouched levels */
for (i = level; i < zsl->level; i++) { /* 如果添加的节点小于默认层级,则更新层级对应的排名 */
update[i]->level[i].span++;
}
x->backward = (update[0] == zsl->header) ? NULL : update[0]; // 指向 NULL
if (x->level[0].forward)
x->level[0].forward->backward = x;
else
zsl->tail = x; // 走这里,尾节点执行 x
zsl->length++; // 节点数加 1
第四步,调整 backward、 zskiplist 的尾结点指针、节点数量。
接下来插入 87.5 fred
节点。
x = zsl->header; /* 表头赋给 x */
for (i = zsl->level-1; i >= 0; i--) { /* 遍历头节点的每个层级,从下标最大层减 1 到 0;i=2-1=1 */
/* store rank that is crossed to reach the insert position */
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1]; /* rank[1] = 0,rank[0] = rank[1] = 0 */
while (x->level[i].forward && /* 前置节点不为 NULL */
(x->level[i].forward->score < score || /* 如果前置节点的分值小于当前要插入的节点的分值 */
(x->level[i].forward->score == score && /* 当分数相同时,则按照字典序来比较两个数值 */
sdscmp(x->level[i].forward->ele,ele) < 0))) /* 因为字典序 fred 大于 alice,因此进入 while 循环 */
{
rank[i] += x->level[i].span; /* while 内第一遍历,rank[1] = 0 + 1 = 1,第二次遍历 rank[0] = 0 + 1 = 1 */
x = x->level[i].forward; /* x 此时指向了 alice 节点 */
}
update[i] = x; /* update[i] 指向各层的本层下一个节点 */
}
第一步,查找要插入的位置。由于分值相同,都是 87.5,那么用字典序比较数值,发现 fred 比 alice 大,那么位置就在 alice 后面。
第二步,更新跳表的高度。由于 fred 的层级为 1,这一步跳过。
第三步,插入节点。
第四步,调整 backward、 zskiplist 的尾结点指针、节点数量。
写入就介绍到这了,这里主要说下,跳表写入节点,时间都耗在查找上面了。这里着重说下两个变量,update 数组存放的节点都是 forward 指向要插入的节点,rank 存放的都是 update 里节点距离要插入节点的跨度。
最后说下删除节点。
// t_zset.c
/* Internal function used by zslDelete, zslDeleteByScore and zslDeleteByRank */
void zslDeleteNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) {
int i;
for (i = 0; i < zsl->level; i++) {
if (update[i]->level[i].forward == x) {
update[i]->level[i].span += x->level[i].span - 1;
update[i]->level[i].forward = x->level[i].forward;
} else {
update[i]->level[i].span -= 1; /* 这里之所以减一,是因为 udpate[i] 虽然没有直接指向删除节点,但高度上超过了 */
}
}
if (x->level[0].forward) {
x->level[0].forward->backward = x->backward;
} else {
zsl->tail = x->backward;
}
while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
zsl->level--;
zsl->length--;
}
/* Delete an element with matching score/element from the skiplist.
* The function returns 1 if the node was found and deleted, otherwise
* 0 is returned.
*
* If 'node' is NULL the deleted node is freed by zslFreeNode(), otherwise
* it is not freed (but just unlinked) and *node is set to the node pointer,
* so that it is possible for the caller to reuse the node (including the
* referenced SDS string at node->ele). */
int zslDelete(zskiplist *zsl, double score, sds ele, zskiplistNode **node) {
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
int i;
x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele,ele) < 0)))
{
x = x->level[i].forward;
}
update[i] = x;
}
/* We may have multiple elements with the same score, what we need
* is to find the element with both the right score and object. */
x = x->level[0].forward;
if (x && score == x->score && sdscmp(x->ele,ele) == 0) {
zslDeleteNode(zsl, x, update);
if (!node)
zslFreeNode(x);
else
*node = x;
return 1;
}
return 0; /* not found */
}
/* Free the specified skiplist node. The referenced SDS string representation
* of the element is freed too, unless node->ele is set to NULL before calling
* this function. */
void zslFreeNode(zskiplistNode *node) {
sdsfree(node->ele);
zfree(node);
}
这里分三步:第一步,依据层级高度,遍历头结点,把指向删除节点的各层级节点放入 update 路径搜索数组;第二步,依据层级高度,遍历路径搜索数组,更新对应的 forward 和 span(因为下一步要释放删除节点所占内存),如要删除的节点是跳表的最大高度,则调整跳表高度;第三步,释放节点内存。
【注】 此博文中的 Redis 版本为 5.0。
参考书籍 :
【1】redis设计与实现(第二版)
【2】Redis 5设计与源码分析