Redis数据结构与对象
1、字符串
Redis没有使用C语言传统的字符串表示,而是自己定义了一种名为简单动态字符串(简称:SDS)的抽象类型,作为Redis的默认字符串显示
举个例子:
执行以下命令:set redis “redis”
那么Redis会在数据库创建一个新的键值对,其中
键值对的键为一个字符串对象,对象的底层为保存着字符串"redis"的SDS对象
键值对的值为一个字符串对象,对象的底层为保存着字符串"redis"的SDS对象
1.1 SDS 的定义
struct sdshdr{
// 保存SDS中已经使用的字节的长度,等于SDS保存字节的长度
int len;
// 未使用字节的长度
int free;
// 字节数组,用来保存字符串
char buff[]
}
图示:
如上图所示:
- free的值为0,表示这个SDS未分配任何未使用空间
- len的值为5,表示SDS保存了一个5字节长的字符串
- buff 数组保存了R, e, d, i, s 五个字符和一个空字符串\0
tips: SDS 遵循了C语言用空字符串结尾的惯例,这样SDS就可以复用部分C语言的函数,而不是自己实现所有的函数操作
1.2 SDS 和 C 字符串的区别
常数复杂度获取字符串长度
因为C字符串并不记录自身的长度信息,所以为了获取C字符串自身的长度,程序必须遍历整个字符串长度,对遇到的每个字符串计数,直到读到空字符串为止,这个操作的复杂度为O(n)。
对于SDS来说,由于在len属性里面记录了SDS本身的长度,所以获取一个SDS长度的复杂度为O(1),当然设置和更新SDS的len属性不需要我们手动维护,这个操作是在SDS API中自动执行的
通过使用SDS代替C字符串,Redis将获取字符串长度的操作的复杂度由O(n)下降到O(1),保证获取字符串长度不会造成Redis瓶颈
防止缓冲区溢出
先来看一下C拼接函数的定义
char *substr(char *dest, const char *src)
因为C字符串不记录自身的长度,所以假设用户在执行 substr 函数时,未给dest字符串分配足够的内存,就会造成缓冲区溢出。下面看一个例子:
假设内存中中有两个相邻的字符串结构如下
s1: [r,e,d,i,s,\0] s2: [m,y,s,q,l,\0]
当我们想执行substr(s1,‘cluster’) 但是确忘记在执行substr之前给s1申请空间,那么当我们执行完函数之后内存中的内容如下:
s1: [r,e,d,i,s, ] s2: [c,l,u,s,t,e] s2的内容被s1 覆盖
和C不同的是SDS的空间分配策略完全不会造成内存溢出,在执行SDS API对SDS字符串修改时,API会先检查SDS的内存空间是否满足修改所需的内存大小,如果不满足的话,API会先自动将SDS的空间扩充至所需大小,再执行API操作
减少修改字符串造成的内存分配次数
由于字符串增长和缩减涉及重新分配内存的操作,由于内存分配一般要用到比较复杂的算法和系统调用,通常为比较耗时的操作。对于Redis这种性能敏感的数据库来说是不可接受的,因此Redis做了如下优化
SDS空间预分配:
空间预分配用于优化字符串的增长操作,当SDS的API对一个SDS进行修改时,并且需要对SDS进行扩展时,程序不止会为SDS分配修改所必须的空间,还会为SDS分配额外的未使用空间,其中,额外分配的未使用空间由以下公式决定:
- 如果对SDS进行修改后,SDS的长度(也就是len属性的值)将小于1M,那么程序分配和len属性相同的未使用空间,这时len属性的值会和free的值相同,举个例子,如果进行修改后,SDS的长度为10,那么程序会为SDS分配额外长度为10的未使用空间,SDS buff的实际长度为10+10+1 为 21 (1为空字符串占用的大小)
- 如果SDS修改后的空间大于1M,则每次额外为SDS分配1M的未使用内存
惰性空间释放
惰性空间释放用来用来优化Redis字符串缩短操作,当SDS的API需要缩短SDS保存的字符串时,程序不会立即使用内存重分配来回收缩短后多出来的字节而是使用free属性将这些字节的数量记录下来,并等待之后使用
二进制安全
C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含字符,否则最先被读入的空字符串将被误认为是字符串的结尾,这些限制使得C字符串只能保存文本数据,而不能保存图片,视频,音频等二进制文件,为了确保Redis可以使用各种场景,SDS的所有的API都做了二进制安全处理,所以SDS可以存储任何格式数据。
兼容部分C字符串函数
因为SDS保持了C字符串已空字符结尾的规则所以SDS可以复用一部分C字符的函数
2、链表
链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增加删除节点来灵活调整链表的长度
Redis中在链表是**list(列表键)**的实现方式之一,当一个列表键包含了数量比较多的元素或者列表键中的元素都是比较长的元素,Redis就会用链表作为列表键的底层实现
2.1 数据结构
typeof struct listNode{
// 前置节点
listNode * prev
// 后置节点
listNode *next
// 节点的值
void *value
}
多个listNode可以通过prev和next指针组成双端链表
虽然仅仅使用多个listNode结构就可以构成链表,但是Redis 提供了另一个对象来持有listNode,使操作更方便
typeof struct list{
// 表头节点
listNode *head
// 表尾节点
listNode *tail
// 链表所包含的节点数量
unsigned long len
// 复制函数
void *(*dup) (void *ptr)
// 释放函数
void *(*free) (void *ptr)
// 对比函数
void *(*match) (void *ptr, void *key)
}
list 结构为链表提供了表头指针,表尾指针,以及链表长度计数器,以及实现多态链表所需的特定类型的函数
下面是有一个list和三个listNode组成的链表
Redis链表的特性可以总结如下:
- 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
- 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问已NULL为终点
- 带表头个表尾指针,通过list结构的head指针和tail指针,程序获取表头和表尾节点的复杂度都为O(1)
- 带链表长度计数器,程序使用list的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)
- 多态,链表节点使用void*指针保存节点值,并且可以通过list结构的函数为特定类型的节点设置特定的函数,所以链表可以保存不同的值
3、字典
字典,又称符号表,关联数组或映射,是一种保存键值对的抽象数据结构,在Redis中字典用来表示数据库,例如当我们调用 set hello world 命令时,hello 就作为Key,world就作为value存储在Redis数据库中。另外字典还是Redis中hash表的实现之一,当hash表中的键值对比较多或者键值对存储的都是比较长的字符串的时候,Redis就会把字典作为hash表的底层实现
3.1 数据结构
哈希表的数据结构
typeof struct dictht{
// 哈希表数组
dictEntry **table
//哈希表大小
unsigned long size
// 哈希表大小掩码,用于计算索引
// 总是等于size-1
unsigned long sizemark
// 已有节点数
unsigned long used
}
table是一个dictEntry结构的数组,每个dictEntry结构保存一个键值对,size属相记录了哈希表的大小,used属性记录了哈希表已有的键值对的数量,sizemark属性和哈希值一起决定一个键应该放在table的哪个索引上
一个哈希表的数据结构如下
dictEntry 的结构如下图所示
typeof struct dictEntry{
void *key
union{
void *val
} v
struct dictEntry *next
}
其中next 属性为指向另一个hashEntry节点的指针,这个指针可以将多个哈希值相同的键值对链接在一起,以此来解决键冲突问题
字典的数据结构
typeof struct dict {
// 类型特定函数
dicttype *type
// 私有数据
void *private
// 哈希表
dict ht[2]
// rehash 索引,当 rehash不在进行时 为 -1
int trehashidx
}
ht 为包含两个hash表的数组,一般情况下只有ht[0]会使用,ht[1]在对ht[0]进行rehash时使用,rehashidx 记录目前rehash的进度,为 -1 时表示没有rehash在进行,一个普通的hash表的数据结构如下
3.2 键冲突
当两个或者以上数量的键被分配到了哈希表数组的同一索引上面时,我们称之为发生了键冲突。Redis采用链地址法来解决键冲突,每个哈希节点有一个
next指针,多个hash表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突问题
3.3 rehash
随着操作的不断进行,hash表保存的键值对会逐渐增加或者减少。为了让hash表的负载因子维持在一个合理的范围内,当哈希表保存的键值对数量太多或者太少时,就要对hash表进行rehash操作
hash表rehash的操作步骤如下:
- 为字典的ht[1]哈希表分配空间,这个哈希表的空间的大小取决于要执行的操作,以及ht[0]当前包含的键值对的数量
如果执行的为扩展操作,ht[1]的大小为第一个大于等于ht[0].used * 2 的2的n次幂,如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used 的2的n次幂 - 将ht[0]中的所有键值对重新rehash到ht[1]上面
- 当ht[0]包含的所有键值对都迁移到了ht[1] 后 ,释放ht[0], 将 ht[1] 置为ht[0], 为下一次rehash做准备
3.4哈希表的扩容和收缩条件
当以下条件的任何一个被满足时,程序会自动开始对hash表的扩容操作
- 服务器目前没有在执行BGSAVE或者BGREWIRTEAOF命令,并且hash表的负载因子大于等于1
- 服务器目前正在执行BGSAVE或者BGREWIRTEAOF命令, 并且hash表的负载因子大于等于5
3.5 渐进式rehash
在3.3 中介绍了rehash的基本过程,但是这个过程并不是一次性完成的,而是分多次,渐进式完成的,这是因为如果hash表中含有百万甚至上亿节点时,一次完成rehash会造成程序在一段时间内无法对外提供服务,以下是渐进式rehash的过程
- 为ht[1] 分配空间,让字典同时持有ht[0]和ht[1]两个hash表
- 在字典中维持一个索引计数器rehashidx,并将他的值设置为0,表示rehash正式开始
- 在rehash期间,每次对字典执行添加,删除,查找,更新等操作时,程序除了执行指定的操作之外,还会顺带将ht[0] 哈希表在rehashidx索引上的所有键值对rehash到ht[1], 当rehash工作结束时,程序将rehash值加1
- 直到某个点,ht[0] 上的所有值都rehash到ht[1] 上时,标志rehash结束,将rehashidx的值设置为-1
渐进式rehash的好处是采用分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每次增删改查上,避免了集中式rehash带来的庞大计算量
4 整数集合
整数集合是集合键(set)的底层实现之一 , 当一个集合中只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的实现方式
4.1 数据结构
整数集合时Redis用于保存整数值的集合抽象数据结构,可以保存int_16,int_32,int_64的整数值并保证集合中不存在重复的值
typeof struct intset{
// 编码方式
unint32_t encoding
// 集合包含的元素数量
unint32_t length
// 保存元素的数组
int8_t contents[]
}
contents 为整数数组的底层实现,整数数组的每个元素都是contents数组的一个项(item),各个项在数组中按值的大小从小到大顺序排列,并且数组中不包含任何数据项,虽然contents结构声明为int8_t的数组,但是contents中存储的数据类型由encoding属性的值决定,encoding 为INTSET_ENC_INT16 时,contents存储int16_t的值,encoding 为INTSET_ENC_INT32 时,contents存储int32_t的值,encoding 为INTSET_ENC_INT64 时,contents存储int64_t的值
升级
当我们要将一个新元素加入到整数集合中,并且新元素的类型比整数集合现有所有的元素的类型都要长时,整数集合会进行升级,然后才能将新元素加到整数集合中
升级整数集合的步骤如下:
- 根据新元素的类型,扩展底层数组的空间大小,并为新元素分配空间
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转化后的元素放在相同的位置上,放置的过程中要保持数组的有序性不变
- 将新元素添加到底层数组里
tips: redis 中不存在降级操作
5 压缩列表
压缩列表是列表键和哈希键的底层实现之一,当一个列表键只包含少量的列表项,并且列表项要么是小整数值,要么是长度比较短的字符串时,Redis就用压缩列表作为列表键的底层实现
5.1 数据结构
zlbytes: 记录整个压缩列表占用的字节数,对压缩列表进行内存重新分配或者计算zlend时使用
zltail:记录压缩列表表尾节点距离压缩列表起始地址有多少字节,通过这个偏移量,程序无需遍历整个压缩列表就可以获取表尾节点的地址
zllen: 记录压缩列表包含节点的数量
zlend: 标记压缩列表的表尾
entry: 列表节点,长度不定,长度由各个节点保存的内容决定