Redis 没有直接使用 C 语言传统的字符串表示(以空字符结尾的字符数组,以下简称 C 字符串), 而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型, 并将 SDS 用作 Redis 的默认字符串表示。
每个 sds.h/sdshdr 结构表示一个 SDS 值:
struct sdshdr { // 记录 buf 数组中已使用字节的数量 // 等于 SDS 所保存字符串的长度 int len; // 记录 buf 数组中未使用字节的数量 int free; // 字节数组,用于保存字符串 char buf[]; };
表 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 实现了空间预分配和惰性空间释放两种优化策略。
空间预分配用于优化 SDS 的字符串增长操作: 当 SDS 的 API 对一个 SDS 进行修改, 并且需要对 SDS 进行空间扩展的时候, 程序不仅会为 SDS 分配修改所必须要的空间, 还会为 SDS 分配额外的未使用空间。
其中, 额外分配的未使用空间数量由以下公式决定:
- 如果对 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 。
惰性空间释放用于优化 SDS 的字符串缩短操作: 当 SDS 的 API 需要缩短 SDS 保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节, 而是使用 free 属性将这些字节的数量记录起来, 并等待将来使用。
与此同时, SDS 也提供了相应的 API , 让我们可以在有需要时, 真正地释放 SDS 里面的未使用空间, 所以不用担心惰性空间释放策略会造成内存浪费。
虽然 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 构建了自己的链表实现。
每个链表节点使用一个 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 成员则是用于实现多态链表所需的类型特定函数:
图 3-2 是由一个 list 结构和三个 listNode 结构组成的链表:
函数 | 作用 | 时间复杂度 |
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 构建了自己的字典实现。
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;
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 结构保存着一个键值对。
图 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;
图 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)的问题,它指向另一个哈希表节点的指针。
因为 dictEntry 节点组成的链表没有指向链表表尾的指针, 所以为了速度考虑, 程序总是将新节点添加到链表的表头位置(复杂度为 O(1)), 排在其他已有节点的前面。
随着操作的不断执行, 哈希表保存的键值对会逐渐地增多或者减少, 为了让哈希表的负载因子(load factor)维持在一个合理的范围之内, 当哈希表保存的键值对数量太多或者太少时, 程序需要对哈希表的大小进行相应的扩展或者收缩。
扩展和收缩哈希表的工作可以通过执行 rehash (重新散列)操作来完成, Redis 对字典的哈希表执行 rehash 的步骤如下:
举个例子, 假设程序要对含有5个键值对字典的 ht[0] 进行扩展操作, 那么程序将执行以下步骤:
其中哈希表的负载因子可以通过公式计算得出:
1 # 负载因子 = 哈希表已保存节点数量 / 哈希表大小 2 load_factor = ht[0].used / ht[0].size
根据 BGSAVE 命令或 BGREWRITEAOF 命令是否正在执行, 服务器执行扩展操作所需的负载因子并不相同, 这是因为在执行 BGSAVE 命令或 BGREWRITEAOF 命令的过程中, Redis 需要创建当前服务器进程的子进程, 而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率, 所以在子进程存在期间, 服务器会提高执行扩展操作所需的负载因子, 从而尽可能地避免在子进程存在期间进行哈希表扩展操作, 这可以避免不必要的内存写入操作, 最大限度地节约内存。
哈希表渐进式 rehash 的详细步骤:
问题:如果渐进式rehash过程中,键值对数量迅速增大,最终在还没有rehash完,又需要扩容情况怎么办?
表 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.h/zskiplistNode 和 redis.h/zskiplist 两个结构定义, 其中 zskiplistNode 结构用于表示跳跃表节点, 而 zskiplist 结构则用于保存跳跃表节点的相关信息, 比如节点的数量, 以及指向表头节点和表尾节点的指针, 等等。
图 5-1 展示了一个跳跃表示例, 位于图片最左边的是 zskiplist 结构, 该结构包含以下属性:
位于 zskiplist 结构右方的是四个 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;
图 5-2 分别展示了三个高度为 1 层、 3 层和 5 层的节点, 因为 C 语言的数组索引总是从 0 开始的, 所以节点的第一层是 level[0] , 而第二层是 level[1] , 以此类推。
每个层都有一个指向表尾方向的前进指针(level[i].forward 属性), 用于从表头向表尾方向访问节点。
图 5-3 用虚线表示出了程序从表头向表尾方向, 遍历跳跃表中所有节点的路径:
举个例子, 图 5-4 用虚线标记了在跳跃表中查找分值为 3.0 、 成员对象为 o3 的节点时, 沿途经历的层: 查找的过程只经过了一个层, 并且层的跨度为 3 , 所以目标节点在跳跃表中的排位为 3 。
再举个例子, 图 5-5 用虚线标记了在跳跃表中查找分值为 2.0 、 成员对象为 o2 的节点时, 沿途经历的层: 在查找节点的过程中, 程序经过了两个跨度为 1 的节点, 因此可以计算出, 目标节点在跳跃表中的排位为 2 。
图 5-6 用虚线展示了如果从表尾向表头遍历跳跃表中的所有节点: 程序首先通过跳跃表的 tail 指针访问表尾节点, 然后通过后退指针访问倒数第二个节点, 之后再沿着后退指针访问倒数第三个节点, 再之后遇到指向 NULL 的后退指针, 于是访问结束。
举个例子, 在图 5-7 所示的跳跃表中, 三个跳跃表节点都保存了相同的分值 10086.0 , 但保存成员对象 o1 的节点却排在保存成员对象 o2 和 o3 的节点之前, 而保存成员对象 o2 的节点又排在保存成员对象 o3 的节点之前, 由此可见, o1 、 o2 、 o3 三个成员对象在字典中的排序为 o1 <= o2 <= o3 。
虽然仅靠多个跳跃表节点就可以组成一个跳跃表, 但通过使用一个 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) 。
整数集合(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), 各个项在数组中按值的大小从小到大有序地排列, 并且数组中不包含任何重复项。
虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组, 但实际上 contents 数组并不保存任何 int8_t 类型的值 —— contents 数组的真正类型取决于 encoding 属性的值:
每当我们要将一个新元素添加到整数集合里面, 并且新元素的类型比整数集合现有所有元素的类型都要长时, 整数集合需要先进行升级(upgrade), 然后才能将新元素添加到整数集合里面。
升级整数集合并添加新元素共分为三步进行:
因为每次向整数集合添加新元素都可能会引起升级, 而每次升级都需要对底层数组中已有的所有元素进行类型转换, 所以向整数集合添加新元素的时间复杂度为 O(N) 。
因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大, 所以这个新元素的值要么就大于所有现有元素, 要么就小于所有现有元素:
整数集合的升级策略有两个好处, 一个是提升整数集合的灵活性, 另一个是尽可能地节约内存。
表 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) |
图 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 ),用于标记压缩列表的末端。 |
图 7-5 展示了一个包含一字节长 previous_entry_length 属性的压缩列表节点, 属性的值为 0x05 , 表示前一节点的长度为 5 字节。
图 7-6 展示了一个包含五字节长 previous_entry_length 属性的压缩节点, 属性的值为 0xFE00002766 , 其中值的最高位字节 0xFE 表示这是一个五字节长的 previous_entry_length 属性, 而之后的四字节 0x00002766 (十进制值 10086 )才是前一节点的实际长度。
节点的 encoding 属性记录了节点的 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 属性。 |
因为以上原因, ziplistPush 等命令的平均复杂度仅为 O(N) , 在实际中, 我们可以放心地使用这些函数, 而不必担心连锁更新会影响压缩列表的性能。
表 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)、双端链表、字典、压缩列表、整数集合, 等等。
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
表 8-1 对象的类型
类型常量 | 对象的名称 |
REDIS_STRING | 字符串对象 |
REDIS_LIST | 列表对象 |
REDIS_HASH | 哈希对象 |
REDIS_SET | 集合对象 |
REDIS_ZSET | 有序集合对象 |
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" |
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" |
类型常量 | 编码 | 对象 |
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"
举个例子, 在列表对象包含的元素比较少时, Redis 使用压缩列表作为列表对象的底层实现:
其他类型的对象也会通过使用多种不同的编码来进行类似的优化。
在接下来的内容中, 我们将分别介绍 Redis 中的五种不同类型的对象, 说明这些对象底层所使用的编码方式, 列出对象从一种编码转换成另一种编码所需的条件, 以及同一个命令在多种不同编码上的实现方法。
举个例子, 如果我们执行以下 SET 命令, 那么服务器将创建一个如图 8-1 所示的 int 编码的字符串对象作为 number 键的值:
1 redis> SET number 10086 2 OK 3 4 redis> OBJECT ENCODING number 5 "int"
举个例子, 如果我们执行以下命令, 那么服务器将创建一个如图 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"
embstr 编码是专门用于保存短字符串的一种优化编码方式, 这种编码和 raw 编码一样, 都使用 redisObject 结构和 sdshdr 结构来表示字符串对象, 但 raw 编码会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构, 而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含 redisObject 和 sdshdr 两个结构, 如图 8-3 所示。
embstr 编码的字符串对象在执行命令时, 产生的效果和 raw 编码的字符串对象执行命令时产生的效果是相同的, 但使用 embstr 编码的字符串对象来保存短字符串值有以下好处:
作为例子, 以下命令创建了一个 embstr 编码的字符串对象作为 msg 键的值, 值对象的样子如图 8-4 所示:
1 redis> SET msg "hello" 2 OK 3 4 redis> OBJECT ENCODING msg 5 "embstr"
表 8-6 字符串对象保存各类型值的编码方式
值 | 编码 |
可以用 long 类型保存的整数。 | int |
可以用 long double 类型保存的浮点数。 | embstr 或者 raw |
字符串值, 或者因为长度太大而没办法用 long 类型表示的整数, 又或者因为长度太大而没办法用 long double 类型表示的浮点数。 | 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 | 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 然后取出并返回字符串指定索引上的字符。 | 直接取出并返回字符串指定索引上的字符。 |
举个例子, 如果我们执行以下 RPUSH 命令, 那么服务器将创建一个列表对象作为 numbers 键的值:
1 redis> RPUSH numbers 1 "three" 5 2 (integer) 3
注意, linkedlist 编码的列表对象在底层的双端链表结构中包含了多个字符串对象, 这种嵌套字符串对象的行为在稍后介绍的哈希对象、集合对象和有序集合对象中都会出现, 字符串对象是 Redis 五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象。
注意
为了简化字符串对象的表示, 我们在图 8-6 使用了一个带有 StringObject 字样的格子来表示一个字符串对象, 而 StringObject 字样下面的是字符串对象所保存的值。
比如说, 图 8-7 代表的就是一个包含了字符串值 "three" 的字符串对象, 它是 8-8 的简化表示。
本书接下来的内容将继续沿用这一简化表示。
当列表对象可以同时满足以下两个条件时, 列表对象使用 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 函数, 定位到双端链表指定索引上的节点, 然后通过赋值操作更新节点的值。 |
举个例子, 如果我们执行以下 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 编码:
不能满足这两个条件的哈希对象需要使用 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 函数返回字典的值。 |
举个例子, 以下代码将创建一个如图 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 编码:
不能满足这两个条件的集合对象需要使用 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 函数, 从字典中删除所有键为给定元素的键值对。 |
1 typedef struct zset { 2 3 zskiplist *zsl; 4 dict *dict; 5 6 } zset;
举个例子, 如果我们执行以下 ZADD 命令, 那么服务器将创建一个有序集合对象作为 price 键的值:
1 redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry 2 (integer) 3
注意
为了展示方便, 图 8-17 在字典和跳跃表中重复展示了各个元素的成员和分值, 但在实际中, 字典和跳跃表会共享元素的成员和分值, 所以并不会造成任何数据重复, 也不会因此而浪费任何内存。
当有序集合对象可以同时满足以下两个条件时, 对象使用 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 | 遍历压缩列表, 查找包含了给定成员的节点, 然后取出成员节点旁边的分值节点保存的元素分值。 | 直接从字典中取出给定成员的分值。 |
例子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 属性来实现的:
举个例子, 对于 LLEN 命令来说:
其他类型特定命令的类型检查过程也和这里展示的 LLEN 命令的类型检查过程类似。
现在, 考虑这样一个情况, 如果我们对一个键执行 LLEN 命令, 那么服务器除了要确保执行命令的是列表键之外, 还需要根据键的值对象所使用的编码来选择正确的 LLEN 命令实现:
借用面向对象方面的术语来说, 我们可以认为 LLEN 命令是多态(polymorphism)的: 只要执行 LLEN 命令的是列表键, 那么无论值对象使用的是 ziplist 编码还是 linkedlist 编码, 命令都可以正常执行。
图 8-19 其他类型特定命令的执行过程也是类似的。
实际上, 我们可以将 DEL 、 EXPIRE 、 TYPE 等命令也称为多态命令, 因为无论输入的键是什么类型, 这些命令都可以正确地执行。他们和 LLEN 等命令的区别在于, 前者是基于类型的多态 —— 一个命令可以同时用于处理多种不同类型的键, 而后者是基于编码的多态 —— 一个命令可以同时用于处理多种不同编码。
1 typedef struct redisObject { 2 3 // ... 4 5 // 引用计数 6 int refcount; 7 8 // ... 9 10 } robj;
函数 | 作用 |
incrRefCount | 将对象的引用计数值增一。 |
decrRefCount | 将对象的引用计数值减一, 当对象的引用计数值等于 0 时, 释放对象。 |
resetRefCount | 将对象的引用计数值设置为 0 , 但并不释放对象, 这个函数通常在需要重新设置对象的引用计数值时使用。 |
作为例子, 以下代码展示了一个字符串对象从创建到释放的整个过程:
1 // 创建一个字符串对象 s ,对象的引用计数为 1 2 robj *s = createStringObject(...) 3 4 // 对象 s 执行各种操作 ... 5 6 // 将对象 s 的引用计数减一,使得对象的引用计数变为 0 7 // 导致对象 s 被释放 8 decrRefCount(s)
其他不同类型的对象也会经历类似的过程。
举个例子, 图 8-21 就展示了包含整数值 100 的字符串对象同时被键 A 和键 B 共享之后的样子, 可以看到, 除了对象的引用计数从之前的 1 变成了 2 之外, 其他属性都没有变化。
比如说, 假设数据库中保存了整数值 100 的键不只有键 A 和键 B 两个, 而是有一百个, 那么服务器只需要用一个字符串对象的内存就可以保存原本需要使用一百个字符串对象的内存才能保存的数据。
注意
创建共享字符串对象的数量可以通过修改 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 所示。
当服务器考虑将一个共享对象设置为键的值对象时, 程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同, 只有在共享对象和目标对象完全相同的情况下, 程序才会将共享对象用作键的值对象, 而一个共享对象保存的值越复杂, 验证共享对象和目标对象是否相同所需的复杂度就会越高, 消耗的 CPU 时间也会越多:
因此, 尽管共享更复杂的对象可以节约更多的内存, 但受到 CPU 时间的限制, Redis 只对包含整数值的字符串对象进行共享。
typedef struct redisObject { // ... unsigned lru:22; // ... } robj;
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设计与实现》
========== 码字不易,转载请注明出处 ==========
原文:https://www.cnblogs.com/xuxh120/p/14400980.html