本文介绍 Redis 中的几种主要的数据结构,从源码上了解每种数据结构的实现,设计方式。部分内容来源 《Redis设计与实现(第二版)》一书。#不仅仅是搬运#

redis 主要的数据结构包含以下几种:

image.png

简单动态字符串

字符串在 redis 底层定义的是一个 sds 的数据结构,并非普通的 C 字符串,sds 全称 Simple Dynamic String,即简单动态字符串。 结构如下所示:

// 简单字符串数据结构定义 sds.h
struct sdshdr {
    int len;    // buf中已经使用的数据长度,查询字符串长度时,可达到最优的时间复杂度
    int free;    // buf中剩余的可用空间
    char buf[];    // 存储的具体数据信息
};

结构属性图示如下:

image.png

从数据结构上看,除了包含字符数组 buf[],还存在两个属性 len 和 free,而设计这两个属性,也充分发挥出了 redis 底层的简单字符串相对于 C 字符串在使用上的优势。

那么 sds 相对于 C 字符串有哪些优势呢?

1. 从 sdshdr 的结构可以看到,属性 len 记录的是当前存储的字符串实际的长度,当需要获取字符串长度时,可以直接读取 len 的值即可,由于在更改 buf 数据时,会同步更新 len 值,因此,读取字符串长度时可以保持 O(1) 的时间复杂度,而不受存储数据长度的影响,而普通 C 字符串需要遍历整个字符串,时间复杂度为 O(N)。

2. 当定义一个单独的字节数组时,对字节数组的拼接操作可能会导致数据溢出的问题,数组长度是初始化时定义好的,如果要拼接另外一个字符串到数组当中,需要先主动检查数据长度,检查长度的时间复杂度是O(N),如直接进行拼接,则不能保证数组长度足够长,造成数据溢出。sds 的字符串拼接,可以根据 free 值检查长度,并且自定义的拼接函数可以在数组长度不够时主动进行扩容,完全杜绝数据溢出问题,这是普通 C 字符串不具备的。

3. 每次修改字符串时,普通 C 字符串都将需要执行一次内存重分配,sds 在更新字符串时,初始化时会预先分配一定长度的空余空间(free值),在字符串长度扩大时,会选检查 free 值是否足够,如足够时,不需要进行内存重分配,缩短字符串时,free 值会更大,不会即刻进行回收,等待 redis 内部进行惰性回收或需要时主动调用 API 回收。

4. C 字符串不能保存空字符,会被当成字符串结束符,sds 根据 len 值来判断是否到了字符串末尾,因此可以存储二进制数据。

5. sds 仍然可以使用 C 字符串 <string.h> 库中的函数

sds 在预分配内存的策略
首先我们从 redis 源码上看如何新建一个 sds

// sds.c
sds sdsnew(const char *init) {
    size_t initlen = (init == NULL) ? 0 : strlen(init);
    return sdsnewlen(init, initlen);
}

sds sdsnewlen(const void *init, size_t initlen) {
    struct sdshdr *sh;
    if (init) {
        sh = zmalloc(sizeof(struct sdshdr)+initlen+1);
    } else {
        sh = zcalloc(sizeof(struct sdshdr)+initlen+1);
    }
    if (sh == NULL) return NULL;
    sh->len = initlen;
    sh->free = 0; // 新建的 sds 不预留任何空间
    if (initlen && init)
        memcpy(sh->buf, init, initlen);
    sh->buf[initlen] = '\0';
    return (char*)sh->buf;
}

可以看出当新增一个 sds 时,不会分配预留空间,可以想象到一下,如果业务当中存储的值设置值后不会做任何变化,那么预留空余空间就造成了浪费,而当真正需要修改的数据,一般情况下修改的次数不会只有 1 次,接下来看当需要对数据进行长度扩充时的源码:

sds sdsMakeRoomFor(sds s, size_t addlen) {
    struct sdshdr *sh, *newsh;
    size_t free = sdsavail(s);
    size_t len, newlen;

    if (free >= addlen) return s;    // 空余空间足够,直接返回

    len = sdslen(s);
    sh = (void*) (s-(sizeof(struct sdshdr)));

    newlen = (len+addlen);    // 最少需要的长度

    // 根据新长度,分配新空间大小
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;

    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);
    if (newsh == NULL) return NULL;
    newsh->free = newlen - len;
    return newsh->buf;
}

SDS_MAX_PREALLOC 为 sds.h 中定义的长度,长度为 1M

#define SDS_MAX_PREALLOC (1024*1024)

因此我们可以看到,当字符串实际上需要扩容时,预分配内存策略如下:

1. 当存储字符串的长度小于 1M,额外分配 1倍 的空余空间

2. 当存储字符串的长度大于 1M,额外分配 1M 的空余空间

链表

redis 中定义的列表为链表实现,并且为双向链表,链表结构源码如下:

// 链表定义    adlist.h
// 链表节点
typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;
// 链表结构
typedef struct list {
    listNode *head;
    listNode *tail;
    void *(*dup)(void *ptr);    // 复制节点值
    void (*free)(void *ptr); // 释放节点值
    int (*match)(void *ptr, void *key);    // 匹配节点值
    unsigned long len;    // 链表长度
} list;

结构定义图示如下:
image.png

从 list 的结构定义来看,与常规的双向链表并没有什么特别之处,因此不再对链表的数据结构做多余的分析。

除了列表的底层实现为链表,此外,如发布与订阅、慢查询、监视器等底层实现也是链表。

字典

字典在 redis 当中,应用非常广泛,redis 数据库是使用字典来作为底层实现的,同时字典也是哈希键的底层实现之一,当哈希键包含的键值对较多,或者键值对中的元素是比较长的字符串,redis 就会使用字典作为哈希键的底层实现。

首先看一下哈希表节点的数据定义:

// dict.h
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    struct dictEntry *next;
} dictEntry;

从结构定义可以看到,每个哈希表节点,包含一个 key-value 值之外,还包含一个 *next 指针,用作哈希表之间形成链表,后续会看到通过形成链表可以处理键冲突的问题。

接下来看下哈希表与字典的结构定义:

// dict.h
/* 
 * 哈希表
 * This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table.
 */
typedef struct dictht {
    dictEntry **table;    // 哈希表数组
    unsigned long size;    // 哈希表大小
    unsigned long sizemask;    // 哈希表大小掩码,总是等于 size-1
    unsigned long used;    // 定义哈希表已有节点数量
} dictht;

/*
 * 字典
 */
typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */
    int iterators; /* number of iterators currently running */
} dict;

通常状态下字典的结构定义图示如下:
image.png

我们可以看到 dict 结构下包含两个 dictht,从源码解释来看,两个 dictht 用作 dict 进行 rehash 时使用,通常情况下,其中一个 ht[1] 是空的哈希表,当进行 rehash 时,会使用到另外一个哈希表,用作重新定义 k-v 值存储的新位置,而 rehashidx 值即为 rehash 的状态值,标记为 -1 时,代表不在 rehash 过程中,而当字典需要进行 rehash 时,该值会被修改为 rehashidx = 0。

何为渐进式 rehash?为什么需要渐进式 rehash 呢?

我们考虑一下,通常对一个比较小的字典进行 rehash 时,可以在非常短的时间内完成,但是如果字典非常大的时候,包含成千上万个键值对,那么计算量就会相对比较大,从而耗时也很长。由于 redis 是单线程模型,如果同步来完成 rehash,那么就会对后边进来的指令造成较为严重的阻塞,严重影响吞吐量。渐进式 rehash,即是一种异步或者说是惰性策略,开启 rehash 时,仅需要更改 rehash 的状态,并不需要等待 rehash 完毕,当有命令读写该字典时,需要读取 rehashidx 值,如果 rehash 正在进行,需要同时操作 ht[0] 和 ht[1],并将操作的键值对移到 ht[1],过程当中 ht[0] 的键值对越累越少,逐渐移动到 ht[1],直到 ht[0] 为空。该策略在 redis 中删除过期 key 同样使用了类似的惰性处理策略。

跳跃表

跳跃表是一种有序的数据结构,支持平均 O(logN)、最坏 O(N) 时间复杂度的查找效率,还可以通过顺序性操作来批量处理节点。redis 中有序集合的底层实现之一即是跳跃表实现的。

初看跳跃表的数据结构比较复杂,我们从源码来看跳跃表的数据结构及操作,首先直接看 redis 中跳跃表的数据结构定义。

/*
 * 跳跃表节点
 */
typedef struct zskiplistNode {
    robj *obj;    // 存储的成员对象
    double score;    // 分值
    struct zskiplistNode *backward;    // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前进指针
        unsigned int span;    // 跨度
    } level[]; // 层,可以有多个
} zskiplistNode;

/*
 * 跳跃表
 */
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;    // 表头、表尾指针
    unsigned long length;    // 节点数量
    int level;    // 表中最大的层数
} zskiplist;

从数据结构上看,每一个跳跃表节点 zskiplistNode 都包含一个后退指针,若干个层 level,每个 level 包含一个前进指针,结构定义图如下:
image.png

从图示 level = 5,length = 3 看出来,跳跃表的表头节点不算作一层,并且表头节点的 level 固定为 32,虽然表头节点也有 backward 指针、也有 obj、score 等属性,但该值并不会被使用到。

我们可以从 t_zset.c 查看创建一个 zskiplist 的源码:

/**
 * t_zset.c 创建一个 zskiplist
 */
zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;
    zsl = zmalloc(sizeof(*zsl));
    zsl->level = 1;    // 初始化时 level 为 1
    zsl->length = 0;
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);  // ZSKIPLIST_MAXLEVEL = 32
    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;
}
/**
 * 创建 zskiplistNode
 */
zskiplistNode *zslCreateNode(int level, double score, robj *obj) {
    zskiplistNode *zn = zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));
    zn->score = score;
    zn->obj = obj;
    return zn;
}

从上述源码可以看出来,新建 zskiplist 时,即对表头节点初始化,生成一个层级为 32,score = 0,obj = null 的 zskiplistNode。此时注意,跳跃表的 level = 1。

再来看一下新增节点:

zskiplistNode *zslInsert(zskiplist *zsl, double score, robj *obj) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    redisAssert(!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 &&
                compareStringObjects(x->level[i].forward->obj,obj) < 0))) {
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;
    }
    /* we assume the key is not already inside, since we allow duplicated
     * scores, and the re-insertion of score and redis object should never
     * happen since the caller of zslInsert() should test in the hash table
     * if the element is already inside or not. */
    level = zslRandomLevel();    // 随机生成一个 32 以内的 level 值,作为新节点的 level 数组长度
    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,obj);    // 创建新节点
    // 插入新节点,更新 forward 指针、及 span 值
    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;
    }

    // 更新未接触的节点,span加 1,从表头直接指向新插入节点
    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;
}

上面源码看起来比较长,但是还算比较简单,值得注意的是,每个节点的层的数量是随机生成的,并且是1至32之内的随机数。从源码及插入操作:

1. 根据分值 score,查找需要更新的节点

2. 查找头节点未使用的层,记录到待更新的节点

3. 创建新节点,插入到跳跃表

4. 更新跨度及指针

5. 更新跳跃表的节点数

既然跳跃表作为维护数据类型的有序性的数据结构,那么数据查找就是它的优势所在了,从前面的代码可以看出来,数据节点之间是根据分值及存储的字符串对象来进行排序的,并且值得注意的是,不同节点的 score 可以相等,但是字符串对象是唯一的,排序是会同时根据 score 及 stringObj 来指定排序位置。

接下来我们看一下跳跃表的查询,主要看 zskGetRank 和 zslGetElementByRank 的源码:

/* Find the rank for an element by both score and key.
 * Returns 0 when the element cannot be found, rank otherwise.
 * Note that the rank is 1-based due to the span of zsl->header to the
 * first element. */
unsigned long zslGetRank(zskiplist *zsl, double score, robj *o) {
    zskiplistNode *x;
    unsigned long rank = 0;
    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 &&
                compareStringObjects(x->level[i].forward->obj,o) <= 0))) {
            rank += x->level[i].span;
            x = x->level[i].forward;
        }

        /* x might be equal to zsl->header, so test if obj is non-NULL */
        if (x->obj && equalStringObjects(x->obj,o)) {
            return rank;
        }
    }
    return 0;
}

以上提供的是根据对象分值查询排名,从表头指针开始进行遍历,根据分值及对象匹配获取排名值,排名结果即从表头节点到目标节点的累计跨度值,最坏时间复杂度为O(N),源码比较简单,不继续做较多分析。

zskiplistNode* zslGetElementByRank(zskiplist *zsl, unsigned long rank) {
    zskiplistNode *x;
    unsigned long traversed = 0;
    int i;
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {
        while (x->level[i].forward && (traversed + x->level[i].span) <= rank)
        {
            traversed += x->level[i].span;
            x = x->level[i].forward;
        }
        if (traversed == rank) {
            return x;
        }
    }
    return NULL;
}

以上根据排名查找指定的对象值,遍历并累计跨度值,跨度值等于 rank 时,即为查找到的节点。

类似的,当进行范围查找时,只需要查找到其中一个节点,再根据前置指针、后置指针向前或向后遍历,即可查找到所有满足的节点。

整数集合

整数集合是集合键的底层实现之一,当集合元素都是整数,并且数量不多时,集合键的就会把整数集合作为底层实现。

整数集合的结构定义如下:

typedef struct intset {
    uint32_t encoding;    // 编码,即元素的数据类型
    uint32_t length;    // 元素个数
    int8_t contents[];    // 存储的元素数组
} intset;

encoding 包含三种类型:INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64,contents 存储的真实数据类型根据 encoding 来确定,三种数据类型的数据范围如下表:

类型 数据范围
INTSET_ENC_INT16 -32768 ~ 32767
INTSET_ENC_INT32 -2147483648 ~ 2147483647
INTSET_ENC_INT64 -9223372036854775808 ~ 9223372036854775807

C 语言中一个数组不能存储两种类型的值,一旦一个 INTSET_ENC_INT16 类型的整型数组中增加一个 INTSET_ENC_INT32 范围的数字,那么整个数组将会进行升级,encoding 变更到 INTSET_ENC_INT32, 并且使得 contents 数组中每个元素转换到 32 位空间存储,如下图所示,因为存在 32768,encoding 的类型为 INTSET_ENC_INT32,每一个元素占用 32 位空间。

image.png

需要注意的是,即便该数组删掉了最后的 32768,该整数集合的类型仍然保持为 INTSET_ENC_INT32,类型没有办法降级。

事实上,当我们使用整数集合时,操作整数集合的元素,内部会根据加入的元素来变更整数集合基础的数据类型,以满足存储需求,那么我们想象一下,理解了该原理,我们是不是可以在业务层面来实现存储优化呢?

假如有这样的场景,需要存储一批 ID,ID 范围从 0~50亿,也就是说如果使用整数集合存储的时候,数据类型占据 16位、32位、64位三种范围的数据值,如果需要存储所有的值时,数据类型必然会升级到 INTSET_ENC_INT64 类型,于是会有很多的 16位、32位数据占用了 64 位的空间,造成了大量的内存浪费。集合键只有在数据较少的情况下,使用整数集合作为底层实现,可以避免这种情况下的内存浪费。

此外,如果业务层面可以根据整数值的大小存储到不同的 key,是不是也可以避免内存浪费呢?比如 0 ~ 32767 范围的存储在 {key16},32768 ~ 2147483647 范围内的存储在 {key32},大于 2147483648 的存储在 {key64}。在很多业务场景下,如果我们留意到这点,是很容易做一些优化的。

压缩列表

压缩列表是列表键和哈希键的底层实现之一,当一个列表键只包含少量元素,或者每个元素的值要么是较小的整数值,要么是较短的字符串,那么 redis 就会用压缩列表作为列表键的底层实现。

下图为压缩列表的组成部分:

image.png

各个部分的详细说明:

组成 长度 含义
zlbytes 4字节 记录整个列表的字节数,对压缩列表进行内存重分配或者计算 zlend 的位置时使用
zltail 4字节 记录表尾节点距离起始地址有多少字节,通过该偏移量,可以确定表尾节点的地址
zllen 2字节 记录压缩列表的节点数,当值小于 UNIT16_MAX(65535)时,即表示压缩列表的节点数量,否则压缩列表需要遍历整个表才能获取真实的节点数
entryN 不定长度 压缩列表包含的数据节点,长度由内容决定
zlend 1字节 特殊值 0xFF(255),标记末端

示例说明如下图:
image.png

zlbytes 值为 0x50(十进制80),zltail 值为 0x3c(十进制60),zllen 值为 0x3(十进制3),表示该压缩列表总长为 80 字节,包含 3 个数据节点,压缩列表的指针 p,加上偏移量 60,即可算出来表尾节点 entry3 的地址。

接下来看看压缩列表 entry 的结构:

typedef struct zlentry {
    // prevrawlensize    编码 prevrawlen 所需的字节大小
    // prevrawlen    前置节点的长度
    unsigned int prevrawlensize, prevrawlen;
    // lensize 编码 len 所需的字节大小
    // len 当前节点值的长度
    unsigned int lensize, len;
    // 当前节点 header 的大小
    unsigned int headersize;
    // 当前节点值的编码类型
    unsigned char encoding;
    // 指向当前节点的指针
    unsigned char *p;
} zlentry;

根据 zlentry 的结构定义,可以看到如果需要从表尾向表头遍历,则可以先获取尾结点的地址,然后根据尾结点 prevrawlen 存储的前置节点的长度,获取前置节点的位置,从而达到从后往前遍历整个表的目的。同样,通过列表固定结构,可以计算出 entry1 的地址,同样根据 entry 的属性值 len 可以计算后一个节点的地址,一直遍历到末尾,从而达到从前往后遍历的目的。

根据节点查找相邻节点源码如下,实现较为简单:

* Return pointer to next entry in ziplist.
 *
 * zl is the pointer to the ziplist
 * p is the pointer to the current element
 *
 * The element after 'p' is returned, otherwise NULL if we are at the end. */
unsigned char *ziplistNext(unsigned char *zl, unsigned char *p) {
    ((void) zl);
    /* "p" could be equal to ZIP_END, caused by ziplistDelete,
     * and we should return NULL. Otherwise, we should return NULL
     * when the *next* element is ZIP_END (there is no next entry). */
    if (p[0] == ZIP_END) {
        return NULL;
    }
    p += zipRawEntryLength(p);
    if (p[0] == ZIP_END) {
        return NULL;
    }
    return p;
}

/* Return pointer to previous entry in ziplist. */
unsigned char *ziplistPrev(unsigned char *zl, unsigned char *p) {
    unsigned int prevlensize, prevlen = 0;
    /* Iterating backwards from ZIP_END should return the tail. When "p" is
     * equal to the first element of the list, we're already at the head,
     * and should return NULL. */
    if (p[0] == ZIP_END) {
        p = ZIPLIST_ENTRY_TAIL(zl);
        return (p[0] == ZIP_END) ? NULL : p;
    } else if (p == ZIPLIST_ENTRY_HEAD(zl)) {
        return NULL;
    } else {
        ZIP_DECODE_PREVLEN(p, prevlensize, prevlen);
        assert(prevlen > 0);
        return p-prevlen;
    }
}

由于压缩列表每个部分的内存都是连续的,如果需要在列表中插入节点时,则需要对该节点往后的节点均往后移,造成内存重分配操作,插入节点源码如下,代码较长,本身源码也包含了注释,根据注释来理解插入的处理也不会觉得太复杂。

/* Insert item at "p". */
static unsigned char *__ziplistInsert(unsigned char *zl, unsigned char *p, unsigned char *s, unsigned int slen) {
    size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), reqlen;    // 当前列表的长度
    unsigned int prevlensize, prevlen = 0;
    size_t offset;
    int nextdiff = 0;
    unsigned char encoding = 0;
    long long value = 123456789; /* initialized to avoid warning. Using a value
                                    that is easy to see if for some reason
                                    we use it uninitialized. */
    zlentry tail;

    /* Find out prevlen for the entry that is inserted. */
    if (p[0] != ZIP_END) {
        // 不是指向末端
        ZIP_DECODE_PREVLEN(p, prevlensize, prevlen);
    } else {
        // 指向末端
        unsigned char *ptail = ZIPLIST_ENTRY_TAIL(zl);
        if (ptail[0] != ZIP_END) {
            prevlen = zipRawEntryLength(ptail);
        }
    }

    /* See if the entry can be encoded */
    if (zipTryEncoding(s,slen,&value,&encoding)) {
        /* 'encoding' is set to the appropriate integer encoding */
        reqlen = zipIntSize(encoding);
    } else {
        /* 'encoding' is untouched, however zipEncodeLength will use the
         * string length to figure out how to encode it. */
        reqlen = slen;
    }
    /* We need space for both the length of the previous entry and
     * the length of the payload. */
    reqlen += zipPrevEncodeLength(NULL,prevlen);    // 计算编码前置节点的长度所需的大小
    reqlen += zipEncodeLength(NULL,encoding,slen);    // 计算编码当前节点所需的大小

    /* When the insert position is not equal to the tail, we need to
     * make sure that the next entry can hold this entry's length in
     * its prevlen field. */
    int forcelarge = 0;
    nextdiff = (p[0] != ZIP_END) ? zipPrevLenByteDiff(p,reqlen) : 0;
    if (nextdiff == -4 && reqlen < 4) {
        nextdiff = 0;
        forcelarge = 1;
    }

    /* Store offset because a realloc may change the address of zl. */
    offset = p-zl;
    zl = ziplistResize(zl,curlen+reqlen+nextdiff);
    p = zl+offset;

    /* Apply memory move when necessary and update tail offset. */
    if (p[0] != ZIP_END) {
        // 新加入节点不是尾结点,需要对后面的节点进行调整
        // 移动现有节点,为新节点的插入空间腾出位置
        memmove(p+reqlen,p-nextdiff,curlen-offset-1+nextdiff);

        /* Encode this entry's raw length in the next entry. */
        if (forcelarge)
            zipPrevEncodeLengthForceLarge(p+reqlen,reqlen);
        else
            zipPrevEncodeLength(p+reqlen,reqlen);

        // 更新 tail 的偏移量
        ZIPLIST_TAIL_OFFSET(zl) =
            intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+reqlen);

        /* When the tail contains more than one entry, we need to take
         * "nextdiff" in account as well. Otherwise, a change in the
         * size of prevlen doesn't have an effect on the *tail* offset. */
        tail = zipEntry(p+reqlen);
        if (p[reqlen+tail.headersize+tail.len] != ZIP_END) {
            ZIPLIST_TAIL_OFFSET(zl) =
                intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+nextdiff);
        }
    } else {
        /* This element will be the new tail. */
        ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(p-zl);
    }

    /* When nextdiff != 0, the raw length of the next entry has changed, so
     * we need to cascade the update throughout the ziplist */
    if (nextdiff != 0) {
        offset = p-zl;
        zl = __ziplistCascadeUpdate(zl,p+reqlen);
        p = zl+offset;
    }

    /* Write the entry */
    p += zipPrevEncodeLength(p,prevlen);
    p += zipEncodeLength(p,encoding,slen);
    if (ZIP_IS_STR(encoding)) {
        memcpy(p,s,slen);
    } else {
        zipSaveInteger(p,value,encoding);
    }
    // 更新计数
    ZIPLIST_INCR_LENGTH(zl,1);
    return zl;
}

从压缩列表的设计和源码上看,当使用压缩列表时,数据存储在一块连续的内存区域,并且每个数据节点的大小可以根据实际存储的内容进行调整数据类型,因此可以节约内存,减少内存碎片,但是由于压缩列表的新增、删除操作需要重分配内存,平均的时间复杂度为O(N),随着数据量的增加,添加、删除的性能会不断下降,包括查找的时间复杂度也是O(N),因此在节约内存的同时,想要保证一定的读写效率,压缩列表并不适合节点数非常多的场景,于是列表键中只有在元素数量较少时才会使用压缩列表作为底层实现。

以上全部内容介绍了 redis 中主要的数据结构,redis 使用这些数据结构创建了一个对象系统,包含字符串对象、列表对象、哈希对象、集合对象和有序结合对象,每种对象都是用其中一种或几种数据结构来作为底层实现。

后续再输出 redis 相关的其他文章。


CaptainXiao
36 声望3 粉丝

生如逆旅,一苇以航。