阅读《Redis深度历险:核心原理和应用实践》的总结-redis数据结构-05

一、压缩列表

Redis 为了节约内存空间使用,zset 和 hash 容器对象在元素个数较少的时候,采用压缩列表 (ziplist) 进行存储。压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙。

127.0.0.1:6379> zadd programming 01 go 02 python 03 java 
(integer) 3
127.0.0.1:6379> debug object programming
Value at:0x7ff251e0d6b0 refcount:1 encoding:ziplist serializedlength:36 lru:13241588 lru_seconds_idle:23
127.0.0.1:6379> hmset books go fast python slow java fast
OK
127.0.0.1:6379> debug object books
Value at:0x7ff251c02c90 refcount:1 encoding:ziplist serializedlength:48 lru:13241656 lru_seconds_idle:17

这里,注意观察 debug object 输出的 encoding 字段都是 ziplist,这就表示内部采用压 缩列表结构进行存储。

ziplist结构

struct ziplist<T> {
      int32 zlbytes; // 整个压缩列表占用字节数
       int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
       int16 zllength; // 元素个数
      T[] entries; // 元素内容列表,挨个挨个紧凑存储 int8 zlend; // 标志压缩列表的结束,值恒为 0xFF

}

压缩列表为了支持双向遍历,所以才会有 ztail_offset 这个字段,用来快速定位到最后一 个元素,然后倒着遍历。entry 块随着容纳的元素类型不同,也会有不一样的结构。

struct entry {
     int<var> prevlen; // 前一个 entry 的字节长度

     int<var> encoding; // 元素类型编码
     optional byte[] content; // 元素内容

}

它的 prevlen 字段表示前一个 entry 的字节长度,当压缩列表倒着遍历时,需要通过这个字段来快速定位到下一个元素的位置。它是一个变长的整数,当字符串长度小于 254(0xFE) 时,使用一个字节表示;如果达到或超出 254(0xFE) 那就使用 5 个字节来表示。第一个字节是 0xFE(254),剩余四个字节表示字符串长度。你可能会觉得用 5 个字节来表示字符串长度,是不是太浪费了。我们可以算一下,当字符串长度比较长的时候,其实 5 个字节也只占用了不到(5/(254+5))<2%的空间。

encoding 字段存储了元素内容的编码类型信息,ziplist 通过这个字段来决定后面的 content 内容的形式

Redis 为了节约存储空间,对 encoding 字段进行了相当复杂的设计。Redis 通过这个字段的前缀位来识别具体存储的数据形式。下面我们来看看 Redis 是如何根据 encoding 的前缀位来区分内容的:

  • 1、00xxxxxx 最大长度位 63 的短字符串,后面的 6 个位存储字符串的位数,剩余的字 节就是字符串的内容。
  • 2、01xxxxxx xxxxxxxx 中等长度的字符串,后面 14 个位来表示字符串的长度,剩余的 字节就是字符串的内容。
  • 3、10000000 aaaaaaaa bbbbbbbb cccccccc dddddddd 特大字符串,需要使用额外 4 个字节 来表示长度。第一个字节前缀是 10,剩余 6 位没有使用,统一置为零。后面跟着字符串内 容。不过这样的大字符串是没有机会使用的,压缩列表通常只是用来存储小数据的。
  • 4、11000000 表示 int16,后跟两个字节表示整数。
  • 5、11010000 表示 int32,后跟四个字节表示整数。
  • 6、11100000 表示 int64,后跟八个字节表示整数。
  • 7、11110000 表示 int24,后跟三个字节表示整数。
  • 8、11111110 表示 int8,后跟一个字节表示整数。
  • 9、11111111 表示 ziplist 的结束,也就是 zlend 的值 0xFF。
  • 10、1111xxxx 表示极小整数,xxxx 的范围只能是 (0001~1101), 也就是 1~13,因为0000、1110、1111 都被占用了。读取到的 value 需要将 xxxx 减 1,也就是整数 0~12 就是 最终的 value。

注意到 content 字段在结构体中定义为 optional 类型,表示这个字段是可选的,对于很 小的整数而言,它的内容已经内联到 encoding 字段的尾部了

增加元素

因为 ziplist 都是紧凑存储,没有冗余空间 (对比一下 Redis 的字符串结构)。意味着插 入一个新的元素就需要调用 realloc 扩展内存。取决于内存分配器算法和当前的 ziplist 内存大小,realloc 可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可能在原有的地址上进行扩展,这时就不需要进行旧内容的内存拷贝。如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以 ziplist 不适合存储大型字符串,存储的元素也不宜过多。

级联更新

前面提到每个 entry 都会有一个 prevlen 字段存储前一个 entry 的长度。如果内容小于 254 字节,prevlen 用 1 字节存储,否则就是 5 字节。这意味着如果某个 entry 经过了修改 操作从 253 字节变成了 254 字节,那么它的下一个 entry 的 prevlen 字段就要更新,从 1 个字节扩展到 5 个字节;如果这个 entry 的长度本来也是 253 字节,那么后面 entry 的 prevlen 字段还得继续更新。

如果 ziplist 里面每个 entry 恰好都存储了 253 字节的内容,那么第一个 entry 内容的修改就会导致后续所有 entry 的级联更新,这就是一个比较耗费计算资源的操作。

级联跟新:https://blog.csdn.net/u010301542/article/details/100830218
[1]:redis3.0 ziplist.c 级联更新源码注释

https://blog.csdn.net/men_wen/article/details/70176753

IntSet 小整数集合

当 set 集合容纳的元素都是整数并且元素个数较小时,Redis 会使用intset 来存储结合元素。intset 是紧凑的数组结构,同时支持 16 位、32 位和 64 位整数。

struct intset<T> {
int32 encoding; // 决定整数位宽是 16 位、32 位还是 64 位 int32 length; // 元素个数
int<T> contents; // 整数数组,可以是 16 位、32 位和 64 位

}

127.0.0.1:6379> sadd pan 1,2,3
(integer) 1
127.0.0.1:6379> sadd in 1 2 3
(integer) 3
127.0.0.1:6379> debug object in
Value at:0x7ff251c02990 refcount:1 encoding:intset serializedlength:15 lru:13242654 lru_seconds_idle:11
127.0.0.1:6379> debug object pan
Value at:0x7ff251f239f0 refcount:1 encoding:hashtable serializedlength:7 lru:13242638 lru_seconds_idle:60

注意观察 debug object 的输出字段 encoding 的值,可以发现当 set 里面放进去了非整数值时,存储形式立即从 intset 转变成了 hash 结构。

1、为什么 set 集合在数量很小的时候不使用 ziplist 来存储?

二、快速列表

Redis 早期版本存储 list 列表数据结构使用的是压缩列表 ziplist 和普通的双向链表linkedlist,也就是元素少时用 ziplist,元素多时用 linkedlist。

// 链表的节点

struct listNode<T> {

      listNode* prev; listNode* next; T value;

}

// 链表

struct list {
       listNode *head;

       listNode *tail;

       long length;

}

考虑到链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。后续版本对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist。

127.0.0.1:6379> rpush shu go python java
(integer) 3
127.0.0.1:6379> debug object shu
Value at:0x7ff251f22ba0 refcount:1 encoding:quicklist serializedlength:31 lru:13245423 lru_seconds_idle:11 ql_nodes:1 ql_avg_node:3.00 ql_ziplist_max:-2 ql_compressed:0 ql_uncompressed_size:29

注意观察上面输出字段 encoding 的值quicklist 是 ziplist 和 linkedlist 的混合体,它将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个ziplist 之间使用双向指针串接起来。

struct ziplist { ...
}
struct ziplist_compressed {
    int32 size;
    byte[] compressed_data;
}
struct quicklistNode {
    quicklistNode* prev;
    quicklistNode* next;
    ziplist* zl; // 指向压缩列表
    int32 size; // ziplist 的字节总数
    int16 count; // ziplist 中的元素数量
    int2 encoding; // 存储形式 2bit,原生字节数组还是 LZF 压缩存储 ...
}
struct quicklist {
    quicklistNode* head;
    quicklistNode* tail;
    long count; // 元素总数
    int nodes; // ziplist 节点的个数
    int compressDepth; // LZF 算法压缩深度 ...
}

上述代码简单地表示了 quicklist 的大致结构。为了进一步节约空间,Redis 还会对ziplist 进行压缩存储,使用 LZF 算法压缩,可以选择压缩深度

每个 ziplist 存多少元素?

quicklist 内部默认单个 ziplist 长度为 8k 字节,超出了这个字节数,就会新起一个 ziplist。ziplist 的长度由配置参数 list-max-ziplist-size 决定

压缩深度

quicklist 默认的压缩深度是 0,也就是不压缩。压缩的实际深度由配置参数 list- compress-depth决定。为了支持快速的 push/pop 操作,quicklist 的首尾两个 ziplist 不压缩,此时深度就是 1。如果深度为 2,就表示 quicklist 的首尾第一个 ziplist 以及首尾第二个 ziplist 都不压缩。

 

三、跳跃列表

Redis 的 zset 是一个复合结构,一方面它需要一个 hash 结构来存储 value 和 score 的 对应关系,另一方面需要提供按照 score 来排序的功能,还需要能够指定 score 的范围来获 取 value 列表的功能,这就需要另外一个结构「跳跃列表」。

zset 的内部实现是一个 hash 字典加一个跳跃列表 (skiplist)。hash 结构在讲字典结构时已经详细分析过了,它很类似于 Java 语言中的 HashMap 结构。本节我们来讲跳跃列表, 它比较复杂,读者要有心理准备。

基本结构

上图就是跳跃列表的示意图,图中只画了四层,Redis 的跳跃表共有 64 层,意味着最多可以容纳 2^64 次方个元素。每一个 kv 块对应的结构如下面的代码中的 zslnode 结构,kv header 也是这个结构,只不过 value 字段是 null 值——无效的,score 是Double.MIN_VALUE,用来垫底的。kv 之间使用指针串起来形成了双向链表结构,它们是有序 排列的,从小到大。不同的 kv 层高可能不一样,层数越高的 kv 越少。同一层的 kv会使用指针串起来。每一个层元素的遍历都是从 kv header 出发。

struct zslnode {
    string value;
    double score; 
    zslnode*[] forwards;  // 多层连接指针
    zslnode* backward;    // 回溯指针
}
struct zsl {
    zslnode* header;          // 跳跃列表头指针
    int maxLevel;             // 跳跃列表当前的最高层
    map<string, zslnode*> ht; // hash 结构的所有键值对
}

查找过程

设想如果跳跃列表只有一层会怎样?插入删除操作需要定位到相应的位置节点 (定位到 最后一个比「我」小的元素,也就是第一个比「我」大的元素的前一个),定位的效率肯定比 较差,复杂度将会是 O(n),因为需要挨个遍历。也许你会想到二分查找,但是二分查找的结 构只能是有序数组。跳跃列表有了多层结构之后,这个定位的算法复杂度将会降到 O(lg(n))。

如图所示,我们要定位到那个紫色的 kv,需要从 header 的最高层开始遍历找到第一个 节点 (最后一个比「我」小的元素),然后从这个节点开始降一层再遍历找到第二个节点 (最 后一个比「我」小的元素),然后一直降到最底层进行遍历就找到了期望的节点 (最底层的最 后一个比我「小」的元素)。 我们将中间经过的一系列节点称之为「搜索路径」,它是从最高层一直到最底层的每一 层最后一个比「我」小的元素节点列表。 有了这个搜索路径,我们就可以插入这个新节点了。不过这个插入过程也不是特别简 单。因为新插入的节点到底有多少层,得有个算法来分配一下,跳跃列表使用的是随机算法。

随机层数

对于每一个新插入的节点,都需要调用一个随机算法给它分配一个合理的层数。直观上 期望的目标是 50% 的 Level1,25% 的 Level2,12.5% 的 Level3,一直到最顶层 2^-63,因为这里每一层的晋升概率是 50%。

不过 Redis 标准源码中的晋升概率只有 25%,也就是代码中的 ZSKIPLIST_P 的值。所 以官方的跳跃列表更加的扁平化,层高相对较低,在单个层上需要遍历的节点数量会稍多一 点。

也正是因为层数一般不高,所以遍历的时候从顶层开始往下遍历会非常浪费。跳跃列表 会记录一下当前的最高层数 maxLevel,遍历时从这个 maxLevel 开始遍历性能就会提高很 多。

插入过程

首先我们在搜索合适插入点的过程中将「搜索路径」摸出来了,然后就可以开始创建新节点了,创建的时候需要给这个节点随机分配一个层数,再将搜索路径上的节点和这个新节点通过前向后向指针串起来。如果分配的新节点的高度高于当前跳跃列表的最大高度,就需 要更新一下跳跃列表的最大高度。

删除过程

删除过程和插入过程类似,都需先把这个「搜索路径」找出来。然后对于每个层的相关 节点都重排一下前向后向指针就可以了。同时还要注意更新一下最高层数 maxLevel。

更新过程

当我们调用 zadd 方法时,如果对应的 value 不存在,那就是插入过程。如果这个value 已经存在了,只是调整一下 score 的值,那就需要走一个更新的流程。假设这个新的score 值不会带来排序位置上的改变,那么就不需要调整位置,直接修改元素的 score 值就可以了。但是如果排序位置改变了,那就要调整位置。那该如何调整位置呢?

一个简单的策略就是先删除这个元素,再插入这个元素,需要经过两次路径搜索。Redis就是这么干的。 不过 Redis 遇到 score 值改变了就直接删除再插入,不会去判断位置是否需要调整,从这点看,Redis 的 zadd 的代码似乎还有优化空间。

如果 score 值都一样呢?

在一个极端的情况下,zset 中所有的 score 值都是一样的,zset 的查找性能会退化为 O(n) 么?Redis 作者自然考虑到了这一点,所以 zset 的排序元素不只看 score 值,如果 score 值相同还需要再比较 value 值 (字符串比较)。

元素排名是怎么算出来的?

前面我们啰嗦了一堆,但是有一个重要的属性没有提到,那就是 zset 可以获取元素的排名 rank。那这个 rank 是如何算出来的?如果仅仅使用上面的结构,rank 是不能算出来的。Redis 在 skiplist 的 forward 指针上进行了优化,给每一个 forward 指针都增加了 span 属性,span 是「跨度」的意思,表示从前一个节点沿着当前层的 forward 指针跳到当前这个节点中间会跳过多少个节点。Redis 在插入删除操作时会小心翼翼地更新 span 值的大小。


struct zslforward { zslnode* item;
    long span; // 跨度
}
struct zsl {
    String value;
    double score;
    zslforward*[] forwards;
    zslnode* backward; // 回溯指针
}

这样当我们要计算一个元素的排名时,只需要将「搜索路径」上的经过的所有节点的跨 度 span 值进行叠加就可以算出元素的最终 rank 值。

 

四、紧凑列表

Redis 5.0 又引入了一个新的数据结构 listpack,它是对 ziplist 结构的改进,在存储空间上会更加节省,而且结构上也比 ziplist 要精简。它的整体形式和 ziplist 还是比较接近的,如果你认真阅读了 ziplist 的内部结构分析,那么 listpack 也是比较容易理解的。

struct listpack<T> {
    int32 total_bytes; // 占用的总字节数 
    int16 size; // 元素个数
    T[] entries; // 紧凑排列的元素列表
    int8 end; // 同 zlend 一样,恒为 0xFF
}

首先这个 listpack 跟 ziplist 的结构几乎一摸一样,只是少了一个 zltail_offset 字段。ziplist 通过这个字段来定位出最后一个元素的位置,用于逆序遍历。不过 listpack 可以通过其它方式来定位出最后一个元素的位置,所以 zltail_offset 字段就省掉了。

struct lpentry { int<var> encoding;
optional byte[] content;
int<var> length; }

元素的结构和 ziplist 的元素结构也很类似,都是包含三个字段。不同的是长度字段放在 了元素的尾部,而且存储的不是上一个元素的长度,是当前元素的长度。正是因为长度放在 了尾部,所以可以省去了 zltail_offset 字段来标记最后一个元素的位置,这个位置可以通过 total_bytes 字段和最后一个元素的长度字段计算出来。

长度字段使用 varint 进行编码,不同于 skiplist 元素长度的编码为 1 个字节或者 5 个 字节,listpack 元素长度的编码可以是 1、2、3、4、5 个字节。同 UTF8 编码一样,它通过 字节的最高为是否为 1 来决定编码的长度。

同样,Redis 为了让 listpack 元素支持很多类型,它对 encoding 字段也进行了较为复杂 的设计

  • 1、0xxxxxxx 表示非负小整数,可以表示 0~127。
  • 2、10xxxxxx 表示小字符串,长度范围是 0~63,content 字段为字符串的内容。 3、110xxxxx yyyyyyyy 表示有符号整数,范围是-2048~2047。
  • 4、1110xxxx yyyyyyyy 表示中等长度的字符串,长度范围是 0~4095,content 字段为字
  • 符串的内容。
  • 5、11110000 aaaaaaaa bbbbbbbb cccccccc dddddddd 表示大字符串,四个字节表示长度,
  • content 字段为字符串内容。
  • 6、11110001 aaaaaaaa bbbbbbbb 表示 2 字节有符号整数。
  • 7、11110010 aaaaaaaa bbbbbbbb cccccccc 表示 3 字节有符号整数。 8、11110011 aaaaaaaa bbbbbbbb cccccccc dddddddd 表示 4 字节有符号整数。 9、11110011 aaaaaaaa ... hhhhhhhh 表示 8 字节有符号整数。
  • 10、11111111 表示 listpack 的结束符号,也就是 0xFF。

级联更新

listpack 的设计彻底消灭了 ziplist 存在的级联更新行为,元素与元素之间完全独立,不 会因为一个元素的长度变长就导致后续的元素内容会受到影响。

取代 ziplist

listpack 的设计的目的是用来取代 ziplist,不过当下还没有做好替换 ziplist 的准备,因 为有很多兼容性的问题需要考虑,ziplist 在 Redis 数据结构中使用太广泛了,替换起来复杂 度会非常之高。它目前只使用在了新增加的 Stream 数据结构中。

 

五、基数树

Rax 是 Redis 内部比较特殊的一个数据结构,它是一个有序字典树 (基数树 Radix Tree),按照 key 的字典序排列,支持快速地定位、插入和删除操作。Redis 五大基础数据结 构里面,能作为字典使用的有 hash 和 zset。hash 不具备排序功能,zset 则是按照 score 进 行排序的。rax 跟 zset 的不同在于它是按照 key 进行排序的。Redis 作者认为 rax 的结构 非常易于理解,但是实现却有相当的复杂度,需要考虑很多的边界条件,需要处理节点的分 裂、合并,一不小心就会出错。

应用

你可以将一本英语字典看成一棵 radix tree,它所有的单词都是按照字典序进行排列,每 个词汇都会附带一个解释,这个解释就是 key 对应的 value。有了这棵树,你就可以快速地 检索单词,还可以查询以某个前缀开头的单词有哪些。

你也可以将公安局的人员档案信息看成一棵 radix tree,它的 key 是每个人的身份证 号,value 是这个人的履历。因为身份证号的编码的前缀是按照地区进行一级一级划分的, 这点和单词非常类似。有了这棵树,你就可以快速地定位出人员档案,还可以快速查询出某 个小片区都有哪些人。

Radix tree 还可以应用于时间序列应用,key 为时间戳,value 为发生在具体时间的事件 内容。因为时间戳的编码也是按照【年月日时分秒毫秒微秒纳秒】进行一级一级划分的,所 以它也可以使用字典序来排序。有了这棵数,我们就可以快速定位出某个具体时间发生了什 么事,也可以查询出一段时间内都有哪些事发生。

我们经常使用的 Web 服务器的 Router 它也是一棵 radix tree。这棵树上挂满了 URL 规则,每个 URL 规则上都会附上一个请求处理器。当一个请求到来时,我们拿这个请求的 URL 沿着树进行遍历,找到相应的请求处理器来处理。因为 URL 中可能存在正则 pattern,而且同一层的节点对顺序没有要求,所以它不算是一棵严格的 radix tree。

Rax 被用在 Redis Stream 结构里面用于存储消息队列,在 Stream 里面消息 ID 的前缀是时间戳 + 序号,这样的消息可以理解为时间序列消息。使用 Rax 结构进行存储就可以快 速地根据消息 ID 定位到具体的消息,然后继续遍历指定消息之后的所有消息。

结构

rax 中有非常多的节点,根节点、叶节点和中间节点,有些中间节点带有 value,有些中 间节点纯粹是结构性需要没有对应的 value。

struct raxNode {
    int<1> isKey; // 是否没有 key,没有 key 的是根节点
    int<1> isNull; // 是否没有对应的 value,无意义的中间节点
    int<1> isCompressed; // 是否压缩存储,这个压缩的概念比较特别 
    int<29> size; // 子节点的数量或者是压缩字符串的长度 (isCompressed) 
    byte[] data; // 路由键、子节点指针、value 都在这里
}

rax 是一棵比较特殊的 radix tree,它在结构上不是标准的 radix tree。如果一个中间节点有多个子节点,那么路由键就只是一个字符。如果只有一个子节点,那么路由键就是一个字 符串。后者就是所谓的「压缩」形式,多个字符压在一起的字符串。比如前面的那棵字典树 在 Rax 算法中将呈现出如下结构:

图中的深蓝色节点就是「压缩」节点。

接下来我们再细看 raxNode.data 里面存储的到底是什么东西,它是一个比较复杂的结 构,按照压缩与否分为两种结构

压缩结构:子节点如果只有一个,那就是压缩结构,data 字段如下伪代码所示:

struct data {
    optional struct { // 取决于 header 的 size 字段是否为零
        byte[] childKey; // 路由键
        raxNode* childNode; // 子节点指针 
    } child;
    optional string value; // 取决于 header 的 isNull 字段 
}

如果是叶子节点,child 字段就不存在。如果是无意义的中间节点 (isNull),那么 value 字段就不存在。

非压缩节点: 如果子节点有多个,那就不是压缩结构,存在多个路由键,一个键是一个字 符。

struct data {
    byte[] childKeys; // 路由键字符列表
    raxNode*[] childNodes; // 多个子节点指针
    optional string value; // 取决于 header 的 isNull 字段
}

也许你会想到如果子节点只有一个,并且路由键字符串的长度为 1 呢,那到底算压缩还 是非压缩?仔细思考一下,在这种情况下,压缩和非压缩在数据结构表现形式上是一样的, 不管 isCompressed 是 0 还好是 1,结构都是一样的。

增删节点

Rax 的增删节点逻辑非常复杂,代码里充斥了太多 ifelse 逻辑。

 

六、SDS(Simple Dynamic String)字符串

Redis 中的字符串是可以修改的字符串,在内存中它是以字节数组的形式存在的。我们知道 C 语言里面的字符串标准形式是以 NULL 作为结束符,但是在 Redis 里面字符串不是这么表示的。因为要获取 NULL 结尾的字符串的长度使用的是 strlen 标准库函数,这个函数的算法复杂度是 O(n),它需要对字节数组进行遍历扫描,作为单线程的 Redis 表示承受不起。

Redis 的字符串叫着「SDS」,也就是 Simple Dynamic String。它的结构是一个带长度信息的字节数组。

struct SDS<T> {
    T capacity; // 数组容量
    T len; // 数组长度
    byte flags; // 特殊标识位,不理睬它 byte[] content; // 数组内容
}

如代码所示,content 里面存储了真正的字符串内容,那 capacity 和 len 表示什么意思呢?它有点类似于 Java 语言的 ArrayList 结构,需要比实际的内容长度多分配一些冗余空间。capacity 表示所分配数组的长度,len 表示字符串的实际长度。前面我们提到字符串是可以修改的字符串,它要支持 append 操作。如果数组没有冗余空间,那么追加操作必然涉及到分配新数组,然后将旧内容复制过来,再 append 新内容。如果字符串的长度非常长,这样的内存分配和复制开销就会非常大。

/* Append the specified binary-safe string pointed by 't' of 'len' bytes to the * end of the specified sds string 's'.
*
* After the call, the passed sds string is no longer valid and all the
* references must be substituted with the new pointer returned by the call. */ sds sdscatlen(sds s, const void *t, size_t len) {
    size_t curlen = sdslen(s); // 原字符串长度
// 按需调整空间,如果 capacity 不够容纳追加的内容,就会重新分配字节数组并复制原字 符串的内容到新数组中
    s = sdsMakeRoomFor(s,len);
if (s == NULL) return NULL; // 内存不足
    memcpy(s+curlen, t, len); // 追加目标字符串的内容到字节数组中
    sdssetlen(s, curlen+len); // 设置追加后的长度值
    s[curlen+len] = '\0'; // 让字符串以\0 结尾,便于调试打印,还可以直接使用 glibc 的字符串
函数进行操作
    return s; 
}

上面的 SDS 结构使用了范型 T,为什么不直接用 int 呢,这是因为当字符串比较短时,len 和 capacity 可以使用 byte 和 short 来表示,Redis 为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示。

Redis 规定字符串的长度不得超过 512M 字节。创建字符串时 len 和 capacity 一样长,不会多分配冗余空间,这是因为绝大多数场景下我们不会使用 append 操作来修改字符串。

embstr vs raw

Redis 的字符串有两种存储方式,在长度特别短时,使用 emb 形式存储 (embeded),当 长度超过 44 时,使用 raw 形式存储

127.0.0.1:6379> set codehole abcdefghijklmnopqrstuvwxyz012345678912345678
OK
127.0.0.1:6379> debug object codehole
Value at:0x7ff251f234c0 refcount:1 encoding:embstr serializedlength:45 lru:13259527 lru_seconds_idle:13
127.0.0.1:6379> set codehole abcdefghijklmnopqrstuvwxyz0123456789123456789
OK
127.0.0.1:6379> debug object codehole
Value at:0x7ff251c02fb0 refcount:1 encoding:raw serializedlength:46 lru:13259545 lru_seconds_idle:8

注意上面 debug object 输出中有个 encoding 字段,一个字符的差别,存储形式就发生 了变化。这是为什么呢?

为了解释这种现象,我们首先来了解一下 Redis 对象头结构体,所有的 Redis 对象都有 下面的这个结构头:

struct RedisObject {
    int4 type; // 4bits
    int4 encoding; // 4bits
    int24 lru; // 24bits
    int32 refcount; // 4bytes
    void *ptr; // 8bytes,64-bit system
} robj;

不同的对象具有不同的类型 type(4bit),同一个类型的 type 会有不同的存储形式 encoding(4bit),为了记录对象的 LRU 信息,使用了 24 个 bit 来记录 LRU 信息。每个对象都有个引用计数,当引用计数为零时,对象就会被销毁,内存被回收。ptr 指针将指向对 象内容 (body) 的具体存储位置。这样一个 RedisObject 对象头需要占据 16 字节的存储空间

接着我们再看 SDS 结构体的大小,在字符串比较小时,SDS 对象头的大小是capacity+3,至少是 3。意味着分配一个字符串的最小空间占用为 19 字节 (16+3)

如图所示,embstr 存储形式是这样一种存储形式,它将 RedisObject 对象头和 SDS 对象连续存在一起,使用malloc方法一次分配而 raw 存储形式不一样,它需要两次 malloc,两个对象头在内存地址上一般是不连续的

而内存分配器 jemalloc/tcmalloc 等分配内存大小的单位都是 2、4、8、16、32、64 等等,为了能容纳一个完整的 embstr 对象,jemalloc 最少会分配 32 字节的空间,如果字符串再稍微长一点,那就是 64 字节的空间。如果总体超出了64 字节,Redis 认为它是一个大字符串,不再使用 emdstr 形式存储,而该用 raw 形式。

当内存分配器分配了64空间时,那这个字符串的长度最大可以是多少呢?这个长度就 是 44。那为什么是 44 呢?

前面我们提到 SDS 结构体中的 content 中的字符串是以字节\0 结尾的字符串,之所以多出这样一个字节,是为了便于直接使用 glibc 的字符串处理函数,以及为了便于字符串的调试打印输出。

看上面这张图可以算出,留给 content 的长度最多只有 45(64-19) 字节了。字符串又是以\0 结尾,所以 embstr 最大能容纳的字符串长度就是 44。

扩容策略

字符串在长度小于 1M 之前,扩容空间采用加倍策略,也就是保留 100% 的冗余空间。当长度超过 1M 之后,为了避免加倍后的冗余空间过大而导致浪费,每次扩容只会多分 配 1M 大小的冗余空间。

 

七、字典

dict 是 Redis 服务器中出现最为频繁的复合型数据结构,除了 hash 结构的数据会用到字典外,整个 Redis 数据库的所有 key 和 value 也组成了一个全局字典,还有带过期时间的 key 集合也是一个字典。zset 集合中存储 value 和 score 值的映射关系也是通过 dict 结构实现的。

struct RedisDb {
    dict* dict; // all keys key=>value
    dict* expires; // all expired keys key=>long(timestamp) ...
}
struct zset {
    dict *dict; // all values zskiplist *zsl;
}

dict 内部结构

dict 结构内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的。但是在dict 扩容缩容时,需要分配新的 hashtable,然后进行渐进式搬迁,这时候两个 hashtable 存储的分别是旧的 hashtable 和新的 hashtable。待搬迁结束后,旧的 hashtable 被删除,新的hashtable 取而代之。

所以,字典数据结构的精华就落在了 hashtable 结构上了。hashtable 的结构和 Java 的HashMap 几乎是一样的,都是通过分桶的方式解决 hash 冲突。第一维是数组,第二维是链表。数组中存储的是第二维链表的第一个元素的指针

struct dictEntry { void* key;
    void* val;
    dictEntry* next; // 链接下一个 entry 
}
struct dictht {
    dictEntry** table; // 二维
    long size; // 第一维数组的长度 
    long used; // hash 表中的元素个数 ...
}

渐进式 rehash

大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元 素重新挂接到新的数组下面,这是一个 O(n)级别的操作,作为单线程的 Redis 表示很难承受 这样耗时的过程。步子迈大了会扯着蛋,所以 Redis 使用渐进式 rehash 小步搬迁。虽然慢一 点,但是肯定可以搬完。

dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing) {
    long index;
    dictEntry *entry;
    dictht *ht;
    // 这里进行小步搬迁
    if (dictIsRehashing(d)) _dictRehashStep(d);
    /* Get the index of the new element, or -1 if
    * the element already exists. */
    if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1) return             
    NULL;
    /* Allocate the memory and store the new entry.
    * Insert the element in top, with the assumption that in a database * system it     
    is more likely that recently added entries are accessed * more frequently. */
    // 如果字典处于搬迁过程中,要将新的元素挂接到新的数组下面
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0]; entry =             
    zmalloc(sizeof(*entry));
    entry->next = ht->table[index];
    ht->table[index] = entry; ht->used++;
    /* Set the hash entry fields. */
    dictSetKey(d, entry, key);
    return entry; 
}

搬迁操作埋伏在当前字典的后续指令中(来自客户端的 hset/hdel 指令等),但是有可能客户端闲下来了,没有了后续指令来触发这个搬迁,那么 Redis 就置之不理了么?当然不会,优雅的 Redis 怎么可能设计的这样潦草。Redis 还会在定时任务中对字典进行主动搬迁。

// 服务器定时任务
void databaseCron() { ...
    if (server.activerehashing) {
        for (j = 0; j < dbs_per_call; j++) {
            int work_done = incrementallyRehash(rehash_db); 
                if (work_done) {
                /* If the function did some work, stop here, we'll do * more at the         
              next cron loop. */
                break; 
                } else {
                /* If this db didn't need rehash, we'll try the next one. */
                    rehash_db++;
                    rehash_db %= server.dbnum; 
                }
            } 
        }
}

查找过程

插入和删除操作都依赖于查找,先必须把元素找到,才可以进行数据结构的修改操作。 hashtable 的元素是在第二维的链表上,所以首先我们得想办法定位出元素在哪个链表上。

func get(key) {
        let index = hash_func(key) % size; 
        let entry = table[index]; 
        while(entry != NULL) {
            if entry.key == target { 
                return entry.value;
             }
            entry = entry.next;
        } 
}

值得注意的是代码中的 hash_func,它会将 key 映射为一个整数,不同的 key 会被映射成分布比较均匀散乱的整数。只有 hash 值均匀了,整个 hashtable 才是平衡的,所有的二维链表的长度就不会差距很远,查找算法的性能也就比较稳定。

hash 函数

hashtable 的性能好不好完全取决于 hash 函数的质量。hash 函数如果可以将 key 打散的比较均匀,那么这个 hash 函数就是个好函数。Redis 的字典默认的 hash 函数是 siphash。siphash 算法即使在输入 key 很小的情况下,也可以产生随机性特别好的输出,而 且它的性能也非常突出。对于 Redis 这样的单线程来说,字典数据结构如此普遍,字典操作 也会非常频繁,hash 函数自然也是越快越好。

hash 攻击

如果 hash 函数存在偏向性,黑客就可能利用这种偏向性对服务器进行攻击。存在偏向性的 hash 函数在特定模式下的输入会导致 hash 第二维链表长度极为不均匀甚至所有的元素都集中到个别链表中,直接导致查找效率急剧下降,从 O(1)退化到 O(n)。有限的服务器 计算能力将会被 hashtable 的查找效率彻底拖垮。这就是所谓 hash 攻击。

扩容条件

/* Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d) {
        /* Incremental rehashing already in progress. Return. */
        if (dictIsRehashing(d)) return DICT_OK;
            /* If the hash table is empty expand it to the initial size. */
           if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
        /* If we reached the 1:1 ratio, and we are allowed to resize the hash *             table (global setting) or we should avoid it but the ratio between * elements/buckets is over the "safe" threshold, we resize doubling * the number of buckets. */
            if (d->ht[0].used >= d->ht[0].size &&(dict_can_resize ||d-        >ht[0].used/d->ht[0].size > dict_force_resize_ratio)){
            return dictExpand(d, d->ht[0].used*2);
                }
return DICT_OK; 

}

正常情况下,当 hash 表中元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。不过如果 Redis 正在做 bgsave,为了减少内存页的过多分离 (Copy On Write),Redis 尽量不去扩容 (dict_can_resize),但是如果 hash 表已经非常满了,元素的个数已经达到了第一维数组长度的 5 倍 (dict_force_resize_ratio),说明 hash 表已经过于拥挤了,这个时候就会强制扩容。

缩容条件

int htNeedsResize(dict *dict) { 
        long long size, used;
        size = dictSlots(dict);
        used = dictSize(dict);
        return (size > DICT_HT_INITIAL_SIZE &&
        (used*100/size < HASHTABLE_MIN_FILL));
}

当 hash 表因为元素的逐渐删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少hash 表的第一维数组空间占用。缩容的条件是元素个数低于数组长度的 10%。缩容不会考虑 Redis 是否正在做 bgsave。

set 的结构

Redis 里面 set 的结构底层实现也是字典,只不过所有的 value 都是 NULL,其它的特性和字典一模一样。

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值