Redis数据结构

常见单位:

1字节(Byte) = 8位(bit) 

1KB( K,千字节) = 1024B  

1MB( M,兆字节) = 1024KB

1GB( G,吉字节,千兆) = 1024MB

1.字符串(SDS)

Redis没有采用C语言中的字符串的表示(以‘/0’结束的字符数组),构建了一种SDS(simple dynamic string)简单动态字符串作为Redis默认字符串表示。

SDS的定义如下:

struct sdshdr{
int len;  //记录buf数组中已使用的字节的数量,等于SDS所保存字符串的长度
int free;//记录buf数组中未使用的字节的数量
char buf[];//用于保存字符串 
}

其中,buf是存放字符串,字符串也是以空字符(\0)结尾,这点和c语言字符串类似。len表示buf中字符串占用的字节数,不包括末尾空字符,free表示空闲的字节数。整个buf大小等于len+free+1,1代表空字符

c语言中的字符串可以储存ASCII编码的字符,并且每一个字符串都是以空字符结尾。为什么Redis不直接使用C语言字符串存储字符串数据,而是要定义sdshdr来存储呢

1.1 C语言字符串和SDS的对比

1.1.1 SDS O(1)获取字符串长度

C 字符串并不记录自身的长度信息, 所以为了获取一个 C 字符串的长度, 程序必须遍历整个字符串,对遇到的每个字符进行计数, 直到遇到空字符为止,这个操作的复杂度为 O(N) 。 而SDS可以直接读取len成员来获取字符串长度,时间复杂度为O(1)。Redis中获取字符串长度的操作相当普遍,所以采用SDS可以有效提升效率。

1.1.2 杜绝缓冲区溢出

C 语言中常见的字符串操作函数如下所示:

//将src字符串拼到dest字符串末尾,默认要求dest空间足够大
char *strcat(char *dest, const char *src);
//将src字符串赋给dest字符串,默认要求dest空间足够大
char *strcpy(char *dest, const char *src);

这些函数在执行时,若dest分配的内存不足,就会发生缓冲区溢出,意外修改了其他数据。而使用SDS的API进行拼接、赋值等操作时,API 会先检查 SDS 的空间是否满足修改所需的要求 如果不满足的话, API 会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作, 所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出问题

1.1.3 减少修改字符串带来的内存重新分配次数

C 字符串的底层实现是一个 len+1 个字符长的数组(额外的一个字符空间用于保存空字符/0)。所以每次增长或者缩短一个 C 字符串, 程序都要对保存这个 C 字符串的数组进行一次内存重分配操作。如果是字符串长度增加,如上例中的strcat,程序首先要通过realloc分配足够大小内存,再执行strcat,忘记就会造成缓冲区溢出。如果是字符串长度变小,程序就要通过free来释放掉不用内存,忘记会产生内存泄漏。所以如果字符串长度发生N次变化,则要进行N次内存分配/释放操作。

SDS通过长度和buf数组避免了这个问题,通过空间预分配和惰性空间释放两种优化策略可以有效减少内存操作次数。空间预分配规则为:在需要对 SDS 进行空间扩展的时候,程序不仅会为 SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间。具体分配多大空间呢?具体如下

如果对 SDS 进行修改之后, SDS 的长度(也即是 len 属性的值)将小于 1 MB , 那么程序分配和 len 属性同样大小的未使用空间, 这时 SDS len 属性的值将和 free 属性的值相同。 举个例子, 如果进行修改之后, SDS 的 len 将变成 13 字节, 那么程序也会分配 13 字节的未使用空间, SDS 的 buf 数组的实际长度将变成 13 + 13 + 1 = 27 字节(额外的一字节用于保存空字符'/0')。

如果对 SDS 进行修改之后, SDS 的长度将大于等于 1 MB , 那么程序会分配 1 MB 的未使用空间。 举个栗子, 如果进行修改之后, SDS 的 len 将变成 30 MB , 那么程序会分配 1 MB 的未使用空间, SDS 的 buf 数组的实际长度将为 30 MB + 1 MB + 1 byte 。

分配的未使用空间可以通过free来记录,这样下次如果要增加字符串长度时,先看free大小能不能满足使用,如果满足可以直接使用,不满足再分配这样就可有效减少分配次数。

惰性空间释放用于优化 SDS 的字符串缩短操作:当 SDS 的 API 需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录起来, 并等待将来使用。与此同时, SDS 也提供了相应的 API , 让我们可以在有需要时, 真正地释放 SDS 里面的未使用空间, 所以不用担心惰性空间释放策略会造成内存浪费。

通过这个策略就能减少Redis连续执行增长字符串所带来的内存重新分配次数

自动扩容机制-sdsMakeRoomFor方法:

/

* s: 源字符串
 * addlen: 新增长度
 */
sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    // sdsavail: s->alloc - s->len, 获取 SDS 的剩余长度
    size_t avail = sdsavail(s);
    size_t len, newlen, reqlen;
    // 根据 flags 获取 SDS 的类型 oldtype
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;
    size_t usable;

    /* Return ASAP if there is enough space left. */
    // 剩余空间大于等于新增空间,无需扩容,直接返回源字符串
    if (avail >= addlen) return s;
    // 获取当前长度
    len = sdslen(s);
    // 
    sh = (char*)s-sdsHdrSize(oldtype);
    // 新长度
    reqlen = newlen = (len+addlen);
    // 断言新长度比原长度长,否则终止执行
    assert(newlen > len);   /* 防止数据溢出 */
    // SDS_MAX_PREALLOC = 1024*1024, 即1MB
    if (newlen < SDS_MAX_PREALLOC)
        // 新增后长度小于 1MB ,则按新长度的两倍扩容
        newlen *= 2;
    else
        // 新增后长度大于 1MB ,则按新长度加上 1MB 扩容
        newlen += SDS_MAX_PREALLOC;
    // 重新计算 SDS 的类型
    type = sdsReqType(newlen);

    /* Don't use type 5: the user is appending to the string and type 5 is
     * not able to remember empty space, so sdsMakeRoomFor() must be called
     * at every appending operation. */
    // 不使用 sdshdr5 
    if (type == SDS_TYPE_5) type = SDS_TYPE_8;
    // 获取新的 header 大小
    hdrlen = sdsHdrSize(type);
    assert(hdrlen + newlen + 1 > reqlen);  /* Catch size_t overflow */
    if (oldtype==type) {
        // 类型没变
        // 调用 s_realloc_usable 重新分配可用内存,返回新 SDS 的头部指针
        // usable 会被设置为当前分配的大小
        newsh = s_realloc_usable(sh, hdrlen+newlen+1, &usable);
        if (newsh == NULL) return NULL; // 分配失败直接返回NULL
        // 获取指向 buf 的指针
        s = (char*)newsh+hdrlen;
    } else {
        // 类型变化导致 header 的大小也变化,需要向前移动字符串,不能使用 realloc
        newsh = s_malloc_usable(hdrlen+newlen+1, &usable);
        if (newsh == NULL) return NULL;
        // 将原字符串copy至新空间中
        memcpy((char*)newsh+hdrlen, s, len+1);
        // 释放原字符串内存
        s_free(sh);
        s = (char*)newsh+hdrlen;
        // 更新 SDS 类型
        s[-1] = type;
        // 设置长度
        sdssetlen(s, len);
    }
    // 获取 buf 总长度(待定)
    usable = usable-hdrlen-1;
    if (usable > sdsTypeMaxSize(type))
        // 若可用空间大于当前类型支持的最大长度则截断
        usable = sdsTypeMaxSize(type);
    // 设置 buf 总长度
    sdssetalloc(s, usable);
    return s;
}

扩容阶段:
若 SDS 中剩余空闲空间 avail 大于新增内容的长度 addlen,则无需扩容;
若 SDS 中剩余空闲空间 avail 小于或等于新增内容的长度 addlen:
若新增后总长度 len+addlen < 1MB,则按新长度的两倍扩容;
若新增后总长度 len+addlen > 1MB,则按新长度加上 1MB 扩容。

内存分配阶段:
根据扩容后的长度选择对应的 SDS 类型:
若类型不变,则只需通过 s_realloc_usable扩大 buf 数组即可;
若类型变化,则需要为整个 SDS 重新分配内存,并将原来的 SDS 内容拷贝至新位置

惰性空间释放机制:

空间预分配策略用于优化 SDS 增长时频繁进行空间分配,而惰性空间释放机制则用于优化 SDS 字符串缩短时并不立即使用内存重分配来回收缩短后多出来的空间,而仅仅更新 SDS 的len属性,多出来的空间供将来使用

sds sdsRemoveFreeSpace(sds s) {
    void *sh, *newsh;
    // 获取类型
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    // 获取 header 大小
    int hdrlen, oldhdrlen = sdsHdrSize(oldtype);
    // 获取原字符串长度
    size_t len = sdslen(s);
    // 获取可用长度
    size_t avail = sdsavail(s);
    // 获取指向头部的指针
    sh = (char*)s-oldhdrlen;

    /* Return ASAP if there is no space left. */
    if (avail == 0) return s;

    // 查找适合这个字符串长度的最优 SDS 类型
    type = sdsReqType(len);
    hdrlen = sdsHdrSize(type);

    /* 如果类型相同,或者至少仍然需要一个足够大的类型,我们只需 realloc buf即可;
     * 否则,说明变化很大,则手动重新分配字符串以使用不同的头文件类型。
     */
    if (oldtype==type || type > SDS_TYPE_8) {
        newsh = s_realloc(sh, oldhdrlen+len+1);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+oldhdrlen;
    } else {
        newsh = s_malloc(hdrlen+len+1);
        if (newsh == NULL) return NULL;
        memcpy((char*)newsh+hdrlen, s, len+1);
        // 释放内存
        s_free(sh);
        s = (char*)newsh+hdrlen;
        s[-1] = type;
        sdssetlen(s, len);
    }
    // 重新设置总长度为len
    sdssetalloc(s, len);
    return s;
}

1.1.4 二进制安全

C语言在存储字符串的时候,只能保存文本数据(只支持ASCII字符,并且中间不能存在空格),当保存的字符串中有空格C语言就只能保存前面部分,SDS的所有API都会以二进制的方式来处理放在buf数组里面的数据,能够保存这种特殊的数据

1.1.5 SDS最长?

Redis 官方给出了最大的字符串容量为 512MB。这是为什么呢?

len是使用int修饰的,这就会导致 buf 最长就是2147483647,无形中限制了字符串的最大长度。(举一反三,也可以通过同样思路得到Java中String类型的大小)

2.链表

2.1链表

 C 语言是没有内置链表这种结构的,所以 Redis 使用了双向链表结构作为自己需要的实现。
众所周知,链表结构的好处在于不需要连续的的内存空间,以及在插入和删除的时间复杂度是 O(1) 级别的,效率较高,但比起数组它的缺点在于,查询效率上没有那么的高

一个节点由头指针 prev ,尾指针 next ,以及节点的值 value

/* * 双向链表节点 */
typedef struct listNode {    
    // 前置节点的指针   
    struct listNode *prev;    
    // 后置节点的指针
    struct listNode *next;    
    // 节点的值    
    void *value;
} listNode;

Redis 中一个具体的双向链表结构的样子

list 结构中 head 指向了双向链表的最开始的一个节点,

tail 指向了双向链表的最后一个节点,

len 代表了双向链表节点的数量,

dup 函数用于复制双向链表节点所保存的值,

free 函数用于释放双向链表节点所保存的值,

match 函数用于对比双向链表节点所保存的值和另外一个的输入值是否相等。

/* * 双向链表结构 */
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;

链表被用于发布订阅、慢查询、监视器等等。

每个链表的结点都是由一个lsitNode结构来表示,每个节点都有指向前置节点和指向后置节点的指针,可以看作为双向链表,且是无环的链表

3.字典(Dict)

3.1 字典定义

字典又称为符号表(symbol table)、关联数组(associative array)或映射(map),用于保存键值对的抽象数据结构

Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

 

ype属性和privdata属性是针对不同类型的键值对,为创造多态字典而设置的:

type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同类型的特定函数

而privdata属性则保存了需要传给那些类型特定函数的可选参数。

typedef struct dictType {
uint64_t (*hashFunction)(const void *key); //计算哈希值的函数
void *(*keyDup)(void *privdata, const void *key); //复制键的函数
void *(*valDup)(void *privdata, const void *obj); //复制值的函数
int (*keyCompare)(void *privdata, const void *key1, const void *key2); //对比键的函数
void (*keyDestructor)(void *privdata, void *key); //销毁键的函数
void (*valDestructor)(void *privdata, void *obj); //销毁值的函数
} dictType;

3.2 Dict添加元素和解决键冲突

添加元素:

1.计算key的哈希值 hash = dict ->type →hashFunction(key);

2.使用哈希表的sizemask属性和哈希值,计算出索引值

index = hash & dict→ht[x].sizemask; (根据情况不同ht[x]可以是ht[0]或者是ht[1])

键冲突:

和hashMap一致采用的链地址法来解决键冲突,使用一个单链表将值串起来

3.3 Dict的扩容

Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低

Dict在每次新增键值对时都会检查负载因子(LoadFactor = ht[0].used / ht[0].size) ,满足以下两种情况时会触发哈希表扩容:

哈希表的 LoadFactor >= 1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程;
哈希表的 LoadFactor > =5,并且服务器目前正在执行 BGSAVE 或者 BGREWRITEAOF 等后台进程 ;

根据BGSAVE命令和BGREWRITEAOF 命令是否正在执行,服务器执行扩展操作所需的负载因子不同呢?

这是因为在执行这些命令过程中需要创建当前线程的子进程,大多数都是采用的写时复制(copy-on-write)

来优化子进程的使用率,所以在子进程存在的期间,为了避免不必要的内存写入操作,会从而提高扩展所需的扩展因子,防止在子线程存在期间对哈希表进行扩容操作

static int _dictExpandIfNeeded(dict *d){
  // 如果正在rehash,则返回ok
  if (dictIsRehashing(d)) return DICT_OK;
   // 如果哈希表为空,则初始化哈希表为默认大小:4
  if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
  // 当负载因子(used/size)达到1以上,并且当前没有进行bgrewrite等子进程操作
  // 或者负载因子超过5,则进行 dictExpand ,也就是扩容
  if (d->ht[0].used >= d->ht[0].size &&
// dict_can_resize 为redis变量表示是否有进行的 BGSAVE 或者 BGREWRITEAOF 等后台进程 0 1 表示
    (dict_can_resize || d->ht[0].used/d->ht[0].size > dict_force_resize_ratio){
    // 扩容大小为used + 1,底层会对扩容大小做判断,实际上找的是第一个大于等于 used+1 的 2^n
    return dictExpand(d, d->ht[0].used + 1);
  }
  return DICT_OK;
}

3.4 Dict的缩容

Dict除了扩容以外,每次删除元素时,也会对负载因子做检查,当LoadFactor < 0.1 时,会做哈希表收缩

3.5 Dict的rehash

随着操作的进行,哈希表保存的键值对会逐渐的增多或者减少,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。

这个过程成为rehash(重新散列),主要的步骤如下:

1.为ht[1]的哈希表分配空间,空间的大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(ht[0].used属性值)

  • 如果是扩展操作,ht[1]的大小为第一个大于等于ht[0].used*2的2^n
  • 如果执行的是收缩操作,ht[1]的大小为第一个大于等于ht[0].used的2^n

2.将保存在ht[0]中的所有兼职对rehash到ht[1]上,重新计算hash和index

3.将键值对迁移到ht[1]完之后(ht[0]变成了空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]创建一个空白的哈希表,为下一次做准备

3.6 渐进式rehash

哈希表进行扩容的时候需要将ht[0]里面的所有键值对rehash到ht[1]里面,这个rehas动作并不是一次性、集中的完成,而是分多次、渐进式的完成

如果在进行渐进式rehash的过程中,进行字典的增删查改怎么操作?

首先会操作ht[0],然后操作ht[1],比如ht[0]没找到,会继续到ht[1]进行查找。如果是添加操作的话只会保存在ht[1]里面,不再操作ht[0],这样保证ht[0]的键值对数量只会减少不会增加

总结:

不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash.

  • 1.计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩:

    • 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n

    • 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n (不得小于4)

  • 2.按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]

  • 3.设置dict.rehashidx = 0,标示开始rehash

  • 4.每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx是否大于-1,如果是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1]

  • 5.将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存

  • 6.将rehashidx赋值为-1,代表rehash结束

  • 7.在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空

4.跳跃表(skiplist)

4.1 跳跃表的定义

跳跃表是一种有序的数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

Redis使用跳跃表作为有序集合健(zset)的底层实现之一,当一个有序集合包含的元素数量比较多,又或者有序集合中的元素的成员是比较长的字符串时,Redis会采用跳跃表作为有序集合健的底层实现

跳跃表的结构:

为什么要使用跳跃表?

为什么Redis不使用红黑树/ 平衡树 这样的树形结构?

首先,因为 zset 要支持随机的插入和删除,所以它 不宜使用数组来实现

性能考虑: 在某些情况下,树形结构需要执行一些类似于 rebalance 这样的可能涉及整棵树的操作,相对来说跳跃表的变化只涉及局部 ;

实现考虑: 在复杂度与红黑树相同的情况下,跳跃表实现起来更简单,看起来也更加直观;

基于以上的一些考虑,Redis 基于 William Pugh 的论文做出一些改进后采用了 跳跃表 这样的结构

如何来确定跳跃表上面的索引层数呢?

层数过多会有什么问题?

层数过少会有什么问题?

层数过多: 查找的效率可能提高,但是在进行插入,删除数据时候,需要维护的索引节点变多

层数过少:维护索引层次数降低,但是查找数据效率降低

新增加节点的时候,调用随机生成层数方法,随机生成一个当前跳跃表所需要的层数,如果生成的层数等于当前层数,新节点只需要加入跳跃表中即可,不需要额外的维护每一个层级的节点数

4.2 Redis的跳跃表实现

Redis 中的跳跃表由 server.h/zskiplistNode 和 server.h/zskiplist 两个结构定义,前者为跳跃表节点,后者则保存了跳跃节点的相关信息,同之前的 集合 list 结构类似,其实只有 zskiplistNode 就可以实现了,但是引入后者是为了更加方便的操作

/* 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[];//多级索引数组
} zskiplistNode;
 
typedef struct zskiplist {
    // 跳跃表头指针
    struct zskiplistNode *header, *tail;
    // 表中节点的数量
    unsigned long length;
    // 表中层数最大的节点的层数
    int level;
}

每次添加一个新的跳跃表节点时候,程序会根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之前的值作为level数组的大小,这个就是层高度

源码在 t_zset.c/zslRandomLevel(void) 中被定义:



#define ZSKIPLIST_MAXLEVEL 32 // 最大层级不超过32
#define ZSKIPLIST_P 0.25

// 随机生成层数
int zslRandomLevel(void) {
 int level = 1;
 // 如果生成的随机数的值小于ZSKIPLIST_P,层数就+1
 while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
 level += 1;
 // 是否超过了最大层数,超过就使用最大层数
 return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

0xFFFF = 65535

random()&0xFFFF运算之后会生成一个0和65535之间的数,ZSKIPLIST_P * 0xFFFF = 0.25 * 65535,所以random()&0xFFFF 小于 0.25 * 65535的概率为25%,也就是层数会增加1的概率不超过25%。

4.3跳跃表的添加

如上图所示,插入一个元素值为76。

1)随机算法运行 【 高度等于2 = ( 高度+1 )+ (高度+1 ) 】

2)查找插入的位置:通过随机算法获得,该元素最高层数是L2

3)插入对应的元素:则从高层往底层处增加该元素的节点

如果通过随机算法获得的层高,高于已有的层数,则添加新的层。如下图所示:

如上图所示,插入一个元素值为89。

1)随机算法运行的状况 【 高度等于4 = ( 高度+1) + (高度+1 ) + (高度+1)+ (高度+1) 】

2)通过随机算法获得,该元素最高层数是L4

3)则从高层往底层处增加该元素的节点

插入元素的步骤:

  1. 因为跳跃表有多层,所以需要遍历每一层,寻找每层要插入的位置,update[i]就记录了每一层要插入的位置
  2. 随机生成跳跃表的层数,如果层数有变化,则需要调整跳跃表的层高
  3. 创建节点,并将节点插入到跳跃表中
  4. 设置backward,新插入节点的前一个节点是update[0],如果update[0]为头结点,当前节点的前一个节点设为null,否则backward设置为update[0]
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;
    serverAssert(!isnan(score));
    //获取头结点
    x = zsl->header;
    /* 寻找每层要插入的位置,从高层开始向下遍历 */
    for (i = zsl->level-1; i >= 0; i--) {
        // rank[i]记录了当前层从header节点到update[i]节点所经历的步长
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        // 如果当前层级下一个节点不为空 并且 下一个节点的score小于要插入节点的分值 或者 下一个节点的score等于要插入节点的score并且对比两个节点存储的元素值之后小于0(字符串比较)
        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]的值
            rank[i] += x->level[i].span;
            // 获取下一个节点
            x = x->level[i].forward;
        }
        // 记录每层需要插入的位置
        update[i] = x;
    }
    // 随机生成跳跃表的层数
    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;
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

    /* 更新跨度 */
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }
    // 设置当前节点的前一个节点,如果update[0]为头结点,当前节点的前一个节点设为null,否则backward设置为update[0]
    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;
}

遍历记录update和rank值:

  • update数组用来记录新节点在每一层的上一个节点,也就是新节点要插到哪个节点后面;
  • rank数组用来记录update节点的排名,也就是在这一层,update节点到头节点的距离,是为了用来计算span。

生成对应的层数

  • 若层数大于最大层数(那么多出来的那些层也需要插入新的节点,而上面的那次遍历是从当前跳跃表最大层数开始的,也就是多出来这些层的update节点和rank还没有获取,因此需要通过下面这段程序,给多出来的这些层写入对应的rank和update节点。这部分很简单,因为这些层还没有节点,所以这些层的update节点只能是头节点,rank也都是0(头节点到头节点),而span则是节点个数(本身该层的头节点此时还没有forward节点,也不该有span,但插入节点后新节点需要用这个span计算新节点的span,因此这里需要把span设置为当前跳跃表中的节点个数)

插入节点:

前面已经找到插入位置(update)了,接下来的插入其实就是单链表插入,这个就不说了。

注意span的计算。

更新未涉及的层:

如果随机生成的层数小于之前跳跃表中的层数,那么大于随机生成的层数的那些层在创建新节点的过程中就没有被操作到(创建新节点的时候是从0遍历到随机生成的层数),对于这些没有操作到

的层,里面的update节点对应的span应当+1(因为后面插入了一个节点)。

设置后继指针

更新跳跃表节点个数

4.4 跳跃表的删除

5.整数集合(intset)

5.1 整数集合实现

整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis会使用集合作为集合键的底层实现

typedef struct intset{
   //编码方式,支持存放16位、32位、64位整数
  	uint32_t encoding;
	//元素个数
	uint32_t length;
	//整数数组,保存集合数据
	int8_t contents[];
}intset;


//其中的 encoding包含三种模式,表示存储的整数大小不同
/* Note that these encodings are ordered, so:
 * INTSET_ENC_INT16 <INTSET_ENC_INT32 < INTSET_ENC_INT64. */
#define INTSET_ENC_INT16 (sizeof(int16_t)) /* 2字节整数,范围类似java的short*/
#define INTSET_ENC_INT32 (sizeof(int32_t)) /* 4字节整数,范围类似java的int */
#define INTSET_ENC_INT64 (sizeof(int64_t)) /* 8字节整数,范围类似java的long */

int8_t contents[];不是表示存储的数据为int8类型,数组真正的类型取决于encoding的属性

为了方便查找,Redis会将intset中所有的整数按照升序依次保存在contents数组中,结构如图:(如何保证升序)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值