蹭热度的标题党-。-,主要是对《Redis设计与实现》数据结构和对象部分的总结。
简单动态字符串
Redis自己构建了一个简单动态字符串SDS(simple dynamic string),作为默认字符串表示。Redis只会使用C字符串作为字面量,大多数情况下,Redis使用SDS作为字符串表示。
为什么不用C语言传统的字符串表示呢?
因为C字符串只用作字符串字面量,在无须修改的场景使用,例如打印log。而Redis需要一个可以被修改的字符串值。
在Redis里,包含字符串值的键值对在底层都是有SDS实现的。举个栗子,
redis> SET msg "hello world"
OK
- 键:是一个字符串对象,底层实现是一个保存着字符串
msg的SDS
- 值:也是一个字符串对象,底层是一个保存着字符串
hello world的SDS
再举一个栗子:
redis>RPUSH fruits "apple" "banana" "cherry"
(integer) 3
- 键:是一个字符串独享,底层实现是一个保存这字符串
fruits的SDS
- 值:是一个列表对象,列表对象包含了三个字符串对象,依次是...
除了用作保存数据库中的字符串之外,SDS还被用作缓冲区buffer:AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区。
SDS的定义
struct sdshdr{
int len; // 记录buf数组中已使用字节的数量,即SDS内字符串的长度
int free; // 记录buf数组中未使用字节的数量
char buf[]; // 字节数据,用于保存字符串
}
看一个SDS示例。这是一个没有分配任何未使用空间,保存五字节长的字符串,并且字符分别为'R'、'e'、'd'、'i'、's',而最后一个字节则保留了空字符'\0'。
为什么SDS遵循C字符串以空字符结尾的惯例呢?
这样做的好处是SDS可以直接重用一部分C字符串函数库里面的函数。保留的空字符串1字节空间不计算在SDS的len属性中,为空字符串分配额外的1字节空间,以及添加空字符串到字符串末尾等操作,都由SDS函数自动完成,对用户透明。
例如,使用<stdio.h>/printf函数,执行
printf("%s", s->buf)来打印字符串的值Redis,而无须为SDS编写专门的打印函数。
SDS可以直接使用一部分C字符串函数库的函数,那么两者有什么区别呢?
2. SDS与C字符串的区别
C语言使用长度为N+1的字符串数据来表示长度N的字符串,并且字符数据的最后一个元素总是空字符'\0'。但C语言使用的这种简单字符串的表示方式,不能满足Redis在安全性、效率以及功能方面的要求。那么SDS比C字符串更适用于Redis的原因是什么?
-
常数复杂度获取字符串长度
C字符串获取字符串长度,需要遍历整个字符串直至遇到空字符串位置,时间复杂度O(N)。
而SDS在len属性中记录了SDS本身的长度,获取SDS长度的时间复杂度O(1)。设置和更新SDS长度的工作都由SDS的API执行时自动完成,使用SDS无须进行任何手动修改长度的工作。
-
杜绝缓存区溢出
除了获取字符串长度的复杂读高之外,C字符串不记录自身长度还容易造成缓冲区溢出buffer overflow。
举个栗子,内存中有两个紧邻着的C字符串s1: Redis和s2: MongoDB,当执行
strcat(s1, " Cluster");
将s1的内容修改为"Redis Cluster", 但在执行前没有为s1分配足够的空间,那么strcat函数执行后,s1的数据将溢出到s2所在的空间中,导致s2保存的内容被意外修改。
而SDS API需要对SDS进行修改时,会先检查SDS空间是否满足所需,如果不满足,API会自动将SDS的空间扩展到执行修改所需的大小。然后才执行实际的修改操作。
例如,SDS API中调用执行拼接的sdscat函数,sdacat会先检查s的长度是否足够,空间不足的话,先扩展s的空间,然后执行拼接操作。拼接操作完成之后的SDS见下图。
这里留个疑问,为什么拼接之后,还为SDS分配了13字节的未使用空间呢?
-
减少修改字符串时带来的内存重分配次数
C字符串本身不记录长度,一个包含了N个字符的C字符串,它的底层实现总是一个N+1个字符长的数据。因此在每次增长或者缩短一个C字符串时,程序总要对保存这个C字符串的数据进行一个内存重分配操作。当然在增长或缩短时,忘记提前分配底层数据的空间大小,就有概率产生缓冲区溢出或内存泄露。
这种内存重分配涉及复杂的算法,并且可能需要执行系统调用,是一个比较耗时的操作。Redis作为数据库,对速度要求严苛、数据被频繁修改的场景,频繁执行内存分配会对性能造成影响。
SDS通过未使用空间解除了字符串长度和底层数据长度之间的关联,而未使用的空间由SDS的free属性记录。
通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略。
- 空间预分配
优化SDS的字符串增长操作,在SDS的API对SDS进行修改,并且需要对SDS进行空间扩展。
对SDS进行修改之后,SDS的len属性值将小于1MB,那么程序分配和len属性同样的未使用空间。
SDS修改后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。
- 惰性空间释放
优化SDS的字符串缩短操作,当SDS的API需要缩短SDS保存的字符串时,程序不立即使用内存重分配来回收缩短后的字节,而是用free属性将这些字节的数量记录起来,并等待将来使用。
与此同时,SDS也提供了相应的API,让我们可以在有需要时,真正释放SDS的未使用空间,而不用担心惰性空间释放会造成内存浪费。
-
二进制安全
C字符串中的字符必须符合某些编码(比如ASCII),并且除了字符串的末尾之外,字符串不能包含空字符,否则当程序读入空字符时,被误认为是字符串结尾。举个栗子,如果空字符来分割多个单词特殊数据格式,那么在识别"Redis Cluster"时,只会识别出其中的"Redis",而忽略"Cluster"。
所有SDS API都会以处理二进制的方式来处理SDS存放在buf数据里的数据。这也是SDS的buf属性成为字节数组的原因,Redis不是用这个数组来保存字符,而是用它来保存一系列的二进制数据。SDS使用len属性的值而不是空字符来判断字符串是否结束,而不是特殊字符,可以保存任意给的二进制数据。
-
兼容部分C字符串函数
SDS的API遵循C字符串以空字符结尾的惯例,可以重用一部分<string.h>库定义的函数。
链表
链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活调整链表的长度。
Redis使用的C语言没有内置这种数据结构,因此Redis构建了自己的链表实现。
Redis中列表键的底层实现之一就是链表。当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为链表键的底层实现。
除了链表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区。
链表和链表节点的实现
每个链表节点使用一个adlist.h/listNode结构来表示:
typedef struct listNode{
struct listNode *prev; // 前置节点
struct listNode *next; // 后置节点
void *value // 节点的值
}listNode;
多个listNode可以通过prev和next指针组成双端链表。
再使用
adlist.h/list来持有链表,操作更方便:
typedef struct list{
listNode *head; // 表头节点
listNode *tail; // 表尾结点
unsigned long len; // 链表所包含的节点数量
void *(*dup)(void *ptr); // 节点值复制函数
void (*free)(void *ptr); // 节点值释放函数
int (*match)(void *ptr, void *key); // 节点值比较函数
}list;
由一个list结构和三个listNode结构组成的链表。
那么,Redis的链表实现的特征可以总结如下:
- 双端:链表节点带有
prev和next指针,获取某个节点的前置节点和后置节点的复杂度是O(1)。 - 无环:表头节点的
prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。 - 带链表长度计数器:程序使用list结构的
len属性对list持有的链表数节点进行计数,程序获取链表中节点数量的复杂度为O(1)。 - 多态:链表节点使用
void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
字典
字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对的抽象数据结构。
字典中的每个键都是独一无二的,程序可以在字段中根据键查找与之关联的值,或者通过键来更新值,也可以根据键来删除整个键值对。
和链表一样,Redis使用的C语言没有内置这种数据结构,因为Redis构建了自己的字典实现。
-
字典在Redis中的应用:
- Redis数据库,对数据库的增删查改也是建立在对字典的操作之上。
redis>SET msg "hello world" OK在数据库中创建一个键为msg,值为hello world的键值对时,这个键值对就保存在代表数据库的字典里面。
-
哈希键,当一个哈希建包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis会使用字典作为哈希键的底层实现。
举个栗子,website是包含10086个键值对的哈希键,哈希键的键都是一些数据库的名字,而键的值是数据库库的主页地址。
redis>HLEN website (integer)10086 redis>HGETALL website 1)"Redis" // 键 2)"Redis.io" // 值 3)"MariaDB" // 键 4)"MariaDB.org"// 值 ...
字典的实现
Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。
哈希表
由dict.h/dictht结构定义:
typedef struct dictht{
dictEntry **table; // 哈希表数组
unsigned long size; // 哈希表大小
unsigned long sizemask; // 哈希表大小掩码,用于计算索引值,等于size-1
// 和哈希值一起决定了一个键应该被放到table数据的哪个索引上。
unsigned long used; // 该哈希表已有节点的数量
}dichht;
table属性是一个数组,数据中的每个元素都指向dict.h/dictEntry 结构的指针,每个dictEntry结构保存着一个键值对。
哈希表节点
每个dictEntry结构都保存这一个键值对:
typedef struct dictEntry{
void *key; // 键
union{ // 值, 可以是一个指针,或者是一个uint64_t整数,或者int64_t整数
void *val;
uint64_tu64;
int64_ts64;
}v;
struct dictEntry *next; // 指向下个哈希表节点,形成链表
}dictEntry;
next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接起来,以此来解决键冲突的问题。
字典
typedef strut dict{
dictType *type; // 类型特定函数
void *privdata; // 私有数据
dictht ht[2]; // 哈希表
int trehashidx; // rehash索引,当rehash不在进行时,值为-1
}dict;
type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的;type属性指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数。privdata属性保存了需要传给那些类型特定函数的可选参数。ht属性包含了两个项的数组,数组的每个项都是一个dictht哈希表,一般情况,字典只适用ht[0]哈希表,ht[1]哈希表只会对ht[0]哈希表进行rehash时使用。rehashidx记录了当前rehash的进度,如果没有进行rehash,则值为-1。
哈希算法
当添加新的键值到字典里面时,先根据键值对的键计算出哈希值和索引值,再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。
hash = dict->type->hashFunction(key); // 哈希函数计算出键key的哈希值
index = hash & dict-> ht[x].sizemask; // 哈希表的sizemask属性和哈希值,计算出索引值
当字典作为数据库或者哈希键的底层实现时,Redis使用MurmurHash2算法计算哈希值。
解决键冲突
当两个或以上数量的键被分配到了哈希表数组的同一索引上,这些键就发生了冲突。
Redis的哈希表使用链地址法来解决键冲突。每个哈希表节点都带有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,同一索引上的多个节点可以用这个单向链表连接起来。解决键冲突问题。
由于dictEntry节点组成的链表没有指向链表表尾的指针,为了速度考虑,新节点会添加到链表表头的位置。复杂度O(1)。
rehash
当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行扩展或收缩,也就是执行rehash(重新散列)操作。
步骤如下:
-
为字典h[1]哈希表分配空间,分配的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(ht[0].used的值)
如果扩展,那么ht[1]大小为第一个
>=ht[0].used*2的2^n;如果收缩,那么ht[1]大小为第一个
>=ht[0].used的2^n; -
ht[0]中的所有键值对rehash到ht[1]上面,重新计算哈希值和索引值,然后将键值对放置到ht[1]的指定位置上。
-
ht[0]包含的所有键值对都迁移到ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新建一个空白哈希表,为下一次rehash做准备。
哈希表的扩展与收缩
负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
如果满足以下任意条件,程序会自动开始对哈希表执行扩展操作:
- 服务器没有执行
BGSAVE命令或者BGREWRITEOF命令,并且哈希表负载因子大于等于1 - 服务器目前正在执行
BGSAVE或者BGREWRITEOF命令,并且哈希表的复杂因子大于等于5
为什么
BGSAVE命令或者BGREWRITEOF命令是否执行,服务器执行扩展操作的负载因子不同呢?因为在执行
BGSAVE命令或者BGREWRITEOF命令过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,而且尽可能地避免子进程存在期间进行哈希表扩展操作。这可以避免不必要的内存写入操作,最大限度地节约内存。
当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。
渐进式rehash
在rehash过程中,需要将ht[0]里面的所有键值对rehash到ht[1]里面。想象一下,如果哈希表内有几千万甚至几亿个键值对,那么一次性将这些键值对rehash到ht[1]的话,庞大的计算量可能会导致服务器一段时间内停止服务。因此但是这个rehash动作并不是一次性的,而是分多次,渐进式完成的。
在字典结构里面,维持了一个rehashidx属性作为索引计数器,在渐进式rehash里面,通过这个属性值的变化标识rehash执行的状态。下面介绍下渐进式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期间,字典的增删查改会在两个表上进行,如果查找一个键的话,会先在ht[0]里面进行查找,如果没找到会在ht[1]里面进行查找;新增的字典值一律会被保存到ht[1]里面。
跳跃表
Skiplist
跳跃表是用于有序元素序列快读搜索查找的一个数据结构,跳跃表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。在有序链表上增加了多级索引,通过索引来实现快速查找。不仅提高搜索性能,同时也提供插入和删除的性能。
跳跃表平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。这和平衡树的效率相当,但跳跃表的实现更为简单。
Redis使用跳跃表作为有序集合键的底层实现之一。如果一个有序集合包含的元素数量比较多,又或者有序集合中的元素的成员是比较长的字符串时,Redis就会使用跳跃表来作为有序集合的底层实现。
举个栗子,
redis> ZRANGE fruit-price 0 2 WITHSCORES
1) "banana"
2) "5"
3) "cherry"
4) "6.5"
5) "apple"
6) "8"
redis> ZCARD fruit-price # 数量
(integer)130
fruit-price有序集合的所有数据都保存在一个跳跃表里面,其中每个跳跃表节点都保存了一款水果的价钱信息,所有水果按价钱的高低从低到高在跳跃表里面排序。
跳跃表在Redis中的实现:有序集合键,集群节点中用作内部数据结构。
跳跃表的实现
由redis.h/zskiplistNode和redis.h/zskiplist两个结构定义,分别表示跳跃表节点和用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。
最左边的结构是
zshiplist结构,该结构包含以下属性:
- header: 指向跳跃表的表头节点
- tail: 指向跳跃表的表尾节点
- level: 记录目前跳跃表内,层数最大的节点的层数(不包含表头节点的层数)
- length: 记录当前跳跃表的长度,即节点的数量(表头节点不计算在内)
在zskiplist结构右方是四个zskiplistNode结构,该结构包含以下属性:
- 层
level:L1,L2,L3...每个层带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,跨度记录当前指针所指向节点和当前节点的距离。 - 后退
backward指针:节点中用BW标记节点的后退指针,它指向位于当前节点的前一个节点。在程序从表尾向表头遍历时使用。 - 分支
score:节点按照各自保存的分值从小到大排列。 - 成员对象
obj:各个节点中的o1,o2和o3是节点保存的成员对象。
表头节点和其他节点的构造一样,也有后退指针、分值和成员对象,只不过表头节点的这些属性不会被用到,所以在图中被省略了这部分。
跳跃表节点
typedef struct zskiplistNode {
// 层
struct zskiplistLevel{
struct zskiplistNode *forward; //前进指针
unsigned int span; // 跨度
} level[];
struct zskiplistNode *backward; // 后退指针
double score; // 分值
robj *obj; // 成员对象
}zskiplistNode;
-
层
程序可以通过层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。
每次创建一个新跳跃表节点的时候,程序都按照幂次定律(越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的高度。
-
前进指针
level[i].forward属性,用于表头向表尾方向访问节点。(遍历过程再了解下) -
跨度
层的跨度
level[i].span属性,用于记录两个节点之间的距离。跨度和遍历操作是什么关系呢?
实际遍历只需要前进指针就可以完成,跨度是用来计算排位的,在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结构就是目标节点在跨越表中的排位。例如在跳跃表中查找分值为3.0、成员对象为o3的节点时,沿途经历的层:查找的过程只经过了一个层,并且层的跨度为3,所以目标节点在跨越表中的排位为3。
-
后退指针
节点的后退指针
backward属性,用于从表尾向表头方向访问节点,跟可以一次跳过多个节点的前进指针不同,每个节点只有一个后退指针,所以每次只能后退至前一个节点。 -
分值和成员
节点的分支
score属性是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大排列。节点的对象
obj属性是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。
跳跃表中的每个节点保存的对象必须是唯一的,但每个对象保存的分值可以相同,分值相同时,按照对象在字典序中的大小排序。
跳跃表
typedef struct zskiplist{
// 表头节点和表尾节点
structz skiplistNode *header, *tail;
unsigned long length; // 表中节点的数量
int level; // 表中层数最大的节点的层数
} zkiplist;
定位表头节点和表尾节点、获取跳跃表的长度、获取跳跃表中层高最大的节点的层数量,复杂度为O(1)
整数集合
整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
整数集合的实现
整数集合intset可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。
intset.h/intset结构表示一个整数集合:
typedef struct intset {
uint32_t encoding; // 编码方式
uint32_t length; // 集合包含的元素数量
int8_t contents[]; // 保存元素的数组
} intset;
contents
contents数组是整数集合的底层实现:整数集合的每个元素都是contents数据的一个数组项item,每个项在数组中按值的大小有序排列,且不包含重复项。
虽然contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,其真正类型取决于encoding属性的值。
例如图中整数集合的底层实现为int64_t类型的数组,整数集合包含四个元素,每个集合元素都是int64_t类型的整数值,且按从小到大的顺序排列。
contents数组的大小为sizeof(int64_t) * 4 = 64 * 4 = 256位。
这里可能会有个疑惑,在上面的contents数组中,只有第一个元素是真正需要int64_t类型来保存的,其他三个值都可以用int16_t来表示,那为什么都用int64_t类型来保存呢?根据整数集合的升级规则,当向一个底层为int16_t数组的整数集合添加一个int64_t类型的整数值时,所有元素都会被转化成int64_t类型。
升级
当将一个新元素添加到整数集合里面,且新元素的类型比整数集合现有元素的所有类型都长时,整数集合需要先升级,才能将新元素添加到整数集合里面。
那么升级就会有三个步骤:
- 根据新元素类型,扩展底层数据空间大小,为新元素分配空间
- 将底层数组现有的所有元素转化成与新元素相同的类型,并放置到正确的位上,保持底层数组的有序性质不变。
- 将新元素添加到底层数组里面。
向整数集合添加新元素的时间复杂度是O(n)。
升级的好处
提升整数结合的灵活性,尽可能地节约内存。
- 因为C语言是静态类型的语言, 为了避免类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里面。整数集合会通过自动升级底层数组来适应新元素。
- 整数集合可以让集合同时保存三个不同类型的值,又可以确保升级操作只会在有需要的时候进行,尽量节省内存。
整数集合不支持降级操作,一旦升级,就一直保持升级后的状态。
压缩列表
压缩列表ziplist是列表键和哈希键的底层实现之一。
当一个列表键只包含少量列表项,并且每个列表项都是小整数或者短字符串时,会使用压缩列表作为列表键的底层实现。当哈希键只包含少量键值对,且每个键值对的键和值要么是小整数值,要么是长度比较短的字符串,那么Redis使用压缩列表来做哈希键的底层实现。
压缩列表的构成
是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数据或者一个整数值。
| 属性 | 类型 | 长度 | 用途 |
|---|---|---|---|
| zlbytes | uint32_t | 4字节 | 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配或者计算zlend的位置时使用 |
| zltail | uint32_t | 4字节 | 记录压缩列表表尾节点距离压缩列表的起始位置有多少字节:通过这个偏移量,可以无需遍历来确定表尾节点的位置 |
| zllen | uint16_t | 2字节 | 节点数量。只能记录65535以下的节点数量,超过的话需要遍历整个压缩列表 |
| entryX | 列表节点 | 不定 | 各个节点,长度由节点保存的内容决定 |
| zlend | uint8_t | 1字节 | 特殊值0xFF(十进制255),用于标记压缩列表的末端 |
压缩列表节点的构成
每个压缩列表的节点可以保存一个字节数组或者一个整数值。
-
字节数据可以是以下三种长度的其中一种:
-
2^6-1,即
content最高位00,表示保存着一字节长的字节数组。 -
2^14-1,即
content最高位01,表示保存着两字节长的字节数据。 -
2^32-1,即
content最高位10,前一字节除最高位外其他位留空,表示保存着五字节长的字节数组。
-
-
整数值可以是以下六种长度的其中一种:
-
4位长,介于0至12之间的无符号整数
-
1字节长的有符号整数
-
3字节长的有符号整数
-
Int16_t类型整数
-
Int32_t类型整数
-
Int64_t类型整数
-
每个压缩列表节点都是由
previous_entry_length,encoding,content三部分组成。
previous_entry_length
以字节为单位,记录压缩列表中前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节。
- 前一节点长度小于254字节(2^8-2),
previous_entry_length属性长度为1字节。 - 前一字节长度大于254字节,
previous_entry_length属性长度为5字节,第一个字节为0xFE(十进制254),后四个字节表示前一字节长度。
因为节点的previous_entry_length属性记录了前一节点的长度,所以通过指针运算,可以计算出前一节点的起始位置。因此,只要拥有一个指向某个节点起始位置的指针,压缩列表可以一直回溯最终到达表头遍历。
encoding
记录节点的content属性所保存数据的类型以及长度。
- 一字节、两字节、五字节,值最高位00、01、10表示
content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录。 - 一字节长,值最高位以11开头的是整数编码。表示
content属性保存着整数值。整数值的类型和长度由编码除去最高两位之后的其他位记录。
content
负责保存节点的值。属性和长度由encoding属性决定。
举个例子,
表示节点保存的是一个字节数据,字节数组的长度为11,节点值为"hello world"。
连锁更新
previous_entry_length属性记录了前一个节点的长度,长度小于254,那么previous_entry_length属性值为1字节长,大于254,则为5字节长。
那么就会存在一种情况,当压缩列表中,有多个连续、长度介于250~253字节之间的节点。如果将一个长度大于等于254节点的新节点设置为表头节点,那么后面节点的previous_entry_length属性需要从原来的1字节长度扩展为5字节长,此节点扩展后,长度会介于254至257之间,那么后续节点也需要同样执行空间分配操作来扩展previous_entry_length。
Redis将这种在特殊情况下产生的连续多次空间扩展操作称为”连锁更新“。
连锁更新在最坏的情况下需要对压缩列表执行N次空间重分配操作,而每次分配的最坏复杂度为O(N),因此连锁更新的最坏复杂度为O(N^2)。
尽管连锁更新的复杂度高,但它真正造成性能问题的几率很低。
- 首先,压缩列表里面要恰好有多个连续、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,实际中这种情况不多见
- 其次,即使出现连锁更新,只要被更新的节点数量不多,就不会对性能造成影响。
ziplistPush、ziplistInsert、ziplistDelete和ziplistDeleteRange都有可能会引发连锁更新,它们最坏复杂度是O(N^2).
因为以上原因,ziplistPush等命令的平均复杂度仅为O(N),可以放心使用,不必担心连锁更新会影响压缩列表的性能。
对象
前面部分介绍了Redis用到的所有数据结构,但Redis并没有直接使用这个数据结构来实现键值对数据库,而是基于数据结构构建了一个对象系统,这个对象系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象。
使用对象的一个好处是,针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。
Redis的对象系统还实现了基于引用计数技术的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存会被释放。另外通过引用计数技术实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。
Redis的对象带有访问时间记录信息,该信息可以用于计算数据库键的空转时长,当服务器启动了maxmemory功能的情况下,空转时长较大的键可能会优先被服务器删除。
对象的类型与编码
Redis使用对象来表示数据库中的键和值,在Redis中新创建一个键值对时,至少会创建两个对象,用来表示键和值。
Redis的每个对象都由一个redisObject结构表示:
typedef struct redisObject{
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
//...
}robj;
类型
| 类型常量 | 对象的名称 |
|---|---|
| REDIS_STRING | 字符串对象 |
| REDIS_LIST | 列表对象 |
| REDIS_HASH | 哈希对象 |
| REDIS_SET | 集合对象 |
| REDIS_ZSET | 有序集合对象 |
对于Redis数据库的键值对来说,键总是字符串对象,而值可以是表格中的其中一种。我们称呼一个键为XX键时,指的是这个键所对应的值的对象类型。
redis> SET msg "hello world"
OK
redis> TYPE msg
string
redis> RPUSH numbers 1 3 5
(integer)6
redis> TYPE numbers
list
编码
Redis对象的数据结构由对象的encoding属性决定。encoding决定了对象使用的编码,也就是这个对象使用了什么数据结构作为对象的底层实现。
| 类型 | 编码常量 | 编码所对应的底层数据结构 |
|---|---|---|
| REDIS_STRING | REDIS_ENCODING_INT | long类型的整数 |
| 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命令可以查看一个数据库键的值对象的编码
redis> SET msg "hello world"
OK
redis> OBJECT ENCODING msg
"embstr"
通过encoding属性来设定对象使用的编码,而不是为特定类型的对象关联一种固定的编码,极大地提升了Redis的灵活性和效率。Redis可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。
举个例子,在列表对象包含的元素比较少时,Redis使用压缩列表作为列表对象的底层实现。因为压缩列表比双端链表更节约空间,并且在元素数量较少时,在内存中以连续块方式保存的压缩列表比起双端链表可以更快载入到缓存。而随着列表对象包含的元素越来越多,压缩列表保存元素的优势逐渐小时,对象会将底层实现从压缩列表转向功能更强、更适合保存大量元素的双端链表上面。
string字符串对象
字符串对象的编码可以是int,raw,embstr。
如果字符串对象保存的是整数值,且可以用long类型来表示,那么在字符串对象会将整数值保存在字符串对象结构的ptr属性里面,并将字符串对象的编码设置为int。
如果字符串对象保存的是一个字符串值,并且长度大于32字节,那么会使用一个SDS保存这个字符串值,编码方式为raw。
如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象会使用embstr编码的方式来保存这个字符串值。
embstr编码时用于保存短字符串的一种优化编码方式,和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象,但raw会调用两次内存分配来创建redisObject结构和sdshdr结构,而embstr只调用一次分配一块连续空间来依次保存这两个结构。
那embstr编码保存短字符串值有什么好处呢?
- 减少内存分配和释放的次数
embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,可以更好地利用缓存带来的优势。
Long double类型的浮点数在Redis中也会作为字符串值来保存的,在执行某些操作前,会先把字符串值转换回浮点数值,操作结束后再转换回字符串值。
编码的转换
int和embstr编码的字符串对象在某些情况下会转化成raw编码的字符串对象。
例如APPEND命令,追加操作只能对字符串值执行(int编码不可),并且Redis没有编写任何embstr的修改程序。因此需要在先将对象编码转化成raw再执行APPEND命令。
字符串命令
SET、GET、APPEND、STELEN、SETRANGE、GETRANGE命令:int、embstr和raw三种编码都可以执行
INCRBYFLOAT :int编码可以执行,而embstr和raw会先将字符串值转化成long double类型,如果不能转化则会返回错误。
INCRBY、DECRBY:int编码可以执行,而embstr和raw不可以。
列表对象
列表对象的编码可以是ziplist和linkedlist。
ziplist编码的列表对象使用压缩列表作为底层实现。每个压缩列表节点entry保存一个列表元素。
linkedlist编码的列表对象使用双端列表作为底层实现, 每个双端链表节点node都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。
linkedlist对象在底层的双端链表结构中包含了多个字符串对象,这个嵌套字符串对象的行为在哈希、集合、有序集合对象中都会出现。
编码转换
同时满意以下两个条件时,列表对象使用ziplist编码,否则使用linkedlist编码。
- 列表对象保存的所有字符串元素的长度都小于64字节;
- 列表对象保存的元素数量小于512个
当然这些上限值是可以修改的。在配置文件至设置list-max-ziplist-value和list-max-ziplist-entries。
列表命令的实现
- 将新元素推入表头或表尾:LPUSH、RPUSH
- 返回并删除表头或表尾:LPOP、RPOP
- 返回指定节点保存的元素:LINDEX
- 插入新节点:LINSERT
- 删除给定元素的节点:LREM
- 删除不在指定索引范围内的节点:LTRIM
- 为指定索引上的节点赋值:LSET
哈希对象
哈希对象的编码是ziplist或者hashtable。
ziplist编码的哈希对象使用压缩列表为底层实现。当有新的键值对要加入到哈希对象时,程序会将保存了键的压缩列表节点推入到压缩列表结尾,然后再保存值到表尾。
因此,同一键值对的两个节点在一起且键在前,值在后。先添加的哈希对象的键值对在表头方向,后插入的在表尾方向。
hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存。字典的键值分别对应对象的键值。
编码转换
同时满足以下两个条件时,哈希对象使用ziplist编码。否则使用hashtable编码。
- 哈希对象保存的所有键值对的键和值长度都小于64字节;
- 哈希对象保存的键值对数量小于512个。
哈希命令的实现
HSET
HGET
HEXISTS
HDEL
HLEN
HGETALL
集合对象
集合对象的编码可以使inset或者hashtable。
intset编码的集合使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。
hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL。
编码的转换
同时满足以下两种条件时,对象使用intset编码。否则使用hashtable编码。
- 集合对象保存的元素都是整数值;
- 集合对象保存的元素数量不超过512个。
集合命令的实现
SADD
SCARD: 返回元素数量
SISMEMBER:查找给定的元素
SMEMBERS:遍历整个整数集合、字典并返回
SRANDMEMBER:随机返回一个元素/字典键
SPOP:随机去一个元素/字典键返回给客户端,再将随机元素删除
SREM:删除给定元素/键值对
有序集合对象
有序集合的编码可以是ziplist和skiplist。
ziplist编码的有序集合使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,而第二个元素则保存元素的分值。
压缩列表的集合内元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,分值较大的元素则被放置在靠近表尾的方向。
skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表。
typedef struct zset{
zskiplist *zsl;
dict *dict;
} zset;
zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,例如ZRANK、ZRANGE等。
zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素 的成员,而值保存了元素的分值。通过字典,可以O(1)复杂度查找给定成员的分值,ZSCORE。
有序集合每个元素的成员是一个字符串对象,每个元素的分值是一个double类型的浮点数。虽然zset结构同时用跳跃表和字典来保存有序集合元素,但这两种数据结构会通过指针来共享相同元素的成员和分值,不会带来额外的内存浪费。
编码的转换
同时满足以下两个条件时,对象使用ziplist编码。否则使用zkiplist编码。
- 有序集合保存的元素数量小于128个;
- 有序集合保存的所有元素成员的长度都小于64字节;
有序集合命令的实现
ZADD
ZCARD
ZCOUNT
ZRANGE
ZREVRANGE
ZREM
ZSCORE
类型检查与命令多态
Redis用于操作键的命令基本上可以分为两种类型。一种可以对任何类型的键执行,比如DEL、EXPIRE、RENAME、TYPE、OBJECT等。
另外一种命令只能对特定类型的键执行,比如:SET、HSET、ZADD、SADD。如果对字符串键执行LLEN命令,那么Redis会返回一个类型错误。
redis> SET msg "hello world"
"hello world"
redis> LLEN msg
(error) WRONGTYPE Operation against a key holding the wrong kind of value
从上面发生的类型错误可以看出,为了确保只有指定类型的键可以执行某些特定的命令,Redis会先检查输入键的类型是否正确,再决定是否执行。类型特定命令所进行的类型检查是通过redisObejct结构的type属性来实现的。例如上面的例子
redis> TYPE msg
REDIS_LIST
通过前面的内容我们了解到,Redis的对象至少拥有两种编码方式,例如列表对象有ziplist和linkedlist两种编码可用,其中前者使用压缩列表API来实现列表命令,后者使用双端链表API来实现列表命令。
例如LLEN命令,除了确保执行命令的是列表键之外,还需要根据编码选择正确的LLEN命令实现。ziplist编码使用ziplistLen函数返回列表的长度,linkedlist编码使用listlength函数来返回双端链表的长度。
可以认为LLEN命令是多态的。DEL、EXPIRE等命令跟LLEN区别在于,前者是基于类型的多态——一个命令可以同时用于处理多种不同类型的键,而LLEN是基于编码的多态——一个命令可以同时用于处理多种不同编码。
内存回收
因为C语言不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数reference counting技术实现的内存回收机制。通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象进行内存回收。
typedef struct redisObject{
// ...
// 引用计数
int refcount;
// ...
} robj;
对象的引用计数信息会随着对象的使用状态而不断变化,创建一个新对象,refcount的值会被初始化为1。当对象被一个新程序使用时,它的refcount会增1,不再被一个程序使用时,refcount减一。当对象的引用计数值变为0时,对象所占用的内存会被释放。
对象共享
引用计数除了用于实现内存回收机制之外,还带有对象共享的作用。在Redis中,让多个键共享同一个值对象需要执行以下两个步骤:
- 将数据库键的值指针指向一个现有的值对象;
- 将共享的值对象的引用计数增一。
Redis会在初始化服务器时,创建一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器用到这些字符串对象时,服务器就会使用到这些共享对象,而不是新创建对象。
创建共享字符串对象的数量可以通过修改redis.h/REDIS_SHARED_INTEGERS常量来修改。
举个例子,如果我们创建一个值为100的键A,并使用OBJECT REFCOUNT命令查看键A值对象的引用计数。
redis> SET A 100
OK
redis> OBJECT REFCOUNT A
(integer)2
注意,这些共享对象不单只有字符串键可以使用,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时间计算得出的。这个命令在访问键的值对象时,不会修改值对象的lru属性。
键的空转时长还有一个作用,如果服务器开启了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存超过了maxmemory的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。