文章目录
- 第一部分:编码
- 第二部分:对象实现
- 第三部分:类型与内存
- 第四部分:单机数据库
- 第五部分:多机数据库
第一部分:编码
一、简单动态字符串(SDS)
简单字符串:Simple Dynamic String(SDS)
用处:
- 保存数据库中的字符串值
- 当作缓冲区:AOF缓冲区、客户端状态的输入缓冲区
1.1 SDS定义(结构体)
sds.h/sdshdr
struct sdshdr{
// 记录buf数组中已使用的字节的数量
// SDS保存的字符串的长度(不包括结尾的'\0')
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,用户保存字符串
char buf[];
}
示例:
- free属性值为5:SDS为buf数组分配了5字节未使用空间
- len属性值为5:SDS保存了5字节长的字符串
1.2 SDS 与C字符串的区别
1.2.1 常数获取字符串
- 因为C字符串并不记录自身长度,每次必须遍历整个字符串进行计数,时间复杂度O(n)
- SDS的len属性记录了SDS本身的长度,获取长度的复杂度为O(1)(设置和更新SDS长度是由SDS的API在执行时自动完成的)
1.2.2 杜绝缓冲区溢出
C字符串的添加操作:
假设程序里有两个内存紧邻的C字符串S1和S2,分别保存了“Redis”和“MongoDB”
如果程序猿决定执行strcat(s1,“ Cluster”),但是忘了在执行合并之前为S1分配足够的内存,那么在strcat函数执行后,s1的数据将溢出覆盖到S2所在的空间,导致S2的内容被意外的修改:
SDS的添加操作:
SDS在执行sdscat(将c字符串拼接到SDS保存的字符串后面)前会先判断剩余空间free够不够,如果不够会先扩展字符串的空间,再执行拼接操作
1.2.3 减少字符串修改带来的内存重分配次数
C字符串操作的缺陷:
每次对C字符串执行添加(strcat)或截断操作(strim)都需要重新分配内存空间;在SDS中通过未使用空间(即free属性记录的值)实现空间预分配和惰性空间释放两种优化策略。
-
空间预分配:(SDS进行空间扩展的时候)
- 如果扩展后SDS的长度(即len)小于1MB,那么程序分配和len属性同样大小的未使用空间free,此时len和free一样长,比如图2-10的free和len都为13字节。
- 如果扩展后SDS的长度(即len)大于等于1MB,那么程序分配1MB的未使用空间free
图2-10执行sdscat添加“ cluster”,重新分配内存后未使用空间free和len都为13字节,此时如果再执行sdscat添加操作,sdscat(s," aaa")则不用重新分配内存,因为free未使用空间有13字节满足“aaa”3个字节。
-
惰性空间释放:(优化SDS字符串缩短操作)
- SDS的API需要缩短SDS保存的字符串时,程序不需要立即重新分配内存来回收缩短后多出来的字节,而是使用free属性记录起来留着使用。
比如SDS执行sdstrim(s,“XY”)移除SDS中所有的“X”和"Y",SDS并没有立即回收多余的8字节,如果将来要执行添加操作,则可以直接添加,减少内存重分配次数。
1.2.4 二进制安全
- C字符串通过’\0‘来判断字符串结尾,对于含有’\0’的特殊数据格式,只能读出Redis
- SDS API使用len属性值判断字符串是否结束,不会过滤掉中间的’\0’,数据在输入时是什么样,被读取时就是什么样
1.2.5 兼容部分C字符串函数
SDS遵循C字符串以’\0‘结尾的惯例,分配空间时总会多分配一个字节的空间保存’\0‘,可以重用部分<String.h>库定义的函数
1.2.6 区别总结
1.3 SDS常用API
总结
-
Redis只会使用C字符串作为字面量,在大多数情况下,Redis使用SDS(SimpleDynamic String,简单动态字符串)作为字符串表示。
-
比起C字符串,SDS具有以下优点:
- 常数复杂度获取字符串长度。
- 杜绝缓冲区溢出。
- 减少修改字符串长度时所需的内存重分配次数。
- 二进制安全。
- 兼容部分C字符串函数。
二、链表(双向链表)
用途:列表键的实现之一
2.1 链表的结构
链表节点的结构体
链表的结构体(双向链表)
2.2 链表的特点
- 双端:pre和next指针
- 带表头指针和表尾指针
- 无环:表头节点的pre和表尾的next都指向null
- 带链表长度计数器:list结构体里的len属性记录了链表的长度
2.3 链表和链表节点的API
总结
- 链表被广泛用于实现Redis的各种功能,比如列表键、发布与订阅、慢查询、监视器等。
- 每个链表节点由一个listNode结构来表示,每个节点都有一个指向前置节点和后置节点的指针,所以Redis的链表实现是双端链表。
- 每个链表使用一个list结构来表示,这个结构带有表头节点指针、表尾节点指针,以及链表长度等信息。
- 因为链表表头节点的前置节点和表尾节点的后置节点都指向NULL,所以Redis的链表实现是无环链表。
- 通过为链表设置不同的类型特定函数,Redis的链表可以用于保存各种不同类型的值。
三、字典
用途:
- Redis数据库:对数据库的增删改查都是建立在对字典的操作上
- 哈希键的底层实现之一
3.1 字典的结构体
3.1.1 哈希表节点
dict.h/dictEntry结构定义(类似HashMap里的Entry)
- key:保存键值对中的键
- v:保存键值对的值,可以是一个指针,或者uint64_t整数或者int64_t整数
- next:指向下一个哈希表节点的指针
3.1.2 哈希表
dict.h/dictht结构定义
- table:哈希表节点数组,数组的每个元素都是指向dictEntry结构体的指针(类似HashMap的table)
- size:哈希表的大小
- used:哈希表已有节点(键值对)的数量
3.1.3 字典
dict.h/dict
- ht是两个哈希表的数组,字典只使用ht[0],rehash时使用ht[1]
- type属性如图
3.1.4 字典结构图
3.2 Hash算法
- 先利用字典的hash函数根据key计算出hash值:使用dict->type->hashFunction(key)
- 根据hash值计算索引值:将hash值与sizemask进行与运算(即hash & (size-1),与hashMap原理相同)
3.3 Hash冲突解决
Redis使用链地址法解决Hash冲突,与HashMap相同,但是Redis为了考虑速度,将冲突的键值对插入到链表的表头位置(复杂度为O(1))
3.4 rehash
rehash:执行扩展或伸缩过程中,给ht[1]分配空间,将ht[0]的键值对重新计算哈希值和索引值,放到ht[1]的指定位置
rehash过程
- ht[1]分配的空间大小取决于当前键值对数量(即ht[0].used)
- 如果执行扩展操作:ht[1]大小为第一个大于等于2*ht[0].used的2^n(2的n次幂),例如当前为12,12*2=24,最接近24的2的幂次方是32
- 如果执行收缩操作:ht[1]大小为第一个大于等于ht[0].used的2^n(2的n次幂)
- 将保存在ht[0]的所有键值对rehash到ht[1]上面
- ht[0]的所有键值对都rehash到ht[1]后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]创建一个空的hash表(table为null),为下一次rehash使用。
负载因子:ht[0].used / ht[0].size
满足以下条件时,会自动对hash表执行扩展操作
-
服务器目前没有执行BGSAVE或BGREWRITEAOF命令,并且哈希表的负载因子大于等于1
-
服务器目前正在执行BGSAVE或BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
注意:BGSAVE或BGREWRITEAOF命令执行需要创建子进程,服务器会提高扩展所需的负载因子来避免执行扩展操作,避免不必要的内存写入操作,最大限度节约内存。
满足以下条件时,会自动对hash表执行收缩操作
- 哈希表的负载因子小于0.1
3.5 渐进式rehash
因为哈希表的键值对可能成千上万,如果一次性rehash到新的哈希表,由于庞大的计算量导致redis服务器在一段时间内停止服务,造成性能影响,所以redis是渐进式、分多次的将ht[0]里的键值对慢慢的rehash到ht[1]
- 为ht[1]分配空间,字典同时拥有ht[0]和ht[1]两个哈希表
- 字典中位置变量rehashindex,值设为0表示正在rehash
- rehash期间,每次对字典的添加、删除、查找、更新时,除了执行指定操作外,顺便将ht[0]的键值对rehash到ht[1]上,每次rehash完成之后,rehashindex值加一。
- 删除、查找、更新操作在ht[0]和ht[1]两个哈希表上进行,比如查找会现在ht[0]查找,如果找到除了返回键值对外,由于查找时计算了hash值可以顺便映射到ht[1]上,没找到再去ht[1]查找
- 增加操作只会在ht[1]执行,不会在ht[0],这样保证了ht[0]的键值对只增不减,随着rehash不断执行最终变成空表
- 随着操作不断进行,ht[0]的所有键值对都会rehash到ht[1]上,此时将rehashindex设为-1表示rehash完成
3.6 字典API
总结
- 字典被广泛用于实现Redis的各种功能,其中包括数据库和哈希键。
- Redis中的字典使用哈希表作为底层实现,每个字典带有两个哈希表,一个平时使用,另一个仅在进行rehash时使用。
- 当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。
- 哈希表使用链地址法来解决键冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。
- 在对哈希表进行扩展或者收缩操作时,程序需要将现有哈希表包含的所有键值对rehash到新哈希表里面,并且这个rehash过程并不是一次性地完成的,而是渐进式地完成的。
四、跳跃表
用途:有序集合键的底层实现之一。
支持平均复杂度O(logN),最坏复杂度O(N)的节点查找
4.1跳跃表的结构体
跳跃表节点的结构体(redis.h/zskiplistNode)
- 层(level):level数组每个元素包含一个指向其他节点的指针forward和跨度span,可以加快访问节点的速度。(每一层类似在链表之上再建立的多层索引),每次创建节点的时候都随机生成一个[1,32]之间的数作为level数组的大小,这个大小就是层的高度。
- 前进指针(forward):用于从表头向表尾访问节点
- 跨度:记录两个节点的距离,指向NULL的前进指针的跨度都为0
- 后退指针(backword):用于从表尾向表头访问节点,每个节点只有一个后退指针,只能退前一个节点
- 分值(score):double类型的浮点数,跳跃表的节点按照分值从小到大排序。
- 成员对象(obj):是一个指针,指向一个字符串对象,字符串对象保存着一个SDS值
注意:同一个跳跃表中,各个节点保存的节点对象必须是唯一的,分值可以相同,分值相同的按照字典序大小进行排序
跳跃表的结构体
typedef struct zskiplist{
// 表头节点和表尾节点
struct zskiplistNode *header,*tail;
// 标中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
}zskiplist;
- header:指向表头指针
- tail:指向表尾指针,定位表尾的复杂度为O(1)
- length:记录节点的数量,获取长度的复杂度为O(1)
- level:O(1)复杂度获取跳跃表中层最高的节点的层数(注意:表头节点的高度不计算在内)
4.2 跳跃表API
总结
- 跳跃表是有序集合的底层实现之一。
- Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成,其中zskiplist用于保存跳跃表信息(比如表头节点、表尾节点、长度),而zskiplistNode则用于表示跳跃表节点。
- 每个跳跃表节点的层高都是1至32之间的随机数。
- 在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的。
- 跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序。
五、整数集合
用途:集合键的底层实现之一
5.1 整数集合结构体
intset.h/intset
- contents:该数组的每个元素都是整数集合的元素,按值大小从小到大排序,并且不包含重复项
- length:记录了整数集合包含的元素数量,也是contents数组的长度
- encoding:决定contents数组的类型,contents的定义为int8_t,但实际取决于encoding的值:
- int16_t:最小值-32768,最大值32767
- int32_t:最小值-2147483648,最大值2^32-1
- int64_t:最小值-264,最大值244-1
5.2 升级
升级定义:当将一个新元素添加到整数集合里,并且新元素的类型比原来整数集合里的类型都要长时,整数集合需要进行升级,然后才能将新元素添加到整数集合里。
升级过程:
- 根据新元素的类型,扩展整数集合数组contents的大小,并为新元素分配空间
- 将底层数组的所有元素类型转换为与新元素相同的类型,并将转换的元素放到正确的位上保持有序性。
- 将新元素添加到底层数组里面:
- 如果新元素比数组的所有元素都小(负数),则插在数组第0位
- 如果新元素比数组的所有元素都大(整数),则插在数组第length-1位
升级示例:
将int32_t类型的65535添加进去,需要重新分配空间。(32x4=128,还需要128-16x3 =80位)
升级的好处:
- 提高灵活性:
- C语言存放int16、int32、int64分别用不同类型的数组保存不同的元素类型
- 整数集合通过自动升级数组来适应元素,所以可以随意的将int16、int32、int64添加到集合中,而不必担心出现类型错误
- 节约内存:要让一个数组保存int16、int32、int64三种类型的值,最简单就是申明为int64类型的数组,可以全部兼容,但是这样如果都是int16或int32类型的元素,将会浪费内存空间,整数集合的升级能同时保存三种不同类型的元素,只有在需要的时候才会对类型升级,确保节约内存
5.3 降级
整数集合不支持降级操作,如果将集合从int16升级到int32后,将int32类型的元素删光只剩int16,整数集合的编码仍然会维持int32
5.4 整数集合API
总结
- 整数集合是集合键的底层实现之一。
- 整数集合的底层实现为数组,这个数组以有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型。
- 升级操作为整数集合带来了操作上的灵活性,并且尽可能地节约了内存。
- 整数集合只支持升级操作,不支持降级操作。
六、压缩列表
用途:列表键和哈希键的底层实现之一
特点:从表尾往表头遍历
6.1 压缩列表结构组成
示例:
压缩列表节点的组成:
-
previous_entry_length:以字节为单位,记录了压缩列表前一个节点的长度
- 如果前一字节的长度小于254字节,则previous_entry_length属性的长度为1字节(8位可以表示254以内的数字)
- 如果前一字节的长度大于等于254字节,则则previous_entry_length属性的长度为5字节,其中第一个字节会被设置为0xFE(十进制254),剩余四个字节表示前一个节点的长度
-
encoding:纪律节点的content属性保留的数据类型及长度:
-
1字节、2字节、5字节:值的最高位为00、01、10的是字节数组的编码,表示content保存的是字节数组,数组的长度由编码去掉最高的两位之后的二进制表示
-
1字节,最高位11开头的是整数编码,整数值的类型由编码去掉最高两位后的二进制表示
-
-
content:负责保存节点的值,可以是一个字符数组或者一个整数值,类型和长度由encoding确定
字符数组要求:
- 长度小于等于(2^6-1)字节的字符数组
- 长度小于等于(2^14-1)字节的字符数组
- 长度小于等于(2^32-1)字节的字符数组
整数值要求:
- 4位长,介于0-12之间的无符号整数
- 1字节长的有符号整数
- 3字节长的有符号整数
- int16_t类型的整数
- int32_t类型的整数
- int64_t类型的整数
示例1:
- 编码最高的两位00表示保存的是一个字节的字符数组
- 后六位001011表示字节数组的长度是11
- content属性的值为“hello world”
示例2:
- 编码的11表示保存的是整数
- 后6位为0表示整数是int16类型
- content属性的10086是节点的值
6.2 连锁更新
特例:有一个压缩列表,所有节点(即e1到eN节点)的大小都是250-253字节的,这是记录前一个节点的previous_entry_length的大小只要一个字节
在以上例子发生一下情况会产生链所更新:
- 将一个大于254字节的节点插入到表头:新节点的大小大于254字节,则e1需要用一个5字节的previous_entry_length记录,由于e1原来的长度是250-253字节,这时候e1的previous_entry_length改为5字节,增加了4字节,将会大于等于254字节,导致e2的previous_entry_length也需要更新,产生连锁反应。
- 删除节点:e1前面有一个small节点小于254字节,small前面有个big节点,大于254字节。这时e1的previous_entry_length记录的是small节点的大小只需一个字节,small节点删除后,e1的previous_entry_length记录的是big节点的大小超过254字节,需要换成五字节的previous_entry_length,导致连锁更新。
注意:
- 连锁更新最坏情况需要对压缩列表进行N次空间重分配,每次空间分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2)
- 真正造成连锁更新的可能性很低
- 首先,压缩列表里恰好you好多个长度介于250-253字节之间的节点才会发生连锁更新,但是这种情况不多见
- 其次,即使出现连锁更新,只要被更新的节点数量不多,救不会对性能造成影响。
6.3 压缩列表API
总结
- 压缩列表是一种为节约内存而开发的顺序型数据结构。
- 压缩列表被用作列表键和哈希键的底层实现之一。
- 压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值。
- 添加新节点到压缩列表,或者从压缩列表中删除节点,可能会引发连锁更新操作,但这种操作出现的几率并不高。
第二部分:对象实现
Redis对象
Redis对象的组成
Redis数据库创建一个键值对时,会创建两个对象:一个对象用作键值对的键(字符串对象),另一个对象用作键值对的值
redisObject对象:
typedef struct redisObject{
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
//引用计数(内存回收使用)
int refcount;
// 记录对象最后一次被命令程序访问的时间
unsigned lru:22;
// ...
} robj;
- 类型:表示字符串、列表、哈希、集合、有序集合物种类型。称呼“字符串键”表示键对应的值是字符串对象,“列表键”表示键对应的值对象是列表对象。TYPE命令返回的是值对应的类型
- ptr:根据encoding属性,ptr指向对象的底层数据结构实现(即SDS、字典、双端链表、跳跃表、整数集合、压缩列表等),可以使用OBJECT ENCODING命令查看对象的编码
五种数据类型对应的编码
一、字符串对象
字符串对象是五种类型对象中会被其他四种类型对象嵌套使用的
1.1 int编码
如果字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,并将字符串对象的编码设置为int
1.2 raw编码(SDS)
要求:
- 对象保存的是一个字符串值
- 字符串值的长度大于32字节
结果:
- 使用简单动态字符串SDS保存字符串值
- 编码设置为raw
1.3 embstr编码(SDS)
embstr编码是一种专门保存短字符串的优化编码方式
要求:
- 对象保存的是一个字符串值
- 字符串值的长度小于等于32字节
embstr与raw编码的区别:
- raw编码通过两次内存分配分别创建redisObject和sdshdr,而embstr则通过一次内存分配来分配一块连续的内存空间给redisObject和sdshdr
embstr的好处:
- embstr编码将创建字符串对象的内存分配次数从raw编码的两次降为1次
- 释放embstr编码的字符串对象只要1次,raw编码需要两次
- embstr编码的所有字符串对象的数据都在连续一块内存里,比raw编码更好的利用缓存带来的优势
注意:
- long double类型的浮点数在redis中是作为字符串保存的
- 取出是会先将字符串转化为浮点数,运算后再转化为字符串保存
1.4 编码的转换
- 当对int编码的字符串执行append操作,将会把对象的编码转化为raw编码,再执行append操作
- 对embstr编码的对象执行任何修改操作,程序会将对象的embstr编码转化为raw编码,再执行修改命令。
- 因为redis没有为embstr编码的字符串编写任何相应的修改程序,所以embstr编码的字符串对象实际上是只读的,执行
1.5 字符串命令的实现
二、列表对象
2.1 ziplist编码(底层压缩列表)
2.2 linkedlist编码(底层双端列表)
每个双端链表节点都保存了一个字符串对象,字符串对象里保存了列表元素(即链表节点的value嵌套指向了一个SDS,里面的字符串数组保存了节点的值)
字符串对象是五种类型对象中会被其他四种类型对象嵌套使用的
2.3 编码转换
使用ziplist编码:
- 列表对象保存的所有字符串元素的长度小于64字节
- 列表对象保存的元素数量小于512个
(上限值可以修改,参考list-max-ziplist-value和list-max-ziplist-entries两个选项说明)
不能满足以上两个条件将使用linkedlist编码
2.4 列表命令
三、哈希对象
3.1 ziplist编码(底层压缩列表)
- 先把键对象添加到链表末尾,再将值对象添加到列表末尾:键和值对象所在的节点是紧挨的,键节点在前,值节点在后
- 先添加的键值对会在偏表头方向,后添加的键值对会在偏表尾的方向
3.2 hashtable编码(底层字典实现)
3.3 编码转换
使用ziplist编码(压缩列表)
- 哈希对象所保存的所有键值对的键和值的字符串长度都小于64字节
- 哈希对象所保存的键值对数量小于512个
(上限值可以修改,参考hash-max-ziplist-value和hash-max-ziplist-entries两个选项说明)
以上任意一个条件不被满足时,就换编码转换,将压缩列表里的所有键值对转移并保存到字典里
3.4 哈希命令的实现
四、集合对象
4.1 intset编码(底层整数集合)
4.2 hashtable编码(底层字典)
注意:字典的值全部设为NULL
4.3 编码转换
使用intset编码:
- 集合对象保存的所有元素都是整数值
- 集合对象保存的元素个数不超过512个
(第二个条件上限值可以修