一、简单动态字符串(SDS)

Redis 没有直接使用 C 语言传统的字符串表示, 而是自己构建了一种名为简单动态字符串(Simple Dynamic String,SDS)的抽象类型, 并将 SDS 用作 Redis 的默认字符串表示。Redis中C字符串只会作为字符串字面量用在一些无需对字符串值修改的地方。
除了用来保存数据库中的字符串值之外, SDS 还被用作缓冲区(buffer): AOF 模块中的 AOF 缓冲区, 以及客户端状态中的输入缓冲区, 都是由 SDS 实现的。

SDS定义

  1. // sds.h/sdshdr
  2. struct sdshdr
  3. {
  4. // 记录buf数组中已经使用字节的数量,等于SDS所保存字符串的长度
  5. int len;
  6. // 记录buf数组中未使用字节的数量
  7. int free;
  8. // 字节数组, 用于保存字符串
  9. char buf[];
  10. };

image.png

SDS与C字符串的区别💖💖💖

常数时间获取字符串长度

因为 C 字符串并不记录自身的长度信息, 所以为了获取一个 C 字符串的长度, 程序必须遍历整个字符串, 对遇到的每个字符进行计数, 直到遇到代表字符串结尾的空字符为止, 这个操作的复杂度为 O(N) 。
和 C 字符串不同, 因为 SDS 在 len 属性中记录了 SDS 本身的长度, 所以获取一个 SDS 长度的复杂度仅为 O(1) 。通过使用 SDS 而不是 C 字符串, Redis 将获取字符串长度所需的复杂度从 O(N) 降低到了 O(1) , 这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈。
比如说, 因为字符串键在底层使用 SDS 来实现, 所以即使对一个非常长的字符串键反复执行 STRLEN 命令, 也不会对系统性能造成任何影响, 因为 STRLEN 命令的复杂度仅为 O(1) 。

杜绝缓冲区溢出

假设程序里有两个在内存中紧邻着的 C 字符串 s1 和 s2 , 其中 s1 保存了字符串 “Redis” , 而 s2 则保存了字符串 “MongoDB” , 如图 2-7 所示。
image.png
如果执行strcat(s1, " Cluster");后由于未给s1分配足够的空间,执行完strcat后,s1的数据溢出到s2所在空间中,导致s2以外被修改,如图2-8所示:
image.png
与 C 字符串不同, SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性: 当 SDS API 需要对 SDS 进行修改时, API 会先检查 SDS 的空间是否满足修改所需的要求, 如果不满足的话, API 会自动将 SDS 的空间扩展至执行修改所需的大小, 然后才执行实际的修改操作, 所以使用 SDS 既不需要手动修改 SDS 的空间大小, 也不会出现前面所说的缓冲区溢出问题。
Snipaste_2022-04-29_17-25-29.png

减少修改字符串时的内存重分配次数

因为 C 字符串并不记录自身的长度, 所以对于一个包含了 N 个字符的 C 字符串来说, 这个 C 字符串的底层实现总是一个 N+1 个字符长的数组(额外的一个字符空间用于保存空字符)。由于C 字符串的长度和底层数组的长度之间存在着这种关联性, 所以每次增长或者缩短一个 C 字符串, 程序都总要对保存这个 C 字符串的数组进行一次内存重分配操作。

  • 如果程序执行的是增长字符串的操作, 比如拼接操作(append), 那么在执行这个操作之前, 程序需要先通过内存重分配来扩展底层数组的空间大小 —— 如果忘了这一步就会产生缓冲区溢出。
  • 如果程序执行的是缩短字符串的操作, 比如截断操作(trim), 那么在执行这个操作之后, 程序需要通过内存重分配来释放字符串不再使用的那部分空间 —— 如果忘了这一步就会产生内存泄漏。

因为内存重分配涉及复杂的算法, 并且可能需要执行系统调用, 所以它通常是一个比较耗时的操作:

  • 在一般程序中, 如果修改字符串长度的情况不太常出现, 那么每次修改都执行一次内存重分配是可以接受的。
  • 但是 Redis 作为数据库, 经常被用于速度要求严苛、数据被频繁修改的场合, 如果每次修改字符串的长度都需要执行一次内存重分配的话, 那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分, 如果这种修改频繁地发生的话, 可能还会对性能造成影响。

为了避免 C 字符串的这种缺陷, SDS 通过未使用空间(free)解除了字符串长度(len)和底层数组长度(free+len+1)之间的关联。通过未使用空间, SDS 实现了空间预分配和惰性空间释放两种优化策略。

空间预分配

空间预分配用于优化 SDS 的字符串增长操作: 当 SDS 的 API 对一个 SDS 进行修改, 并且需要对 SDS 进行空间扩展的时候, 程序不仅会为 SDS 分配修改所必须要的空间, 还会为 SDS 分配额外的未使用空间。额外分配的未使用空间数量的公式如下:

  • 如果对SDS进行修改后,数据结构与对象 - 图5,那么程序分配和len属性同样大小的未使用空间free,此时SDS的len属性的值和free属性的值相同,SDS的buf大小为:len+free+1B。其中的1B为结束符'\0'
  • 如果对SDS进行修改后,SDS的数据结构与对象 - 图6,那么程序会为free分配1MB的未使用空间。

在扩展 SDS 空间之前, SDS API 会先检查未使用空间是否足够, 如果足够的话, API 就会直接使用未使用空间, 而无须执行内存重分配。
通过空间预分配策略, Redis 可以减少连续执行字符串增长操作所需的内存重分配次数, 将连续增长 N 次字符串所需的内存重分配次数从必定 N 次降低为最多 N 次。
Snipaste_2022-04-29_17-25-29.png

惰性空间释放

惰性空间释放用于优化 SDS 的字符串缩短操作: 当 SDS 的 API 需要缩短 SDS 保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节, 而是使用 free 属性将这些字节的数量记录起来, 并等待将来使用。

二进制安全

C 字符串中的字符必须符合某种编码(比如 ASCII), 并且除了字符串的末尾之外, 字符串里面不能包含空字符, 否则最先被程序读入的空字符将被误认为是字符串结尾。这些限制使得 C 字符串只能保存文本数据, 而不能保存像图片、音频、视频、压缩文件这样的二进制数据。如下C字符串只能识别”Redis”,不能识别”Cluster”。
image.png
为了确保 Redis 可以适用于各种不同的使用场景, SDS 的 API 都是二进制安全的(binary-safe): 所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据, 程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的, 它被读取时就是什么样。所以SDS的buf属性也称为字节数组,Redis既可以用该数组保存文本数据,也可以保存图片、音频、视频等二进制数据。

兼容部分C字符串函数

虽然 SDS 的 API 都是二进制安全的, 但它们一样遵循 C 字符串以空字符结尾的惯例: 这些 API 总会将 SDS 保存的数据的末尾设置为空字符, 并且总会在为 buf 数组分配空间时多分配一个字节来容纳这个空字符, 这是为了让那些保存文本数据的 SDS 可以重用一部分 <string.h> 库定义的函数。

SDS主要API

函数 作用 时间复杂度
sdsnew 创建一个包含给定 C 字符串的 SDS 。 O(N) , N 为给定 C 字符串的长度。
sdsempty 创建一个不包含任何内容的空 SDS 。 O(1)
sdsfree 释放给定的 SDS 。 O(1)
sdslen 返回 SDS 的已使用空间字节数。 这个值可以通过读取 SDS 的 len 属性来直接获得, 复杂度为 O(1) 。
sdsavail 返回 SDS 的未使用空间字节数。 这个值可以通过读取 SDS 的 free 属性来直接获得, 复杂度为 O(1) 。
sdsdup 创建一个给定 SDS 的副本(copy)。 O(N) , N 为给定 SDS 的长度。
sdsclear 清空 SDS 保存的字符串内容。 因为惰性空间释放策略,复杂度为 O(1) 。
sdscat 将给定 C 字符串拼接到 SDS 字符串的末尾。 O(N) , N 为被拼接 C 字符串的长度。
sdscatsds 将给定 SDS 字符串拼接到另一个 SDS 字符串的末尾。 O(N) , N 为被拼接 SDS 字符串的长度。
sdscpy 将给定的 C 字符串复制到 SDS 里面, 覆盖 SDS 原有的字符串。 O(N) , N 为被复制 C 字符串的长度。
sdsgrowzero 用空字符将 SDS 扩展至给定长度。 O(N) , N 为扩展新增的字节数。
sdsrange 保留 SDS 给定区间内的数据, 不在区间内的数据会被覆盖或清除。 O(N) , N 为被保留数据的字节数。
sdstrim 接受一个 SDS 和一个 C 字符串作为参数, 从 SDS 左右两端分别移除所有在 C 字符串中出现过的字符。 O(M*N) , M 为 SDS 的长度, N 为给定 C 字符串的长度。
sdscmp 对比两个 SDS 字符串是否相同。 O(N) , N 为两个 SDS 中较短的那个 SDS 的长度。

二、链表

链表定义

  1. // 链表结点类型定义在: adlist.h/listNode
  2. typedef struct listNode {
  3. // 前置结点
  4. struct listNode *prev;
  5. // 后置节点
  6. struct listNode *next;
  7. // 节点的值
  8. void *value;
  9. }listNode;
  10. // 链表类型定义在: adlist.h/list
  11. typedef struct list {
  12. // 表头节点
  13. listNode *head;
  14. // 表尾节点
  15. listNode *tail;
  16. // 链表所包含的节点数量
  17. unsigned long len;
  18. // 节点值复制函数
  19. void *(*dup)(void *ptr);
  20. // 节点值释放函数
  21. void (*free)(void *ptr);
  22. // 节点值对比函数
  23. int (*match)(void *ptr, void *key);
  24. } list;

list 结构为链表提供了表头指针 head 、表尾指针 tail , 以及链表长度计数器 len , 而 dup 、 free 和 match 成员则是用于实现多态链表所需的类型特定函数:

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

Snipaste_2022-04-30_15-50-21.png
Redis 的链表实现的特性总结如下:

  • 双端: 链表节点带有 prev 和 next 指针, 获取某个节点的前置节点和后置节点的复杂度都是 O(1) 。
  • 无环: 表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL , 对链表的访问以 NULL 为终点。
  • 带表头指针和表尾指针: 通过 list 结构的 head 指针和 tail 指针, 程序获取链表的表头节点和表尾节点的复杂度为 O(1) 。
  • 带链表长度计数器: 程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数, 程序获取链表中节点数量的复杂度为 O(1) 。
  • 多态: 链表节点使用 void* 指针来保存节点值, 并且可以通过 list 结构的 dup 、 free 、 match 三个属性为节点值设置类型特定函数, 所以链表可以用于保存各种不同类型的值。

链表主要API

函数 作用 时间复杂度
listSetDupMethod 将给定的函数设置为链表的节点值复制函数。 O(1) 。
listGetDupMethod 返回链表当前正在使用的节点值复制函数。 复制函数可以通过链表的 dup 属性直接获得, O(1)
listSetFreeMethod 将给定的函数设置为链表的节点值释放函数。 O(1) 。
listGetFree 返回链表当前正在使用的节点值释放函数。 释放函数可以通过链表的 free 属性直接获得, O(1)
listSetMatchMethod 将给定的函数设置为链表的节点值对比函数。 O(1)
listGetMatchMethod 返回链表当前正在使用的节点值对比函数。 对比函数可以通过链表的 match 属性直接获得, O(1)
listLength 返回链表的长度(包含了多少个节点)。 链表长度可以通过链表的 len 属性直接获得, O(1) 。
listFirst 返回链表的表头节点。 表头节点可以通过链表的 head 属性直接获得, O(1) 。
listLast 返回链表的表尾节点。 表尾节点可以通过链表的 tail 属性直接获得, O(1) 。
listPrevNode 返回给定节点的前置节点。 前置节点可以通过节点的 prev 属性直接获得, O(1) 。
listNextNode 返回给定节点的后置节点。 后置节点可以通过节点的 next 属性直接获得, O(1) 。
listNodeValue 返回给定节点目前正在保存的值。 节点值可以通过节点的 value 属性直接获得, O(1) 。
listCreate 创建一个不包含任何节点的新链表。 O(1)
listAddNodeHead 将一个包含给定值的新节点添加到给定链表的表头。 O(1)
listAddNodeTail 将一个包含给定值的新节点添加到给定链表的表尾。 O(1)
listInsertNode 将一个包含给定值的新节点添加到给定节点的之前或者之后。 O(1)
listSearchKey 查找并返回链表中包含给定值的节点。 O(N) , N 为链表长度。
listIndex 返回链表在给定索引上的节点。 O(N) , N 为链表长度。
listDelNode 从链表中删除给定节点。 O(1) 。
listRotate 将链表的表尾节点弹出,然后将被弹出的节点插入到链表的表头, 成为新的表头节点。 O(1)
listDup 复制一个给定链表的副本。 O(N) , N 为链表长度。
listRelease 释放给定链表,以及链表中的所有节点。 O(N) , N 为链表长度。

三、字典

字典, 又称符号表(symbol table)、关联数组(associative array)或者映射(map), 是一种用于保存键值对(key-value pair)的抽象数据结构。字典中的每个键都是独一无二的, 程序可以在字典中根据键查找与之关联的值, 或者通过键来更新值, 又或者根据键来删除整个键值对, 等等。
字典在 Redis 中的应用相当广泛, 比如 Redis 的数据库就是使用字典来作为底层实现的, 对数据库的增、删、查、改操作也是构建在对字典的操作之上的。Redis 的字典使用哈希表作为底层实现, 一个哈希表里面可以有多个哈希表节点, 而每个哈希表节点就保存了字典中的一个键值对。

哈希表实现💖💖💖

  1. // 哈希表类型定义在dict.h/dictht
  2. typedef struct dictht {
  3. // 哈希表数组
  4. dictEntry **table;
  5. // 哈希表大小
  6. unsigned long size;
  7. // 哈希表大小掩码,用于计算索引值,总是等于 size - 1
  8. unsigned long sizemask;
  9. // 该哈希表已有节点的数量
  10. unsigned long used;
  11. } dictht;

table 属性是一个数组, 数组中的每个元素都是一个指向 dict.h/dictEntry 结构的指针, 每个 dictEntry 结构保存着一个键值对。
size 属性记录了哈希表的大小, 也即是 table 数组的大小, 而 used 属性则记录了哈希表目前已有节点(键值对)的数量。
sizemask 属性的值总是等于 size - 1 , 这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面。
Snipaste_2022-04-30_16-06-30.png

哈希表结点实现

  1. typedef struct dictEntry {
  2. // 键
  3. void *key;
  4. // 值
  5. union {
  6. void *val;
  7. uint64_t u64;
  8. int64_t s64;
  9. } v;
  10. // 指向下个哈希表节点,形成链表
  11. struct dictEntry *next;
  12. } dictEntry;

key 属性保存着键值对中的键, 而 v 属性则保存着键值对中的值, 其中键值对的值可以是一个指针, 或者是一个 uint64_t 整数, 又或者是一个 int64_t 整数。
next 属性是指向另一个哈希表节点的指针, 这个指针可以将多个哈希值相同的键值对连接在一次, 以此来解决键冲突(collision)的问题。
Snipaste_2022-04-30_16-06-30.png

字典的实现

  1. // 字典类型定义在:dict.h/dict
  2. typedef struct dict {
  3. // 类型特定函数
  4. dictType *type;
  5. // 私有数据
  6. void *privdata;
  7. // 哈希表
  8. dictht ht[2];
  9. // rehash 索引, 当 rehash 不在进行时,值为 -1
  10. int rehashidx; /* rehashing not in progress if rehashidx == -1 */
  11. } dict;
  12. typedef struct dictType {
  13. // 计算哈希值的函数
  14. unsigned int (*hashFunction)(const void *key);
  15. // 复制键的函数
  16. void *(*keyDup)(void *privdata, const void *key);
  17. // 复制值的函数
  18. void *(*valDup)(void *privdata, const void *obj);
  19. // 对比键的函数
  20. int (*keyCompare)(void *privdata, const void *key1, const void *key2);
  21. // 销毁键的函数
  22. void (*keyDestructor)(void *privdata, void *key);
  23. // 销毁值的函数
  24. void (*valDestructor)(void *privdata, void *obj);
  25. } dictType;

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

  • type 属性是一个指向 dictType 结构的指针, 每个 dictType 结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数。
  • 而 privdata 属性则保存了需要传给那些类型特定函数的可选参数。

ht 属性是一个包含两个项的数组, 数组中的每个项都是一个 dictht 哈希表, 一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。
除了 ht[1] 之外, 另一个和 rehash 有关的属性就是 rehashidx : 它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1 。
Snipaste_2022-04-30_16-06-30.png

哈希算法

当要将一个新的键值对添加到字典里面时, 程序需要先根据键值对的键计算出哈希值和索引值, 然后再根据索引值, 将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。Redis 使用 MurmurHash2 算法来计算键的哈希值,该算法的优点在于, 即使输入的键是有规律的, 算法仍能给出一个很好的随机分布性, 并且算法的计算速度也非常快。Redis 计算哈希值和索引值的方法如下:

  1. // 使用字典设置的哈希函数,计算键 key 的哈希值
  2. hash = dict->type->hashFunction(key);
  3. // 使用哈希表的 sizemask 属性和哈希值,计算出索引值
  4. // 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
  5. index = hash & dict->ht[x].sizemask;

解决键冲突

冲突是指有两个及以上数量的键被分配到了哈希表数组的同一个索引上面。Redis中的哈希表使用链地址法解决键冲突:每个哈希表节点(dictEntry)都有一个 next 指针, 多个哈希表节点可以用 next 指针构成一个单向链表, 被分配到同一个索引上的多个节点可以用这个单向链表连接起来。
因为 dictEntry 节点组成的链表没有指向链表表尾的指针, 所以为了速度, 程序总是将新节点添加到链表的表头位置(复杂度为 O(1)), 排在其他已有节点的前面。
Snipaste_2022-04-30_16-06-30.png

rehash与渐进式rehash💖💖💖

随着操作的进行, 哈希表保存的键值对会逐渐地增多或者减少, 为了让哈希表的负载因子(load factor)维持在一个合理的范围之内, 当哈希表保存的键值对数量太多或者太少时, 程序需要对哈希表的大小进行相应的扩展或者收缩。
扩展和收缩哈希表的工作可以通过执行 rehash (重新散列)操作来完成, Redis 对字典的哈希表执行 rehash 的步骤如下:

  1. 为字典的 ht[1] 哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及 ht[0] 当前包含的键值对数量(即 ht[0].used 属性的值):
    • 如果执行的是扩展操作, 那么 ht[1] 的大小为第一个大于等于ht[0].used*2的 2n ;
    • 如果执行的是收缩操作, 那么 ht[1] 的大小为第一个大于等于ht[0].used 的 2n。
  2. 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到 ht[1] 哈希表的指定位置上。
  3. 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后 (ht[0] 变为空表), 释放 ht[0] , 将 ht[1] 设置为 ht[0] , 并在 ht[1] 新创建一个空白哈希表, 为下一次 rehash 做准备。

哈希表的扩展与收缩

当哈希表的负载因子小于 0.1 时, 程序自动开始对哈希表执行收缩操作。
当以下条件中的任意一个被满足时, 程序会自动开始对哈希表执行扩展操作:

  1. 服务器目前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 且哈希表的负载因子大于等于 1 ;
  2. 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 且哈希表的负载因子大于等于 5 ;

负载因子的计算公式,负载因子 = 哈希表已保存节点数量 / 哈希表大小:load_factor = ht[0].used / ht[0].size

根据 BGSAVE 命令或 BGREWRITEAOF 命令是否正在执行, 服务器执行扩展操作所需的负载因子并不相同, 这是因为在执行 BGSAVE 命令或 BGREWRITEAOF 命令的过程中, Redis 需要创建当前服务器进程的子进程, 而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率, 所以在子进程存在期间, Redis服务器会提高执行扩展操作所需的负载因子, 从而尽可能地避免在子进程存在期间进行哈希表扩展操作, 这可以避免不必要的内存写入操作, 最大限度地节约内存。

渐进式rehash💖💖💖

扩展或收缩哈希表需要将 ht[0] 里面的所有键值对 rehash 到 ht[1] 里面, 但这个 rehash 动作并不是一次性、集中式地完成的, 而是分多次、渐进式地完成的。
需要渐进式rehash的原因在于:如果ht[0]里只保存少量的键值对,服务器可以在瞬间将这些键值对rehash到ht[1];如果ht[0]里保存的键值对数量庞大,一次性rehash会导致服务器一段时间内停止服务。
因此, 为了避免 rehash 对服务器性能造成影响, 服务器不是一次性将 ht[0] 里面的所有键值对全部 rehash 到 ht[1] , 而是分多次、渐进式地将 ht[0] 里面的键值对慢慢地 rehash 到 ht[1] 。
redis里哈希表渐进式rehash的步骤如下:

  1. 为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
  2. 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
  3. 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
  4. 随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。

渐进式 rehash 的好处:它采取分而治之的方式, 将 rehash 键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。

渐进式 rehash 执行期间的哈希表操作

因为在进行渐进式 rehash 的过程中, 字典会同时使用 ht[0] 和 ht[1] 两个哈希表, 所以在渐进式 rehash 进行期间, 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在 ht[0] 里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找, 诸如此类。
另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作: 这一措施保证了 ht[0] 包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。

个人思考:如何同时采用一次性rehash/渐进式rehash操作。
如上所述,如果ht[0]中保存少量的键值对时可以进行一次性哈希操作,将ht[0]中所有的键值对rehash到ht[1]中,但如何界定少量与大量的键值对?我觉得,如果在dictht中有一个记录当前哈希表中键值对的个数的静态变量(如kvcnt),每次插入或删除键值对时更新kvcnt即可。当kvcnt小于等于某个固定值时进行一次性哈希操作,否则进行渐进式rehash操作。

字典API

函数 作用 时间复杂度
dictCreate 创建一个新的字典。 O(1)
dictAdd 将给定的键值对添加到字典里面。 O(1)
dictReplace 将给定的键值对添加到字典里面, 如果键已经存在于字典,那么用新值取代原有的值。 O(1)
dictFetchValue 返回给定键的值。 O(1)
dictGetRandomKey 从字典中随机返回一个键值对。 O(1)
dictDelete 从字典中删除给定键所对应的键值对。 O(1)
dictRelease 释放给定字典,以及字典中包含的所有键值对。 O(N) , N 为字典包含的键值对数量。

四、跳跃表💖💖💖

参考资料:
https://www.jianshu.com/p/9d8296562806
https://juejin.cn/post/6910476641990868999
https://juejin.cn/post/6844903446475177998
https://ethsonliu.com/2019/09/skip-list.html

跳跃表实现

是一个可以在有序元素中实现快速查询的数据结构,其插入,查找,删除操作的平均效率都为O(logn)。Redis 只在两个地方用到了跳跃表, 一个是实现有序集合键, 另一个是在集群节点中用作内部数据结构。
Redis 的跳跃表由redis.h/zskiplistNoderedis.h/zskiplist两个结构定义, 其中 zskiplistNode 结构用于表示跳跃表节点, 而 zskiplist 结构则用于保存跳跃表节点的相关信息, 比如节点的数量, 以及指向表头节点和表尾节点的指针, 等等。
注:表头节点和其他节点的构造是一样的, 表头节点也有后退指针、分值和成员对象, 不过表头节点的这些属性都不会被用到, 所以图中省略了这些部分, 只显示了表头节点的各个层。
Snipaste_2022-05-05_20-01-21.png

  1. // 跳跃表节点的实现由 redis.h/zskiplistNode 结构定义
  2. typedef struct zskiplistNode {
  3. // 后退指针
  4. struct zskiplistNode *backward;
  5. // 分值
  6. double score;
  7. // 成员对象
  8. robj *obj;
  9. // 层
  10. struct zskiplistLevel {
  11. // 前进指针
  12. struct zskiplistNode *forward;
  13. // 跨度
  14. unsigned int span;
  15. } level[];
  16. } zskiplistNode;
  17. typedef struct zskiplist {
  18. // 跳跃表的表头节点和表尾节点: 程序定位表头节点和表尾节点的复杂度为O(1).
  19. struct zskiplistNode *header, *tail;
  20. // 表中节点的数量(表头节点不计算在内): 程序可以在O(1)复杂度内返回跳跃表的长度.
  21. unsigned long length;
  22. // 表中层数最大的节点的层数(表头节点的层数不计算在内): O(1)复杂度获取最大层层数
  23. int level;
  24. } zskiplist;

层:跳跃表节点的 level 数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度, 一般来说, 层的数量越多, 访问其他节点的速度就越快。每次创建一个新跳跃表节点的时候, 程序都根据幂次定律 (power law,越大的数出现的概率越小) 随机生成一个介于 1 和 32 之间的值作为 level 数组的大小, 这个大小就是层的“高度”。
前进指针:每个层都有一个指向表尾方向的前进指针(level[i].forward属性), 用于从表头向表尾方向访问节点。
图 5-3 用虚线表示出了程序从表头向表尾方向, 遍历跳跃表中所有节点的路径:

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

Snipaste_2022-05-05_20-01-21.png
跨度:层的跨度(level[i].span 属性)用于记录两个节点之间的距离

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

跨度实际上是用来计算排位(rank)的: 在查找某个节点的过程中, 将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。
图 5-4 用虚线标记了在跳跃表中查找分值为 3.0 、成员对象为 o3 的节点时, 沿途经历的层: 查找的过程只经过了一个层, 并且层的跨度为 3 , 所以目标节点在跳跃表中的排位为 3 。
Snipaste_2022-05-05_20-20-02.png
图 5-5 用虚线标记了在跳跃表中查找分值为 2.0 、 成员对象为 o2 的节点时, 沿途经历的层: 在查找节点的过程中, 程序经过了两个跨度为 1 的节点, 因此可以计算出, 目标节点在跳跃表中的排位为 2 。
Snipaste_2022-05-05_20-20-02.png

分值和成员:节点的分值(score 属性)是一个 double 类型的浮点数, 跳跃表中的所有节点都按分值从小到大来排序。节点的成员对象(obj 属性)是一个指针, 它指向一个字符串对象, 而字符串对象则保存着一个 SDS 值。
在同一个跳跃表中, 各个节点保存的成员对象必须是唯一的, 但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序, 成员对象较小的节点会排在前面(靠近表头的方向), 而成员对象较大的节点则会排在后面(靠近表尾的方向)。
在图 5-7 所示的跳跃表中, 三个跳跃表节点都保存了相同的分值 10086.0 , 但保存成员对象 o1 的节点却排在保存成员对象 o2 和 o3 的节点之前, 而保存成员对象 o2 的节点又排在保存成员对象 o3 的节点之前, 由此可见, o1 、 o2 、 o3 三个成员对象在字典中的排序为o1 <= o2 <= o3
Snipaste_2022-05-05_20-20-02.png

跳跃表 API

函数 作用 时间复杂度
zslCreate 创建一个新的跳跃表。 O(1)
zslFree 释放给定跳跃表,以及表中包含的所有节点。 O(N) , N 为跳跃表的长度。
zslInsert 将包含给定成员和分值的新节点添加到跳跃表中。 平均 O(log N) ,最坏 O(N) , N 为跳跃表长度。
zslDelete 删除跳跃表中包含给定成员和分值的节点。 平均 O(log N) ,最坏 O(N) , N 为跳跃表长度。
zslGetRank 返回包含给定成员和分值的节点在跳跃表中的排位。 平均 O(log N) ,最坏 O(N) , N 为跳跃表长度。
zslGetElementByRank 返回跳跃表在给定排位上的节点。 平均 O(log N) ,最坏 O(N) , N 为跳跃表长度。
zslIsInRange 给定一个分值范围(range), 比如 0 到 15 , 20 到 28 ,诸如此类, 如果给定的分值范围包含在跳跃表的分值范围之内, 那么返回 1 ,否则返回 0 。 通过跳跃表的表头节点和表尾节点, 这个检测可以用 O(1) 复杂度完成。
zslFirstInRange 给定一个分值范围, 返回跳跃表中第一个符合这个范围的节点。 平均 O(log N) ,最坏 O(N) 。 N 为跳跃表长度。
zslLastInRange 给定一个分值范围, 返回跳跃表中最后一个符合这个范围的节点。 平均 O(log N) ,最坏 O(N) 。 N 为跳跃表长度。
zslDeleteRangeByScore 给定一个分值范围, 删除跳跃表中所有在这个范围之内的节点。 O(N) , N 为被删除节点数量。
zslDeleteRangeByRank 给定一个排位范围, 删除跳跃表中所有在这个范围之内的节点。 O(N) , N 为被删除节点数量。

五、整数集合

整数集合(intset)是集合键的底层实现之一:当一个集合只包含整数值元素, 并且该集合的元素数量不多时, Redis 就会使用整数集合作为集合键的底层实现。整数集合可以保存类型为 int16_t 、 int32_t 或者 int64_t 的整数值, 并且保证集合中不会出现重复元素。

整数集合实现

  1. // intset.h/intset 结构表示一个整数集合
  2. typedef struct intset {
  3. // 编码方式
  4. uint32_t encoding;
  5. // 集合包含的元素数量
  6. uint32_t length;
  7. // 保存元素的数组
  8. int8_t contents[];
  9. } intset;

contents数组是整数集合的底层实现: 整数集合的每个元素都是 contents 数组的一个数组项, 各个项在数组中按值从小到大有序地排列, 并且数组中不包含任何重复项。
length属性记录了整数集合包含的元素数量, 也即是 contents 数组的长度。
虽然 intset 结构将 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 )。

    升级

    每当将一个新元素添加到整数集合中,且新元素类型比整数集合现有元素类型都长时,整数集合要先升级,然后才能将新元素添加到整数集合里。升级步骤:

  • 根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间;

  • 将底层数组所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变;
  • 将新元素添加到底层数组里。
    • 新元素小于所有现有元素时, 会被放置在底层数组的最开头(索引 0 );
    • 新元素大于所有现有元素时, 会被放置在底层数组的最末尾(索引 length-1 )。

升级策略的优点:

  • 提升灵活性:因为整数集合可以通过自动升级底层数组来适应新元素, 所以可以随意地将 int16_t 、 int32_t 或者 int64_t 类型的整数添加到集合中, 而不必担心出现类型错误, 这种做法非常灵活。
  • 节约内存:整数集合既可以让集合能同时保存三种不同类型的值, 又可以确保升级操作只会在有需要的时候进行, 这可以尽量节省内存。

    降级

    整数集合不支持降级操作, 一旦对数组进行了升级, 编码就会一直保持升级后的状态。

    整数集合API

    | 函数 | 作用 | 时间复杂度 | | —- | —- | —- | | intsetNew | 创建一个新的整数集合。 | O(1) | | intsetAdd | 将给定元素添加到整数集合里面。 | O(N) | | intsetRemove | 从整数集合中移除给定元素。 | O(N) | | intsetFind | 检查给定值是否存在于集合。 | 因为底层数组有序,查找可以通过二分查找法来进行, 所以复杂度为 O(log N) 。 | | intsetRandom | 从整数集合中随机返回一个元素。 | O(1) | | intsetGet | 取出底层数组在给定索引上的元素。 | O(1) | | intsetLen | 返回整数集合包含的元素个数。 | O(1) | | intsetBlobLen | 返回整数集合占用的内存字节数。 | O(1 |

六、压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键/哈希键只包含少量列表项, 并且每个列表项要么就是小整数值, 要么就是长度比较短的字符串, 那么 Redis 就会使用压缩列表来做列表键/哈希键的底层实现。

压缩列表构成💖💖

使用压缩列表的目的是为了节约内存。ziplist是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个ziplist可以包含任意多个结点(entry),每个entry可以保存一个字节数组或一个整数值。
image.png

  • zlbytes:uint32_t类型,记录整个ziplist占用的内存字节数。在ziplist进行内存重分配或计算zlend时使用。
  • zltail:uint32_t类型,记录ziplist尾结点距离ziplist起始结点有多少字节。通过该偏移量程序无序遍历整个ziplist就可以确定表尾结点的地址。
  • zlen:uint16_t类型,记录压缩列表的结点数量,当该属性值小于UINT16_MAX(65536)时,该属性值就是ziplist实际包含结点的数量。当该属性值大于UINT16_MAX时,结点的真实数量需要遍历整个ziplist才能计算出。
  • entryX:列表结点,结点的长度由结点保存的内容决定。每个结点都由previous_entry_lengthencodingcontent三部分组成。
  • zlend:uint8_t,特殊值0xFF,用于标记ziplist尾端。

    压缩列表结点构成

    每个压缩列表节点可以保存一个字节数组或者一个整数值。其中, 字节数组可以是以下三种长度的其中一种:

  • 长度小于等于 63 (2^6-1)字节的字节数组;

  • 长度小于等于 16383 (2^14-1) 字节的字节数组;
  • 长度小于等于 4294967295 (2^32-1)字节的字节数组;

整数值则可以是以下六种长度的其中一种:

  • 4 位长,介于 0 至 12 之间的无符号整数;
  • 1 字节长的有符号整数;
  • 3 字节长的有符号整数;
  • int16_t 类型整数;
  • int32_t 类型整数;
  • int64_t 类型整数。

    previous_entry_length

    节点的 previous_entry_length 属性以字节为单位, 记录了压缩列表中前一个节点的长度。previous_entry_length 属性的长度可以是 1 字节或者 5 字节:

  • 如果前一节点的长度小于 254 字节, 那么 previous_entry_length 属性的长度为 1 字节: 前一节点的长度就保存在这一个字节里面。

  • 如果前一节点的长度大于等于 254 字节, 那么 previous_entry_length 属性的长度为 5 字节: 其中属性的第一字节会被设置为 0xFE (十进制值 254), 而之后的四个字节则用于保存前一节点的长度。

ziplist的从表尾向表头遍历操作的实现原理: 因为节点的 previous_entry_length 属性记录了前一个节点的长度, 所以程序可以根据当前节点的起始地址来计算出前一个节点的起始地址(当前结点指针减去当前结点的previous_entry_length属性值)。

encoding

节点的 encoding 属性记录了节点的 content 属性所保存数据的类型以及长度:

  • 一字节、两字节或者五字节长, 值的最高位为 00 、 01 或者 10开头 的是字节数组编码: 这种编码表示节点的 content 属性保存着字节数组, 数组的长度由编码除去最高两位之后的其他位记录;
  • 一字节长, 值的最高位以 11 开头的是整数编码: 这种编码表示节点的 content 属性保存着整数值, 整数值的类型和长度由编码除去最高两位之后的其他位记录;

下表记录了所有可用的字节数组编码与可用的整数编码,表格中的下划线_表示留空,而b、x等变量则代表实际的二进制数据。

编码 编码长度 content 属性保存的值
00bbbbbb 1 字节 长度小于等于 63 字节的字节数组。
01bbbbbb xxxxxxxx 2 字节 长度小于等于 16383 字节的字节数组。
10__ aaaaaaaa bbbbbbbb cccccccc dddddddd 5 字节 长度小于等于 4294967295 的字节数组。
编码 编码长度 content 属性保存的值
11000000 1 字节 int16_t 类型的整数。
11010000 1 字节 int32_t 类型的整数。
11100000 1 字节 int64_t 类型的整数。
11110000 1 字节 24 位有符号整数。
11111110 1 字节 8 位有符号整数。
1111xxxx 1 字节 使用这一编码的节点没有相应的 content 属性, 因为编码本身的 xxxx 四个位已经保存了一个介于 0 和 12 之间的值, 所以它无须 content 属性。

content

节点的 content 属性负责保存节点的值, 节点值可以是一个字节数组或者整数, 值的类型和长度由节点的 encoding 属性决定。
下图展示了一个保存字节数组的节点示例:

  • 编码的最高两位 00 表示节点保存的是一个字节数组;
  • 编码的后六位 001011 记录了字节数组的长度 11 ;
  • content 属性保存着节点的值 “hello world” 。

image.png

连锁更新💖💖💖

ziplist每个结点的previous_endtry_length属性都记录了前一个结点的长度:

  • 如果前一节点的长度小于 254 字节, 那么 previous_entry_length 属性需要用 1 字节长的空间来保存这个长度值。
  • 如果前一节点的长度大于等于 254 字节, 那么 previous_entry_length 属性需要用 5 字节长的空间来保存这个长度值。

考虑产生连锁更新的特殊情况:在一个压缩列表中, 有多个连续的、长度介于 250 字节到 253 字节之间的节点 e1 至 eN【下图一所示】,这N个结点的长度都小于254字节,所以记录这些结点的长度只需1字节长度的previous_entry_length属性。
这时如果将一个长度大于等于254的新结点数据结构与对象 - 图21设置为该ziplist的表头结点,那么数据结构与对象 - 图22会成为e1的前置结点【下图二所示】。由于e1的previous_entry_length属性仅1字节,无法保存新结点的长度,所以需要对ziplist执行空间重分配操作,并将e1结点的previous_entry_length属性从1字节扩展为5字节。
由于e1长度原本在250~253之间,在对e1的previous_entry_length扩展到5字节空间后,e2结点的1字节previous_entry_length属性不能再保存e1的长度,所以也需将e2的previous_entry_length属性扩展到5字节,同理后续结点都需要进行扩展。
image.png
在特殊情况下产生的连续多次空间扩展操作称之为”连锁更新”(cascade update)。除了添加新节点可能会引发连锁更新之外, 删除节点也可能会引发连锁更新。因为连锁更新在最坏情况下需要对压缩列表执行 N 次空间重分配操作, 而每次空间重分配的最坏复杂度为 O(N) , 所以连锁更新的最坏复杂度为 O(N^2) 。
虽然连锁更新时间复杂度较高,但造成性能问题的概率较低:

  • ziplist恰好有多个连续的,长度介于250~253字节的结点,连锁更新才能发生,但实际中该情况不多见;
  • 即使出现连锁更新,只要更新数量不多,就不会对性能产生影响。

压缩列表API

因为 ziplistPush 、 ziplistInsert 、 ziplistDelete 和 ziplistDeleteRange 四个函数都有可能会引发连锁更新, 所以它们的最坏复杂度都是 O(N^2)。

函数 作用 算法复杂度
ziplistNew 创建一个新的压缩列表。 O(1)
ziplistPush 创建一个包含给定值的新节点, 并将这个新节点添加到压缩列表的表头或者表尾。 平均 O(N) ,最坏 O(N^2) 。
ziplistInsert 将包含给定值的新节点插入到给定节点之后。 平均 O(N) ,最坏 O(N^2) 。
ziplistIndex 返回压缩列表给定索引上的节点。 O(N)
ziplistFind 在压缩列表中查找并返回包含了给定值的节点。 因为节点的值可能是一个字节数组, 所以检查节点值和给定值是否相同的复杂度为 O(N) , 而查找整个列表的复杂度为 O(N^2)。
ziplistNext 返回给定节点的下一个节点。 O(1)
ziplistPrev 返回给定节点的前一个节点。 O(1)
ziplistGet 获取给定节点所保存的值。 O(1)
ziplistDelete 从压缩列表中删除给定的节点。 平均 O(N) ,最坏 O(N^2) 。
ziplistDeleteRange 删除压缩列表在给定索引上的连续多个节点。 平均 O(N) ,最坏 O(N^2) 。
ziplistBlobLen 返回压缩列表目前占用的内存字节数。 O(1)
ziplistLen 返回压缩列表目前包含的节点数量。 节点数量小于 65535 时 O(1) , 大于 65535 时 O(N) 。

七、对象

Redis没有直接使用简单动态字符串(SDS)、双端链表、字典、压缩列表、整数集合等数据结构直接实现键值数据库,而是基于这些数据结构创建了一个对象系统,该系统包括字符串对象,列表对象,哈希对象,集合对象和有序集合对象,每种对象都至少用到了前面的一种数据结构。详情见:基本数据类型
使用对象的两个好处:

  • Redis在执行命令前,可以根据对象类型判断该对象能否执行给定命令;
  • 可以针对不同场景,为对象设置多种数据结构,优化不同场景下的使用效率。

Redis 的对象系统还实现了基于引用计数技术的内存回收机制: 当程序不再使用某个对象的时候, 这个对象所占用的内存就会被自动释放; 另外, Redis 还通过引用计数技术实现了对象共享机制, 这一机制可以在适当的条件下, 通过让多个数据库键共享同一个对象来节约内存。
Redis对象带有访问时间记录信息, 该信息可以用于计算数据库键的空转时长, 在服务器启用了 maxmemory 功能的情况下, 空转时长较大的那些键可能会优先被服务器删除。

对象类型与编码

每次在Redis数据库种创建一个键值对时,至少会创建两个对象,一个对象用作键对象,另一个对象用作值对象。

  1. typedef struct redisObject {
  2. // 类型
  3. unsigned type:4;
  4. // 编码
  5. unsigned encoding:4;
  6. // 指向底层实现数据结构的指针
  7. void *ptr;
  8. // ...
  9. } robj;

类型

对象的 type 属性记录了对象的类型,取值如下表。对于Redis种保存的键值对而言:键总是一个字符串对象,而值可以是下表中的任意一种。对一个数据库键执行type命令时, 命令返回的结果为数据库键对应的值对象的类型, 而不是键对象的类型。

类型常量 对象的名称 TYPE 命令的输出
REDIS_STRING 字符串对象 “string”
REDIS_LIST 列表对象 “list”
REDIS_HASH 哈希对象 “hash”
REDIS_SET 集合对象 “set”
REDIS_ZSET 有序集合对象 “zset”

编码和底层实现

对象的 ptr 指针指向对象的底层实现数据结构, 而这些数据结构由对象的 encoding 属性决定。encoding 属性记录了对象所使用的编码, 也即是说这个对象使用了什么数据结构作为对象的底层实现, 这个属性的值可以是下表类型列列出的常量的其中一个。

类型 编码 对象 编码对应的数据结构
REDIS_STRING REDIS_ENCODING_INT 使用整数值实现的字符串对象。 long 类型的整数
REDIS_STRING REDIS_ENCODING_EMBSTR 使用 embstr 编码的简单动态字符串实现的字符串对象。 embstr 编码的简单动态字符串
REDIS_STRING REDIS_ENCODING_RAW 使用简单动态字符串实现的字符串对象。 简单动态字符串
REDIS_LIST REDIS_ENCODING_ZIPLIST 使用压缩列表实现的列表对象。 压缩列表
REDIS_LIST REDIS_ENCODING_LINKEDLIST 使用双端链表实现的列表对象。 双端链表
REDIS_HASH REDIS_ENCODING_ZIPLIST 使用压缩列表实现的哈希对象。 压缩列表
REDIS_HASH REDIS_ENCODING_HT 使用字典实现的哈希对象。 字典
REDIS_SET REDIS_ENCODING_INTSET 使用整数集合实现的集合对象。 整数集合
REDIS_SET REDIS_ENCODING_HT 使用字典实现的集合对象。 字典
REDIS_ZSET REDIS_ENCODING_ZIPLIST 使用压缩列表实现的有序集合对象。 压缩列表
REDIS_ZSET REDIS_ENCODING_SKIPLIST 使用跳跃表和字典实现的有序集合对象。 跳跃表和字典

OBJECT ENCODING 对不同编码的输出

对象所使用的底层数据结构 编码常量 OBJECT ENCODING 命令输出
整数 REDIS_ENCODING_INT “int”
embstr 编码的简单动态字符串(SDS) REDIS_ENCODING_EMBSTR “embstr”
简单动态字符串 REDIS_ENCODING_RAW “raw”
字典 REDIS_ENCODING_HT “hashtable”
双端链表 REDIS_ENCODING_LINKEDLIST “linkedlist”
压缩列表 REDIS_ENCODING_ZIPLIST “ziplist”
整数集合 REDIS_ENCODING_INTSET “intset”
跳跃表和字典 REDIS_ENCODING_SKIPLIST “skiplist”

字符串对象

编码

字符串对象的编码可以是 int 、 raw 或者 embstr 。

  • 如果一个字符串对象保存的是整数值, 且可以用 long类型表示该整数值, 那么字符串对象会将整数值保存在字符串对象结构的 ptr 属性里面(将 void* 转换成 long ), 并将字符串对象的编码设置为 int 。
    image.png
  • 如果字符串对象保存的是一个长度大于 39 字节的字符串值、范围大于long的整数、范围大于long double的浮点数,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值, 并将对象的编码设置为 raw。
    image.png
  • 如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度小于等于 39 字节, 那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。

image.png
embstr 编码是专门用于保存短字符串的一种优化编码方式, 这种编码和 raw 编码一样, 都使用 redisObject 结构和 sdshdr 结构来表示字符串对象,,但

  • raw 编码会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构, 而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含 redisObject 和 sdshdr 两个结构;
  • 释放 embstr 编码的字符串对象只需要调用一次内存释放函数, 而释放 raw 编码的字符串对象需要调用两次内存释放函数;
  • 因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面, 所以这种编码的字符串对象比起 raw 编码的字符串对象能够更好地利用缓存带来的优势。

此外,可以用 long double 类型表示的浮点数在 Redis 中也是作为字符串值来保存的: 如果要保存一个浮点数到字符串对象里面, 那么程序会先将这个浮点数转换成字符串值, 然后再保存起转换所得的字符串值。有需要时将字符串转换成浮点数,执行完操作后再转换成字符串。

编码转换

int 编码的字符串对象和 embstr 编码的字符串对象在条件满足的情况下, 会被转换为 raw 编码的字符串对象。
对于 int 编码的字符串对象来说, 如果向对象执行了一些命令, 使得这个对象保存的不再是整数值, 而是一个字符串值, 那么字符串对象的编码将从 int 变为 raw 。

  1. redis> SET number 10086
  2. OK
  3. redis> OBJECT ENCODING number
  4. "int"
  5. redis> APPEND number " is a good number!"
  6. (integer) 23
  7. redis> OBJECT ENCODING number
  8. "raw"

由于Redis没有为embstr编码的字符串编写修改程序(int和raw编码的程序有),所以embstr编码的字符串是只读的。因此对 embstr 编码的字符串对象执行任何修改命令时, 程序会先将对象的编码从 embstr 转换成 raw , 然后再执行修改命令。

  1. redis> SET msg "hello world"
  2. OK
  3. redis> OBJECT ENCODING msg
  4. "embstr"
  5. redis> APPEND msg " again!"
  6. (integer) 18
  7. redis> OBJECT ENCODING msg
  8. "raw"

列表对象

列表对象的编码可以是 ziplist 或者 linkedlist 。
ziplist编码的列表对象使用压缩列表作为底层实现, 每个压缩列表节点(entry)保存了一个列表元素。linkedlist编码的列表对象使用双端链表作为底层实现, 每个双端链表节点(node)都保存了一个字符串对象, 而每个字符串对象都保存了一个列表元素。
编码转换
当列表对象可以同时满足以下两个条件时, 列表对象使用 ziplist 编码:

  • 列表对象保存的所有字符串元素的长度都小于 64 字节;
  • 列表对象保存的元素数量小于 512 个;

不能满足这两个条件的列表对象需要使用 linkedlist 编码。

哈希对象

哈希对象的编码可以是 ziplist 或者 hashtable 。
ziplist 编码的哈希对象, 每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾, 因此:

  • 保存了同一键值对的两个节点总是紧挨在一起, 保存键的节点在前, 保存值的节点后;
  • 先添加到哈希对象中的键值对会被放在压缩列表的表头方向, 而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。

hashtable 编码的哈希对象, 哈希对象中的每个键值对都使用一个字典键值对来保存:

  • 字典的每个键都是一个字符串对象, 对象中保存了键值对的键;
  • 字典的每个值都是一个字符串对象, 对象中保存了键值对的值。

当哈希对象可以同时满足以下两个条件时, 哈希对象使用 ziplist 编码:

  1. 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
  2. 哈希对象保存的键值对数量小于 512 个;

不能满足这两个条件的哈希对象需要使用 hashtable 编码。

集合对象

集合对象的编码可以是 intset 或者 hashtable 。
intset编码的集合对象使用整数集合作为底层实现, 集合对象包含的所有元素都被保存在整数集合里面。 hashtable编码的集合对象使用字典作为底层实现, 字典的每个键都是一个字符串对象, 每个字符串对象包含了一个集合元素, 而字典的值则全部被设置为 NULL。
当集合对象可以同时满足以下两个条件时, 对象使用 intset 编码:

  • 集合对象保存的所有元素都是整数值;
  • 集合对象保存的元素数量不超过 512 个;

不能满足这两个条件的集合对象需要使用 hashtable 编码。

有序集合对象

有序集合的编码可以是 ziplist 或者 skiplist 。
ziplist 编码的有序集合对象, 每个集合元素使用两个紧挨在一起的压缩列表节点来保存, 第一个节点保存元素的成员(member), 而第二个元素则保存元素的分值(score)。
压缩列表内的集合元素按分值从小到大进行排序, 分值较小的元素被放置在靠近表头的方向, 而分值较大的元素则被放置在靠近表尾的方向。
skiplist 编码的有序集合对象使用 zset 结构作为底层实现, 一个 zset 结构同时包含一个字典和一个跳跃表:

  1. typedef struct zset {
  2. zskiplist *zsl;
  3. dict *dict;
  4. } zset;

zset 结构中的 zsl 跳跃表按分值从小到大保存了所有集合元素, 每个跳跃表节点都保存了一个集合元素: 跳跃表节点的 object 属性保存了元素的成员, 而跳跃表节点的 score 属性则保存了元素的分值。 通过zsl, 程序可以对有序集合进行范围型操作, 比如 ZRANK 、 ZRANGE 等命令就是基于跳跃表 API 来实现的。
zset 结构中的 dict 字典为有序集合创建了一个从成员到分值的映射, 字典中的每个键值对都保存了一个集合元素: 字典的键保存了元素的成员, 而字典的值则保存了元素的分值。 通过dict, 程序可以用 O(1) 复杂度查找给定成员的分值, ZSCORE 命令就是根据这一特性实现的, 而很多其他有序集合命令都在实现的内部用到了这一特性。

当有序集合对象可以同时满足以下两个条件时, 对象使用 ziplist 编码:

  • 有序集合保存的元素数量小于 128 个;
  • 有序集合保存的所有元素成员的长度都小于 64 字节;

不能满足以上两个条件的有序集合对象将使用 skiplist 编码。

类型检查与命令多态

Redis为了确保只有指定类型的键可以执行某些特定的命令, 在执行一个类型特定的命令之前, Redis 会先检查输入键的类型是否正确, 然后再决定是否执行给定的命令。
类型特定命令所进行的类型检查是通过 redisObject 结构的 type 属性来实现的:

  • 在执行一个类型特定命令之前, 服务器会先检查输入数据库键的值对象是否为执行命令所需的类型, 如果是的话, 服务器就对键执行指定的命令;
  • 否则, 服务器将拒绝执行命令, 并向客户端返回一个类型错误。

Redis 除了会根据值对象的类型来判断键是否能够执行指定命令之外, 还会根据值对象的编码方式, 选择正确的命令实现代码来执行命令。例如:列表对象有 ziplist 和 linkedlist 两种编码可用, 其中前者使用压缩列表 API 来实现列表命令, 而后者则使用双端链表 API 来实现列表命令。例如对一个键执行llen命令,服务器除了要确保执行命令的是列表键之外, 还需要根据键的值对象所使用的编码来选择正确的 LLEN 命令实现:

  • 如果列表对象的编码为 ziplist , 那么说明列表对象的实现为压缩列表, 程序将使用 ziplistLen 函数来返回列表的长度;
  • 如果列表对象的编码为 linkedlist , 那么说明列表对象的实现为双端链表, 程序将使用 listLength 函数来返回双端链表的长度;

    内存回收

    因为 C 语言并不具备自动的内存回收功能, 所以 Redis 在对象系统中构建了一个引用计数技术实现的内存回收机制, 通过该机制, 程序可以通过跟踪对象的引用计数信息, 在适当的时候自动释放对象并进行内存回收。
    每个对象的引用计数由redisObject结构的refCount属性记录, 对象的整个生命周期可以划分为创建对象、操作对象、释放对象三个阶段。对象的引用计数信息会随着对象的使用状态而不断变化:

  • 在创建一个新对象时, 引用计数的值会被初始化为 1 ;

  • 当对象被一个新程序使用时, 它的引用计数值会被增一;
  • 当对象不再被一个程序使用时, 它的引用计数值会被减一;
  • 当对象的引用计数值变为 0 时, 对象所占用的内存会被释放。

    内存共享

    除了用于实现引用计数内存回收机制之外, 对象的引用计数属性还带有对象共享的作用。
    在 Redis 中, 让多个键共享同一个值对象需要执行以下两个步骤:
  1. 将数据库键的值指针指向一个现有的值对象;
  2. 将被共享的值对象的引用计数增一。

共享对象机制有助于节约内存,数据库中保存相同对象的值越多,共享机制节约的内存越多。Redis 会在初始化服务器时, 创建一万个字符串对象, 这些对象包含了从 0 到 9999 的所有整数值, 当服务器需要用到值为 0 到 9999 的字符串对象时, 服务器就会使用这些共享对象, 而不是新创建对象。创建共享字符串对象的数量可以通过修改 redis.h/REDIS_SHARED_INTEGERS 常量来修改。

Redis 只对包含整数值的字符串对象进行共享
当服务器考虑将一个共享对象设置为键的值对象时, 程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同, 只有在共享对象和目标对象完全相同的情况下, 程序才会将共享对象用作键的值对象, 而一个共享对象保存的值越复杂, 验证共享对象和目标对象是否相同所需的复杂度就会越高, 消耗的 CPU 时间也会越多:

  • 如果共享对象是保存整数值的字符串对象, 验证操作的复杂度为 O(1) ;
  • 如果共享对象是保存字符串值的字符串对象, 验证操作的复杂度为 O(N) ;
  • 如果共享对象是包含了多个值(或者对象的)对象, 比如列表对象或者哈希对象, 验证操作的复杂度将会是 O(N^2) 。

对象空转时长

除了typeencodingptrrefcount四个属性之外, redisObject 结构包含的最后一个属性为lru属性, 该属性记录了对象最后一次被命令程序访问的时间。
OBJECT IDLETIME 命令可以打印出给定键的空转时长, 该时长就是通过将当前时间减去键的值对象的 lru 时间计算得出的。
注:OBJECT IDLETIME命令在访问键的值对象时, 不会修改值对象的 lru 属性。
键的空转时长另外一项作用是: 如果服务器打开了 maxmemory 选项, 并且服务器用于回收内存的算法为 volatile-lru 或者 allkeys-lru , 那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时, 空转时长较高的那部分键会优先被服务器释放, 从而回收内存。