Redis五大类型及底层实现原理

目录

简单动态字符串 
链表 
字典 
跳跃表 
整数集合 
压缩列表 
对象 node

对象的类型与编码
字符串对象
列表对象
哈希对象redis

集合对象
有序集合对象
类型检查与命令多态
内存回收
对象共享
对象的空转时长算法

 

简单动态字符串 

导读

  • Redis 只会使用 C 字符串做为字面量, 在大多数状况下, Redis 使用 SDS (Simple Dynamic String,简单动态字符串)做为字符串表示。
  • 比起 C 字符串, SDS 具备如下优势:
    1. 常数复杂度获取字符串长度。
    2. 杜绝缓冲区溢出。
    3. 减小修改字符串长度时所需的内存重分配次数。
    4. 二进制安全。
    5. 兼容部分 C 字符串函数。

 

简单动态字符串

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

SDS 的定义

每一个 sds.h/sdshdr 结构表示一个 SDS 值:数组

struct sdshdr {

    // 记录 buf 数组中已使用字节的数量
    // 等于 SDS 所保存字符串的长度
    int len;

    // 记录 buf 数组中未使用字节的数量
    int free;

    // 字节数组,用于保存字符串
    char buf[];

};

 

SDS vs C字符串

表 2-1 C 字符串和 SDS 之间的区别缓存

C 字符串 SDS
获取字符串长度的复杂度为O(N)。 获取字符串长度的复杂度为O(1)。
API 是不安全的,可能会形成缓冲区溢出。 API 是安全的,不会形成缓冲区溢出。
修改字符串长度N次必然须要执行N次内存重分配。 修改字符串长度N次最多须要执行N次内存重分配。
只能保存文本数据。 能够保存文本或者二进制数据。
可使用全部<string.h>库中的函数。 可使用一部分<string.h>库中的函数。

 

常数复杂度获取字符串长度

经过使用 SDS 而不是 C 字符串, Redis 将获取字符串长度所需的复杂度从 O(N) 下降到了 O(1) , 这确保了获取字符串长度的工做不会成为 Redis 的性能瓶颈。安全

 

杜绝缓冲区溢出

 减小修改字符串时带来的内存重分配次数:经过未使用空间, SDS 实现了空间预分配和惰性空间释放两种优化策略。服务器

  1. 空间预分配 - 经过这种策略, SDS 将连续增加 N 次字符串所需的内存重分配次数从一定 N 次下降为最多 N 次。

空间预分配用于优化 SDS 的字符串增加操做: 当 SDS 的 API 对一个 SDS 进行修改, 而且须要对 SDS 进行空间扩展的时候, 程序不只会为 SDS 分配修改所必需要的空间, 还会为 SDS 分配额外的未使用空间。数据结构

其中, 额外分配的未使用空间数量由如下公式决定:app

  • 若是对 SDS 进行修改以后, SDS 的长度(也便是 len 属性的值)将小于 1 MB , 那么程序分配和 len 属性一样大小的未使用空间, 这时 SDS len 属性的值将和 free 属性的值相同。 举个例子, 若是进行修改以后, SDS 的 len 将变成 13 字节, 那么程序也会分配 13 字节的未使用空间, SDS 的 buf 数组的实际长度将变成 13 + 13 + 1 = 27 字节(额外的一字节用于保存空字符)。
  • 若是对 SDS 进行修改以后, SDS 的长度将大于等于 1 MB , 那么程序会分配 1 MB 的未使用空间。 举个例子, 若是进行修改以后, SDS 的 len 将变成 30 MB , 那么程序会分配 1 MB 的未使用空间, SDS 的 buf 数组的实际长度将为 30 MB + 1 MB + 1 byte 。
  1. 惰性空间释放 - 经过这种策略, SDS 避免了缩短字符串时所需的内存重分配操做, 并为未来可能有的增加操做提供了优化。

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

与此同时, SDS 也提供了相应的 API , 让咱们能够在有须要时, 真正地释放 SDS 里面的未使用空间, 因此不用担忧惰性空间释放策略会形成内存浪费。 

 

二进制安全

  • 全部 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据, 程序不会对其中的数据作任何限制、过滤、或者假设 —— 数据在写入时是什么样的, 它被读取时就是什么样。这也是咱们将 SDS 的 buf 属性称为字节数组的缘由 —— Redis 不是用这个数组来保存字符, 而是用它来保存一系列二进制数据。
  • SDS 使用 len 属性的值而不是空字符来判断字符串是否结束。
  • 经过使用二进制安全的 SDS , 而不是 C 字符串, 使得 Redis 不只能够保存文本数据, 还能够保存任意格式的二进制数据。

 

兼容部分 C 字符串函数

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

表 2-2 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 的长度。

 

 


 

链表

导读

链表提供了高效的节点重排能力, 以及顺序性的节点访问方式, 而且能够经过增删节点来灵活地调整链表的长度。由于 Redis 使用的 C 语言并无内置这种数据结构, 因此 Redis 构建了本身的链表实现。

  • 链表被普遍用于实现 Redis 的各类功能, 好比列表键, 发布与订阅, 慢查询, 监视器, 等等。
  • 每一个链表节点由一个 listNode 结构来表示, 每一个节点都有一个指向前置节点和后置节点的指针, 因此 Redis 的链表实现是双端链表。
  • 每一个链表使用一个 list 结构来表示, 这个结构带有表头节点指针、表尾节点指针、以及链表长度等信息。
  • 由于链表表头节点的前置节点和表尾节点的后置节点都指向 NULL , 因此 Redis 的链表实现是无环链表。
  • 经过为链表设置不一样的类型特定函数, Redis 的链表能够用于保存各类不一样类型的值。

 

链表和链表节点的实现

每一个链表节点使用一个 adlist.h/listNode 结构来表示:

1 typedef struct listNode {
 2 
 3     // 前置节点
 4     struct listNode *prev;
 5 
 6     // 后置节点
 7     struct listNode *next;
 8 
 9     // 节点的值
10     void *value;
11 
12 } listNode;

 

多个 listNode 能够经过 prev 和 next 指针组成双端链表, 如图 3-1 所示。

 

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

 1 typedef struct list {
 2 
 3     // 表头节点
 4     listNode *head;
 5 
 6     // 表尾节点
 7     listNode *tail;
 8 
 9     // 链表所包含的节点数量
10     unsigned long len;
11 
12     // 节点值复制函数
13     void *(*dup)(void *ptr);
14 
15     // 节点值释放函数
16     void (*free)(void *ptr);
17 
18     // 节点值对比函数
19     int (*match)(void *ptr, void *key);
20 
21 } list;

 

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

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

图 3-2 是由一个 list 结构和三个 listNode 结构组成的链表:

 

 

 

 

链表和链表节点的 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为链表长度。

 


 

字典

Redis 所使用的 C 语言并无内置这种数据结构, 所以 Redis 构建了本身的字典实现。

  • 字典, 又称符号表(symbol table)、关联数组(associative array)或者映射(map), 是一种用于保存键值对(key-value pair)的抽象数据结构。
  • 在字典中, 一个键(key)能够和一个值(value)进行关联(或者说将键映射为值), 这些关联的键和值就被称为键值对。
  • 字典中的每一个键都是独一无二的, 程序能够在字典中根据键查找与之关联的值, 或者经过键来更新值, 又或者根据键来删除整个键值对, 等等。

 

导读

  • 字典被普遍用于实现 Redis 的各类功能, 其中包括数据库和哈希键。
  • Redis 中的字典使用哈希表做为底层实现, 每一个字典带有两个哈希表, 一个用于平时使用, 另外一个仅在进行 rehash 时使用。
  • 当字典被用做数据库的底层实现, 或者哈希键的底层实现时, Redis 使用 MurmurHash2 算法来计算键的哈希值。
  • 哈希表使用链地址法来解决键冲突, 被分配到同一个索引上的多个键值对会链接成一个单向链表。
  • 在对哈希表进行扩展或者收缩操做时, 程序须要将现有哈希表包含的全部键值对 rehash 到新哈希表里面, 而且这个 rehash 过程并非一次性地完成的, 而是渐进式地完成的。

 

字典的实现

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

 

字典

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

 1 typedef struct dict {
 2 
 3     // 类型特定函数
 4     dictType *type;
 5 
 6     // 私有数据
 7     void *privdata;
 8 
 9     // 哈希表
10     dictht ht[2];
11 
12     // rehash 索引
13     // 当 rehash 不在进行时,值为 -1
14     int rehashidx; /* rehashing not in progress if rehashidx == -1 */
15 
16 } dict;

 

  • type 属性和 privdata 属性是针对不一样类型的键值对, 为建立多态字典而设置的:
  • type 属性是一个指向 dictType 结构的指针, 每一个 dictType 结构保存了一簇用于操做特定类型键值对的函数, Redis 会为用途不一样的字典设置不一样的类型特定函数。
  • 而 privdata 属性则保存了须要传给那些类型特定函数的可选参数。
  • ht 属性是一个包含两个项的数组, 数组中的每一个项都是一个 dictht 哈希表, 通常状况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。
  • 除了 ht[1] 以外, 另外一个和 rehash 有关的属性就是 rehashidx : 它记录了 rehash 目前的进度, 若是目前没有在进行 rehash , 那么它的值为 -1 。

 

哈希表

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

 1 typedef struct dictht {
 2 
 3     // 哈希表数组
 4     dictEntry **table;
 5 
 6     // 哈希表大小
 7     unsigned long size;
 8 
 9     // 哈希表大小掩码,用于计算索引值
10     // 老是等于 size - 1
11     unsigned long sizemask;
12 
13     // 该哈希表已有节点的数量
14     unsigned long used;
15 
16 } dictht;
17 table 属性是一个数组, 数组中的每一个元素都是一个指向 dict.h/dictEntry 结构的指针, 每一个 dictEntry 结构保存着一个键值对。

 

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

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

 

 

 

 

哈希表节点

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

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

 

  • key 属性保存着键值对中的键。
  • v 属性则保存着键值对中的值, 其中键值对的值能够是一个指针, 或者是一个 uint64_t 整数, 又或者是一个 int64_t 整数。
  • next 属性是指向另外一个哈希表节点的指针, 这个指针能够将多个哈希值相同的键值对链接在一次, 以此来解决键冲突(collision)的问题。

 

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

 

 

 

 

哈希算法

当要将一个新的键值对添加到字典里面时, 程序须要先根据键值对的键计算出哈希值和索引值, 而后再根据索引值, 将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。

Redis 计算哈希值和索引值的方法以下:

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

 

 

 

举个例子, 对于图 4-4 所示的字典来讲, 若是咱们要将一个键值对 k0 和 v0 添加到字典里面, 那么程序会先使用语句:

hash = dict->type->hashFunction(k0);

 

计算键 k0 的哈希值。

假设计算得出的哈希值为 8 , 那么程序会继续使用语句:

index = hash & dict->ht[0].sizemask = 8 & 3 = 0;

计算出键 k0 的索引值 0 , 这表示包含键值对 k0 和 v0 的节点应该被放置到哈希表数组的索引 0 位置上, 如图 4-5 所示。

 

 

 

当字典被用做数据库的底层实现, 或者哈希键的底层实现时, Redis 使用 MurmurHash2 算法来计算键的哈希值。

MurmurHash 算法最初由 Austin Appleby 于 2008 年发明, 这种算法的优势在于, 即便输入的键是有规律的, 算法仍能给出一个很好的随机分布性, 而且算法的计算速度也很是快。

MurmurHash 算法目前的最新版本为 MurmurHash3 , 而 Redis 使用的是 MurmurHash2 , 关于 MurmurHash 算法的更多信息能够参考该算法的主页: http://code.google.com/p/smhasher/ 。

 

解决键冲突

哈希表节点的next 属性是用来解决键冲突(collision)的问题,它指向另外一个哈希表节点的指针。

  • 当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时, 咱们称这些键发生了冲突(collision)。
  • Redis 的哈希表使用链地址法(separate chaining)来解决键冲突: 每一个哈希表节点都有一个 next 指针, 多个哈希表节点能够用 next 指针构成一个单向链表, 被分配到同一个索引上的多个节点能够用这个单向链表链接起来, 这就解决了键冲突的问题。

由于 dictEntry 节点组成的链表没有指向链表表尾的指针, 因此为了速度考虑, 程序老是将新节点添加到链表的表头位置(复杂度为 O(1)), 排在其余已有节点的前面。

 

rehash

随着操做的不断执行, 哈希表保存的键值对会逐渐地增多或者减小, 为了让哈希表的负载因子(load factor)维持在一个合理的范围以内, 当哈希表保存的键值对数量太多或者太少时, 程序须要对哈希表的大小进行相应的扩展或者收缩。

扩展和收缩哈希表的工做能够经过执行 rehash (从新散列)操做来完成, Redis 对字典的哈希表执行 rehash 的步骤以下:

  1. 为字典的 ht[1] 哈希表分配空间, 这个哈希表的空间大小取决于要执行的操做, 以及 ht[0] 当前包含的键值对数量 (也便是 ht[0].used 属性的值):
       若是执行的是扩展操做, 那么 ht[1] 的大小为第一个大于等于 ht[0].used * 2 的 2^n (2 的 n 次方幂);
       若是执行的是收缩操做, 那么 ht[1] 的大小为第一个大于等于 ht[0].used 的 2^n 。
  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 作准备。

举个例子, 假设程序要对含有5个键值对字典的 ht[0] 进行扩展操做, 那么程序将执行如下步骤:

  1. ht[0].used 当前的值为 5 , 5 * 2 = 10 , 而 第一个大于等于10的且2 的 n 次方的数是16, 因此程序会将 ht[1] 哈希表的大小设置为 16 。
  2. 将 ht[0] 包含的5个键值对都 rehash 到 ht[1]。
  3. 释放 ht[0] ,并将 ht[1] 设置为 ht[0] ,而后为 ht[1] 分配一个空白哈希表。

 

哈希表的扩展与收缩

  1. 当哈希表的负载因子小于 0.1 时, 程序自动开始对哈希表执行收缩操做。
  2. 当如下条件中的任意一个被知足时, 程序会自动开始对哈希表执行扩展操做:
  • 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 而且哈希表的负载因子大于等于 1 ;
  • 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 而且哈希表的负载因子大于等于 5 ;

其中哈希表的负载因子能够经过公式计算得出:

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

 

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

 

渐进式 rehash

  • 上一节说过, 扩展或收缩哈希表须要将 ht[0] 里面的全部键值对 rehash 到 ht[1] 里面, 可是, 这个 rehash 动做并非一次性、集中式地完成的, 而是分屡次、渐进式地完成的。
  • 缘由在于, 若是哈希表里保存的键值对数量巨大, 有四百万、四千万甚至四亿个键值对, 那么要一次性将这些键值对所有 rehash 到 ht[1] 的话, 庞大的计算量可能会致使服务器在一段时间内中止服务。
  • 所以, 为了不 rehash 对服务器性能形成影响, 服务器不是一次性将 ht[0] 里面的全部键值对所有 rehash 到 ht[1] , 而是分屡次、渐进式地将 ht[0] 里面的键值对慢慢地 rehash 到 ht[1] 。
  • 渐进式 rehash 的好处在于它采起分而治之的方式, 将 rehash 键值对所需的计算工做均滩到对字典的每一个添加、删除、查找和更新操做上, 从而避免了集中式 rehash 而带来的庞大计算量。

哈希表渐进式 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完,又须要扩容状况怎么办?

 

字典 API

表 4-1 字典的主要操做 API

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

 


 

 

跳跃表

导读

  • 跳跃表是有序集合的底层实现之一, 除此以外它在 Redis 中没有其余应用。
  • Redis 的跳跃表实现由 zskiplist 和 zskiplistNode 两个结构组成, 其中 zskiplist 用于保存跳跃表信息(好比表头节点、表尾节点、长度), 而 zskiplistNode 则用于表示跳跃表节点。
  • 每一个跳跃表节点的层高都是 1 至 32 之间的随机数。
  • 在同一个跳跃表中, 多个节点能够包含相同的分值, 但每一个节点的成员对象必须是惟一的
  • 跳跃表中的节点按照分值大小进行排序, 当分值相同时, 节点按照成员对象的大小进行排序。

 

跳跃表的实现

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 是节点所保存的成员对象。

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

 

跳跃表节点 zskiplistNode

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

 1 typedef struct zskiplistNode {
 2 
 3     // 后退指针
 4     struct zskiplistNode *backward;
 5 
 6     // 分值
 7     double score;
 8 
 9     // 成员对象
10     robj *obj;
11 
12     //
13     struct zskiplistLevel {
14 
15         // 前进指针
16         struct zskiplistNode *forward;
17 
18         // 跨度
19         unsigned int span;
20 
21     } level[];
22 
23 } zskiplistNode;

 

 

  • 跳跃表节点的 level 数组能够包含多个元素, 每一个元素都包含一个指向其余节点的指针, 程序能够经过这些层来加快访问其余节点的速度, 通常来讲, 层的数量越多, 访问其余节点的速度就越快。
  • 每次建立一个新跳跃表节点的时候, 程序都根据幂次定律 (power law,越大的数出现的几率越小) 随机生成一个介于 1 和 32 之间的值做为 level 数组的大小, 这个大小就是层的“高度”。

图 5-2 分别展现了三个高度为 1 层、 3 层和 5 层的节点, 由于 C 语言的数组索引老是从 0 开始的, 因此节点的第一层是 level[0] , 而第二层是 level[1] , 以此类推。

 

 

 

 

前进指针

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

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

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

 

 

跨度

  • 层的跨度(level[i].span 属性)用于记录两个节点之间的距离:
    •    两个节点之间的跨度越大, 它们相距得就越远。
    •    指向 NULL 的全部前进指针的跨度都为 0 , 由于它们没有连向任何节点。
  • 初看上去, 很容易觉得跨度和遍历操做有关, 但实际上并非这样 —— 遍历操做只使用前进指针就能够完成了, 跨度其实是用来计算排位(rank)的: 在查找某个节点的过程当中, 将沿途访问过的全部层的跨度累计起来, 获得的结果就是目标节点在跳跃表中的排位。

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

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

 

  

后退指针

  • 节点的后退指针(backward 属性)用于从表尾向表头方向访问节点: 跟能够一次跳过多个节点的前进指针不一样, 由于每一个节点只有一个后退指针, 因此每次只能后退至前一个节点

图 5-6 用虚线展现了若是从表尾向表头遍历跳跃表中的全部节点: 程序首先经过跳跃表的 tail 指针访问表尾节点, 而后经过后退指针访问倒数第二个节点, 以后再沿着后退指针访问倒数第三个节点, 再以后遇到指向 NULL 的后退指针, 因而访问结束。

 

 

 

分值和成员

  • 节点的分值(score 属性)是一个 double 类型的浮点数, 跳跃表中的全部节点都按分值从小到大来排序
  • 节点的成员对象(obj 属性)是一个指针, 它指向一个字符串对象, 而字符串对象则保存着一个 SDS 值。
  • 在同一个跳跃表中, 各个节点保存的成员对象必须是惟一的, 可是多个节点保存的分值却能够是相同的: 分值相同的节点将按照成员对象在字典序中的大小来进行排序, 成员对象较小的节点会排在前面(靠近表头的方向), 而成员对象较大的节点则会排在后面(靠近表尾的方向)。

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

 

 

 

跳跃表 zskiplist

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

zskiplist 结构的定义以下:

 1 typedef struct zskiplist {
 2 
 3     // 表头节点和表尾节点
 4     struct zskiplistNode *header, *tail;
 5 
 6     // 表中节点的数量
 7     unsigned long length;
 8 
 9     // 表中层数最大的节点的层数
10     int level;
11 
12 } zskiplist;
13 header 和 tail 指针分别指向跳跃表的表头和表尾节点, 经过这两个指针, 程序定位表头节点和表尾节点的复杂度为 O(1) 。

 

  • header 和 tail 指针分别指向跳跃表的表头和表尾节点, 经过这两个指针, 程序定位表头节点和表尾节点的复杂度为 O(1) 。
  • length 属性用来记录节点的数量, 程序能够在 O(1) 复杂度内返回跳跃表的长度。
  • level 属性则用于在 O(1) 复杂度内获取跳跃表中层高最大的那个节点的层数量, 注意表头节点的层高并不计算在

 

 

 

 


 

 

整数集合

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

 

导读

  • 整数集合是集合键的底层实现之一。
  • 整数集合的底层实现为数组, 这个数组以有序无重复的方式保存集合元素, 在有须要时, 程序会根据新添加元素的类型, 改变这个数组的类型。
  • 升级操做为整数集合带来了操做上的灵活性, 而且尽量地节约了内存。
  • 整数集合只支持升级操做, 不支持降级操做。

 

整数集合的实现

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

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

1 typedef struct intset {
 2 
 3     // 编码方式
 4     uint32_t encoding;
 5 
 6     // 集合包含的元素数量
 7     uint32_t length;
 8 
 9     // 保存元素的数组
10     int8_t contents[];
11 
12 } intset;
13 contents 数组是整数集合的底层实现: 整数集合的每一个元素都是 contents 数组的一个数组项(item), 各个项在数组中按值的大小从小到大有序地排列, 而且数组中不包含任何重复项。

 

  • 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 )。

 

升级

每当咱们要将一个新元素添加到整数集合里面, 而且新元素的类型比整数集合现有全部元素的类型都要长时, 整数集合须要先进行升级(upgrade), 而后才能将新元素添加到整数集合里面。

升级整数集合并添加新元素共分为三步进行:

  1. 根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间。
  2. 将底层数组现有的全部元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 并且在放置元素的过程当中, 须要继续维持底层数组的有序性质不变。
  3. 将新元素添加到底层数组里面。

由于每次向整数集合添加新元素均可能会引发升级, 而每次升级都须要对底层数组中已有的全部元素进行类型转换, 因此向整数集合添加新元素的时间复杂度为 O(N) 。

 

升级以后新元素的摆放位置

由于引起升级的新元素的长度老是比整数集合现有全部元素的长度都大, 因此这个新元素的值要么就大于全部现有元素, 要么就小于全部现有元素:

  • 在新元素小于全部现有元素的状况下, 新元素会被放置在底层数组的最开头(索引 0 );
  • 在新元素大于全部现有元素的状况下, 新元素会被放置在底层数组的最末尾(索引 length-1 )。

 

升级的好处

整数集合的升级策略有两个好处, 一个是提高整数集合的灵活性, 另外一个是尽量地节约内存。

 

提高灵活性

  • 由于 C 语言是静态类型语言, 为了不类型错误, 咱们一般不会将两种不一样类型的值放在同一个数据结构里面。
  • 整数集合能够经过自动升级底层数组来适应新元素, 因此咱们能够随意地将 int16_t 、 int32_t 或者 int64_t 类型的整数添加到集合中, 而没必要担忧出现类型错误, 这种作法很是灵活。

 

节约内存

  • 要让一个数组能够同时保存 int16_t 、 int32_t 、 int64_t 三种类型的值, 最简单的作法就是直接使用 int64_t 类型的数组做为整数集合的底层实现。不过这样一来,就会出现浪费内存的状况。
  • 整数集合如今的作法既可让集合能同时保存三种不一样类型的值, 又能够确保升级操做只会在有须要的时候进行, 这能够尽可能节省内存。

 

降级

  • 整数集合不支持降级操做, 一旦对数组进行了升级, 编码就会一直保持升级后的状态。
  • 举个例子, 对于一个整数集合来讲, 即便咱们将集合里惟一一个真正须要使用 int64_t 类型来保存的元素 4294967295 删除了, 整数集合的编码仍然会维持 INTSET_ENC_INT64 , 底层数组也仍然会是 int64_t 类型的。

 

整数集合 API

表 6-1 列出了整数集合的操做 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)

 

 


 

压缩列表

导读

  • 压缩列表是一种为节约内存而开发的顺序型数据结构。
  • 压缩列表被用做列表键哈希键的底层实现之一。
  • 压缩列表能够包含多个节点,每一个节点能够保存一个字节数组或者整数值。
  • 添加新节点到压缩列表, 或者从压缩列表中删除节点, 可能会引起连锁更新操做, 但这种操做出现的概率并不高。

 

压缩列表的构成

  • 压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。
  • 一个压缩列表能够包含任意多个节点(entry), 每一个节点能够保存一个字节数组或者一个整数值。

图 7-1 展现了压缩列表的各个组成部分, 表 7-1 则记录了各个组成部分的类型、长度、以及用途。

 

 

表 7-1 压缩列表各个组成部分的详细说明

属性 类型 长度 用途
zlbytes uint32_t 4 字节 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。
zltail uint32_t 4 字节 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节: 经过这个偏移量,程序无须遍历整个压缩列表就能够肯定表尾节点的地址。
zllen uint16_t 2 字节 记录了压缩列表包含的节点数量: 当这个属性的值小于 UINT16_MAX (65535)时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量须要遍历整个压缩列表才能计算得出。
entryX 列表节点 不定 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlend uint8_t 1 字节 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。

 

压缩列表节点的构成

  • 每一个压缩列表节点都由 previous_entry_length 、 encoding 、 content 三个部分组成, 如图 7-4 所示。

 

 

  • 每一个压缩列表节点能够保存一个字节数组或者一个整数值, 其中, 字节数组能够是如下三种长度的其中一种:
  1. 长度小于等于 63 (2^{6}-1)字节的字节数组;
  2. 长度小于等于 16383 (2^{14}-1) 字节的字节数组;
  3. 长度小于等于 4294967295 (2^{32}-1)字节的字节数组;
  • 而整数值则能够是如下六种长度的其中一种:
  1. 4 位长,介于 0 至 12 之间的无符号整数;
  2. 1 字节长的有符号整数;
  3. 3 字节长的有符号整数;
  4. int16_t 类型整数;
  5. int32_t 类型整数;
  6. 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), 而以后的四个字节则用于保存前一节点的长度。
  • 由于节点的 previous_entry_length 属性记录了前一个节点的长度, 因此程序能够经过指针运算, 根据当前节点的起始地址来计算出前一个节点的起始地址。

图 7-5 展现了一个包含一字节长 previous_entry_length 属性的压缩列表节点, 属性的值为 0x05 , 表示前一节点的长度为 5 字节。

图 7-6 展现了一个包含五字节长 previous_entry_length 属性的压缩节点, 属性的值为 0xFE00002766 , 其中值的最高位字节 0xFE 表示这是一个五字节长的 previous_entry_length 属性, 而以后的四字节 0x00002766 (十进制值 10086 )才是前一节点的实际长度。

 

 

encoding

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

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

表 7-2 记录了全部可用的字节数组编码, 而表 7-3 则记录了全部可用的整数编码。 表格中的下划线 _ 表示留空, 而 b 、 x 等变量则表明实际的二进制数据, 为了方便阅读, 多个字节之间用空格隔开。

编码 编码长度 content 属性保存的值
00bbbbbb 1 字节 长度小于等于 63 字节的字节数组。
01bbbbbb xxxxxxxx 2 字节 长度小于等于 16383 字节的字节数组。
10______ aaaaaaaa bbbbbbbb cccccccc dddddddd 5 字节 长度小于等于 4294967295 的字节数组。

表 7-3 整数编码

编码 编码长度 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 属性决定
  • 图 7-9 展现了一个保存字节数组的节点示例:
  • 编码的最高两位 00 表示节点保存的是一个字节数组;
  • 编码的后六位 001011 记录了字节数组的长度 11 ;
  • content 属性保存着节点的值 "hello world" 。

  • 图 7-10 展现了一个保存整数值的节点示例:
  • 编码 11000000 表示节点保存的是一个 int16_t 类型的整数值;
  • content 属性保存着节点的值 10086 。

 

连锁更新

  • 添加新节点可能会引起连锁更新以外,
  • 删除节点也可能会引起连锁更新。
  • 由于连锁更新在最坏状况下须要对压缩列表执行 N 次空间重分配操做, 而每次空间重分配的最坏复杂度为 O(N) , 因此连锁更新的最坏复杂度为 O(N^2) 
  • 要注意的是, 尽管连锁更新的复杂度较高, 但它真正形成性能问题的概率是很低的:
  • 首先, 压缩列表里要刚好有多个连续的、长度介于 250 字节至 253 字节之间的节点, 连锁更新才有可能被引起, 在实际中, 这种状况并很少见;
  • 其次, 即便出现连锁更新, 但只要被更新的节点数量很少, 就不会对性能形成任何影响: 好比说, 对三五个节点进行连锁更新是绝对不会影响性能的;

由于以上缘由, ziplistPush 等命令的平均复杂度仅为 O(N) , 在实际中, 咱们能够放心地使用这些函数, 而没必要担忧连锁更新会影响压缩列表的性能。

 

压缩列表 API

表 7-4 列出了全部用于操做压缩列表的 API 。

函数 做用 算法复杂度
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) 。

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

 


 

对象

在前面的数个章节里, 咱们陆续介绍了 Redis 用到的全部主要数据结构, 好比简单动态字符串(SDS)、双端链表、字典、压缩列表、整数集合, 等等。

  • Redis 并无直接使用这些数据结构来实现键值对数据库, 而是基于这些数据结构建立了一个对象系统, 这个系统包含字符串对象列表对象哈希对象集合对象有序集合对象这五种类型的对象, 每种对象都用到了至少一种咱们前面所介绍的数据结构
  • 经过这五种不一样类型的对象,(1)Redis 能够在执行命令以前, 根据对象的类型来判断一个对象是否能够执行给定的命令。 (2)能够针对不一样的使用场景, 为对象设置多种不一样的数据结构实现, 从而优化对象在不一样场景下的使用效率。
  • Redis 的对象系统还实现了基于引用计数技术内存回收机制: 当程序再也不使用某个对象的时候, 这个对象所占用的内存就会被自动释放; 另外, Redis 还经过引用计数技术实现了对象共享机制, 这一机制能够在适当的条件下, 经过让多个数据库键共享同一个对象来节约内存。
  • 最后, Redis 的对象带有访问时间记录信息, 该信息能够用于计算数据库键的空转时长, 在服务器启用了 maxmemory 功能的状况下, 空转时长较大的那些键可能会优先被服务器删除。

 

导读

  • Redis 数据库中的每一个键值对的键和值都是一个对象。
  • Redis 共有字符串、列表、哈希、集合、有序集合五种类型的对象, 每种类型的对象至少都有两种或以上的编码方式, 不一样的编码能够在不一样的使用场景上优化对象的使用效率。
  • 服务器在执行某些命令以前, 会先检查给定键的类型可否执行指定的命令, 而检查一个键的类型就是检查键的值对象的类型。
  • Redis 的对象系统带有引用计数实现的内存回收机制, 当一个对象再也不被使用时, 该对象所占用的内存就会被自动释放。
  • Redis 会共享值为 0 到 9999 的字符串对象。
  • 对象会记录本身的最后一次被访问的时间, 这个时间能够用于计算对象的空转时间。

 

对象的类型与编码

  • Redis 使用对象来表示数据库中的键和值, 每次当咱们在 Redis 的数据库中新建立一个键值对时, 咱们至少会建立两个对象, 一个对象用做键值对的键(键对象), 另外一个对象用做键值对的值(值对象)。
  • Redis 中的每一个对象都由一个 redisObject 结构表示, 该结构中和保存数据有关的三个属性分别是 type 属性、 encoding 属性和 ptr 属性:
 1 typedef struct redisObject {
 2 
 3     // 类型
 4     unsigned type:4;
 5 
 6     // 编码
 7     unsigned encoding:4;
 8 
 9     // 指向底层实现数据结构的指针
10     void *ptr;
11 
12     // ...
13 
14 } robj;

 

举个例子, 如下 SET 命令在数据库中建立了一个新的键值对, 其中键值对的键是一个包含了字符串值 "msg" 的对象, 而键值对的值则是一个包含了字符串值 "hello world" 的对象:

1 redis> SET msg "hello world"
2 OK

 

 

类型

  • 对象的 type 属性记录了对象的类型, 这个属性的值能够是如下常量的其中一个。

表 8-1 对象的类型

类型常量 对象的名称
REDIS_STRING 字符串对象
REDIS_LIST 列表对象
REDIS_HASH 哈希对象
REDIS_SET 集合对象
REDIS_ZSET 有序集合对象
  • 对于 Redis 数据库保存的键值对来讲, 键老是一个字符串对象, 而值则能够是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种, 所以:
  • 当咱们称呼一个数据库键为“字符串键”时, 咱们指的是“这个数据库键所对应的值为字符串对象”;
  • 当咱们称呼一个键为“列表键”时, 咱们指的是“这个数据库键所对应的值为列表对象”,诸如此类。
  • TYPE 命令的实现方式也与此相似, 当咱们对一个数据库键执行 TYPE 命令时, 命令返回的结果为数据库键对应的值对象的类型, 而不是键对象的类型:
1 # 键为字符串对象,值为列表对象
2 redis> RPUSH numbers 1 3 5
3 (integer) 6
4 
5 redis> TYPE numbers
6 list

 

表 8-2 列出了 TYPE 命令在面对不一样类型的值对象时所产生的输出。

对象 对象 type 属性的值 TYPE 命令的输出
字符串对象 REDIS_STRING "string"
列表对象 REDIS_LIST "list"
哈希对象 REDIS_HASH "hash"
集合对象 REDIS_SET "set"
有序集合对象 REDIS_ZSET "zset"

 

编码和底层实现

  1. 对象的 ptr 指针指向对象的底层实现数据结构, 而这些数据结构由对象的 encoding 属性决定。

encoding 属性记录了对象所使用的编码, 也便是说这个对象使用了什么数据结构做为对象的底层实现, 这个属性的值能够是表 8-3 列出的常量的其中一个。

编码常量 编码所对应的底层数据结构 OBJECT ENCODING 命令输出
REDIS_ENCODING_INT long 类型的整数 "int"
REDIS_ENCODING_EMBSTR 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"
  1. 其中,每种type类型的对象都至少使用了两种不一样的编码, 表 8-4 不一样类型和编码的对象
类型常量 编码 对象
REDIS_STRING REDIS_ENCODING_INT 使用整数值实现的字符串对象。
REDIS_STRING REDIS_ENCODING_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 命令能够查看一个数据库键的值对象的编码:

 1 redis> SET msg "hello wrold"
 2 OK
 3 
 4 redis> OBJECT ENCODING msg
 5 "embstr"
 6 
 7 redis> SET story "long long long long long long ago ..."
 8 OK
 9 
10 redis> OBJECT ENCODING story
11 "raw"
12 
13 redis> SADD numbers 1 3 5
14 (integer) 3
15 
16 redis> OBJECT ENCODING numbers
17 "intset"
18 
19 redis> SADD numbers "seven"
20 (integer) 1
21 
22 redis> OBJECT ENCODING numbers
23 "hashtable"

 

  1. 经过 encoding 属性来设定对象所使用的编码, 而不是为特定类型的对象关联一种固定的编码, 极大地提高了 Redis 的灵活性和效率, 由于 Redis 能够根据不一样的使用场景来为一个对象设置不一样的编码, 从而优化对象在某一场景下的效率。

举个例子, 在列表对象包含的元素比较少时, Redis 使用压缩列表做为列表对象的底层实现:

  • 由于压缩列表比双端链表更节约内存, 而且在元素数量较少时, 在内存中以连续块方式保存的压缩列表比起双端链表能够更快被载入到缓存中;
  • 随着列表对象包含的元素愈来愈多, 使用压缩列表来保存元素的优点逐渐消失时, 对象就会将底层实现从压缩列表转向功能更强、也更适合保存大量元素的双端链表上面;

其余类型的对象也会经过使用多种不一样的编码来进行相似的优化。

在接下来的内容中, 咱们将分别介绍 Redis 中的五种不一样类型的对象, 说明这些对象底层所使用的编码方式, 列出对象从一种编码转换成另外一种编码所需的条件, 以及同一个命令在多种不一样编码上的实现方法。

 

字符串对象

  • 字符串对象的编码能够是 int 、 raw 或者 embstr 。
  • 若是一个字符串对象保存的是整数值, 而且这个整数值能够用 long 类型来表示, 那么字符串对象会将整数值保存在字符串对象结构的 ptr 属性里面(将 void* 转换成 long ), 并将字符串对象的编码设置为 int 。

举个例子, 若是咱们执行如下 SET 命令, 那么服务器将建立一个如图 8-1 所示的 int 编码的字符串对象做为 number 键的值:

1 redis> SET number 10086
2 OK
3 
4 redis> OBJECT ENCODING number
5 "int"

 

 

  • 若是字符串对象保存的是一个字符串值, 而且这个字符串值的长度大于 39 字节, 那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值, 并将对象的编码设置为 raw 。

举个例子, 若是咱们执行如下命令, 那么服务器将建立一个如图 8-2 所示的 raw 编码的字符串对象做为 story 键的值:

1 redis> SET story "Long, long, long ago there lived a king ..."
2 OK
3 
4 redis> STRLEN story
5 (integer) 43
6 
7 redis> OBJECT ENCODING story
8 "raw"

 

 

  • 若是字符串对象保存的是一个字符串值, 而且这个字符串值的长度小于等于 39 字节, 那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。

embstr 编码是专门用于保存短字符串的一种优化编码方式, 这种编码和 raw 编码同样, 都使用 redisObject 结构和 sdshdr 结构来表示字符串对象, 但 raw 编码会调用两次内存分配函数来分别建立 redisObject 结构和 sdshdr 结构, 而 embstr 编码则经过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含 redisObject 和 sdshdr 两个结构, 如图 8-3 所示。

 

embstr 编码的字符串对象在执行命令时, 产生的效果和 raw 编码的字符串对象执行命令时产生的效果是相同的, 但使用 embstr 编码的字符串对象来保存短字符串值有如下好处:

  1. embstr 编码将建立字符串对象所需的内存分配次数从 raw 编码的两次下降为一次。
  2. 释放 embstr 编码的字符串对象只须要调用一次内存释放函数, 而释放 raw 编码的字符串对象须要调用两次内存释放函数。
  3. 由于 embstr 编码的字符串对象的全部数据都保存在一块连续的内存里面, 因此这种编码的字符串对象比起 raw 编码的字符串对象可以更好地利用缓存带来的优点。

做为例子, 如下命令建立了一个 embstr 编码的字符串对象做为 msg 键的值, 值对象的样子如图 8-4 所示:

1 redis> SET msg "hello"
2 OK
3 
4 redis> OBJECT ENCODING msg
5 "embstr"

 

 

 

  • 最后要说的是, 能够用 long double 类型表示的浮点数在 Redis 中也是做为字符串值来保存的: 若是咱们要保存一个浮点数到字符串对象里面, 那么程序会先将这个浮点数转换成字符串值, 而后再保存起转换所得的字符串值。在有须要的时候, 程序会将保存在字符串对象里面的字符串值转换回浮点数值, 执行某些操做, 而后再将执行操做所得的浮点数值转换回字符串值, 并继续保存在字符串对象里面。

表 8-6 字符串对象保存各种型值的编码方式

编码
能够用 long 类型保存的整数。 int
能够用 long double 类型保存的浮点数。 embstr 或者 raw
字符串值, 或者由于长度太大而没办法用 long 类型表示的整数, 又或者由于长度太大而没办法用 long double 类型表示的浮点数。 embstr 或者 raw

 

编码的转换

  • int 编码的字符串对象和 embstr 编码的字符串对象在条件知足的状况下, 会被转换为 raw 编码的字符串对象。
  • 对于 int 编码的字符串对象来讲, 若是咱们向对象执行了一些命令, 使得这个对象保存的再也不是整数值, 而是一个字符串值, 那么字符串对象的编码将从 int 变为 raw 。好比APPEND 命令
  • 另外, 由于 Redis 没有为 embstr 编码的字符串对象编写任何相应的修改程序 (只有 int 编码的字符串对象和 raw 编码的字符串对象有这些程序), 因此 embstr 编码的字符串对象其实是只读的: 当咱们对 embstr 编码的字符串对象执行任何修改命令时, 程序会先将对象的编码从 embstr 转换成 raw , 而后再执行修改命令; 由于这个缘由, embstr 编码的字符串对象在执行修改命令以后, 总会变成一个 raw 编码的字符串对象。

 

字符串命令的实现

由于字符串键的值为字符串对象, 因此用于字符串键的全部命令都是针对字符串对象来构建的, 表 8-7 列举了其中一部分字符串命令, 以及这些命令在不一样编码的字符串对象下的实现方法。

命令 int 编码的实现方法 embstr 编码的实现方法 raw 编码的实现方法
SET 使用 int 编码保存值。 使用 embstr 编码保存值。 使用 raw 编码保存值。
GET 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 而后向客户端返回这个字符串值。 直接向客户端返回字符串值。 直接向客户端返回字符串值。
APPEND 将对象转换成 raw 编码, 而后按 raw 编码的方式执行此操做。 将对象转换成 raw 编码, 而后按 raw 编码的方式执行此操做。 调用 sdscatlen 函数, 将给定字符串追加到现有字符串的末尾。
INCRBYFLOAT 取出整数值并将其转换成 long double 类型的浮点数, 对这个浮点数进行加法计算, 而后将得出的浮点数结果保存起来。 取出字符串值并尝试将其转换成 long double 类型的浮点数, 对这个浮点数进行加法计算, 而后将得出的浮点数结果保存起来。 若是字符串值不能被转换成浮点数, 那么向客户端返回一个错误。 取出字符串值并尝试将其转换成 long double 类型的浮点数, 对这个浮点数进行加法计算, 而后将得出的浮点数结果保存起来。 若是字符串值不能被转换成浮点数, 那么向客户端返回一个错误。
INCRBY 对整数值进行加法计算, 得出的计算结果会做为整数被保存起来。 embstr 编码不能执行此命令, 向客户端返回一个错误。 raw 编码不能执行此命令, 向客户端返回一个错误。
DECRBY 对整数值进行减法计算, 得出的计算结果会做为整数被保存起来。 embstr 编码不能执行此命令, 向客户端返回一个错误。 raw 编码不能执行此命令, 向客户端返回一个错误。
STRLEN 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 计算并返回这个字符串值的长度。 调用 sdslen 函数, 返回字符串的长度。 调用 sdslen 函数, 返回字符串的长度。
SETRANGE 将对象转换成 raw 编码, 而后按 raw 编码的方式执行此命令。 将对象转换成 raw 编码, 而后按 raw 编码的方式执行此命令。 将字符串特定索引上的值设置为给定的字符。
GETRANGE 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 而后取出并返回字符串指定索引上的字符。 直接取出并返回字符串指定索引上的字符。  

 

列表对象

  • 列表对象的编码能够是 ziplist 或者 linkedlist 。
  • ziplist 编码的列表对象使用压缩列表做为底层实现, 每一个压缩列表节点(entry)保存了一个列表元素。
  • 另外一方面, linkedlist 编码的列表对象使用双端链表做为底层实现, 每一个双端链表节点(node)都保存了一个字符串对象, 而每一个字符串对象都保存了一个列表元素。

举个例子, 若是咱们执行如下 RPUSH 命令, 那么服务器将建立一个列表对象做为 numbers 键的值:

1 redis> RPUSH numbers 1 "three" 5
2 (integer) 3

 

 

 

 

 

 注意, linkedlist 编码的列表对象在底层的双端链表结构中包含了多个字符串对象, 这种嵌套字符串对象的行为在稍后介绍的哈希对象、集合对象和有序集合对象中都会出现, 字符串对象是 Redis 五种类型的对象中惟一一种会被其余四种类型对象嵌套的对象。

注意

为了简化字符串对象的表示, 咱们在图 8-6 使用了一个带有 StringObject 字样的格子来表示一个字符串对象, 而 StringObject 字样下面的是字符串对象所保存的值。

好比说, 图 8-7 表明的就是一个包含了字符串值 "three" 的字符串对象, 它是 8-8 的简化表示。

本书接下来的内容将继续沿用这一简化表示。

 

编码转换

当列表对象能够同时知足如下两个条件时, 列表对象使用 ziplist 编码:

  1. 列表对象保存的全部字符串元素的长度都小于 64 字节
  2. 列表对象保存的元素数量小于 512 个

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

  • 对于使用 ziplist 编码的列表对象来讲, 当使用 ziplist 编码所需的两个条件的任意一个不能被知足时, 对象的编码转换操做就会被执行: 本来保存在压缩列表里的全部列表元素都会被转移并保存到双端链表里面, 对象的编码也会从 ziplist 变为 linkedlist 。

注意

以上两个条件的上限值是能够修改的, 具体请看配置文件中关于 list-max-ziplist-value 选项和 list-max-ziplist-entries 选项的说明。

 

列表命令的实现

由于列表键的值为列表对象, 因此用于列表键的全部命令都是针对列表对象来构建的,

表 8-8 列出了其中一部分列表键命令, 以及这些命令在不一样编码的列表对象下的实现方法。

命令 ziplist 编码的实现方法 linkedlist 编码的实现方法
LPUSH 调用 ziplistPush 函数, 将新元素推入到压缩列表的表头。 调用 listAddNodeHead 函数, 将新元素推入到双端链表的表头。
RPUSH 调用 ziplistPush 函数, 将新元素推入到压缩列表的表尾。 调用 listAddNodeTail 函数, 将新元素推入到双端链表的表尾。
LPOP 调用 ziplistIndex 函数定位压缩列表的表头节点, 在向用户返回节点所保存的元素以后, 调用 ziplistDelete 函数删除表头节点。 调用 listFirst 函数定位双端链表的表头节点, 在向用户返回节点所保存的元素以后, 调用 listDelNode 函数删除表头节点。
RPOP 调用 ziplistIndex 函数定位压缩列表的表尾节点, 在向用户返回节点所保存的元素以后, 调用 ziplistDelete 函数删除表尾节点。 调用 listLast 函数定位双端链表的表尾节点, 在向用户返回节点所保存的元素以后, 调用 listDelNode 函数删除表尾节点。
LINDEX 调用 ziplistIndex 函数定位压缩列表中的指定节点, 而后返回节点所保存的元素。 调用 listIndex 函数定位双端链表中的指定节点, 而后返回节点所保存的元素。
LLEN 调用 ziplistLen 函数返回压缩列表的长度。 调用 listLength 函数返回双端链表的长度。
LINSERT 插入新节点到压缩列表的表头或者表尾时, 使用 ziplistPush 函数; 插入新节点到压缩列表的其余位置时, 使用 ziplistInsert 函数。 调用 listInsertNode 函数, 将新节点插入到双端链表的指定位置。
LREM 遍历压缩列表节点, 并调用 ziplistDelete 函数删除包含了给定元素的节点。 遍历双端链表节点, 并调用 listDelNode 函数删除包含了给定元素的节点。
LTRIM 调用 ziplistDeleteRange 函数, 删除压缩列表中全部不在指定索引范围内的节点。 遍历双端链表节点, 并调用 listDelNode 函数删除链表中全部不在指定索引范围内的节点。
LSET 调用 ziplistDelete 函数, 先删除压缩列表指定索引上的现有节点, 而后调用 ziplistInsert 函数, 将一个包含给定元素的新节点插入到相同索引上面。 调用 listIndex 函数, 定位到双端链表指定索引上的节点, 而后经过赋值操做更新节点的值。

 

哈希对象

  • 哈希对象的编码能够是 ziplist 或者 hashtable 
  • ziplist 编码的哈希对象使用压缩列表做为底层实现, 每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 而后再将保存了值的压缩列表节点推入到压缩列表表尾, 所以:
    • 保存了同一键值对的两个节点老是紧挨在一块儿, 保存键的节点在前, 保存值的节点在后;
    • 先添加到哈希对象中的键值对会被放在压缩列表的表头方向, 然后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
  • 另外一方面, hashtable 编码的哈希对象使用字典做为底层实现, 哈希对象中的每一个键值对都使用一个字典键值对来保存:
    • 字典的每一个键都是一个字符串对象, 对象中保存了键值对的键;
    • 字典的每一个值都是一个字符串对象, 对象中保存了键值对的值。

举个例子, 若是咱们执行如下 HSET 命令, 那么服务器将建立一个列表对象做为 profile 键的值:

1 redis> HSET profile name "Tom"
2 (integer) 1
3 
4 redis> HSET profile age 25
5 (integer) 1
6 
7 redis> HSET profile career "Programmer"
8 (integer) 1

 

 

 

 

 

 

编码转换

当哈希对象能够同时知足如下两个条件时, 哈希对象使用 ziplist 编码:

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

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

  • 对于使用 ziplist 编码的列表对象来讲, 当使用 ziplist 编码所需的两个条件的任意一个不能被知足时, 对象的编码转换操做就会被执行: 本来保存在压缩列表里的全部键值对都会被转移并保存到字典里面, 对象的编码也会从 ziplist 变为 hashtable 。

注意

这两个条件的上限值是能够修改的, 具体请看配置文件中关于 hash-max-ziplist-value 选项和 hash-max-ziplist-entries 选项的说明。

 

哈希命令的实现

由于哈希键的值为哈希对象, 因此用于哈希键的全部命令都是针对哈希对象来构建的, 表 8-9 列出了其中一部分哈希键命令, 以及这些命令在不一样编码的哈希对象下的实现方法。

命令 ziplist 编码实现方法 hashtable 编码的实现方法
HSET 首先调用 ziplistPush 函数, 将键推入到压缩列表的表尾, 而后再次调用 ziplistPush 函数, 将值推入到压缩列表的表尾。 调用 dictAdd 函数, 将新节点添加到字典里面。
HGET 首先调用 ziplistFind 函数, 在压缩列表中查找指定键所对应的节点, 而后调用 ziplistNext 函数, 将指针移动到键节点旁边的值节点, 最后返回值节点。 调用 dictFind 函数, 在字典中查找给定键, 而后调用 dictGetVal 函数, 返回该键所对应的值。
HEXISTS 调用 ziplistFind 函数, 在压缩列表中查找指定键所对应的节点, 若是找到的话说明键值对存在, 没找到的话就说明键值对不存在。 调用 dictFind 函数, 在字典中查找给定键, 若是找到的话说明键值对存在, 没找到的话就说明键值对不存在。
HDEL 调用 ziplistFind 函数, 在压缩列表中查找指定键所对应的节点, 而后将相应的键节点、 以及键节点旁边的值节点都删除掉。 调用 dictDelete 函数, 将指定键所对应的键值对从字典中删除掉。
HLEN 调用 ziplistLen 函数, 取得压缩列表包含节点的总数量, 将这个数量除以 2 , 得出的结果就是压缩列表保存的键值对的数量。 调用 dictSize 函数, 返回字典包含的键值对数量, 这个数量就是哈希对象包含的键值对数量。
HGETALL 遍历整个压缩列表, 用 ziplistGet 函数返回全部键和值(都是节点)。 遍历整个字典, 用 dictGetKey 函数返回字典的键, 用 dictGetVal 函数返回字典的值。

 

集合对象

  • 集合对象的编码能够是 intset 或者 hashtable 。
  • intset 编码的集合对象使用整数集合做为底层实现, 集合对象包含的全部元素都被保存在整数集合里面。
  • 另外一方面, hashtable 编码的集合对象使用字典做为底层实现, 字典的每一个键都是一个字符串对象, 每一个字符串对象包含了一个集合元素, 而字典的值则所有被设置为 NULL 。

举个例子, 如下代码将建立一个如图 8-12 所示的 intset 编码集合对象:

1 redis> SADD numbers 1 3 5
2 (integer) 3

 

 

 

如下代码将建立一个如图 8-13 所示的 hashtable 编码集合对象:

1 redis> SADD fruits "apple" "banana" "cherry"
2 (integer) 3

 

 

 

 

 

编码的转换

当集合对象能够同时知足如下两个条件时, 对象使用 intset 编码:

  1. 集合对象保存的全部元素都是整数值;
  2. 集合对象保存的元素数量不超过 512 个;

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

  • 对于使用 intset 编码的集合对象来讲, 当使用 intset 编码所需的两个条件的任意一个不能被知足时, 对象的编码转换操做就会被执行: 本来保存在整数集合中的全部元素都会被转移并保存到字典里面, 而且对象的编码也会从 intset 变为 hashtable 。

注意

第二个条件的上限值是能够修改的, 具体请看配置文件中关于 set-max-intset-entries 选项的说明。

 

集合命令的实现

由于集合键的值为集合对象, 因此用于集合键的全部命令都是针对集合对象来构建的, 表 8-10 列出了其中一部分集合键命令, 以及这些命令在不一样编码的集合对象下的实现方法。

表 8-10 集合命令的实现方法

命令 intset 编码的实现方法 hashtable 编码的实现方法
SADD 调用 intsetAdd 函数, 将全部新元素添加到整数集合里面。 调用 dictAdd , 以新元素为键, NULL 为值, 将键值对添加到字典里面。
SCARD 调用 intsetLen 函数, 返回整数集合所包含的元素数量, 这个数量就是集合对象所包含的元素数量。 调用 dictSize 函数, 返回字典所包含的键值对数量, 这个数量就是集合对象所包含的元素数量。
SISMEMBER 调用 intsetFind 函数, 在整数集合中查找给定的元素, 若是找到了说明元素存在于集合, 没找到则说明元素不存在于集合。 调用 dictFind 函数, 在字典的键中查找给定的元素, 若是找到了说明元素存在于集合, 没找到则说明元素不存在于集合。
SMEMBERS 遍历整个整数集合, 使用 intsetGet 函数返回集合元素。 遍历整个字典, 使用 dictGetKey 函数返回字典的键做为集合元素。
SRANDMEMBER 调用 intsetRandom 函数, 从整数集合中随机返回一个元素。 调用 dictGetRandomKey 函数, 从字典中随机返回一个字典键。
SPOP 调用 intsetRandom 函数, 从整数集合中随机取出一个元素, 在将这个随机元素返回给客户端以后, 调用 intsetRemove 函数, 将随机元素从整数集合中删除掉。 调用 dictGetRandomKey 函数, 从字典中随机取出一个字典键, 在将这个随机字典键的值返回给客户端以后, 调用 dictDelete 函数, 从字典中删除随机字典键所对应的键值对。
SREM 调用 intsetRemove 函数, 从整数集合中删除全部给定的元素。 调用 dictDelete 函数, 从字典中删除全部键为给定元素的键值对。

 

有序集合对象

  • 有序集合的编码能够是 ziplist 或者 skiplist 。
  • ziplist 编码的有序集合对象使用压缩列表做为底层实现, 每一个集合元素使用两个紧挨在一块儿的压缩列表节点来保存, 第一个节点保存元素的成员(member), 而第二个元素则保存元素的分值(score)。
  • 压缩列表内的集合元素按分值从小到大进行排序, 分值较小的元素被放置在靠近表头的方向, 而分值较大的元素则被放置在靠近表尾的方向。
  • skiplist 编码的有序集合对象使用 zset 结构做为底层实现, 一个 zset 结构同时包含一个字典和一个跳跃表:
1 typedef struct zset {
2     
3     zskiplist *zsl;
4     dict *dict;
5     
6 } zset;

 

    • zset 结构中的 zsl 跳跃表按分值从小到大保存了全部集合元素, 每一个跳跃表节点都保存了一个集合元素: 跳跃表节点的 object 属性保存了元素的成员, 而跳跃表节点的 score 属性则保存了元素的分值。 经过这个跳跃表, 程序能够对有序集合进行范围型操做, 好比 ZRANK 、 ZRANGE 等命令就是基于跳跃表 API 来实现的。
    • zset 结构中的 dict 字典为有序集合建立了一个从成员到分值的映射, 字典中的每一个键值对都保存了一个集合元素: 字典的键保存了元素的成员, 而字典的值则保存了元素的分值。 经过这个字典, 程序能够用 O(1) 复杂度查找给定成员的分值, ZSCORE 命令就是根据这一特性实现的, 而不少其余有序集合命令都在实现的内部用到了这一特性。
    • 值得一提的是, 虽然 zset 结构同时使用跳跃表和字典来保存有序集合元素, 但这两种数据结构都会经过指针来共享相同元素的成员和分值, 因此同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值, 也不会所以而浪费额外的内存。
  • 有序集合每一个元素的成员都是一个字符串对象, 而每一个元素的分值都是一个 double 类型的浮点数。

举个例子, 若是咱们执行如下 ZADD 命令, 那么服务器将建立一个有序集合对象做为 price 键的值:

1 redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
2 (integer) 3

 

  • 若是 price 键的值对象使用的是 ziplist 编码, 那么这个值对象将会是图 8-14 所示的样子, 而对象所使用的压缩列表则会是 8-15 所示的样子。

 

 

  • 若是前面 price 键建立的不是 ziplist 编码的有序集合对象, 而是 skiplist 编码的有序集合对象, 那么这个有序集合对象将会是图 8-16 所示的样子, 而对象所使用的 zset 结构将会是图 8-17 所示的样子。

 

 

  

 注意

为了展现方便, 图 8-17 在字典和跳跃表中重复展现了各个元素的成员和分值, 但在实际中, 字典和跳跃表会共享元素的成员和分值, 因此并不会形成任何数据重复, 也不会所以而浪费任何内存。

 

为何有序集合须要同时使用跳跃表和字典来实现?

  • 在理论上来讲, 有序集合能够单独使用字典或者跳跃表的其中一种数据结构来实现, 但不管单独使用字典仍是跳跃表, 在性能上对比起同时使用字典和跳跃表都会有所下降。
  • 举个例子, 若是咱们只使用字典来实现有序集合, 那么虽然以 O(1) 复杂度查找成员的分值这一特性会被保留, 可是, 由于字典以无序的方式来保存集合元素, 因此每次在执行范围型操做 —— 好比 ZRANK 、 ZRANGE 等命令时, 程序都须要对字典保存的全部元素进行排序, 完成这种排序须要至少 O(N \log N) 时间复杂度, 以及额外的 O(N) 内存空间 (由于要建立一个数组来保存排序后的元素)。
  • 另外一方面, 若是咱们只使用跳跃表来实现有序集合, 那么跳跃表执行范围型操做的全部优势都会被保留, 但由于没有了字典, 因此根据成员查找分值这一操做的复杂度将从 O(1) 上升为 O(\log N) 。
  • 由于以上缘由, 为了让有序集合的查找和范围型操做都尽量快地执行, Redis 选择了同时使用字典和跳跃表两种数据结构来实现有序集合。

 

编码的转换

当有序集合对象能够同时知足如下两个条件时, 对象使用 ziplist 编码:

  1. 有序集合保存的元素数量小于 128 个;
  2. 有序集合保存的全部元素成员的长度都小于 64 字节;

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

  • 对于使用 ziplist 编码的有序集合对象来讲, 当使用 ziplist 编码所需的两个条件中的任意一个不能被知足时, 程序就会执行编码转换操做, 将本来储存在压缩列表里面的全部集合元素转移到 zset 结构里面, 并将对象的编码从 ziplist 改成 skiplist 。

注意

以上两个条件的上限值是能够修改的, 具体请看配置文件中关于 zset-max-ziplist-entries 选项和 zset-max-ziplist-value 选项的说明。

 

有序集合命令的实现

由于有序集合键的值为有序集合对象, 因此用于有序集合键的全部命令都是针对有序集合对象来构建的, 表 8-11 列出了其中一部分有序集合键命令, 以及这些命令在不一样编码的有序集合对象下的实现方法。

命令 ziplist 编码的实现方法 zset 编码的实现方法
ZADD 调用 ziplistInsert 函数, 将成员和分值做为两个节点分别插入到压缩列表。 先调用 zslInsert 函数, 将新元素添加到跳跃表, 而后调用 dictAdd 函数, 将新元素关联到字典。
ZCARD 调用 ziplistLen 函数, 得到压缩列表包含节点的数量, 将这个数量除以 2 得出集合元素的数量。 访问跳跃表数据结构的 length 属性, 直接返回集合元素的数量。
ZCOUNT 遍历压缩列表, 统计分值在给定范围内的节点的数量。 遍历跳跃表, 统计分值在给定范围内的节点的数量。
ZRANGE 从表头向表尾遍历压缩列表, 返回给定索引范围内的全部元素。 从表头向表尾遍历跳跃表, 返回给定索引范围内的全部元素。
ZREVRANGE 从表尾向表头遍历压缩列表, 返回给定索引范围内的全部元素。 从表尾向表头遍历跳跃表, 返回给定索引范围内的全部元素。
ZRANK 从表头向表尾遍历压缩列表, 查找给定的成员, 沿途记录通过节点的数量, 当找到给定成员以后, 途经节点的数量就是该成员所对应元素的排名。 从表头向表尾遍历跳跃表, 查找给定的成员, 沿途记录通过节点的数量, 当找到给定成员以后, 途经节点的数量就是该成员所对应元素的排名。
ZREVRANK 从表尾向表头遍历压缩列表, 查找给定的成员, 沿途记录通过节点的数量, 当找到给定成员以后, 途经节点的数量就是该成员所对应元素的排名。 从表尾向表头遍历跳跃表, 查找给定的成员, 沿途记录通过节点的数量, 当找到给定成员以后, 途经节点的数量就是该成员所对应元素的排名。
ZREM 遍历压缩列表, 删除全部包含给定成员的节点, 以及被删除成员节点旁边的分值节点。 遍历跳跃表, 删除全部包含了给定成员的跳跃表节点。 并在字典中解除被删除元素的成员和分值的关联。
ZSCORE 遍历压缩列表, 查找包含了给定成员的节点, 而后取出成员节点旁边的分值节点保存的元素分值。 直接从字典中取出给定成员的分值。

 

类型检查与命令多态

  • Redis 中用于操做键的命令基本上能够分为两种类型。
  • 其中一种命令能够对任何类型的键执行, 好比说 DEL 命令、 EXPIRE 命令、 RENAME 命令、 TYPE 命令、 OBJECT 命令, 等等。
  • 而另外一种命令只能对特定类型的键执行, 好比说:
    • SET 、 GET 、 APPEND 、 STRLEN 等命令只能对字符串键执行;
    • HDEL 、 HSET 、 HGET 、 HLEN 等命令只能对哈希键执行;
    • RPUSH 、 LPOP 、 LINSERT 、 LLEN 等命令只能对列表键执行;
    • SADD 、 SPOP 、 SINTER 、 SCARD 等命令只能对集合键执行;
    • ZADD 、 ZCARD 、 ZRANK 、 ZSCORE 等命令只能对有序集合键执行;

例子1, 如下代码就展现了使用 DEL 命令来删除三种不一样类型的键:

 1 # 字符串键
 2 redis> SET msg "hello"
 3 OK
 4 
 5 # 列表键
 6 redis> RPUSH numbers 1 2 3
 7 (integer) 3
 8 
 9 # 集合键
10 redis> SADD fruits apple banana cherry
11 (integer) 3
12 
13 redis> DEL msg
14 (integer) 1
15 
16 redis> DEL numbers
17 (integer) 1
18 
19 redis> DEL fruits
20 (integer) 1

 

例子2, 咱们能够用 SET 命令建立一个字符串键, 而后用 GET 命令和 APPEND 命令操做这个键, 但若是咱们试图对这个字符串键执行只有列表键才能执行的 LLEN 命令, 那么 Redis 将向咱们返回一个类型错误:

 1 redis> SET msg "hello world"
 2 OK
 3 
 4 redis> GET msg
 5 "hello world"
 6 
 7 redis> APPEND msg " again!"
 8 (integer) 18
 9 
10 redis> GET msg
11 "hello world again!"
12 
13 redis> LLEN msg
14 (error) WRONGTYPE Operation against a key holding the wrong kind of value

 

 

类型检查的实现

从上面发生类型错误的代码示例能够看出, 为了确保只有指定类型的键能够执行某些特定的命令, 在执行一个类型特定的命令以前, Redis 会先检查输入键的类型是否正确, 而后再决定是否执行给定的命令。

类型特定命令所进行的类型检查是经过 redisObject 结构的 type 属性来实现的:

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

举个例子, 对于 LLEN 命令来讲:

  1. 在执行 LLEN 命令以前, 服务器会先检查输入数据库键的值对象是否为列表类型, 也便是, 检查值对象 redisObject 结构 type 属性的值是否为 REDIS_LIST , 若是是的话, 服务器就对键执行 LLEN 命令;
  2. 不然的话, 服务器就拒绝执行命令并向客户端返回一个类型错误;

 

 

 其余类型特定命令的类型检查过程也和这里展现的 LLEN 命令的类型检查过程相似。

 

多态命令的实现

  • Redis 除了会根据值对象的类型来判断键是否可以执行指定命令以外, 还会根据值对象的编码方式, 选择正确的命令实现代码来执行命令。
  • 举个例子, 在前面介绍列表对象的编码时咱们说过, 列表对象有 ziplist 和 linkedlist 两种编码可用, 其中前者使用压缩列表 API 来实现列表命令, 然后者则使用双端链表 API 来实现列表命令。

如今, 考虑这样一个状况, 若是咱们对一个键执行 LLEN 命令, 那么服务器除了要确保执行命令的是列表键以外, 还须要根据键的值对象所使用的编码来选择正确的 LLEN 命令实现:

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

借用面向对象方面的术语来讲, 咱们能够认为 LLEN 命令是多态(polymorphism)的: 只要执行 LLEN 命令的是列表键, 那么不管值对象使用的是 ziplist 编码仍是 linkedlist 编码, 命令均可以正常执行。

图 8-19 其余类型特定命令的执行过程也是相似的。

 

 

 实际上, 咱们能够将 DEL 、 EXPIRE 、 TYPE 等命令也称为多态命令, 由于不管输入的键是什么类型, 这些命令均可以正确地执行。他们和 LLEN 等命令的区别在于, 前者是基于类型的多态 —— 一个命令能够同时用于处理多种不一样类型的键, 而后者是基于编码的多态 —— 一个命令能够同时用于处理多种不一样编码。

 

内存回收

  • 由于 C 语言并不具有自动的内存回收功能, 因此 Redis 在本身的对象系统中构建了一个引用计数reference counting)技术实现的内存回收机制, 经过这一机制, 程序能够经过跟踪对象的引用计数信息, 在适当的时候自动释放对象并进行内存回收。
  • 每一个对象的引用计数信息由 redisObject 结构的 refcount 属性记录:
     1 typedef struct redisObject {
     2 
     3     // ...
     4 
     5     // 引用计数
     6     int refcount;
     7 
     8     // ...
     9 
    10 } robj;
  • 对象的引用计数信息会随着对象的使用状态而不断变化:
    • 在建立一个新对象时, 引用计数的值会被初始化为 1 ;
    • 当对象被一个新程序使用时, 它的引用计数值会被增一;
    • 当对象再也不被一个程序使用时, 它的引用计数值会被减一;
    • 当对象的引用计数值变为 0 时, 对象所占用的内存会被释放。
  • 表 8-12 列出了修改对象引用计数的 API , 这些 API 分别用于增长、减小、重置对象的引用计数。
函数 做用
incrRefCount 将对象的引用计数值增一。
decrRefCount 将对象的引用计数值减一, 当对象的引用计数值等于 0 时, 释放对象。
resetRefCount 将对象的引用计数值设置为 0 , 但并不释放对象, 这个函数一般在须要从新设置对象的引用计数值时使用。
  • 对象的整个生命周期能够划分为建立对象、操做对象、释放对象三个阶段。

做为例子, 如下代码展现了一个字符串对象从建立到释放的整个过程:

1 // 建立一个字符串对象 s ,对象的引用计数为 1
2 robj *s = createStringObject(...)
3 
4 // 对象 s 执行各类操做 ...
5 
6 // 将对象 s 的引用计数减一,使得对象的引用计数变为 0
7 // 致使对象 s 被释放
8 decrRefCount(s)

其余不一样类型的对象也会经历相似的过程。

 

对象共享

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

举个例子, 图 8-21 就展现了包含整数值 100 的字符串对象同时被键 A 和键 B 共享以后的样子, 能够看到, 除了对象的引用计数从以前的 1 变成了 2 以外, 其余属性都没有变化。

 

 

  • 共享对象机制对于节约内存很是有帮助, 数据库中保存的相同值对象越多, 对象共享机制就能节约越多的内存。

好比说, 假设数据库中保存了整数值 100 的键不仅有键 A 和键 B 两个, 而是有一百个, 那么服务器只须要用一个字符串对象的内存就能够保存本来须要使用一百个字符串对象的内存才能保存的数据。

  • 目前来讲, Redis 会在初始化服务器时, 建立一万个字符串对象, 这些对象包含了从 0 到 9999 的全部整数值, 当服务器须要用到值为 0 到 9999 的字符串对象时, 服务器就会使用这些共享对象, 而不是新建立对象。

注意

建立共享字符串对象的数量能够经过修改 redis.h/REDIS_SHARED_INTEGERS 常量来修改。

举个例子, 若是咱们建立一个值为 100 的键 A , 并使用 OBJECT REFCOUNT 命令查看键 A 的值对象的引用计数, 咱们会发现值对象的引用计数为 2 :

1 redis> SET A 100
2 OK
3 
4 redis> OBJECT REFCOUNT A
5 (integer) 2

 

引用这个值对象的两个程序分别是持有这个值对象的服务器程序, 以及共享这个值对象的键 A , 如图 8-22 所示。

 

 

  • 另外, 这些共享对象不仅仅只有字符串键可使用, 那些在数据结构中嵌套了字符串对象的对象(linkedlist 编码的列表对象、 hashtable 编码的哈希对象、 hashtable 编码的集合对象、以及 zset 编码的有序集合对象)均可以使用这些共享对象。

 

为何 Redis 不共享包含字符串的对象?

当服务器考虑将一个共享对象设置为键的值对象时, 程序须要先检查给定的共享对象和键想建立的目标对象是否彻底相同, 只有在共享对象和目标对象彻底相同的状况下, 程序才会将共享对象用做键的值对象, 而一个共享对象保存的值越复杂, 验证共享对象和目标对象是否相同所需的复杂度就会越高, 消耗的 CPU 时间也会越多:

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

所以, 尽管共享更复杂的对象能够节约更多的内存, 但受到 CPU 时间的限制, Redis 只对包含整数值的字符串对象进行共享。

 

对象的空转时长

  • 除了前面介绍过的 type 、 encoding 、 ptr 和 refcount 四个属性以外, redisObject 结构包含的最后一个属性为 lru 属性, 该属性记录了对象最后一次被命令程序访问的时间:
typedef struct redisObject {
   // ... 
   unsigned lru:22; 
   // ... 
} robj;
  • OBJECT IDLETIME 命令能够打印出给定键的空转时长, 这一空转时长就是经过将当前时间减去键的值对象的 lru 时间计算得出的.
  • 除了能够被 OBJECT IDLETIME 命令打印出来以外, 键的空转时长还有另一项做用: 若是服务器打开了 maxmemory 选项, 而且服务器用于回收内存的算法为 volatile-lru 或者 allkeys-lru , 那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时, 空转时长较高的那部分键会优先被服务器释放, 从而回收内存。
    • 配置文件的 maxmemory 选项和 maxmemory-policy 选项的说明介绍了关于这方面的更多信息。
 1 redis> SET msg "hello world"
 2 OK
 3 
 4 # 等待一小段时间
 5 redis> OBJECT IDLETIME msg
 6 (integer) 20
 7 
 8 # 等待一阵子
 9 redis> OBJECT IDLETIME msg
10 (integer) 180
11 
12 # 访问 msg 键的值
13 redis> GET msg
14 "hello world"
15 
16 # 键处于活跃状态,空转时长为 0
17 redis> OBJECT IDLETIME msg
18 (integer) 0

 

Redis五种类型的键的介绍到这里就结束了,欢迎和你们讨论、交流。 

 

内容参考自: 《Redis设计与实现》

 

 ========== 码字不易,转载请注明出处 ==========

相关文章
相关标签/搜索