前言
有点遗憾,梁敬彬和梁敬弘老师关于数据库的第二本佳作《收获,不止SQl优化》短期内可能不会去看了。
正如老师在《收获,不止Oracle》第一章中所写,数据库是个庞大的体系,应该根据自己的需求去学习。
目前看来,时机未到。我需要更多的积累之后,再去阅读关于SQL优化的书。毕竟没有足够的使用经验,很难达到“哦,原来可以这么优化”的顿悟的感觉。
所以暂且搁置。开始 Redis 的学习,知识的广度也很重要。
《Redis设计与实现》第一部分:数据结构与对象包含第2-8章。第一章是引言,介绍书的大概结构和阅读顺序,这里省略。
第二章 - 简单动态字符串
Redis 没有直接使用 C 语言传统的字符串(以空字符结尾的字符数组),而是自己实现了一种新的字符串类型,叫做简单动态字符串,简称
SDS(Simple Dynamic String)。并使用 SDS 来作为 Redis 的默认字符串表示。
Redis 中在一些无需对字符串进行修改的地方会使用C字符串作为字符串字面量(string literal),比如打印日志等。
而在需要修改字符串时,会使用 SDS 表示字符串值。比如:
1 | set msg "hello world" |
Redis 会在数据库中创建一个键值对,键是一个 SDS 对象,保存着字符串 “msg”,值也是一个 SDS 对象,保存着字符串 “hello world”。
除了用来保存数据库中的字符串值之外,SDS 还被用作缓冲区(buffer):AOF 模块中的 AOF 缓冲区,以及客户端状态中的输入缓冲区,都是由
SDS 实现的。
SDS 的定义
每个sds.h/sdshdr结构表示一个SDS值:
1 | struct sdshdr { |
上面是一个 SDS 的示例,其中
- free 属性为 0,表示 SDS 没有分配任何未使用空间
- len 属性为 5,表示 SDS 保存了一个 5 字节长的字符串
- buf 属性是一个字节数组,数组中保存着字符串 “hello” 的五个字节和最后一个空字符 ‘\0’
SDS 遵循 C 字符串以空字符结尾的惯例,保存空字符的 1 字节空间不计算在 SDS 的 len 属性里面。
并且为空字符分配额外的 1 字节空间,以及添加空字符到字符串末尾等操作,都是由 SDS 函数自动完成的,所以这个空字符对于 SDS
的使用者来说是完全透明的。
遵循空字符结尾这一惯例的好处是,SDS 可以直接重用一部分 C 字符串函数库里面的函数。而无需为 SDS 做任何额外的工作。
下面这个 SDS 和之前展示的 SDS 的区别在于,这个 SDS 为 buf 数组分配了五字节未使用空间,所以它的 free 属性的值为 5
后面介绍 free 空间在 SDS 中的作用。
SDS 与 C 字符串的区别
根据传统,C 语言使用长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组的最后一个元素总是空字符’\0’。
常数复杂度获取字符串长度
因为 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为O(N)。
和 C 字符串不同,因为 SDS 在 len 属性中记录了 SDS 本身的长度,所以获取一个 SDS 长度的复杂度仅为O(1)。
设置和更新 SDS 长度的工作是由 SDS 的 API 在执行时自动完成的,使用 SDS 无须进行任何手动修改长度的工作。
杜绝缓冲区溢出
除了获取字符串长度的复杂度高之外,C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出(buffer overflow)。
举个例子,<string.h>/strcat 函数可以将 src 字符串中的内容拼接到 dest 字符串的末尾。
因为 C 字符串不记录自身的长度,所以 strcat 假定用户在执行这个函数时,已经为 dest 分配了足够多的内存,可以容纳 src 字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出。
而当 SDS API 需要对 SDS 进行修改时,API 会先检查 SDS 的空间是否满足修改所需的要求,如果不满足的话,API 会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出问题。
减少修改字符串时带来的内存重分配次数
因为 C 字符串并不记录自身的长度,所以对于一个包含了 N 个字符的 C 字符串来说,这个 C 字符串的底层实现总是一个 N+1 个字符长的数组(额外的一个字符空间用于保存空字符)。
因为 C 字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个 C 字符串,程序都总要对保存这个 C 字符串的数组进行一次内存重分配操作:
- 如果程序执行的是增长字符串的操作,比如拼接操作(append),那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就会产生缓冲区溢出。
- 如果程序执行的是缩短字符串的操作,比如截断操作(trim),那么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就会产生内存泄漏。
因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作。
由于 Redis 对性能的要求非常高,经常被用于数据频繁修改的场合。所以是不能接受每次修改字符串都需要进行内存重分配操作的。
SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联:在 SDS 中,buf 数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由 SDS 的 free 属性记录。
通过未使用空间,SDS 实现了空间预分配和惰性空间释放两种优化策略。
- 空间预分配
空间预分配用于优化 SDS 的字符串增长操作:当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候,程序不仅会为 SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间。
额外分配的未使用空间数量由以下公式决定,这样可以减少连续修改字符串所需的内存重分配次数:- 如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS len属性的值将和free属性的值相同。举个例子,如果进行修改之后,SDS的len将变成13字节,那么程序也会分配13字节的未使用空间,SDS的buf数组的实际长度将变成13+13+1=27字节(额外的一字节用于保存空字符)。
- 如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。举个例子,如果进行修改之后,SDS的len将变成30MB,那么程序会分配1MB的未使用空间,SDS的buf数组的实际长度将为30MB+1MB+1byte。
- 惰性空间释放
惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。
通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化。
SDS也提供了相应的API,可以在有需要时,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。
二进制安全
C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
虽然数据库一般用于保存文本数据,但使用数据库来保存二进制数据的场景也不少见。
因此,为了确保Redis可以适用于各种不同的使用场景,SDS的API都是二进制安全的(binary-safe)。
所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样。
这也是SDS的buf属性被称为字节数组的原因——Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据。
兼容部分C字符串函数
虽然SDS的API都是二进制安全的,但它们一样遵循C字符串以空字符结尾的惯例:这些API总会将SDS保存的数据的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分<string.h>库定义的函数。
SDS API
SDS 主要操作 API
API | 作用 | 时间复杂度 |
---|---|---|
sdsnew |
创建一个包含给定 C 字符串的 SDS | O(M),M 为给定 C 字符串的长度 |
sdsempty |
创建一个空的 SDS | O(1) |
sdsfree |
释放给定的 SDS | O(1),只是释放内存 |
sdslen |
返回 SDS 的已使用空间字节数 | O(1),直接读取元数据 |
sdsavail |
返回 SDS 的剩余可用空间字节数 | O(1),直接读取元数据 |
sdsdup |
创建一个给定 SDS 的副本 | O(M),M 为给定 SDS 的长度 |
sdsclear |
清空 SDS 保存的字符串内容 | O(1),保留空间,不释放内存 |
sdscat |
将给定 C 字符串拼接到 SDS 字符串的末尾 | O(M),M 为被拼接 C 字符串的长度 |
sdscatsds |
将给定 SDS 字符串拼接到另一个 SDS 字符串的末尾 | O(M),M 为被拼接 SDS 字符串的长度 |
sdscpy |
将给定的 C 字符串复制到 SDS 中,覆盖 SDS 原有的字符串 | O(N),N 为被复制 C 字符串的长度 |
sdsgrowzero |
扩展 SDS 到指定长度,如果长度变大,则在新增空间中填充零字符 | O(M),M 为扩展新增的字节数 |
sdssubstr |
保留 SDS 给定区间内的数据,不在区间内的数据会被覆盖或删除 | O(N),N 为被保留数据的字节数 |
sdstrim |
移除 SDS 中所有在给定 C 字符串中出现过的字符 | O(M),M 为给定 C 字符串的长度 |
sdscmp |
比较两个 SDS 字符串是否相同 | O(N),N 为两个 SDS 中较短的那个的长度 |
End
Redis只会使用C字符串作为字面量,在大多数情况下,Redis使用SDS(Simple Dynamic String,简单动态字符串)作为字符串表示。
这里对字符串的包装,跟很多其他语言中的字符串类似。不过大部分语言中的字符串是不可变的。
可能和 go 中数组更类似,使用 make 来创建,可以传两个整型,第一个是长度,第二个是容量。
第三章 - 链表
链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。
作为一种常用数据结构,链表内置在很多高级的编程语言里面,因为Redis使用的C语言并没有内置这种数据结构,所以Redis构建了自己的链表实现。
链表在Redis中的应用非常广泛,比如列表键的底层实现之一就是链表。当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。
除了链表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区(output buffer)
链表和链表节点的实现
每个链表节点使用一个adlist.h/listNode结构来表示:
1 | typedef struct listNode { |
多个listNode可以通过prev和next指针组成双端链表,如下图
虽然仅仅使用多个listNode结构就可以组成链表,但使用adlist.h/list来持有链表的话,操作起来会更方便。
1 | typedef struct list { |
list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dup、free和match成员则是用于实现多态链表所需的类型特定函数:
- dup函数用于复制链表节点所保存的值
- free函数用于释放链表节点所保存的值
- match函数则用于对比链表节点所保存的值和另一个输入值是否相等
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 |
将给定的函数设置为链表的节点值复制函数 | 复制函数可以通过链表的 dup 属性直接获得,O(1) |
listGetDupMethod |
返回链表当前正在使用的节点值复制函数 | O(1) |
listSetFreeMethod |
将给定的函数设置为链表的节点值释放函数 | 释放函数可以通过链表的 free 属性直接获得,O(1) |
listGetFreeMethod |
返回链表当前正在使用的节点值释放函数 | O(1) |
listSetMatchMethod |
将给定的函数设置为链表的节点值对比函数 | 对比函数可以通过链表的 match 属性直接获得,O(1) |
listGetMatchMethod |
返回链表当前正在使用的节点值对比函数 | 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)的抽象数据结构。
在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值),这些关联的键和值就称为键值对。
字典中的每个键都是独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除整个键值对,等等。
字典经常作为一种数据结构内置在很多高级编程语言里面,但Redis所使用的C语言并没有内置这种数据结构,因此Redis构建了自己的字典实现。
字典在Redis中的应用相当广泛,比如Redis的数据库就是使用字典来作为底层实现的,对数据库的增、删、查、改操作也是构建在对字典的操作之上的。
字典的实现
Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。
哈希表
Redis字典所使用的哈希表由dict.h/dictht结构定义:
1 | typedef struct dictht { |
table 属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。
size 属性记录了哈希表的大小,也即是table数组的大小。
used 属性则记录了哈希表目前已有节点(键值对)的数量。
sizemask 属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。
哈希表节点
哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:
1 | typedef struct dictEntry { |
key 属性保存着键值对中的键。
v 属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数。
next 属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决键冲突(collision)的问题。
字典
Redis中的字典由dict.h/dict结构表示:
1 | typedef struct dict { |
type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的:
- type 属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
- privdata 属性保存了需要传给那些类型特定函数的可选参数。
ht 属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]
哈希表,ht[1]
哈希表只会在对ht[0]
哈希表进行rehash时使用。
rehashidx 属性记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1。
1 | typedef struct dictType { |
哈希算法
当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。
Redis计算哈希值和索引值的方法如下:
使用字典设置的哈希函数,计算键 key 的哈希值hash = dict->type->hashFunction(key);
使用哈希表的 sizemask 属性和哈希值,计算出索引值
根据情况不同,ht[x]
可以是ht[0]
或者ht[1]
index = hash & dict->ht[x].sizemask;
当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。
MurmurHash Wiki
解决键冲突
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision)。
Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。
因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面。
Rehash
随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。
扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:
- 为字典的
ht[1]
哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]
当前包含的键值对数量(也即是ht[0]
.used属性的值):- 如果执行的是扩展操作,那么
ht[1]
的大小为第一个大于等于ht[0]
.used*2的2^n(2的n次方幂),即两倍。 - 如果执行的是收缩操作,那么
ht[1]
的大小为第一个大于等于ht[0]
.used的2^n。即元素数量的最小倍数。
- 如果执行的是扩展操作,那么
- 将保存在
ht[0]
中的所有键值对rehash到ht[1]
上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]
哈希表的指定位置上。 - 当
ht[0]
包含的所有键值对都迁移到了ht[1]
之后(ht[0]
变为空表),释放ht[0]
,将ht[1]
设置为ht[0]
,并在ht[1]
新创建一个空白哈希表,为下一次rehash做准备。
当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:
- 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
- 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。
当哈希表的负载因子小于0.1(即小于当前大小十倍)时,程序自动开始对哈希表执行收缩操作。
其中哈希表的负载因子可以通过下面的公式算出:
负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同。
这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率。
所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。
BGSAVE
命令用于在后台异步地执行数据快照(snapshot)保存操作,即创建 Redis 数据的 RDB 文件。执行这个命令时,Redis 会生成一个 RDB 文件,并将当前数据库中的所有数据保存到这个文件中。BGREWRITEAOF
命令用于在后台异步地重写 AOF(Append Only File)日志文件。AOF 是 Redis 提供的另一种持久化方式,通过记录每一个写命令来实现数据的持久化。
写时复制(copy-on-write)是指当一个进程创建一个子进程时,子进程会共享父进程的内存页面,而不是立即拷贝整个内存数据。这种共享是“只读”的,直到某一方(父进程或子进程)试图修改共享的数据时,系统才会真正复制该内存页面。这种方式避免了在创建子进程时立即拷贝大量内存数据的开销。
渐进式rehash
扩展或收缩哈希表需要将ht[0]
里面的所有键值对rehash到ht[1]
里面,但是,这个rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。
这样做的原因在于,如果ht[0]
里只保存着四个键值对,那么服务器可以在瞬间就将这些键值对全部rehash到ht[1]
;
但是,如果哈希表里保存的键值对数量不是四个,而是四百万、四千万甚至四亿个键值对,那么要一次性将这些键值对全部rehash到ht[1]
的话,庞大的计算量可能会导致服务器在一段时间内停止服务。
因此,为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]
里面的所有键值对全部rehash到ht[1]
,而是分多次、渐进式地将ht[0]
里面的键值对慢慢地rehash到ht[1]
。
哈希表渐进式rehash的详细步骤:
- 为
ht[1]
分配空间,让字典同时持有ht[0]
和ht[1]
两个哈希表。 - 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。
- 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将
ht[0]
哈希表在rehashidx索引上的所有键值对rehash到ht[1]
,当rehash工作完成之后,程序将rehashidx属性的值增一。 - 随着字典操作的不断执行,最终在某个时间点上,
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操作的执行而最终变成空表。
字典API
函 数 | 作用 | 时间复杂度 |
---|---|---|
diotcreate | 创建一个新的字典 | 0(1) |
dietAdd | 将给定的键值对添加到字典里面 | 0(1) |
diotkeplace | 将给定的健值对添加到字典里面,如果键已经 存在于字典,那么用新值取代原有的值 | 0(1) |
dictretchvalue | 这回给定键的值 | 0(1) |
diotCetRandomKey | 从字典中随机返回个键值对 | 0(1) |
第五章 - 跳跃表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。
在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。
Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串时,Redis就会使用跳跃表来作为有序集合键的底层实现。
和链表、字典等数据结构被广泛地应用在Redis内部不同,Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构,除此之外,跳跃表在Redis里面没有其他用途。
跳跃表的实现
Redis的跳跃表由redis.h/zskiplistNode和redis.h/zskiplist两个结构定义,其中zskiplistNode结构用于表示跳跃表节点,而zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。
上图一个跳跃表示例,位于图片最左边的是 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是节点所保存的成员对象。
注意表头节点和其他节点的构造是一样的:表头节点也有后退指针、分值和成员对象,不过表头节点的这些属性都不会被用到,所以图中省略了这些部分,只显示了表头节点的各个层。
跳跃表节点
跳跃表节点的实现由redis.h/zskiplistNode结构定义:
1 | typedef struct zskiplistNode { |
层
跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。
每次创建一个新跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”。
前进指针
每个层都有一个指向表尾方向的前进指针(level[i].forward
属性),用于从表头向表尾方向访问节点。
跨度
层的跨度(level[i].span
属性)用于记录两个节点之间的距离:
- 两个节点之间的跨度越大,它们相距得就越远。
- 指向NULL的所有前进指针的跨度都为0,因为它们没有连向任何节点。
初看上去,很容易以为跨度和遍历操作有关,但实际上并不是这样,遍历操作只使用前进指针就可以完成了,跨度实际上是用来计算排位(rank)的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。
当查找某个值时,程序会从最高层开始逐层往下搜索,每一层会利用跨度跳过多个节点,直到找到目标节点或进入下一层。 可以提高查询的效率。
后退指针
节点的后退指针(backward属性)用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。
分值和成员
节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。
节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。
在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。
跳跃表
仅靠多个跳跃表节点就可以组成一个跳跃表。
但通过使用一个zskiplist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或者快速地获取跳跃表节点的数量(也即是跳跃表的长度)等信息。
zskiplist结构的定义如下:
1 | typedef struct zskiplist { |
header和tail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1)。
通过使用length属性来记录节点的数量,程序可以在O(1)复杂度内返回跳跃表的长度。
level属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头节点的层高并不计算在内。
跳跃表API
函数 | 作用 | 时间复杂度 |
---|---|---|
zslCreate |
创建一个新的跳跃表 | O(1) |
zslFree |
释放给定的跳跃表及其包含的所有节点 | O(N),N 为跳跃表的长度 |
zslInsert |
将包含给定成员和分值的新节点添加到跳跃表中 | 平均 O(log N),最坏 O(N) |
zslDelete |
删除跳跃表中包含给定成员和分值的节点 | 平均 O(log N),最坏 O(N) |
zslGetRank |
返回包含给定成员和分值的节点在跳跃表中的排名 | 平均 O(log N),最坏 O(N) |
zslGetElementByRank |
返回跳跃表中指定排名的节点 | 平均 O(log N),最坏 O(N) |
zslIsInRange |
判断跳跃表中是否存在至少一个节点的分值在给定范围内 | O(1),只需检查表头和表尾节点即可 |
zslFirstInRange |
返回跳跃表中第一个符合指定分值范围的节点 | 平均 O(log N),最坏 O(N) |
第六章 - 整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
整数集合的实现
整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为 int16_t、int32_t或者int64_t 的整数值,并且保证集合中不会出现重复元素。
每个intset.h/intset结构表示一个整数集合:
1 | typedef struct intset { |
contents 数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
length 属性记录了整数集合包含的元素数量,也即是contents数组的长度。
虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值:
- 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组,数组里的每个项都是一个int16_t类型的整数值(最小值为-32768,最大值为32767)。
- 如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组,数组里的每个项都是一个int32_t类型的整数值(最小值为-2147483648,最大值为2147483647)。
- 如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组,数组里的每个项都是一个int64_t类型的整数值(最小值为-9223372036854775808,最大值为9223372036854775807)。
当向一个底层为int16_t数组的整数集合添加一个int64_t类型的整数值时,整数集合已有的所有元素都会被转换成int64_t类型。即升级规则。
升级规则(Redis 会对整数集合进行自动升级,以支持不同大小的整数类型。)
自动升级触发条件:当一个新插入的整数无法在当前整数集合的类型范围内表示时,Redis 会触发升级。
全量复制与排序:Redis 会分配新的内存空间,将原来的所有整数复制到新类型的整数集合中。升级后的整数集合会保持有序状态,以便于后续查找操作。
升级不可逆:一旦整数集合升级到更大类型,就不会降级。例如,一旦升级到int64_t
类型,即使删除所有大整数,类型也不会恢复。
升级
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。
升级整数集合并添加新元素共分为三步进行:
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
- 将新元素添加到底层数组里面。
因为每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中已有的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为O(N)。
因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素的值要么就大于所有现有元素,要么就小于所有现有元素:
- 在新元素小于所有现有元素的情况下,新元素会被放置在底层数组的最开头(索引0);
- 在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引length-1)。
整数集合的升级策略有两个好处,一个是提升整数集合的灵活性,另一个是尽可能地节约内存。
因为C语言是静态类型语言,为了避免类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里面。
但是,因为整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将int16_t、int32_t或者int64_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就会使用压缩列表来做列表键的底层实现。
压缩列表的构成
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
压缩列表主要由以下几个部分组成:
Header(头部):
zlbytes
:4字节,表示整个压缩列表的总字节数,便于在内存中管理。zltail
:4字节,记录最后一个元素的偏移量,使得可以快速定位尾部元素。zllen
:2字节,表示压缩列表中包含的元素数量。如果数量大于65535
,zllen
只表示 65535,实际数量需要遍历确定。
Entry(节点):
previous_entry_length
:1-5字节,表示前一个元素的长度,用于双向遍历。如果前一个元素长度小于 254 字节,previous_entry_length
用 1 字节表示;否则,用 5 字节表示。encoding
:1-5字节,记录当前元素的数据类型和长度,用于解析元素内容。例如,数据可以是字节数组或整数类型。content
:变长,存储元素的实际数据。
End(结尾标志):
- 压缩列表以 1 字节特殊标志
0xFF
结尾,表示压缩列表结束。
- 压缩列表以 1 字节特殊标志
压缩列表节点的构成
节点由以下组成:
previous_entry_length
:1-5字节,表示前一个元素的长encoding
:1-5字节,记录当前元素的数content
:变长,存储元素的实际数据。
每个压缩列表节点可以保存一个字节数组或者一个整数值,其中,字节数组可以是以下三种长度的其中一种:
- 长度小于等于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),而之后的四个字节则用于保存前一节点的长度。
因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。
举个例子,如果我们有一个指向当前节点起始地址的指针c,那么我们只要用指针c减去当前节点previous_entry_length属性的值,就可以得出一个指向前一个节点起始地址的指针p
双向遍历
对于正向遍历。因为每个节点的大小不固定,需要计算当前节点的长度,以根据每个节点的实际长度进行跳转。
对于整数类型,encoding 直接决定了所占用的字节数(1、2、4 或 8 字节)。
对于字节数组类型,encoding 包含了字节数组的长度信息,用于确定 content 部分的大小。
对于反向遍历。则是通过previous_entry_length来实现的,它记录了当前节点的前一个节点的长度,从而可以计算出前一个节点的起始地址,从而进行跳转。
encoding
节点的encoding属性记录了节点的content属性所保存数据的类型以及长度。
编码类型 | encoding 值 |
编码字节数 | 表示的数据类型 | 内容字节数 |
---|---|---|---|---|
字符串编码 | 00xxxxxx |
1 字节 | 字符串,长度 ≤ 63 字节 | 6 位表示长度(0-63) |
01xxxxxx xxxxxxxx |
2 字节 | 字符串,长度 ≤ 16383 字节 | 14 位表示长度(0-16383) | |
100xxxxx xxxxxxxx xxxxxxxx xxxxxxxx |
5 字节 | 字符串,长度 ≤ 4294967295 字节 | 4 字节表示长度(0-4294967295) | |
整数编码 | 1111 0000 |
1 字节 | 4 位有符号整数 | 无 |
1100 0000 |
1 字节 | 1 字节有符号整数 | 1 字节内容 | |
1101 0000 |
1 字节 | 2 字节有符号整数 | 2 字节内容 | |
1110 0000 |
1 字节 | 4 字节有符号整数 | 4 字节内容 | |
1111 0001 |
1 字节 | 8 字节有符号整数 | 8 字节内容 |
content
节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。
连锁更新
前面有提到,每个节点的previous_entry_length属性都记录了前一个节点的长度:
- 如果前一节点的长度小于254字节,那么previous_entry_length属性需要用1字节长的空间来保存这个长度值。
- 如果前一节点的长度大于等于254字节,那么previous_entry_length属性需要用5字节长的空间来保存这个长度值。
那么有一种情况
在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点e1至eN,因为e1至eN的所有节点的长度都小于254字节,所以记录这些节点的长度只需要1字节长的previous_entry_length属性。
如果将一个长度大于等于254字节的新节点new设置为压缩列表的表头节点,那么new将成为e1的前置节点。
因为e1的previous_entry_length属性仅长1字节,它没办法保存新节点new的长度,所以程序将对压缩列表执行空间重分配操作,并将e1节点的previous_entry_length属性从原来的1字节长扩展为5字节长。
同时,e1原本的长度介于250字节至253字节之间,在为previous_entry_length属性新增四个字节的空间之后,e1的长度就变成了介于254字节至257字节之间,而这种长度使用1字节长的previous_entry_length属性是没办法保存的。
所以e2也需要进行重新分配,以此类推。程序需要不断地对压缩列表执行空间重分配操作,直到eN为止。
Redis将这种在特殊情况下产生的连续多次空间扩展操作称之为“连锁更新”(cascade update)
除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新。
因为连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N 2)。
要注意的是,尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的:
- 首先,压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见;
- 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响:比如说,对三五个节点进行连锁更新是绝对不会影响性能的;
因为以上原因,ziplistPush等命令的平均复杂度仅为O(N),在实际中,我们可以放心地使用这些函数,而不必担心连锁更新会影响压缩列表的性能。
压缩列表API
函数 | 作用 | 算法复杂度 |
---|---|---|
ziplistNew |
创建一个新的压缩列表 | O(1) |
ziplistPush |
将给定值的新节点添加到压缩列表的表头或表尾 | 平均 O(N),最坏 O(N^2) |
ziplistInsert |
在给定节点之后插入包含给定值的新节点 | 平均 O(N),最坏 O(N^2) |
ziplistIndex |
返回压缩列表给定索引处的节点 | O(N) |
ziplistFind |
在压缩列表中查找并返回包含给定值的节点 | O(N),值检查为 O(M) |
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),否则为 O(N) |
其中:
M
是节点值的字节数(如果节点包含字符串值),表示比较过程中所需的检查复杂度。N
是压缩列表中节点的总数。
因为ziplistPush、ziplistInsert、ziplistDelete和ziplistDeleteRange四个函数都有可能会引发连锁更新,所以它们的最坏复杂度都是O(N^2)。
第八章 - 对象
前面介绍了Redis用到的所有主要数据结构,比如简单动态字符串(SDS)、双端链表、字典、压缩列表、整数集合等等。
Redis并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象。
通过这五种不同类型的对象,Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令。
使用对象的另一个好处是,我们可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。
除此之外,Redis的对象系统还实现了基于引用计数技术的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放;
另外,Redis还通过引用计数技术实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。
最后,Redis的对象带有访问时间记录信息,该信息可以用于计算数据库键的空转时长,在服务器启用了maxmemory功能的情况下,空转时长较大的那些键可能会优先被服务器删除。
对象的类型与编码
Redis使用对象来表示数据库中的键和值,每次当我们在Redis的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。
Redis中的每个对象都由一个redisObject结构表示,该结构中和保存数据有关的三个属性分别是type属性、encoding属性和ptr属性:
1 | typedef struct redisObject { |
类型
对象的type属性记录了对象的类型,这个属性的值可以是下面列出的常量的其中一个。
类型常量 | 对象的名称 |
---|---|
REDIS_STRING | 字符串对象 |
REDIS_LIST | 列表对象 |
REDIS_HASH | 哈希对象 |
REDIS_SET | 集合对象 |
REDIS_ZSET | 有序集合对象 |
对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种,因此:
- 当我们称呼一个数据库键为“字符串键”时,我们指的是“这个数据库键所对应的值为字符串对象”;
- 当我们称呼一个键为“列表键”时,我们指的是“这个数据库键所对应的值为列表对象”。
TYPE命令的实现方式也与此类似,当我们对一个数据库键执行TYPE命令时,命令返回的结果为数据库键对应的值对象的类型,而不是键对象的类型。
不同类型值对象的TYPE命令输出:
对象 | 对象type属性的值 | TYPE命令的输出 |
---|---|---|
字符串对象 | REDIS_STRING | “string” |
列表对象 | REDIS_LIST | “list” |
哈希对象 | REDIS_HASH | “hash” |
集合刘象 | REDIS_SET | “set” |
有序集合对象 | REDIS_ZSET | “zset” |
编码和底层实现
对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定。
encoding属性记录了对象所使用的编码,也即是说这个对象使用了什么数据结构作为对象的底层实现,这个属性的值可以是下面列出的常量的其中一个。
数据类型 | 编码常量 | 编码所对应的底层数据结构 |
---|---|---|
String | REDIS_ENCODING_INT |
整型类型的整数 |
String | REDIS_ENCODING_EMBSTR |
embstr 编码的简单动态字符串 |
String | REDIS_ENCODING_RAW |
简单动态字符串 |
Hash | REDIS_ENCODING_ZIPLIST |
压缩列表 |
Hash | REDIS_ENCODING_HT |
字典 |
List | REDIS_ENCODING_LINKEDLIST |
双端链表 |
List | REDIS_ENCODING_ZIPLIST |
压缩列表 |
Set | REDIS_ENCODING_INTSET |
整数集合 |
Set | REDIS_ENCODING_HT |
字典 |
Sorted Set | REDIS_ENCODING_ZIPLIST |
压缩列表 |
Sorted Set | REDIS_ENCODING_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命令可以查看一个数据库键的值对象的编码。下面列出了不同编码的对象所对应的OBJECT ENCODING命令输出。
对象所使用的底层数据结构 | 编码常量 | OBJECT ENCODING 命令输出 |
---|---|---|
整数 | REDIS_ENCODING_INT |
"int" |
embstr 编码的简单动态字符串(SDS) | REDIS_ENCODING_EMBSTR |
"embstr" |
简单动态字符串(SDS) | REDIS_ENCODING_RAW |
"raw" |
字典 | REDIS_ENCODING_HT |
"hashtable" |
双端链表 | REDIS_ENCODING_LINKEDLIST |
"linkedlist" |
压缩列表 | REDIS_ENCODING_ZIPLIST |
"ziplist" |
整数集合 | REDIS_ENCODING_INTSET |
"intset" |
跳跃表和字典 | REDIS_ENCODING_SKIPLIST |
"skiplist" |
通过encoding属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固定的编码,极大地提升了Redis的灵活性和效率,因为Redis可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。
举个例子,在列表对象包含的元素比较少时,Redis使用压缩列表作为列表对象的底层实现:
- 因为压缩列表比双端链表更节约内存,并且在元素数量较少时,在内存中以连续块方式保存的压缩列表比起双端链表可以更快被载入到缓存中;
- 随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失时,对象就会将底层实现从压缩列表转向功能更强、也更适合保存大量元素的双端链表上面;
其他类型的对象也会通过使用多种不同的编码来进行类似的优化。
字符串对象
字符串对象的编码可以是int、raw或者embstr。
如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面(将void*转换成long),并将字符串对象的编码设置为int。
如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw。
如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。
embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象。
但raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构。
embstr编码的字符串对象在执行命令时,产生的效果和raw编码的字符串对象执行命令时产生的效果是相同的,但使用embstr编码的字符串对象来保存短字符串值有以下好处:
- embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次。
- 释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数。
- 因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起raw编码的字符串对象能够更好地利用缓存带来的优势。
可以用 long double 类型表示的浮点数在Redis中也是作为字符串值来保存的。
如果我们要保存一个浮点数到字符串对象里面,那么程序会先将这个浮点数转换成字符串值,然后再保存转换所得的字符串值。
在有需要的时候,程序会将保存在字符串对象里面的字符串值转换回浮点数值,执行某些操作,然后再将执行操作所得的浮点数值转换回字符串值,并继续保存在字符串对象里面。
编码的转换
int编码的字符串对象和embstr编码的字符串对象在条件满足的情况下,会被转换为raw编码的字符串对象。
对于int编码的字符串对象来说,如果我们向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码将从int变为raw。
另外,因为Redis没有为embstr编码的字符串对象编写任何相应的修改程序(只有int编码的字符串对象和raw编码的字符串对象有这些程序),所以embstr编码的字符串对象实际上是只读的。
当我们对embstr编码的字符串对象执行任何修改命令时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。因为这个原因,embstr编码的字符串对象在执行修改命令之后,总会变成一个raw编码的字符串对象。
字符串命令的实现
因为字符串键的值为字符串对象,所以用于字符串键的所有命令都是针对字符串对象来构建的,表8-7列举了其中一部分字符串命令,以及这些命令在不同编码的字符串对象下的实现方法。
命令 | int 编码的实现方法 |
embstr 编码的实现方法 |
raw 编码的实现方法 |
---|---|---|---|
SET |
使用整数编码保存值 | 使用 embstr 编码保存值 |
使用 raw 编码保存值 |
GET |
拷贝整数值并转换为字符串后返回给客户端 | 直接返回字符串值 | 直接返回字符串值 |
APPEND |
转换为 raw 编码后按 raw 编码方式执行 |
转换为 raw 编码后按 raw 编码方式执行 |
将给定字符串追加到现有字符串的末尾 |
INCRBYFLOAT |
转换整数为浮点数,计算结果并保存浮点数结果 | 尝试将字符串转换为 long double 类型的浮点数,计算并保存 |
尝试将字符串转换为 long double 类型的浮点数,计算并保存 |
INCRBY |
执行加法计算,保存结果为整数 | embstr 编码无法执行此命令,返回错误 |
raw 编码无法执行此命令,返回错误 |
DECRBY |
执行减法计算,保存结果为整数 | embstr 编码无法执行此命令,返回错误 |
raw 编码无法执行此命令,返回错误 |
STRLEN |
将整数转换为字符串并返回其长度 | 返回字符串的长度 | 返回字符串的长度 |
SETRANGE |
转换为 raw 编码后按 raw 编码方式执行 |
转换为 raw 编码后按 raw 编码方式执行 |
将指定索引位置上的值设置为给定字符 |
GETRANGE |
将整数转换为字符串并返回指定索引上的字符 | 返回字符串指定索引上的字符 | 返回字符串指定索引上的字符 |
列表对象
列表对象的编码可以是ziplist或者linkedlist。
ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。
linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。
编码转换
当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:
- 列表对象保存的所有字符串元素的长度都小于64字节;
- 列表对象保存的元素数量小于512个;
不能满足这两个条件的列表对象需要使用linkedlist编码。
以上两个条件的上限值是可以修改的,具体请看配置文件中关于 list-max-ziplist-value
选项和 list-max-ziplist-entries
选项的说明。
对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行,原本保存在压缩列表里的所有列表元素都会被转移并保存到双端链表里面,对象的编码也会从ziplist变为linkedlist。
列表命令的实现
因为列表键的值为列表对象,所以用于列表键的所有命令都是针对列表对象来构建的,下面列出了其中一部分列表键命令,以及这些命令在不同编码的列表对象下的实现方法。
命令 | 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编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:
- 字典的每个键都是一个字符串对象,对象中保存了键值对的键;
- 字典的每个值都是一个字符串对象,对象中保存了键值对的值。
编码转换
当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:
- 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;
- 哈希对象保存的键值对数量小于512个;不能满足这两个条件的哈希对象需要使用hashtable编码。
这两个条件的上限值是可以修改的,具体请看配置文件中关于hash-max-ziplist-value选项和hash-max-ziplist-entries选项的说明。
对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行。
原本保存在压缩列表里的所有键值对都会被转移并保存到字典里面,对象的编码也会从ziplist变为hashtable。
除了键的长度太大会引起编码转换之外,值的长度太大也会引起编码转换。
哈希命令的实现
因为哈希键的值为哈希对象,所以用于哈希键的所有命令都是针对哈希对象来构建的,下面列出了其中一部分哈希键命令,以及这些命令在不同编码的哈希对象下的实现方法。
命令 | 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。
编码的转换
当集合对象可以同时满足以下两个条件时,对象使用intset编码:
- 集合对象保存的所有元素都是整数值;
- 集合对象保存的元素数量不超过512个。
不能满足这两个条件的集合对象需要使用hashtable编码。
第二个条件的上限值是可以修改的,具体请看配置文件中关于set-max-intset-entries选项的说明。
对于使用intset编码的集合对象来说,当使用intset编码所需的两个条件的任意一个不能被满足时,就会执行对象的编码转换操作。
原本保存在整数集合中的所有元素都会被转移并保存到字典里面,并且对象的编码也会从intset变为hashtable。
集合命令的实现
因为集合键的值为集合对象,所以用于集合键的所有命令都是针对集合对象来构建的,下面列出了其中一部分集合键命令,以及这些命令在不同编码的集合对象下的实现方法。
命令 | intset 编码实现方法 |
hashtable 编码实现方法 |
---|---|---|
SADD |
调用 intsetAdd 函数,将所有新元素添加到整数集合中 |
调用 dictAdd 函数,以新元素为键,NULL 值,将键值对添加到字典中 |
SCARD |
调用 intsetLen 函数,返回整数集合包含的元素数量 |
调用 dictSize 函数,返回字典中包含的键值对数量 |
SISMEMBER |
调用 intsetFind 函数,在整数集合中查找给定的元素,找到则说明元素存在,未找到则说明元素不存在 |
调用 dictFind 函数,在字典中查找给定的元素,找到则说明元素存在,未找到则说明元素不存在 |
SMEMBERS |
遍历整个整数集合,使用 intsetGet 函数返回集合元素 |
遍历整个字典,使用 dictGetKey 函数返回字典的键作为集合元素 |
SRANDMEMBER |
调用 intsetRandom 函数,从整数集合中随机返回一个元素 |
调用 dictGetRandomKey 函数,从字典中随机返回一个键 |
SPOP |
调用 intsetPop 函数,从整数集合中随机取出一个元素,并在将这个随机元素返回给客户端之后,调用 intsetRemove 函数将随机元素从整数集合中删除 |
调用 dictGetRandomKey 函数,从字典中随机取出一个键,并在将这个随机字典键的值返回给客户端之后,调用 dictDelete 函数删除随机字典键所对应的键值对 |
SREM |
调用 intsetRemove 函数,从整数集合中移除所有给定的元素 |
调用 dictDelete 函数,从字典中删除所有键为给定元素的键值对 |
有序集合对象
有序集合的编码可以是ziplist或者skiplist。
ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score)。
压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。
skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表。
zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。
通过这个跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK、ZRANGE等命令就是基于跳跃表API来实现的。
除此之外,zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。
通过这个字典,程序可以用O(1)复杂度查找给定成员的分值,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性。
有序集合每个元素的成员都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。
值得一提的是,虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存。
为什么有序集合需要同时使用跳跃表和字典来实现?
在理论上,有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现,但无论单独使用字典还是跳跃表,在性能上对比起同时使用字典和跳跃表都会有所降低。
举个例子,如果我们只使用字典来实现有序集合,那么虽然以O(1)复杂度查找成员的分值这一特性会被保留,但是,因为字典以无序的方式来保存集合元素,所以每次在执行范围型操作——比如ZRANK、ZRANGE等命令时。
程序都需要对字典保存的所有元素进行排序,完成这种排序需要至少O(NlogN)时间复杂度,以及额外的O(N)内存空间(因为要创建一个数组来保存排序后的元素)。
另一方面,如果我们只使用跳跃表来实现有序集合,那么跳跃表执行范围型操作的所有优点都会被保留,但因为没有了字典,所以根据成员查找分值这一操作的复杂度将从O(1)上升为O(logN)。
因为以上原因,为了让有序集合的查找和范围型操作都尽可能快地执行,Redis选择了同时使用字典和跳跃表两种数据结构来实现有序集合。
编码的转换
当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码:
- 有序集合保存的元素数量小于128个;
- 有序集合保存的所有元素成员的长度都小于64字节;
不能满足以上两个条件的有序集合对象将使用skiplist编码。
以上两个条件的上限值是可以修改的,具体请看配置文件中关于zset-max-ziplist-entries选项和zset-max-ziplist-value选项的说明。
对于使用ziplist编码的有序集合对象来说,当使用ziplist编码所需的两个条件中的任意一个不能被满足时,就会执行对象的编码转换操作。
原本保存在压缩列表里的所有集合元素都会被转移并保存到zset结构里面,对象的编码也会从ziplist变为skiplist。
有序集合命令的实现
因为有序集合键的值为哈希对象,所以用于有序集合键的所有命令都是针对哈希对象来构建的,下面列出了其中一部分有序集合键命令,以及这些命令在不同编码的哈希对象下的实现方法。
命令 | ziplist 编码实现方法 |
zset 编码实现方法 |
---|---|---|
ZADD |
调用 ziplistInsert 函数,将成员和分值作为两个节点分别插入到压缩列表 |
先调用 zslInsert 函数,将新元素添加到跳跃表,然后调用 dictAdd 函数,将新元素与分值的关联添加到字典中 |
ZCARD |
调用 ziplistLength 函数,获得压缩列表包含的节点数量,将这个数量除以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等命令只能对有序集合键执行;
类型检查的实现
从为了确保只有指定类型的键可以执行某些特定的命令,在执行一个类型特定的命令之前,Redis会先检查输入键的类型是否正确,然后再决定是否执行给定的命令。
类型特定命令所进行的类型检查是通过redisObject结构的type属性来实现的:
- 在执行一个类型特定命令之前,服务器会先检查输入数据库键的值对象是否为执行命令所需的类型,如果是的话,服务器就对键执行指定的命令;
- 否则,服务器将拒绝执行命令,并向客户端返回一个类型错误。
多态命令的实现
Redis除了会根据值对象的类型来判断键是否能够执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令。
比如,列表对象有ziplist和linkedlist两种编码可用,其中前者使用压缩列表API来实现列表命令,而后者则使用双端链表API来实现列表命令。
如果我们对一个键执行LLEN命令,那么服务器除了要确保执行命令的是列表键之外,还需要根据键的值对象所使用的编码来选择正确的LLEN命令实现:
- 如果列表对象的编码为ziplist,那么说明列表对象的实现为压缩列表,程序将使用ziplistLen函数来返回列表的长度;
- 如果列表对象的编码为linkedlist,那么说明列表对象的实现为双端链表,程序将使用listLength函数来返回双端链表的长度;
借用面向对象方面的术语来说,我们可以认为LLEN命令是多态(polymorphism)的,只要执行LLEN命令的是列表键,那么无论值对象使用的是ziplist编码还是linkedlist编码,命令都可以正常执行。
实际上,我们也可以将DEL、EXPIRE、TYPE等命令也称为多态命令,因为无论输入的键是什么类型,这些命令都可以正确地执行。
DEL、EXPIRE等命令和LLEN等命令的区别在于,前者是基于类型的多态——一个命令可以同时用于处理多种不同类型的键,而后者是基于编码的多态——一个命令可以同时用于处理多种不同编码。
内存回收
因为C语言并不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制。
通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。
每个对象的引用计数信息由redisObject结构的refcount属性记录:
1 | typedef struct redisObject { |
对象的引用计数信息会随着对象的使用状态而不断变化:
- 在创建一个新对象时,引用计数的值会被初始化为1;
- 当对象被一个新程序使用时,它的引用计数值会被增一;
- 当对象不再被一个程序使用时,它的引用计数值会被减一;
- 当对象的引用计数值变为0时,对象所占用的内存会被释放。
下面列出了修改对象引用计数的API,这些API分别用于增加、减少、重置对象的引用计数。
函数 | 作用 |
---|---|
incrRefCount | 将对象的引用计数值增加 1。 |
decrRefCount | 将对象的引用计数值减少 1。当对象的引用计数值等于 0 时,释放对象。 |
resetRefCount | 将对象的引用计数值设置为指定值,通常在需要重置引用计数时使用。 |
对象共享
除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。
举个例子,假设键A创建了一个包含整数值100的字符串对象作为值对象。
如果这时键B也要创建一个同样保存了整数值100的字符串对象作为值对象,那么服务器有以下两种做法:
- 为键B新创建一个包含整数值100的字符串对象;
- 让键A和键B共享同一个字符串对象;
以上两种方法很明显是第二种方法更节约内存。
在Redis中,让多个键共享同一个值对象需要执行以下两个步骤:
- 将数据库键的值指针指向一个现有的值对象;
- 将被共享的值对象的引用计数增一。
目前来说,Redis会在初始化服务器时,创建一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要用到值为0到9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象。
创建共享字符串对象的数量可以通过修改redis.h/REDIS_SHARED_INTEGERS常量来修改。
可以使用OBJECT REFCOUNT
命令查看对象的引用计数。
另外,这些共享对象不单单只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的对象(linkedlist编码的列表对象、hashtable编码的哈希对象、hashtable编码的集合对象,以及zset编码的有序集合对象)都可以使用这些共享对象。
为什么Redis不共享包含字符串的对象?
当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同,只有在共享对象和目标对象完全相同的情况下,程序才会将共享对象用作键的值对象。
而一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同所需的复杂度就会越高,消耗的CPU时间也会越多:
- 如果共享对象是保存整数值的字符串对象,那么验证操作的复杂度为O(1);
- 如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为O(N);
- 如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,那么验证操作的复杂度将会是O(N 2)。
因此,尽管共享更复杂的对象可以节约更多的内存,但受到CPU时间的限制,Redis只对包含整数值的字符串对象进行共享。
对象的空转时长
除了前面介绍过的type、encoding、ptr和refcount四个属性之外,redisObject结构包含的最后一个属性为lru属性,该属性记录了对象最后一次被命令程序访问的时间:
1 | typedef struct redisObject { |
OBJECT IDLETIME
命令可以打印出给定键的空转时长,这一空转时长就是通过将当前时间减去键的值对象的lru时间计算得出的:
OBJECT IDLETIME
命令的实现是特殊的,这个命令在访问键的值对象时,不会修改值对象的lru属性。
除了可以被OBJECT IDLETIME命令打印出来之外,键的空转时长还有另外一项作用:
如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为 volatile-lru 或者 allkeys-lru,那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。
配置文件的 maxmemory 选项和 maxmemory-policy 选项的说明介绍了关于这方面的更多信息。