• 沒有找到結果。

将节点添加到某个/某些节点的前面

2.2 压缩列表

2.2.5 将节点添加到某个/某些节点的前面

address | ZIPLIST_ENTRY_TAIL ZIPLIST_ENTRY_END

|

add new entry here

|

Redis 设计与实现, 第一版

|<--->|

expand space

然后设置 new 节点的各项值——到目前为止,一切都和前面介绍的添加操作一样:

set value, property, length, etc.

| v

+---+---+---+---+---+---+

| | | | | | |

| prev | new | next | next + 1 | next + 2 | ... |

| | | | | | |

+---+---+---+---+---+---+

现在,新的 new 节点取代原来的 prev 节点,成为了 next 节点的新前驱节点,不过,因为这时 next 节点的 pre_entry_length 域编码的仍然是 prev 节点的长度,所以程序需要将 new 节点 的长度编码进 next 节点的 pre_entry_length 域里,这里会出现三种可能:

1. next 的 pre_entry_length 域的长度正好能够编码 new 的长度(都是 1 字节或者都是 5 字节)

2. next 的 pre_entry_length 只有 1 字节长,但编码 new 的长度需要 5 字节 3. next 的 pre_entry_length 有 5 字节长,但编码 new 的长度只需要 1 字节 对于情况 1 和 3 ,程序直接更新 next 的 pre_entry_length 域。

如果是第二种情况,那么程序必须对 ziplist 进行内存重分配,从而扩展 next 的空间。然而,

因为 next 的空间长度改变了,所以程序又必须检查 next 的后继节点——next+1 ,看它的 pre_entry_length 能否编码 next 的新长度,如果不能的话,程序又需要继续对 next+1 进行 扩容。。。

这就是说,在某个/某些节点的前面添加新节点之后,程序必须沿着路径一个个检查后续的节 点是否满足新长度的编码要求,直到遇到一个能满足要求的节点(如果有一个能满足,那么这 个节点之后的其他节点也满足),或者到达 ziplist 的末端 zlend 为止,这种检查操作的复杂度 为 O(N2) 。

不过,因为只有在新添加节点的后面有连续多个长度接近 254 的节点时,这种连锁更新才会发 生,所以可以普遍地认为,这种连锁更新发生的概率非常小,在一般情况下,将添加操作看成 是 O(N ) 复杂度也是可以的。

执行完这三种情况的其中一种后,程序更新 ziplist 的各项属性,至此,添加操作完成。

Note: 在第三种情况中,程序实际上是可以执行类似于情况二的动作的:它可以一个个地检 查新节点之后的节点,尝试收缩它们的空间长度,不过 Redis 决定不这么做,因为在一些情况 下,比如前面提到的,有连续多个长度接近 254 的节点时,可能会出现重复的扩展——收缩

——再扩展——再收缩的抖动(flapping)效果,这会让操作的性能变得非常差。

1) 定位目标节点,并计算节点的空间长度 target-size : target start here

|

target start here

|

| <--- memmove

3) 检查 next 、next+1 等后续节点能否满足新前驱节点的编码。和添加操作一样,删除操作也 ZIPLIST_ENTRY_END 为止,这样整个 ziplist 就遍历完了:

p + e1-size + e2-size

e1-size e2-size

当进行从后往前遍历的时候,程序从指向节点 eN 的指针 p 出发,取出 eN 的 pre_entry_length 值,然后用 p 减去 pre_entry_length ,这就将指针移动到了前一个节点 eN-1 。。。一直这样 做下去,直到 p 遇到 ZIPLIST_ENTRY_HEAD 为止,这样整个 ziplist 就遍历完了。

54 Chapter 2. 内存映射数据结构

Redis 设计与实现, 第一版

p - eN.pre_entry_length

|

| p

| |

V V

+---+---+---+---+---+---+---+

| ZIPLIST | | | | | | ZIPLIST |

| ENTRY | e1 | e2 | ... | eN-1 | eN | ENTRY |

| HEAD | | | | | | END |

+---+---+---+---+---+---+---+

2.2.8 查找元素、根据值定位节点

这两个操作和遍历的原理基本相同,不再赘述。

2.2.9 小结

• ziplist 是由一系列特殊编码的内存块构成的列表,它可以保存字符数组或整数值,它还是 哈希键、列表键和有序集合键的底层实现之一。

• ziplist 典型分布结构如下:

area |<---- ziplist header ---->|<--- entries --->|<-end->|

size 4 bytes 4 bytes 2 bytes ? ? ? ? 1 byte

+---+---+---+---+---+---+---+---+

component | zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend | +---+---+---+---+---+---+---+---+

^ ^ ^

address | | |

ZIPLIST_ENTRY_HEAD | ZIPLIST_ENTRY_END

|

ZIPLIST_ENTRY_TAIL

• ziplist 节点的分布结构如下:

area |<--- entry --->|

+---+---+---+---+

component | pre_entry_length | encoding | length | content | +---+---+---+---+

• 添加和删除 ziplist 节点有可能会引起连锁更新,因此,添加和删除操作的最坏复杂度为 O(N2) ,不过,因为连锁更新的出现概率并不高,所以一般可以将添加和删除操作的复 杂度视为 O(N ) 。

56 Chapter 2. 内存映射数据结构

第 3 章

Redis 数据类型

既然 Redis 的键值对可以保存不同类型的值,那么很自然就需要对键值的类型进行检查以及多 态处理。

为了让基于类型的操作更加方便地执行,Redis 创建了自己的类型系统。

在这一部分,我们将对 Redis 所使用的对象系统进行了解,并分别观察字符串、哈希表、列表、

集合和有序集类型的底层实现。

3.1 对象处理机制

在 Redis 的命令中,用于对键(key)进行处理的命令占了很大一部分,而对于键所保存的值的 类型(后简称“键的类型” ),键能执行的命令又各不相同。

比如说,LPUSH 和 LLEN 只能用于列表键,而 SADDSRANDMEMBER 只能用于集合 键,等等。

另外一些命令,比如DEL、TTL和TYPE ,可以用于任何类型的键,但是,要正确实现这些 命令,必须为不同类型的键设置不同的处理方式:比如说,删除一个列表键和删除一个字符串 键的操作过程就不太一样。

以上的描述说明,Redis 必须让每个键都带有类型信息,使得程序可以检查键的类型,并为它 选择合适的处理方式。

另外,在前面介绍各个底层数据结构时有提到,Redis 的每一种数据类型,比如字符串、列表、

有序集,它们都拥有不只一种底层实现(Redis 内部称之为编码,encoding),这说明,每当对 某种数据类型的键进行操作时,程序都必须根据键所采取的编码,进行不同的操作。

比如说,集合类型就可以由字典和整数集合两种不同的数据结构实现,但是,当用户执行 ZADD 命令时,他/她应该不必关心集合使用的是什么编码,只要 Redis 能按照ZADD 命令的

为了解决以上问题,Redis 构建了自己的类型系统,这个系统的主要功能包括:

• redisObject 对象。

• 基于 redisObject 对象的类型检查。

• 基于 redisObject 对象的显式多态函数。

• 对 redisObject 进行分配、共享和销毁的机制。

以下小节将分别介绍类型系统的这几个方面。

Note: 因为 C 并不是面向对象语言,这里将 redisObject 称呼为对象一是为了讲述的方便,

二是希望通过模仿 OOP 的常用术语,让这里的内容更容易被理解,redisObject 实际上是只 是一个结构类型。

3.1.1 redisObject 数据结构,以及 Redis 的数据类型

redisObject 是 Redis 类型系统的核心,数据库中的每个键、值,以及 Redis 本身处理的参数,

都表示为这种数据类型。

redisObject 的定义位于 redis.h : /*

* Redis 对象

*/

typedef struct redisObject { // 类型

unsigned type:4;

// 对齐位

unsigned notused:2;

// 编码方式

unsigned encoding:4;

// LRU 时间(相对于 server.lruclock)

unsigned lru:22;

// 引用计数 int refcount;

// 指向对象的值 void *ptr;

} robj;

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

type 记录了对象所保存的值的类型,它的值可能是以下常量的其中一个(定义位于 redis.h): /*

* 对象类型

*/

#define REDIS_STRING 0 // 字符串

#define REDIS_LIST 1 // 列表

58 Chapter 3. Redis 数据类型

Redis 设计与实现, 第一版

#define REDIS_SET 2 // 集合

#define REDIS_ZSET 3 // 有序集

#define REDIS_HASH 4 // 哈希表

encoding 记录了对象所保存的值的编码,它的值可能是以下常量的其中一个 (定义位于 redis.h):

/*

* 对象编码

*/

#define REDIS_ENCODING_RAW 0 // 编码为字符串

#define REDIS_ENCODING_INT 1 // 编码为整数

#define REDIS_ENCODING_HT 2 // 编码为哈希表

#define REDIS_ENCODING_ZIPMAP 3 // 编码为 zipmap

#define REDIS_ENCODING_LINKEDLIST 4 // 编码为双端链表

#define REDIS_ENCODING_ZIPLIST 5 // 编码为压缩列表

#define REDIS_ENCODING_INTSET 6 // 编码为整数集合

#define REDIS_ENCODING_SKIPLIST 7 // 编码为跳跃表

ptr 是一个指针,指向实际保存值的数据结构,这个数据结构由 type 属性和 encoding 属性决 定。

举 个 例 子, 如 果 一 个 redisObject 的 type 属 性 为 REDIS_LIST , encoding 属 性 为 REDIS_ENCODING_LINKEDLIST ,那么这个对象就是一个 Redis 列表,它的值保存在一个双 端链表内,而 ptr 指针就指向这个双端链表;

另 一 方 面, 如 果 一 个 redisObject 的 type 属 性 为 REDIS_HASH , encoding 属 性 为 REDIS_ENCODING_ZIPMAP ,那么这个对象就是一个 Redis 哈希表,它的值保存在一个 zipmap 里,而 ptr 指针就指向这个 zipmap ;诸如此类。

下图展示了 redisObject 、Redis 所有数据类型、以及 Redis 所有编码方式(底层实现)三者 之间的关系:

redisObject

Note: REDIS_ENCODING_ZIPMAP 没有出现在图中,因为从 Redis 2.6 开始,它不再是任何数 据类型的底层结构。

3.1.2 命令的类型检查和多态

有了 redisObject 结构的存在,在执行处理数据类型的命令时,进行类型检查和对编码进行多 态操作就简单得多了。

3. 根据 redisObject 的 encoding 属性所指定的编码,选择合适的操作函数来处理底层的 数据结构。

4. 返回数据结构的操作结果作为命令的返回值。

作为例子,以下展示了对键 key 执行 LPOP 命令的完整过程:

60 Chapter 3. Redis 数据类型

Redis 设计与实现, 第一版

LPOP key

Redis 从数据库中查找 key 对应的 redisObject 结构

数据库返回 NULL ?

key 不存在 返回空回复

redisObject 的类型为 REDIS_LIST ? 否

调用多态 pop 函数 是

key 不是列表 返回类型错误

从 ziplist 中弹出最左节点

对象的编码为 ZIPLIST

从双端链表中弹出最左节点 对象的编码为 LINKEDLIST

返回被弹出的元素

3.1.3 对象共享

为了利用这种常见情况,Redis 在内部使用了一个Flyweight 模式 :通过预分配一些常见的值 对象,并在多个数据结构之间共享这些对象,程序避免了重复分配的麻烦,也节约了一些 CPU 时间。

Redis 预分配的值对象有如下这些:

• 各种命令的返回值,比如执行成功时返回的 OK ,执行错误时返回的 ERROR ,类型错误时 返回的 WRONGTYPE ,命令入队事务时返回的 QUEUED ,等等。

• 包 括 0 在 内, 小 于 redis.h/REDIS_SHARED_INTEGERS 的 所 有 整 数

(REDIS_SHARED_INTEGERS 的默认值为 10000)

因为命令的回复值直接返回给客户端,所以它们的值无须进行共享;另一方面,如果某个命令 的输入值是一个小于 REDIS_SHARED_INTEGERS 的整数对象,那么当这个对象要被保存进数据 库时,Redis 就会释放原来的值,并将值的指针指向共享对象。

作为例子,下图展示了三个列表,它们都带有指向共享对象数组中某个值对象的指针:

列表A 20130101 * 10086

共享整数对象数组 0 ... 81 ... 100 123 ... 300 ... 999 ... 10000

列表B * 12345678910 * 列表C * * -25 *

三个列表的值分别为:

• 列表 A :[20130101, 300, 10086] ,

• 列表 B :[81, 12345678910, 999] ,

• 列表 C :[100, 0, -25, 123] 。

Note: 共享对象只能被带指针的数据结构使用。

需要提醒的一点是,共享对象只能被字典和双端链表这类能带有指针的数据结构使用。

像整数集合和压缩列表这些只能保存字符串、整数等字面值的内存数据结构,就不能使用共享 对象。

3.1.4 引用计数以及对象的销毁

当将 redisObject 用作数据库的键或者值,而不是用来储存参数时,对象的生命期是非常长 的,因为 C 语言本身没有自动释放内存的相关机制,如果只依靠程序员的记忆来对对象进行追 踪和销毁,基本是不太可能的。

另一方面,正如前面提到的,一个共享对象可能被多个数据结构所引用,这时像是“这个对象被 引用了多少次? ”之类的问题就会出现。

为了解决以上两个问题,Redis 的对象系统使用了引用计数技术来负责维持和销毁对象,它的 运作机制如下:

• 每个 redisObject 结构都带有一个 refcount 属性,指示这个对象被引用了多少次。

• 每个 redisObject 结构都带有一个 refcount 属性,指示这个对象被引用了多少次。