Redis 基础数据类型重温

有一天你突然收到一条线上告警:Redis 内存使用率 85%。你吓坏了赶紧先进行扩容然后再去分析 big key。等你进行完这一系列操作之后老板叫你去复盘,期间你们聊到了业务的数据存储在 Redis 中占用多大内存的问题。老板按照序列化为 String 的方式来评估每个 key 对应的 value 大概多少字节来计算总 key 数占用多大空间。你努力的回想了一下当年你面试的时候背诵的 ”真理“,总感觉哪里不太对。于是你在夜深人静的时候又打开了 ”Redis 面试宝典“。

Redis 核心对象:redisObject

为什么 Redis 不直接存储一个字符串进去非要自己封装一个对象呢?

Redis 为用户提供了多种类型的数据结构,对于用户而言你无需关心数据结构内部是如何存储,只需要一条命令行即可。在 Redis 内部要实现这些不同命令行对应的功能远远不止这么简单,用户越简单,底层越复杂。需要根据不同的命令去处理不同的类型的操作,那么这些类型信息就是在 redisObject 对象中封装。基于 redisObject 对象,底层可以进行类型检查,对象分配、销毁等操作。

redisObject 定义如下:

/*
 * Redis 对象
 */
typedef struct redisObject {

    // 类型
    unsigned type:4;

    // 编码方式
    unsigned encoding:4;

    // LRU - 24位, 记录最末一次访问时间(相对于lru_clock); 或者 LFU(最少使用的数据:8位频率,16位访问时间)
    unsigned lru:LRU_BITS; // LRU_BITS: 24

    // 引用计数
    int refcount;

    // 指向底层数据结构实例
    void *ptr;

} robj;

lru属性: 记录了对象最后一次被命令程序访问的时间

空转时长:当前时间减去键的值对象的 lru 时间,就是该键的空转时长。Object idle time 命令可以打印出给定键的空转时长。如果服务器打开了 maxmemory 选项,并且服务器用于回收内存的算法为 volatile-lru 或者 allkeys-lru,那么当服务器占用的内存数超过了maxmemory 选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。

type、encoding、ptr 是最重要的三个属性。

type 记录了对象所保存的值的类型,它的值可能是以下常量中的一个:

/*
* 对象类型
*/
#define OBJ_STRING 0 // 字符串
#define OBJ_LIST 1 // 列表
#define OBJ_SET 2 // 集合
#define OBJ_ZSET 3 // 有序集
#define OBJ_HASH 4 // 哈希表

encoding 记录了对象所保存的值的编码,它的值可能是以下常量中的一个:

/*
* 对象编码
*/
#define OBJ_ENCODING_RAW 0     /* Raw representation */
#define OBJ_ENCODING_INT 1     /* Encoded as integer */
#define OBJ_ENCODING_HT 2      /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3  /* 注意:版本2.6后不再使用. */
#define OBJ_ENCODING_LINKEDLIST 4 /* 注意:不再使用了,旧版本2.x中String的底层之一. */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define OBJ_ENCODING_INTSET 6  /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7  /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8  /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */

ptr 是一个指针,指向实际保存值的数据结构,这个数据结构由 type 和 encoding 属性决定。举个例子, 如果一个 redisObject 的 type 属性为OBJ_LIST , encoding 属性为OBJ_ENCODING_QUICKLIST ,那么这个对象就是一个Redis 列表(List),它的值保存在一个 QuickList 的数据结构内,而 ptr 指针就指向 quicklist 的对象。

下图展示了 redisObject 、Redis 所有数据类型、Redis 所有编码方式以及底层数据结构之间的关系:

可能这样说你看起来还是懵逼,那我们就把每一种数据类型都拿出来分析一下。

string 类型

string 是 Redis 中最基本的数据类型,Redis 是用 C 开发的,但是 Redis 中的 string 和 C 中的 string 有很大的区别。

string 类型在 Redis 中有三种存储方式:int、raw、embstr。即 这种 string 并不是字面意义上的 string。

int 格式存储

当一个 key 的 value 是整型并且 value 长度不超过 20 个字符,Redis 就将其编码为 int 类型。很显然这样做是为了节约内存。

Redis 默认会缓存 10000 个整型值(#define OBJSHAREDINTEGERS 10000),这就意味着,如果有 10 个不同的 key,value 都是 10000 以内的值,事实上全部都是共享同一个对象。

SDS (simple dynamic string)

在 Redis 3.2 版本之前如果存储的是一个字符串且长度大于 39 个字节,就会使用 SDS 的格式来存储,并且将 encoding 设置为 raw。如果存储的是一个字符串但是长度小于等于 39 个字节,则将 encoding 设置为 embstr 来存储。

在 3.2 版本之后 39 被改为 44。

sds 的定义如下:

struct sdshdr {  
      
    // buf 中已占用空间的长度  
    int len;  
  
    // buf 中剩余可用空间的长度  
    int free;  
  
    // 数据空间  
    char buf[];  
};  

字段含义:

  • len 用于记录 buf 中已经使用的空间长度;
  • free 用于记录 buf 中还空余的空间(初次分配空间一般没有空余,在对字符串修改的时候,会有剩余空间出现);
  • buf 字符数组,用于记录我们的字符串。

当你在 Redis 存储一个 hello 时,它在 sds 中存储的结构大概如下:

raw 格式和 embstr 格式的区别在于

raw 编码会调用两次内存分配来分别创建 redisObject 结构和 sdshdr 结构,而 embstr 编码则通过一次内存分配函数来获得一块连续的空间,空间中依次包含 redisObject 和 sdshdr 结构。

同样对于内存释放来说,embstr 只需要一次,而 sdshdr 需要两次。

另外,因为 embstr 编码格式的字符串对象所有数据都是保存在一块连续的内存块中,那么对于查找操作来说可以将整块数据放入缓存中,更有利于读取。

SDS 和 C 字符串的区别

字符串长度计算

C 语言中的字符串使用长度为 n+1 的字符串数组来表达长度为 n 的字符串,获取字符串长度需要遍历整个数组。而 SDS 使用独立的 len 字段来记录长度。

C 缓冲区溢出

有两个在内存中紧邻的字符串”hello“ 和 ”world“,现在要把 ”hello“ 改为 ”helloo“,但是忘记重新为 ”helloo“ 分配新的内存空间,那么在 C 中会把 ”helloo“ 的位置往后溢出到后面的内存空间,即 ”world“ 的位置会被挤占。这两个单词就变为:”hellooorld”。

使用 SDS 则不用担心这种问题。Redis 会在执行修改操作之前先检查分配给 SDS 的空间是否够用,如果不够会先拓展空间再执行修改操作。

另外 SDS 还提供两个实用功能:空间预分配 和 惰性释放空间

预分配策略:

  • 如果修改后的 SDS 长度 < 1MB,预分配同样 len 大小的空间;

  • 如果修改后的 SDS 长度 > 1MB,预分配1MB 大小的空间。

惰性空间释放

缩短 SDS 空间时并不立即进行内存空间释放,而是记录 free 字节数。下次修改数据如果需要重新分配空间时优先取这一部分字节而不是重新分配。

Hash 类型

hash 类型的底层存储分别有两种方式:ziplist 和 hashtable。

hashtable 存储

hashtable 实现方式我们就不细说大家都懂,基于 hash 值来实现,Redis 解决 hash 冲突使用链地址法。与 Java 中的 HashMap 不同的是,在链地址法解决冲突的过程中,对于一个冲突的 key 总是会添加到当前链表的表头而不是表尾,这样添加节点的时间复杂度就为 o(1)。

hash 的存储逻辑我们就不说,详细说一下 rehash 的过程,这个实现比较有意思。

Redis 的 字典结构体 dict 中存放了两张 hash 表:

typedef struct dict{
    dictType *type; //dictType结构,dictType结构中包含自定义的函数,这些函数使得key和value能够存储任何类型的数据
    void *privdata; //私有数据,保存着dictType结构中函数的 参数
    dictht ht[2]; //两张哈希表
    long rehashidx; //rehash的标记,rehashidx=-1表示没有进行rehash,rehash时每迁移一个桶就对rehashidx加一
    int itreators;  //正在迭代的迭代器数量
}

正常使用的就是 ht[0],ht[1] 是只有在扩容/缩容 的时候才会用到。hash 都是有负载因子的,这里也不例外:

​ load factor = ht[0].used / ht[0].size

触发 rehash 有两种情况,一种是触发扩容,一种是触发缩容。

触发扩容的条件包括:

  • 服务器当前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且 load factor >= 1;
  • 服务器当前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且 load factor >= 5;

触发缩容的条件:

负载因子 < 0.1 时((ht[0].used / ht[0].siz) < 0.1)。

再说很好玩的一点:渐进式 rehash。这个在 Java 的 HashMap 中是没有的。

所谓渐进式 rehash 即 rehash 不是一次性、集中式的完成,而是分多次、渐进式的进行。

这样做的原因在于如果 hash 表里的 KV 对很大时,比如上百万的情况如果一次性将这些数据全部从 ht[0] 移到 ht[1] 所需的庞大计算量可能会导致 Redis 在一段时间内停止服务。为了避免这种情况所以 Redis 采取了渐进式 rehash。

渐进式 rehash 期间,Redis 会逐渐的将 ht[0] 的数据转移到 ht[1] ,查找/删除/更新 操作会先查 ht[0], 查不到会再查 ht[1],新增操作只会在 ht[1] 上执行。

zipList 存储

说完 hashtable 存储我们再来说另一种存储方式:zipList。翻译过来是压缩列表,但是并不是我们理解表意上面的那种压缩。

zipList 是 list 键、 hash 键以及 zset 键的底层实现之一(3.0 之后 list 键已经不直接使用 zipList 和 linkedList 作为底层实现,取而代之的是 quickList)。

Redis 官方文档表示: zipList 是一个经过特殊编码的双向链表,这种双向链表与普通的双向链表的区别在于:它不存储上个节点和下个节点的指针,而是上个节点的长度和当前节点的长度。它的设计目标就是为了提高存储效率,zipList 适用于字段少和字段值少的场景。可用于存储字符串或整数,整数是按照二进制表示进行编码,而不是编码成字符串。

另外普通的链表中的每一项可能都不在一块连续的内存空间中,通过指针来表示数据引用。而 zipList 为了减少内存碎片率和提高查询效率,一个 zipList 对象将独自占用一块完整的独立内存空间。

下图展示了 zipList 的构成:

  • zlbytes:32 bit,表示 zipList 占用的字节总数(也包括 zlbytes 本身占用的 4 个字节)。
  • zltail: 32 bit,表示 zipList 表中最后一项(entry)在 zipList 中的偏移字节数。zltail 的存在使得我们可以很方便地找到最后一项(不用遍历整个zipList ),从而可以在 zipList 尾端快速地执行 push 或 pop 操作。
  • zllen: 16 bit, 表示 zipList 中数据项(entry)的个数。zllen 字段因为只有 16 bit,所以可以表达的最大值为 2^16-1。这里需要特别注意的是,如果 zipList 中数据项个数超过了 16bit 能表达的最大值,zipList 仍然可以来表示。那怎么表示呢?这里做了这样的规定:如果 zllen 小于等于 216-2(也就是不等于216-1),那么 zllen 就表示 zipList 中数据项的个数;否则,也就是 zllen 等于 16bit 全为 1 的情况,那么 zllen 就不表示数据项个数了,这时候要想知道 zipList 中数据项总数,那么必须对 zipList 从头到尾遍历各个数据项才能计数出来。
  • entry:表示真正存放数据的数据项,长度不定。一个数据项(entry)也有它自己的内部结构。具体结构我们下面画图解释。
  • zlend:zipList 最后 1 个字节,是一个结束标记,值固定等于 255。

再说 zipList entry 的结构:

pre_entry_length:根据编码方式的不同, pre_entry_length 可能占用 1 字节或者 5 字节:

  • 1 字节:如果前一节点的长度小于 254 字节,便使用一个字节保存它的值。
  • 5 字节:如果前一节点的长度大于等于 254 字节,那么将第 1 个字节的值设为 254 ,然后用接下来的 4 个字节保存实际长度。

encoding 和 length

encoding 和 length 两部分一起决定了 content 部分所保存的数据的类型(以及长度)。其中, encoding 的长度为 2 bit , 它的值可以是 00 、 01 、 10 和 11 :

  • 00 、 01 和 10 表示 content 部分保存着字符数组。
  • 11 表示 content 部分保存着整数。

content 部分保存着节点的内容,类型和长度由 encoding 和 length 决定。

压缩列表的本质还是一个字节数组,操作时按照既定规则将字符写入 entry 中。既然底层是一块连续的内存块那么大小肯定是有限制的, hash 结构何时使用 hashtable ,何时使用 zipList 来进行存储呢?

当我们为某个 key 第一次执行 hset key field value 的时候,Redis 会创建一个 hash 结构,这个新创建的 hash 结构的底层就是 zipList 结构来存储的。

随着数据插入的越多,达到 Redis 配置的默认值:

hash-max-ziplist-entries 512
hash-max-ziplist-value 64
  • 当 hash 中的数据项(即field-value对)的数目超过 512 的时候,也就是 zipList 数据项超过 1024 的时候(请参考 t_hash.c 中的 hashTypeSet 函数)。
  • 当 hash 中插入的任意一个 value 的长度超过了 64 的时候(请参考 t_hash.c 中的 hashTypeTryConversion函数)。

之所以有这两个条件限制,是因为 zipList 变大后会有几个缺点:

  • 每次插入或修改引发的 realloc 操作会有更大的概率造成内存拷贝,从而降低性能。
  • 一旦发生内存拷贝,内存拷贝的成本也相应增加,因为要拷贝更大的一块数据,类似 COW 的复制机制。
  • 当 zipList 数据项过多的时候,在它上面查找指定的数据项就会性能变得很低,需要遍历整个链表。

List 类型

List 结构在 3.2 版本之前是使用 zipList 和 linkedList 来实现的, 之后引入的新的数据结构 quickList。这里我们来看一下 linkedList 和 quickList 的结构是什么样的。

linkedList 是一个双向链表,他和普通的链表一样都是由指向前后节点的指针。插入、修改、更新的时间复杂度尾 O(1),但是查询的时间复杂度确实 O(n)。

linkedList 和 quickList 的底层实现是采用链表进行实现,在 C 语言中并没有内置的链表这种数据结构,Redis 实现了自己的链表结构。linkList 维护了一个 length 字段来保存链表长度,所以查询长度时间复杂度为o(1)。

之所以 zipList 和 linkedList 会被取代,是因为这两种数据结构都有各自的缺点:

  • zipList 因为使用的是一块连续的内存块,存储和读取效率高,但是不利于修改删除这种需要频繁的申请和释放内存的操作,并且当 zipList 长度很长的时候, 一次 realloc 可能会耗费很长时间。
  • linkedList 胜在插入数据时间复杂度低,但是每个节点需要维护两个指针增加了开销,各个节点又是单独的内存块,地址不一定连续,这就会导致回收的时候内存碎片率增高。

为了解决这两个数据结构天然带来的影响,开发者研究出 quickList 结构,quickList 也不是什么银弹神奇,概略来说它就是 zipList 和 linkedList 两者的结合体。quickList 的定义如下:

typedef struct quicklistNode {
    struct quicklistNode *prev; // 前一个节点
    struct quicklistNode *next; // 后一个节点
    unsigned char *zl; // ziplist结构,压缩的ziplist会指向一个quicklistLZF结构
    unsigned int sz;             /* ziplist的大小 */
    unsigned int count : 16;     /* ziplist的item个数*/
    unsigned int encoding : 2;   /* ziplist是否压缩,1没有压缩,2压缩*/
    unsigned int container : 2;  /* 目前固定为2,表示使用ziplist作为数据容器 */
    unsigned int recompress : 1; /* 是否压缩,1表示压缩。有些命令需要做解压,因此用该标记以便后续压缩*/
    unsigned int attempted_compress : 1; /* 暂时不用管,自动测试用的 */
    unsigned int extra : 10; /* 扩展字段,目前还没被使用,刚好凑成32bit */
} quicklistNode;

从结构体不难看出,quickList 的外层就是一个 linkedList ,只不过 linkedList 里面放的不是 source data,而是 zipList。所以 quickList 不能算作是伟大的发明,称为在伟大发明肩上做了一点微小的工作比较合适(事实上也不算微小)。

了解了 quickList 是什么之后我们应该会有一些问题:既然底层还是 zipList,那怎么解决 zipList 过长的问题呢?

如果每个 zipList 上的数据越少,那么整个 quickList 上的节点就越多,内存碎片率就越高;反之如果只有一个 zipList 那跟直接使用 zipList 也没什么区别。所以 quickList 还是做了一些细节处理的。

Redis 提供了一个配置参数 list-max-ziplist-size,使用者可以来根据自己的情况进行调整。

这个参数可以取正值,也可以取负值。当取正数时,表示按照你配置的个数来限制每个 zipList 的长度。比如配置了 list-max-ziplist-size=5 即表示 zipList 最多包含 5 个元素。

当取负数时,表示按照占用字节数来限制每个 zipList 的长度。Redis 配置只能从 -1 – -5 这几个数中选择:

  • -5: 每个 quickList 节点上的 zipList 大小不能超过 64 Kb。
  • -4: 每个 quickList 节点上的 zipList 大小不能超过 32 Kb。
  • -3: 每个 quickList 节点上的 zipList 大小不能超过 16 Kb。
  • -2: 每个 quickList 节点上的 zipList 大小不能超过 8 Kb。(-2是Redis给出的默认值)
  • -1: 每个 quickList 节点上的 zipList 大小不能超过 4 Kb。

使用 List 结构来存储数据大概率也是两边的数据被先访问的概率大一些 ,中间的数据被访问的频率很低。如果你的应用场景符合这个特点,List 还提供一个选项:即把中间的数据进行压缩以节省空间。Redis 的配置参数 list-compress-depth 就是用来完成这个设置。

这个参数表示一个 quickList 两端不被压缩的节点个数。注:这里的节点个数是指 quickList 双向链表的节点个数,而不是指 zipList 里面的数据项个数。实际上一个 quickList 节点上的 zipList 如果被压缩就是整体被压缩。

参数 list-compress-depth 的取值含义如下:

  • 0: 是个特殊值,表示都不压缩。这是 Redis 的默认值。
  • 1: 表示 quickList 两端各有 1 个节点不压缩,中间的节点压缩。
  • 2: 表示 quickList 两端各有 2 个节点不压缩,中间的节点压缩。
  • 3: 表示 quickList 两端各有 3 个节点不压缩,中间的节点压缩。
  • 依此类推…

由于 0 是个特殊值,很容易看出 quickList 的头节点和尾节点总是不被压缩的,以便于在表的两端进行快速存取。

Set 集合

List 列表不会管你存入的数据是否是重复的,来了即入队。Set 是不可重复的集合,底层使用两种存储结构来支持:一种是 hashtable,一种是 inset。

hashtable 我们上面已经说过,key 为 set 元素的值, value 为 null。inset 是一种新的数据结构,可以理解为数组。使用 inset 数据结构需要满足两个条件:

  • 元素个数不少于默认值 512;
  • 元素可以用整型来表示。

简单说:Set 集合如果存储的是非整型数据或者是整型数据且元素个数少于 512 个时底层会用 hashtable 结构来存储;否则就用 inset 结构来存储。

inset 结构是由整数组成的集合,实际上是有序集合,从而便于使用二分查找法更快的搜索。它在内存分配上同 zipList 一样,也是使用一块连续的内存空间,但是它做的更好的是:对于大整数和小整数(按照绝对值)采取了不同的编码以节省空间。

inset 的定义如下:

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;

#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

encoding 表示数据编码方式。它有三种可取的值:

  • INTSET_ENC_INT16 表示每个元素用 2 个字节存储;

  • INTSET_ENC_INT32 表示每个元素用 4 个字节存储

  • INTSET_ENC_INT64 表示每个元素用 8 个字节存储。

因此,intset 中存储的整数最多只能占用 64 bit。

length 表示 inset 中元素的个数。它和encoding 一起构成了 inset 的头部。contents 数组是实际存储数据的地方,

​ contents 总长度(byte) = ( encoding * length)/ 8

contents 里面的元素有两个特性:

  • 元素不重复;
  • 元素在数组中由小到大排列。

正因为此特性,所以在查找元素的时候可以采用二分查找法,查询的时间复杂度为 o(lgN)。

需要注意的是: encoding 是对整个 inset 结构体生效,所以意味着 contents 里面所有的数据都采用同一种编码。如果一开始你存储的数据比较小使用的是 int16 类型的编码方式,后面存储了一个大数据之后需要升级为 int64 类型,contents 里面所有的数据都将使用这一类型编码。是否升级使用新的编码格式取决于数组中最长的元素。

ZSet 集合

ZSet 是有序集合,底层是基于 zipList 和 SkipList 来实现的。zipList 我们已经说过,这里来说一下 SkipList。

SkipList 我们在准备面试的时候多多少少都会去准备一下,所以大家应该也不陌生。以下是个典型的跳跃表例子(图片来自维基百科):

从图中可以看到, 跳跃表主要由以下部分构成:

  • 表头(head):负责维护跳跃表的节点指针。
  • 跳跃表节点:保存着元素值,以及多个层。
  • 层:保存着指向其他元素的指针。高层的指针越过的元素数量大于等于低层的指针,为了提高查找的效率,程序总是从高层先开始访问,然后随着元素值范围的缩小,慢慢降低层次。
  • 表尾:全部由 NULL 组成,表示跳跃表的末尾。

SkipList 设计之初是作为替换平衡树的一种选择。AVL树有着严格的 o(lgN)的查询效率,但是对于插入操作可能需要多次旋转导致效率变低所以又研发了红黑树结构。但是红黑树有个问题就是在并发更新环境下效率变低,左右旋自平衡的过程中需要锁住较多节点。

跳表的实现也比较简单,就是在普通的单向链表基础上增加了一些分层的索引。算法大家可以自行搜索,这里就不详细介绍。Redis 的跳表与普通跳表有一些区别,除了有 value 之外,还有一个 score 的属性。score 对 value 做排序辅助用。我们主要关注 Redis 对跳表做的更改:

  1. 允许重复的 score 值:多个不同的 memberscore 值可以相同。
  2. 进行对比操作时,不仅要检查 score 值,还要检查 member :当 score 值可以重复时,单靠 score 值无法判断一个元素的身份,所以需要连 member 域都一并检查才行。
  3. 每个节点都带有一个高度为 1 层的后退指针,用于从表尾方向向表头方向迭代:当执行 ZREVRANGE 或 ZREVRANGEBYSCORE 这类以逆序处理有序集的命令时,就会用到这个属性。

ZSet 底层使用 zipList 和 SkipList 分别在不同的情况下:

[value,score] 键值对数少于 128 个且每个元素长度小于 64 字节的时候使用 zipList 存储。当不满足前面的条件时则使用 SkipList 来存储 value,key 和 score 则使用 hashtable 来存储。

目前常用的 key-value 数据结构有三种:Hash 表、红黑树、SkipList,它们各自有着不同的优缺点:

  • Hash 表:插入、查找最快,为 O(1);如使用链表实现则可实现无锁;数据有序化需要显式的排序操作
  • 红黑树:插入、查找为 O(logn),但常数项较小;无锁实现的复杂性很高,一般需要加锁;数据天然有序。
  • SkipList:插入、查找为 O(logn),但常数项比红黑树要大;底层结构为链表,可无锁实现;数据天然有序。

ZSet 之所以选用 SkipList 而不是红黑树,是因为对于 ZRANGE 或者 ZREVRANGE 这样的范围查找操作,跳表底层的双向链表十分便捷,如果使用红黑树结构在找到指定范围的小值之后,还需要中序遍历查找其他不超过大值得节点。并且对于插入删除来说,跳表只用更改底层链表的指针即可,而红黑树可能需要调整子树层高。

我们看一下 SkipList 的结构:

// 单个节点
typedef struct zskiplistNode {
    robj *obj;                      // member,实为 sds
    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;

如果一个链表有 n 个结点,如果每两个结点抽取出一个结点建立索引的话,那么第一级索引的结点数大约就是 n/2,第二级索引的结点数大约为 n/4,以此类推第 m 级索引的节点数大约为 n/(2^m)。

假如一共有 m 级索引,第 m 级的结点数为两个,通过上边我们找到的规律,那么求得 n / ( 2^m ) = 2,从而求出 m=log(n) – 1。如果加上原始链表,那么整个跳表的高度就是 log(n) 。

我们在查询跳表的时候,如果每一层都需要遍历 k 个结点,那么最终的时间复杂度就为 O(k * log(n))。那这个 k 值为多少呢,按照我们每两个结点提取一个基点建立索引的情况,我们每一级最多需要遍历两个节点,所以 k=2。那跳表查询数据的时间复杂度就是 o(2 * log(n)),常数 2 忽略不计,就是 o(log(n))了。

如果严格按照这种两下两层链表上节点个数 2:1 的对应关系进行维护索引,对于插入或者删除数据的时候很有可能会将索引调整的复杂度升到o(n),Redis 为了避免这一问题,它不要求上下两层索引之间节点个数有严格的对应关系,而是为每层节点随机出一个层数,这个随机过程并不是一个服从均匀分布的随机数,计算过程如下:

  1. 每个节点都有一个指向第一层的指针(所有数据都在第一层);
  2. 如果一个节点有第 i 层(i >=1)指针,即节点已经在第一层到第i层链表中,那么它有第i+1层指针的概率为P;
  3. 节点最大层数不允许超过一个最大值,记为 MaxLevel。

以上过程的伪码如下:

int randomLevel(){
    int level = 1;
    while (Math.random()<p && level<MaxLevel){
        level ++ ;
    }
    return level;
}

在 Redis 中上面两个参数 p 和 MaxLevel 的取值是固定的:

p = 1/4
MaxLevel = 32 (5.0版本以后是64)

根据这个算法,一个新增的节点有 50% 的概率在level[1],25%的概率在level[2],12.5%的概率在level[3],以此类推,有 2^63 个元素的情况下会有 31 层,且每一层向上晋升的概率都是50%。

总结

以上介绍了 Redis 的 5 种基本类型数据结构:string、list、hash、set、zset,详细讨论了这些数据结构底层的实现逻辑,可以看到 Redis 在节约内存方面做了很多的努力,不仅仅是新存储结构的开发还包括多少数据量用什么数据结构的探讨。

除了 5 种基本类型数据结构外,还有 3 种高级数据结构:HyperLogLog、Geo、BloomFilter。限于篇幅,我们下一篇接着探讨。

Tags: