Redis的几大数据结构之一的ZSet实现的就是Ordered Set有序集合,通常在实际业务开发中ZSet也是较为高频使用的数据结构,可以用来实现排行榜、有序队列等应用。ZSet本身根据以下2个变量控制底层数据结构的选用,底层有ziplist和dict+skiplist的实现方式。

为了尽量节省内存,zset在以下2个条件都成立时会使用ziplist数据结构实现
zset-max-ziplist-entries 128 // 当zset中存的元素数量 <= 128时
zset-max-ziplist-value 64 // 当每个元素的字节大小 <= 64时

在一开始调用zadd命令时如果val的大小<=64字节,肯定先用ziplist创建。在以后每次zadd命令都会检查这2个条件以便进行ziplist到skiplist的转化。那么skiplist会不会退化成ziplist? 也会的,在进行zinter和zunion命令的时候,因为对2个集合的元素做了合并或求交集,可能对集合的元素数量改变了就需要进行转化。

从zadd命令作为切入点看看zset是如何选择实例化数据结构的

/* This generic command implements both ZADD and ZINCRBY. */
void zaddGenericCommand(client *c, int flags) {
    
    // 省略了解析命令,提取输入参数,校验命令语法的代码..

    /* redis有16个库,每个库本身是一个dict的实现,这里根据输入key找redis obj对象 */
    zobj = lookupKeyWrite(c->db,key);
    if (zobj == NULL) {
        if (xx) goto reply_to_client; /* 无此key且操作参数为xx模式,直接响应 */
        // 如果redis.conf配置的max_ziplist_entries是0或者本次添加的元素大小超过了max_ziplist_value(默认64字节),就用skiplist+dict实现zset
        if (server.zset_max_ziplist_entries == 0 ||
            server.zset_max_ziplist_value < sdslen(c->argv[scoreidx+1]->ptr))
        {
            // 创建skiplist+dict,每个redis obj对象有encoding字段标识它是什么数据类型
            zobj = createZsetObject();
        } else {
            // 创建ziplist,一般第一次zadd都会走这里,除非元素比较大
            zobj = createZsetZiplistObject();
        }
        // 加到redis库中
        dbAdd(c->db,key,zobj);
    } else {
        if (zobj->type != OBJ_ZSET) {
            addReply(c,shared.wrongtypeerr);
            goto cleanup;
        }
    }
    // 遍历元素,加入zset,elements在前面解析客户端输入参数时计算出
    for (j = 0; j < elements; j++) {
        double newscore;
        // ele对应的分数
        score = scores[j];
        int retflags = flags;
        // ele元素
        ele = c->argv[scoreidx+1+j*2]->ptr;
        // 执行zadd命令逻辑
        int retval = zsetAdd(zobj, score, ele, &retflags, &newscore);
        // 出错了直接响应
        if (retval == 0) {
            addReplyError(c,nanerr);
            goto cleanup;
        }
        // 统计加入、更新的元素
        if (retflags & ZADD_ADDED) added++;
        if (retflags & ZADD_UPDATED) updated++;
        if (!(retflags & ZADD_NOP)) processed++;
        // score用来回应incrby命令,告诉用户此元素最新的分数
        score = newscore;
    }
    // dirty统计一段时间内redis改变的key数量,用来rdb save做判断
    server.dirty += (added+updated);

    // 省略失败清理资源代码和响应客户端代码
}

 在上面可以看到实例化zset底层数据结构的逻辑,但是就添加元素来说是在zsetAdd函数实现。

int zsetAdd(
        // zset对象,可能是ziplist或skiplist
        robj *zobj,
        // 分数
        double score,
        // 元素值
        sds ele,
        // 命令参数
        int *flags,
        // newscore指针,计算新分数并响应
        double *newscore) {
    /* 提取命令参数 */
    int incr = (*flags & ZADD_INCR) != 0;
    int nx = (*flags & ZADD_NX) != 0;
    int xx = (*flags & ZADD_XX) != 0;
    *flags = 0; /* We'll return our response flags. */
    double curscore;

    /* score有效性检查 */
    if (isnan(score)) {
        *flags = ZADD_NAN;
        return 0;
    }

    /* 根据数据结构执行添加逻辑 */
    // ziplist
    if (zobj->encoding == OBJ_ENCODING_ZIPLIST) {
        unsigned char *eptr;
        // zzlFind,zl是ziplist缩写,ziplist查找ele
        if ((eptr = zzlFind(zobj->ptr, ele,&curscore)) != NULL) {
            // ziplist已存在ele值
            /* nx模式,啥都不做,统计个数据返回 */
            if (nx) {
                *flags |= ZADD_NOP;
                return 1;
            }

            /* 如果是zincrby命令,就算下加分 */
            if (incr) {
                score += curscore;
                if (isnan(score)) {
                    *flags |= ZADD_NAN;
                    return 0;
                }
                if (newscore) *newscore = score;
            }

            /* 先删后加,因为涉及分数的改变需要重排序,不能直接改 */
            if (score != curscore) {
                zobj->ptr = zzlDelete(zobj->ptr, eptr);
                zobj->ptr = zzlInsert(zobj->ptr, ele, score);
                *flags |= ZADD_UPDATED;
            }
            return 1;
        } else if (!xx) {
            // 元素不存在且不是only update命令模式
            /* Optimize: check if the element is too large or the list
             * becomes too long *before* executing zzlInsert.
             * 可以看到作者在这里留了个优化点,未来希望先检查转化skiplist,再插入
             * */
            zobj->ptr = zzlInsert(zobj->ptr, ele, score);
            // ziplist转化skiplist的2个条件,数量超过128或单个元素大小超过64字节
            if (zzlLength(zobj->ptr) > server.zset_max_ziplist_entries)
                zsetConvert(zobj,OBJ_ENCODING_SKIPLIST);
            if (sdslen(ele) > server.zset_max_ziplist_value)
                zsetConvert(zobj,OBJ_ENCODING_SKIPLIST);
            if (newscore) *newscore = score;
            *flags |= ZADD_ADDED;
            return 1;
        } else {
            // 元素已存在,且不是xx(only update),啥都不做直接响应
            *flags |= ZADD_NOP;
            return 1;
        }
    } 
    // skiplist
    else if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
        // redis obj对象指向的数据是 zset结构体,内部实现就是dict+skiplist
        zset *zs = zobj->ptr;
        zskiplistNode *znode;
        dictEntry *de;
        // O(1)时间找到元素
        de = dictFind(zs->dict,ele);
        if (de != NULL) {
            /* 元素已存在且nx模式,直接返回 */
            if (nx) {
                *flags |= ZADD_NOP;
                return 1;
            }
            // 拿到元素的分数,O(1),这里拿出的是double指针,指针取值再赋给curscore
            curscore = *(double*)dictGetVal(de);

            /* 如果是zincrby命令,就算下加分 */
            if (incr) {
                score += curscore;
                if (isnan(score)) {
                    *flags |= ZADD_NAN;
                    return 0;
                }
                if (newscore) *newscore = score;
            }
            /* 分数变了,改一下跳表和哈希表的元素分数 */
            if (score != curscore) {
                znode = zslUpdateScore(zs->zsl, curscore, ele,score);
                dictGetVal(de) = &znode->score; /* 直接更新下dict中的元素分数 */
                *flags |= ZADD_UPDATED;
            }
            return 1;
        } else if (!xx) {
            ele = sdsdup(ele);
            znode = zslInsert(zs->zsl,score,ele);
            serverAssert(dictAdd(zs->dict,ele,&znode->score) == DICT_OK);
            *flags |= ZADD_ADDED;
            if (newscore) *newscore = score;
            return 1;
        } else {
            // 元素不存在且only update,啥都不做,统计个数据返回
            *flags |= ZADD_NOP;
            return 1;
        }
    } else {
        serverPanic("Unknown sorted set encoding");
    }
    return 0; /* Never reached. */
}

zset的添加元素分为对2个数据结构的操作,先看看ziplist压缩列表是如何找到一个元素的

ziplist数据结构实现

首先要清楚ziplist的内存结构:

area |<---- ziplist header ---->|<----------- entries ------------->|<-end->| size 4 bytes 4 bytes 2 bytes ? ? ? ? 1 byte +---------+--------+-------+--------+--------+--------+--------+-------+ component | zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend | +---------+--------+-------+--------+--------+--------+--------+-------+ ^ ^ ^ address | | | ZIPLIST_ENTRY_HEAD | ZIPLIST_ENTRY_END | ZIPLIST_ENTRY_TAIL

header是固定的10字节长度,442分别代表:ziplist总字节数、到ziplist表尾的字节数即指向ziplist_entry_end的偏移量、ziplist元素数量


长度/类型

域的值

zlbytes

uint32_t

整个 ziplist 占用的内存字节数,对 ziplist 进行内存重分配,或者计算末端时使用。

zltail

uint32_t

到达 ziplist 表尾节点的偏移量。 通过这个偏移量,可以在不遍历整个 ziplist 的前提下,弹出表尾节点。

zllen

uint16_t

ziplist 中节点的数量。 当这个值小于 UINT16_MAX (65535)时,这个值就是 ziplist 中节点的数量; 当这个值等于 UINT16_MAX 时,节点的数量需要遍历整个 ziplist 才能计算得出。

entryX

?

ziplist 所保存的节点,各个节点的长度根据内容而定。

zlend

uint8_t

255 的二进制值 1111 1111 (UINT8_MAX) ,用于标记 ziplist 的末端。

 ziplist的entry的内存结构比较复杂,分为pre_entry_length、encoding、length、content4个部分,每个部分不是定长的,就不解析了。

area |<------------------- entry -------------------->| +------------------+----------+--------+---------+ component | pre_entry_length | encoding | length | content | +------------------+----------+--------+---------+

zzlFind函数

unsigned char *zzlFind(
        // ziplist对象指针
        unsigned char *zl,
        // 元素
        sds ele,
        // 分数
        double *score) {
    // 先找到开头元素的地址,跳过header
    unsigned char *eptr = ziplistIndex(zl,0), *sptr;

    while (eptr != NULL) {
        // ziplistNext函数就是算当前entry的字节长度,然后指针加上这个偏移地址就指向下一个entry的起始地址
        sptr = ziplistNext(zl,eptr);
        serverAssert(sptr != NULL);
        // 对比,根据entry的encoding类型,是字符串或数字提取content字段作比较
        if (ziplistCompare(eptr,(unsigned char*)ele,sdslen(ele))) {
            /* 找到元素,拿到分数 */
            if (score != NULL) *score = zzlGetScore(sptr);
            return eptr;
        }
        /* 移动指针 */
        eptr = ziplistNext(zl,sptr);
    }
    return NULL;
}

ziplistIndex()给定下标返回次下标的元素首地址

unsigned char *ziplistIndex(
        // ziplist指针首地址
        unsigned char *zl,
        // 第几个元素下标
        int index) {
    unsigned char *p;
    unsigned int prevlensize, prevlen = 0;
    // idx负数从后往前找
    if (index < 0) {
        index = (-index)-1;
        p = ZIPLIST_ENTRY_TAIL(zl);
        if (p[0] != ZIP_END) {
            ZIP_DECODE_PREVLEN(p, prevlensize, prevlen);
            while (prevlen > 0 && index--) {
                p -= prevlen;
                ZIP_DECODE_PREVLEN(p, prevlensize, prevlen);
            }
        }
    }
    // idx正数从前往后找
    else {
        // ziplist头是zlbytes(总字节长度)+zltail(尾偏移量)+zllen(entry数)共10个字节,跳过10个字节,p现在指向第一个entry首地址
        p = ZIPLIST_ENTRY_HEAD(zl);
        // ziplist尾是一个固定1字节的255数字
        // p没指向尾结点就一直向后移动找到指定下标的元素,每次向后移动多少字节是要计算entry长度的
        while (p[0] != ZIP_END && index--) {
            // 计算当前的entry长度
            p += zipRawEntryLength(p);
        }
    }
    // p到尾结点都没找到就返回空,否则返回下标的首地址
    return (p[0] == ZIP_END || index > 0) ? NULL : p;
}

看完上面的zzlFind函数至少我们知道了ziplist是如何通过下标找到一个entry的,全都是计算内存地址偏移量找到entry。又因为每个entry不是定长的内存结构,所以要根据协议解析当前entry字节长度,然后不断指针偏移指向下一个元素,最后ziplist尾结点是定长1字节的255数字标识,所以可以停止遍历。

代码回到zsetAdd,现在找到了元素,下一步要删除元素和添加元素,执行zzlDelete和zzlInsert

/* Delete (element,score) pair from ziplist. Use local copy of eptr because we
 * don't want to modify the one given as argument. */
unsigned char *zzlDelete(unsigned char *zl, unsigned char *eptr) {
    unsigned char *p = eptr;
    // 元素的值和分数在ziplist中是紧挨一起的entry,所以删两次
    zl = ziplistDelete(zl,&p);
    zl = ziplistDelete(zl,&p);
    return zl;
}

ziplistDelete解析。总体上ziplist删一个元素分为3步:1.计算第一个被删除元素到最后一个被删除元素的长度 2.后面元素内存覆盖被删除的空间 3.重新分配ziplist内存空间,多余内存回收掉

/* Delete a single entry from the ziplist, pointed to by *p.
 * Also update *p in place, to be able to iterate over the
 * ziplist, while deleting entries. */
unsigned char *ziplistDelete(
        unsigned char *zl,
        unsigned char **p) {
    // *p是待删除元素的首地址,*p-zl是算出ziplist头到被删除的首地址长度,待会返回p的下一个元素首地址
    size_t offset = *p-zl;
    // 从p开始删一个元素,3步:1.计算p开始向后删除元素的长度 2.数据复制 3.重新分配空间
    // 2.数据复制是将被删除元素后的元素copy覆盖到被删除空间
    // 3.重新分配空间是,元素删除后ziplist长度变短了,重新分配ziplist指针的空间,让os回收多余的内存
    zl = __ziplistDelete(zl,*p,1);

    /* Store pointer to current element in p, because ziplistDelete will
     * do a realloc which might result in a different "zl"-pointer.
     * When the delete direction is back to front, we might delete the last
     * entry and end up with "p" pointing to ZIP_END, so check this. */
    *p = zl+offset;
    return zl;
}

zzlInsert,ziplist的插入逻辑和删除逻辑差不多是相反的操作,同样的需要数据复制和重新分配空间,因为ziplist变长了

/* Insert (element,score) pair in ziplist. This function assumes the element is
 * not yet present in the list. */
unsigned char *zzlInsert(
        // ziplist
        unsigned char *zl,
        // 元素
        sds ele,
        // 分数
        double score) {
    // 注意这里第一个entry是元素值,第二个entry才是元素的分数
    unsigned char *eptr = ziplistIndex(zl,0), *sptr;
    double s;

    while (eptr != NULL) {
        // 找到元素分数
        sptr = ziplistNext(zl,eptr);
        serverAssert(sptr != NULL);
        s = zzlGetScore(sptr);
        if (s > score) {
            // 会插入2个entry,元素值和分数
            // 插入分3步:1.将元素编码 2.重新分配空间,根据元素大小增大ziplist空间 3.重新移动,eptr后面的元素往后移动
            zl = zzlInsertAt(zl, eptr, ele, score);
            break;
        } else if (s == score) {
            /* 分数相同的时候比较元素值的字典序大小,如果p指向元素比较小就插入,否则继续遍历 */
            if (zzlCompareElements(eptr,(unsigned char*)ele,sdslen(ele)) > 0) {
                // 相同的插入
                zl = zzlInsertAt(zl, eptr, ele, score);
                break;
            }
        }
        /* Move to next element. */
        eptr = ziplistNext(zl,sptr);
    }
    /* 要么ziplist是空的要么新元素是最大的 */
    if (eptr == NULL)
        zl = zzlInsertAt(zl,NULL,ele,score);
    return zl;
}

从ziplist的内存结构来看是线性的内存数据,导致了插入和删除要进行数据移动和重新分配内存(realloc函数),重新分配内存是可能会移动数据的,因为当扩大地址时不能保证原地址的后续连续空间是空闲的则有可能会在进程的空闲页中分配新地址然后再copy数据过去,最后返回新地址。如此一来最坏情况下需要复制两次内存数据。所以作者设定的长度128和元素大小64字节作为ziplist的阈值,超过就要转换为skiplist。不过是凭经验值设置的?

如果zset数据元素过多,或数据元素大小过大就会转化为dict+skiplist实现。

redis源码 resp协议 redis zset源码_redis源码 resp协议

skiplist数据结构实现

先看看skiplist的数据结构,redis中header头结点的level数组有64级,因为header要保证指向任意层级的新节点。

redis源码 resp协议 redis zset源码_数据结构_02

其中redis实现跳表节点的结构体,这个结构体比较重要,可以看看层级数组是和表结点相关联的。即每个表结点都有自己的层级数组,数组每一级都有指向下一个节点的指针和到下个节点的距离。每个节点的level数组随着节点被创建,用节点的高度创建level数组长度。

typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    // 该节点的层级数组,生成节点时会随机一个节点的高度,level数组的长度也随即确定
    struct zskiplistLevel {
        // 某一级指向下个节点的指针
        struct zskiplistNode *forward;
        // 到下个节点的距离,隔1个节点算1个距离
        unsigned long span;
    } level[];
} zskiplistNode;

回到zsetAdd方法,如果zset是skiplist实现,它的添加流程源码如下。只要弄明白跳表的插入逻辑,我们就知道了跳表的查找一个元素逻辑。

因为在插入元素时要寻找新元素的插入位置,这个过程就是从跳表最高层级的最小节点开始,逐级向下和逐个向后找当前层级比新节点大的元素,如果当前层级的节点的next节点比新节点小,则x指针往后移动指向当前节点的next节点,继续找。

如果当前层级的节点的next节点比新节点大,找到新节点在第i层级的插入位置,则记录update[i]表示在第i层级当前节点要被更新next指针和span距离。然后在当前节点的level数组下降一层级继续找插入位置。

/* 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) {
    // 跳表节点,节点里有该节点的0~最高层的下一节点指针,最高有64层
    // update数组存每一级要更新节点,因为每一级都会插入一个新节点,所以要更新之前旧节点的next指针
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    // 每一级节点从header到update[i]节点的距离,隔了一个节点算1个距离
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;
    // 确保score合法
    serverAssert(!isnan(score));
    // 跳表的header节点是虚节点,不存任何ele和score,方便操作
    // x节点会一直往后移动
    x = zsl->header;
    // 从当前跳表最高级开始找新节点插入位置,整体趋势从高往低逐级降,从前往后逐个找
    // 依次记录每一级要更新的节点,这些节点是新节点的前继节点
    for (i = zsl->level-1; i >= 0; i--) {
        /* rank[i]初始值是rank[i+1]是因为x指针一直往后移动,从高层级累加下来的距离 */
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        // 如果第i级的x节点没有next或 next大于新节点,则当前x节点就是要插入的位置
        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)))
        {
            // 节点的span代表当前节点到下个节点的距离,rank[i]就是update[i]节点在第i级,从头结点到它的距离
            rank[i] += x->level[i].span;
            // x指针往后移动
            x = x->level[i].forward;
        }
        // 在第i级要插入的位置,x节点就是新节点的前继节点,记录下后面更新每一级要插入位置的前继节点的next指针
        update[i] = x;
    }
    // 走到这里肯定从高到低每一级的插入位置到找好了

    /* 生成新节点要插入的多高的层级,默认1级起,1/4概率升级,最高64级,升级概率可配置 */
    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现在指向新节点
    x = zslCreateNode(level, score, ele);
    // 从最低级到新节点的最高级,逐级插入新节点,每一级插入的位置就是插到update[i]节点后面
    for (i = 0; i < level; i++) {
        // 这里2行是链表节点插入
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;

        /* 新节点在第i级的span应该是update[i]节点在第i级的span距离,
         * 相当于只是新节点代替了此位置的update[i]节点,但是这个位置的节点到下个节点的距离是不变的 */
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        // update[i]节点到下个节点的距离就是原来的距离+1,因为它后面现在多了个新节点
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

    /* 当一个节点在某一级next指针不指向具体节点时,这一级的span是节点到尾结点的距离
     * 所以,当新节点的最高级比跳表现有的最高级低时,新节点之上到跳表最高级的那些层级的节点层级距离也要更新+1
     * */
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }
    // 更新下新节点next指针和back指针的指向,串联起来最底层的有序链表
    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    // 跳表长度增加了1
    zsl->length++;
    return x;
}