【Redis笔记-2】Redis内部数据结构

来源:互联网 发布:云计算一般薪资多少 编辑:程序博客网 时间:2024/05/19 16:07

Redis设计与实现 —— 内部数据结构


本文目录

    • 本文目录
    • 开篇明志
    • 简单动态字符串Simple Dynamic String
      • 0 重写一个SDS数据结构的目的
      • 1 SDS的定义
      • 2 free未使用空间的作用
          • 21 空间预分配
          • 22 惰性空间释放
    • 链表
      • 0 为什么要有双端链表
      • 1 链表和链表节点的实现
    • 字典
      • 0 为什么要有字典
      • 1 字典的实现
        • 10 哈希表
        • 11 哈希表节点
        • 12 字典
      • 2 解决键冲突
      • 3 rehash
      • 4 哈希表的扩展与收缩
    • 跳跃表
        • 0 跳跃表的实现
        • 1 跳跃表节点
        • 2层
        • 3 前进指针
        • 4 跨度
        • 5 后退指针
        • 6 分值和成员
        • 7 跳跃表
    • 整数集合
      • 1 整数集合的实现
    • 参考文献

开篇明志

Redis数据库是一种 Key-Value 数据库,而且 value 支持5种数据,本文将介绍redis内部几种数据结构。他们分别是:

  • 字符串(SDS)
  • 字典(map)
  • 列表(list)
  • 集合(set)
  • 有序集

1.简单动态字符串(Simple Dynamic String)

Redis 没有直接使用 C 语言传统的字符串表示(以空字符结尾的字符数组,以下简称 C 字符串), 而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型, 并将 SDS 用作 Redis 的默认字符串表示。

在 Redis 里面, C 字符串只会作为字符串字面量(string literal), 用在一些无须对字符串值进行修改的地方, 比如打印日志:

redisLog(REDIS_WARNING,"Redis is now ready to exit, bye bye...");

当 Redis 需要的不仅仅是一个字符串字面量, 而是一个可以被修改的字符串值时, Redis 就会使用 SDS 来表示字符串值: 比如在 Redis 的数据库里面, 包含字符串值的键值对在底层都是由 SDS 实现的。

举个例子, 如果客户端执行命令:

redis> SET msg "hello world"OK

那么 Redis 将在数据库中创建了一个新的键值对, 其中:

键值对的键是一个字符串对象, 对象的底层实现是一个保存着字符串 “msg” 的 SDS 。
键值对的值也是一个字符串对象, 对象的底层实现是一个保存着字符串 “hello world” 的 SDS 。
又比如说, 如果客户端执行命令:

redis> RPUSH fruits "apple" "banana" "cherry"(integer) 3

那么 Redis 将在数据库中创建一个新的键值对, 其中:

  • 键值对的【键】是一个字符串对象, 对象的底层实现是一个保存了字符串 “fruits” 的 SDS 。
  • 键值对的【值】是一个列表对象, 列表对象包含了三个字符串对象, 这三个字符串对象分别由三个 SDS 实现: 第一个 SDS 保存着字符串 “apple” , 第二个 SDS 保存着字符串 “banana” , 第三个 SDS 保存着字符串 “cherry” 。

这里写图片描述

  • Redis只会使用C字符串作为字面量,在大多数情况下,Redis使用SDS(Simple Dynamic String)作为字符串表示,而不是C字符串(以\0结尾的char*)
  • 对比C字符串,sds有以下特性
    • 可以高效执行长度计算O(1)
    • 减少修改字符串长度时所需的内存分配次数
    • 可以高效执行append操作(通过free提前分配)
    • 二进制安全
  • sds会为追加操作进行优化,加快追加操作的速度,并降低内存分配的次数,代价是多占用内存,且不会主动释放

    1.0 重写一个SDS数据结构的目的

    对于char[]来说,长度操作是O(N)的, 复杂度还是太高, append 和 trim 操作时候会遇到频繁分配内存的问题,同时C语言的字符串表示方式,并不能满足Redis对字符串在安全性,效率以及功能方面的需要。 基于以上原因和目的, Redis 重写了 一个结构体用来表示 SDS , 这个结构体叫 sdshdr

    1.1 SDS的定义

    每个sds.h/sdshdr【sds.h源码文件里面的名为sdshdr结构体】结构表示一个SDS值:

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

这里写图片描述

  • free属性的值为0, 表示这个SDS没有分配任何未使用空间。
  • len属性的值为5, 表示这个SDS保存了一个5字节长的字符串。
  • buf属性是一个char类型的数组,数组的前五个字节分别保存了 ‘R’, ‘e’, ‘d’, ‘i’, ‘s’ 五个字符,而最后一个字节则保存了空字符 ‘\0’

SDS遵循C字符串以空字符结尾的框里,且保存空字符 ‘\0’ 的一个字节空间不计算在SDS的len属性里面,并且为空字符串分配额外的1字节空间,以及添加字符到字符串末尾等操作,都是有SDS函数自动完成,遵循空字符串结尾这一管理的好处是,SDS可以直接重用一部分C字符串函数库里面的函数。

1.2 free未使用空间的作用

为了避免内存频繁分配的问题, SDS通过未使用空间解除了字符串长度和底层数组之前的关联:在SDS中, buf数字的长度不一定就是字符串数量加一, 数组里面可以包含未使用的字节, 而这些字节的数量就由SDS的free属性记录。

通过未使用空间,SDS实现了空间预分配和惰性空间释放两种策略。

1.2.1 空间预分配:

简单理解是SDS字符串增长, SDS空间扩展, 通过空间预分配策略, Redis可以减少连续执行字符串增长操作所需的内存分配次数。

1.2.2 惰性空间释放

用来优化SDS字符串缩短操作,程序并不立即使用内存重新分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。

换句话说,已经分配的内存不会主动去回收, 等待下次重用。 避免了缩短字符串时候内存重新分配, 为将来可能有的增长操作提供了优化, 即无需再次分配内存。

此外, SDS提供了相应API可以再需要时,真正释放SDS未使用的空间,不用担心该策略造成内存泄漏风险。

本部分写作时间: 11.05.2017


2 链表

链表提供了高效的节点重排能力, 以及顺序性的节点访问方式, 并且可以通过增删节点来灵活地调整链表的长度。

双端链表就是有2个指针,一个指向当前节点的前驱节点,一个指向当前节点的后继节点。

  • Redis自己实现了双端链表
  • 双端链表主要两个作用:
    • 作为redis列表类型的底层实现之一
    • 作为通用数据结构被其他模块使用
  • 双端链表及其节点的性能特征如下:
    • 节点带有前驱和后继指针
    • 链表是双向的,所以对表头和表尾操作都是O(1)
    • 链表节点可以会被维护,LLEN复杂度为O(1)

2.0 为什么要有双端链表?

在Java或者C++中,都有现成的容器供我们使用,但是C没有。于是作者自己造了一个双端链表数据结构,同时双端链表也是Redis最基础的数据结构之一。

2.1 链表和链表节点的实现

每个链表节点使用一个 adlist.h/listNode【源码文件adlist.h 下面的 名为listNode 的结构体】 结构来表示:

typedef struct listNode {    // 前置节点    struct listNode *prev;    // 后置节点    struct listNode *next;    // 节点的值    void *value;} listNode;

多个 listNode 可以通过 prev 和 next 指针组成双端链表, 如图 3-1 所示。
这里写图片描述

虽然仅仅使用多个 listNode 结构就可以组成链表, 但使用 adlist.h/list 来持有链表的话, 操作起来会更方便:

typedef struct list {    // 表头节点    listNode *head;    // 表尾节点    listNode *tail;    // 链表所包含的节点数量    unsigned long len;    // 节点值复制函数    void *(*dup)(void *ptr);    // 节点值释放函数    void (*free)(void *ptr);    // 节点值对比函数    int (*match)(void *ptr, void *key);} list;

每个链表使用一个 list 结构来表示, 这个结构带有表头节点指针 head、表尾节点指针 tail、以及链表长度 len 等信息。而 dup 、 free 和 match 成员则是用于实现多态链表所需的类型特定函数:

  • dup 复制 链表节点 所保存的值;
  • free 释放 链表节点 所保存的值;
  • match 对比 链表节点 所保存的值 和 另一个输入值是否相等。

图 3-2 是由一个 list 结构和三个 listNode 结构组成的链表:
这里写图片描述

Redis 的链表实现的特性可以总结如下:

  • 双端: 链表节点带有 prev 和 next 指针, 获取某个节点的前置节点和后置节点的复杂度都是 O(1) 。

  • 无环:因为链表表头节点的前置节点和表尾节点的后置节点都指向 NULL , 所以 Redis 的链表实现是无环链表。

  • 带表头指针和表尾指针: 通过 list 结构的 head 指针和 tail 指针, 程序获取链表的表头节点和表尾节点的复杂度为 O(1) 。

  • 带链表长度计数器: 程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数, 程序获取链表中节点数量的复杂度为 O(1) 。

  • 多态: 链表节点使用 void* 指针来保存节点值, 并且可以通过 list 结构的 dup 、 free 、 match 三个属性为节点值设置类型特定函数, 所以链表可以用于保存各种不同类型的值。

    • 通过为链表设置不同的类型特定函数, Redis 的链表可以用于保存各种不同类型的值。

本部分写作时间: 12.05.2017


3 字典

字典, 是一种用于保存键值对(key-value pair)的抽象数据结构。或者说是Map吧。

3.0 为什么要有字典

字典经常作为一种数据结构内置在很多高级编程语言里面, 但 Redis 所使用的 C 语言并没有内置这种数据结构, 因此 Redis 构建了自己的字典实现。

字典在 Redis 中的应用相当广泛, 比如 Redis 的数据库就是使用字典来作为底层实现的, 对数据库的增、删、查、改操作也是构建在对字典的操作之上的。

举个例子, 当我们执行命令:

redis> SET msg "hello world"OK

在数据库中创建一个键为 “msg” , 值为 “hello world” 的键值对时, 这个键值对就是保存在代表数据库的字典里面的。

3.1 字典的实现

Redis 的字典使用哈希表作为底层实现, 一个哈希表里面可以有多个哈希表节点, 而每个哈希表节点就保存了字典中的一个键值对。

接下来的三个小节将分别介绍 Redis 的哈希表、哈希表节点、以及字典的实现。

3.1.0 哈希表

Redis 字典所使用的哈希表由 dict.h/dictht 结构定义:

typedef struct dictht {    // 哈希表数组    dictEntry **table;    // 哈希表大小    unsigned long size;    // 哈希表大小掩码,用于计算索引值    // 总是等于 size - 1    unsigned long sizemask;    // 该哈希表已有节点的数量    unsigned long used;} dictht;

table 属性是一个数组, 数组中的每个元素都是一个指向 dict.h/dictEntry 结构的指针, 每个 dictEntry 结构保存着一个键值对。

size 属性记录了哈希表的大小, 也即是 table 数组的大小, 而 used 属性则记录了哈希表目前已有节点(键值对)的数量。

sizemask 属性的值总是等于 size - 1 , 这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面。

图 4-1 展示了一个大小为 4 的空哈希表 (没有包含任何键值对)。

这里写图片描述

3.1.1 哈希表节点

哈希表节点使用 dictEntry 结构表示, 每个 dictEntry 结构都保存着一个键值对:

typedef struct dictEntry {    // 键    void *key;    // 值    union {        void *val;        uint64_t u64;        int64_t s64;    } v;    // 指向下个哈希表节点,形成链表    struct dictEntry *next;} dictEntry;

key 属性保存着键值对中的键, 而 v 属性则保存着键值对中的值, 其中键值对的值可以是一个指针, 或者是一个 uint64_t 整数, 又或者是一个 int64_t 整数。

next 属性是指向另一个哈希表节点的指针, 这个指针可以将多个哈希值相同的键值对连接在一次, 以此来解决键冲突(collision)的问题。

举个例子, 图 4-2 就展示了如何通过 next 指针, 将两个索引值相同的键 k1 和 k0 连接在一起。
这里写图片描述

3.1.2 字典

Redis 中的字典由 dict.h/dict 结构表示:

typedef struct dict {    // 类型特定函数    dictType *type;    // 私有数据    void *privdata;    // 哈希表    dictht ht[2];    // rehash 索引    // 当 rehash 不在进行时,值为 -1    int rehashidx; /* rehashing not in progress if rehashidx == -1 */} dict;

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

  • type 属性是一个指向 dictType 结构的指针, 每个 dictType 结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数。
  • 而 privdata 属性则保存了需要传给那些类型特定函数的可选参数。
typedef struct dictType {    // 计算哈希值的函数    unsigned int (*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;

ht 属性是一个包含两个项的数组, 数组中的每个项都是一个 dictht 哈希表, 一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。

除了 ht[1] 之外, 另一个和 rehash 有关的属性就是 rehashidx : 它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1 。

图 4-3 展示了一个普通状态下(没有进行 rehash)的字典:

这里写图片描述

3.2 解决键冲突

理解这一块的内容直接按照HashMap 的 哈希表解决冲突的一样。

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时, 我们称这些键发生了冲突(collision)。

Redis 的哈希表使用链地址法(separate chaining)来解决键冲突: 每个哈希表节点都有一个 next 指针, 多个哈希表节点可以用 next 指针构成一个单向链表, 被分配到同一个索引上的多个节点可以用这个单向链表连接起来, 这就解决了键冲突的问题。

举个例子, 假设程序要将键值对 k2 和 v2 添加到图 4-6 所示的哈希表里面, 并且计算得出 k2 的索引值为 2 , 那么键 k1 和 k2 将产生冲突, 而解决冲突的办法就是使用 next 指针将键 k2 和 k1 所在的节点连接起来, 如图 4-7 所示。

这里写图片描述

这里写图片描述

因为 dictEntry 节点组成的链表没有指向链表表尾的指针, 所以为了速度考虑, 程序总是将新节点添加到链表的表头位置(复杂度为 O(1)), 排在其他已有节点的前面,这一点和 HashMap 中相关内容的原理也是一致的。新节点总放到链表的最前面,这样可以避免遍历整个单向链表。

3.3 rehash

rehash的触发条件:
当新插入一个键值对的时候,根据used/size得到一个比例,如果这个比例超过阈值,就自动触发rehash过程。rehash分为两种:

  • 自然rehash:满足used/size >= 1 && dict_can_resize条件触发的
  • 强制rehash:满足used/size >= dict_force_resize_ratio(默认为5)条件触发的。

思考一下,为什么需要两种rehash呢?

答案还是为了性能,不过这点考虑的是redis服务的整体性能。

当redis使用后台子进程对字典进行rehash的时候,为了最大化利用系统的copy on write机制,子进程会暂时将自然rehash关闭,这就是dict_can_resize的作用。

当持久化任务完成后,将dict_can_resize设为true,就可以继续进行自然rehash;

但是考虑另外一种情况,当现有字典的碰撞率太高了,size是指针数组的大小,used是hash表节点数量,那么就必须马上进行rehash防止再插入的值继续碰撞,这将浪费很长时间。所以超过dict_force_resize_ratio后,无论在进行什么操作,都必须进行rehash。


rehash过程很简单,分为3步:

  1. 给ht[1]分配至少2倍于ht[0]的空间
  2. 将ht[0]数据迁移到ht[1]
  3. 清空ht[0], 将ht[0]指针指向ht[1],ht[1]指针指向ht[0] 【把ht[0] 和 ht[1] 换一下 地址】

举个例子:
举个例子, 假设程序要对图 4-8 所示字典的 ht[0] 进行扩展操作, 那么程序将执行以下步骤:

  • ht[0].used 当前的值为 4 , 4 * 2 = 8 , 而 8 (2^3)恰好是第一个大于等于 4 的 2 的 n 次方, 所以程序会将 ht[1] 哈希表的大小设置为 8 。 图 4-9 展示了 ht[1] 在分配空间之后, 字典的样子。

  • 将 ht[0] 包含的四个键值对都 rehash 到 ht[1] , 如图 4-10 所示。
    释放 ht[0] ,并将 ht[1] 设置为 ht[0] ,然后为 ht[1] 分配一个空白哈希表,如图 4-11 所示。

  • 至此, 对哈希表的扩展操作执行完毕, 程序成功将哈希表的大小从原来的 4 改为了现在的 8 。

这里写图片描述

这里写图片描述

这里写图片描述


3.4 哈希表的扩展与收缩

负载因子 = 哈希表已保存节点数量 / 哈希表大小load_factor = ht[0].used / ht[0].size

redis为了减少存储空间,rehash还有一个特性是缩减空间,当多次进行删除操作后,如果used/size的比例小于一个阈值(现在是10%),那么就会触发缩减空间rehash,过程和增加空间类似,不详述了。

Notice:
当哈希表的负载因子小于 0.1 时, 程序自动开始对哈希表执行收缩操作。


本部分写作时间: 13.05.2017


4 跳跃表

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

跳跃表支持平均 O(\log N) 最坏 O(N) 复杂度的节点查找, 还可以通过顺序性操作来批量处理节点。

在大部分情况下, 跳跃表的效率可以和平衡树相媲美, 并且因为跳跃表的实现比平衡树要来得更为简单, 所以有不少程序都使用跳跃表来代替平衡树。

Redis 使用跳跃表作为有序集合键的底层实现之一:
如果一个有序集合包含的元素数量比较多, 又或者有序集合中元素的成员(member)是比较长的字符串时, Redis 就会使用跳跃表来作为有序集合键的底层实现。

4.0 跳跃表的实现

链表 + 二分查找的结合体

  • 跳跃表是一种随机化数据结构(它的层是随机产生的),查找、添加、删除操作都是O(logN)级别的。
  • 跳跃表目前在redis的唯一用处就是有序集类型的底层数据结构之一(另外一个还是字典)
  • 当然,根据redis的特性,作者对跳跃表进行了修改
    • socre可以重复
    • 对比一个元素需要同时检查它的score和member
    • 每个节点带有高度为1的后退指针,用于从表尾方向向表头方向迭代

Redis 的跳跃表由 redis.h/zskiplistNode 和 redis.h/zskiplist 两个结构定义, 其中 zskiplistNode 结构用于表示跳跃表节点, 而 zskiplist 结构则用于保存跳跃表节点的相关信息, 比如 节点的数量, 以及指向 表头节点表尾节点的指针, 等等。

这里写图片描述

图 5-1 展示了一个跳跃表示例, 位于图片最左边的是 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 是节点所保存的成员对象。

注意表头节点和其他节点的构造是一样的: 表头节点也有后退指针、分值和成员对象, 不过表头节点的这些属性都不会被用到, 所以图中省略了这些部分, 只显示了表头节点的各个层。

4.1 跳跃表节点

跳跃表节点的实现由 redis.h/zskiplistNode 结构定义:

typedef struct zskiplistNode {    // 后退指针    struct zskiplistNode *backward;    // 分值    double score;    // 成员对象    robj *obj;    // 层    struct zskiplistLevel {        // 前进指针        struct zskiplistNode *forward;        // 跨度        unsigned int span;    } level[];} zskiplistNode;

4.2层

跳跃表节点的 level 数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度, 一般来说, 层的数量越多, 访问其他节点的速度就越快。

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

图 5-2 分别展示了三个高度为 1 层、 3 层和 5 层的节点, 因为 C 语言的数组索引总是从 0 开始的, 所以节点的第一层是 level[0] , 而第二层是 level[1] , 以此类推。
这里写图片描述

4.3 前进指针

每个层都有一个指向表尾方向的前进指针(level[i].forward 属性), 用于从表头向表尾方向访问节点。

图 5-3 用虚线表示出了程序从表头向表尾方向, 遍历跳跃表中所有节点的路径:

  1. 迭代程序首先访问跳跃表的第一个节点(表头), 然后从第四层的前进指针移动到表中的第二个节点。
  2. 在第二个节点时, 程序沿着第二层的前进指针移动到表中的第三个节点。
  3. 在第三个节点时, 程序同样沿着第二层的前进指针移动到表中的第四个节点。
  4. 当程序再次沿着第四个节点的前进指针移动时, 它碰到一个 NULL , 程序知道这时已经到达了跳跃表的表尾, 于是结束这次遍历。

这里写图片描述

4.4 跨度

层的跨度(level[i].span 属性)用于记录两个节点之间的距离:

  • 两个节点之间的跨度越大, 它们相距得就越远。
  • 指向 NULL 的所有前进指针的跨度都为 0 , 因为它们没有连向任何节点。

初看上去, 很容易以为跨度和遍历操作有关, 但实际上并不是这样 —— 遍历操作只使用前进指针就可以完成了, 跨度实际上是用来计算排位(rank)的: 在查找某个节点的过程中, 将沿途访问过的所有层的跨度累计起来, 得到的结果就是目标节点在跳跃表中的排位。

举个例子, 图 5-4 用虚线标记了在跳跃表中查找分值为 3.0 、 成员对象为 o3 的节点时, 沿途经历的层: 查找的过程只经过了一个层, 并且层的跨度为 3 , 所以目标节点在跳跃表中的排位为 3 。

这里写图片描述

再举个例子, 图 5-5 用虚线标记了在跳跃表中查找分值为 2.0 、 成员对象为 o2 的节点时, 沿途经历的层: 在查找节点的过程中, 程序经过了两个跨度为 1 的节点, 因此可以计算出, 目标节点在跳跃表中的排位为 2 。

这里写图片描述

4.5 后退指针

节点的后退指针(backward 属性)用于从表尾向表头方向访问节点: 跟可以一次跳过多个节点的前进指针不同, 因为每个节点只有一个后退指针, 所以每次只能后退至前一个节点。

图 5-6 用虚线展示了如果从表尾向表头遍历跳跃表中的所有节点: 程序首先通过跳跃表的 tail 指针访问表尾节点, 然后通过后退指针访问倒数第二个节点, 之后再沿着后退指针访问倒数第三个节点, 再之后遇到指向 NULL 的后退指针, 于是访问结束。

这里写图片描述

4.6 分值和成员

节点的分值(score 属性)是一个 double 类型的浮点数, 跳跃表中的所有节点都按分值从小到大来排序。

节点的成员对象(obj 属性)是一个指针, 它指向一个字符串对象, 而字符串对象则保存着一个 SDS 值。

在同一个跳跃表中, 各个节点保存的成员对象必须是唯一的, 但是多个节点保存的分值却可以是相同的: 分值相同的节点将按照成员对象在字典序中的大小来进行排序, 成员对象较小的节点会排在前面(靠近表头的方向), 而成员对象较大的节点则会排在后面(靠近表尾的方向)。

举个例子, 在图 5-7 所示的跳跃表中, 三个跳跃表节点都保存了相同的分值 10086.0 , 但保存成员对象 o1 的节点却排在保存成员对象 o2 和 o3 的节点之前, 而保存成员对象 o2 的节点又排在保存成员对象 o3 的节点之前, 由此可见, o1 、 o2 、 o3 三个成员对象在字典中的排序为 o1 <= o2 <= o3 。

这里写图片描述


4.7 跳跃表

虽然仅靠多个跳跃表节点就可以组成一个跳跃表, 如图 5-8 所示。
这里写图片描述

但通过使用一个 zskiplist 结构来持有这些节点, 程序可以更方便地对整个跳跃表进行处理, 比如快速访问跳跃表的表头节点和表尾节点, 又或者快速地获取跳跃表节点的数量(也即是跳跃表的长度)等信息, 如图 5-9 所示。

这里写图片描述

zskiplist 结构的定义如下:

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

header 和 tail 指针分别指向跳跃表的表头和表尾节点, 通过这两个指针, 程序定位表头节点和表尾节点的复杂度为 O(1) 。

通过使用 length 属性来记录节点的数量, 程序可以在 O(1) 复杂度内返回跳跃表的长度。

level 属性则用于在 O(1) 复杂度内获取跳跃表中层高最大的那个节点的层数量, 注意表头节点的层高并不计算在内。

本部分写作时间: 14.05.2017


5 整数集合

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

举个例子, 如果我们创建一个只包含五个元素的集合键, 并且集合中的所有元素都是整数值, 那么这个集合键的底层实现就会是整数集合:

redis> SADD numbers 1 3 5 7 9(integer) 5redis> OBJECT ENCODING numbers"intset"
  • 整数集合是集合键的底层实现之一。
  • 整数集合的底层实现为数组, 这个数组以有序、无重复的方式保存集合元素, 在有需要时, 程序会根据新添加元素的类型, 改变这个数组的类型。
  • 升级操作为整数集合带来了操作上的灵活性, 并且尽可能地节约了内存。
  • 整数集合只支持升级操作, 不支持降级操作。

5.1 整数集合的实现

整数集合(intset)是 Redis 用于保存整数值的集合抽象数据结构, 它可以保存类型为 int16_t 、 int32_t 或者 int64_t 的整数值, 并且保证集合中不会出现重复元素。

每个 intset.h/intset 结构表示一个整数集合:

typedef struct intset {    // 编码方式    uint32_t encoding;    // 集合包含的元素数量    uint32_t length;    // 保存元素的数组    int8_t contents[];} intset;

contents 数组是整数集合的底层实现: 整数集合的每个元素都是 contents 数组的一个数组项(item), 各个项在数组中按值的大小从小到大有序地排列, 并且数组中不包含任何重复项。

length 属性记录了整数集合包含的元素数量, 也即是 contents 数组的长度。

虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组, 但实际上 contents 数组并不保存任何 int8_t 类型的值 —— contents 数组的真正类型取决于 encoding 属性的值:

  • 如果 encoding 属性的值为 INTSET_ENC_INT16 , 那么 contents 就是一个 int16_t 类型的数组, 数组里的每个项都是一个 int16_t 类型的整数值 (最小值为 -32,768 ,最大值为 32,767 )。

  • 如果 encoding 属性的值为 INTSET_ENC_INT32 , 那么 contents 就是一个 int32_t 类型的数组, 数组里的每个项都是一个 int32_t 类型的整数值 (最小值为 -2,147,483,648 ,最大值为 2,147,483,647 )。

  • 如果 encoding 属性的值为 INTSET_ENC_INT64 , 那么 contents 就是一个 int64_t 类型的数组, 数组里的每个项都是一个 int64_t 类型的整数值 (最小值为 -9,223,372,036,854,775,808 ,最大值为 9,223,372,036,854,775,807 )。

未完待续。。。


参考文献

http://www.yiibai.com/redis/
http://github.thinkingbar.com/redisbook_chapter01/
http://github.thinkingbar.com/redisbook_chapter02/
《Redis 设计与实现》
http://redisbook.com/index.html

1 0