0. Redis中的数据结构:
Redis作为缓存数据库,需要频繁的对内存中的数据进行操作,为了提升数据库的运行效率及安全性,Redis对C库函数中的字符串数据结构进行了封装;
同时,Redis数据库基于多种场景需求,实现了一些C库函数中没有实现的数据结构类型,包括链表、字典、跳跃表、整数集合和压缩列表。
基于这6种数据结构,Redis封装了5中对象类型。
1. 简单动态字符串(sds):
1.1 SDS的实现:
struct sdshdr {
int len; //记录buf数组中已使用字节的数量,等于SDS所保存的字符串长度
int free; //记录buf中未使用的字节数
char buf[]; //字节数组,用于保存字符串内容
};
一个SDS的结构示意图:
1.2 sds 与 C字符串的区别:
Redis只会使用C库函数中的字符串去表示 字符串常量,当需要表示字符串变量时,就会使用自定义的SDS(Simple Dynamic String),二者的区别在于:
(1)降低计算字符串长度的时间复杂度:
C字符串当需要计算长度时,需要遍历整个字符串,直到遇到 ‘\0’ ,时间复杂度是 O(n);
对于SDS只需要获取len的值即可,时间复杂度 O(1)。
这确保了获取字符串长度的工作不会成为Redis的性能瓶颈。
(2)杜绝缓冲区溢出:
C字符串不记录自身长度,所以容易造成缓冲区溢出,例如:
//字符串拼接函数:将dest拼接到src结尾,如果src的内存空间不足,则会造成缓冲区溢出
char *strcat(char *dest, char *src);
如果使用SDS,当需要对SDS保存的内容进行修改时,会先检查SDS的空间是否满足修改所需的要求,如果不满足,API会自动将SDS的内存空间扩展至所需大小,再进行修改。
(3)减少修改字符串时带来的内存充分配次数:
C字符串底层的实现是一个数组,每次在进行扩展或缩短时,都需要进行一次内存的重分配:
如果需要扩展时没有扩展,则会造成缓存区溢出;
如果需要缩短时没有将多余的空间释放,则会造成内存泄漏。
分配内存是一个非常性能的操作,在一般的程序中还可以接受,但Redis作为数据库,经常进行速度要求严苛、数据被频繁修改的场合,如果每次对字符串的修改都要重新分配内存,会对数据库的性能造成影响。在SDS中会进行内存空间预分配,free字段保存额外的未使用空间的大小。
==> 空间预分配 和 惰性删除:
通过未使用空间,SDS实现了两种优化策略:
(a)空间预分配:
SDS的空间预分配原则:
① 如果对SDS修改后,SDS的长度(即 len 的值)小于 1MB,则分配与len同样大小的使用空间(len = free,总长度=len+free+1;例如修改后 len=13,则free=len=13,总长度=13+13+1=27);
② 如果对SDS修改后,SDS的长度大于 1MB,则额外分配 1MB的未使用长度(例如修改后 len=30MB,则总长度=30MB+1MB+1Byte)
(b)惰性空间删除:
当需要缩短SDS保存的字符串时,程序并不会立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录下来,并等待将来使用。
通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化。
与此同时,SDS也提供了真正释放SDS的未使用空间的API,所以不用担心惰性空间释放策略会造成内存浪费。
(4)二进制安全:
C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符串,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像 图片、音频、视频、压缩文件这样的二级制数据。
而Redis作为数据库,是需要保存各种二进制数据的,为了确保Redis可以适用于各种不同的使用场景,SDS的API都是二进制安全的(binary-safe):数据在写入时是什么样,在被读取时就是什么样。程序不会对其中的数据进行任何限制、过滤或者假设。
通过使用二进制安全的SDS,而不是C字符串,使得Redis不仅可以保存文本数据,还可以保存任意格式的二级制数据。
注意:
SDS遵循C字符串以 ‘\0’ 空字符结尾的惯例,由SDS函数自动添加,且 len 字段不包含 ‘\0’ 空字符的长度,这样做的好处是可以直接复用一部分的C语言的库函数,如复用printf,而不用单独定义打印函数:
printf("%s\n", s->buf);
2. 双端链表(linked list):
2.1 链表与链表结点的定义:
// adlist.h/listNode
typedef struct listNode {
struct listNode *prev; //前置节点
struct listNode *next; //后置节点
void *value; //节点值
} listNode;
// 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;
一个链表的结构示意图:
2.2 Redis链表的特性:
(1)多态:
链表节点使用 void* 指针保存节点的值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
(PS: 想要多态,就用 void*,可以保存不同类型的值)
(2)带链表长度计数器:
list结构体中使用 len成员变量对链表中的节点数进行计数,保证程序获取链表长度的时间复杂度是 O(1)。
(3)带表头指针和表尾指针:
list 结构体中使用 head 和 tail 指针分别保存链表的表头节点和表尾节点,保证获取链表头和链表尾的时间复杂度都是 O(1)。
3. 字典(dict):
3.1 字典的定义:
// dict.h/dictEntry
typedef struct dictEntry {
void *key; //键
union { //值:可以是指针,或64位的无符号、有符号整数
void *val;
uint64_t u64;
sint64_t s64;
} v;
struct dictEntry *next; //在键冲突时,next指针指向同一个key下的下一个哈希节点,形成链表
} dictEntry;
// dictht.h/dictht
typedef struct dictht {
dictEntry **table; //哈希表数组(指针数组,数组元素为指向哈希表节点的指针:*dictEntry)
unsigned long size; //哈希表数组table的大小(sizeof(array)),即数组元素的个数,包括已经使用的和未使用的
unsigned long sizemask; //sizemask=size-1,哈希表大小掩码,用于计算索引值
unsigned long used; //哈希表中已有的节点数量(注意:used是可能会大于size的,即当哈希表的负载因子大于1时)
} dictht;
// dict.h/dict
typedef struct dict {
dictType *type; //关于哈希操作的函数
void *privdata; //哈希函数中需要用到的私有数据
dict ht[2]; //哈希表,包括ht[0]和 ht[1] 两个哈希表,一般情况下只是用ht[0],ht[1]只在rehash时使用
int rehashidx; //rehash索引。用于指示rehash进行到ht[0] 上的下标位置
} dict;
// 关于 dictType *type :
typedef struct dictType {
unsigned int (*hashFunction)(const void *key); //哈希函数:用于根据key值计算出哈希值
void *(*keyDup)(void *privdata, const void *key); //复制键
void *(*valDup)(void *privdata, const void *obj); //复制值
int *(*keyCompare)(void *privdata, const void *key1, const void *key2); //比较键
void *(*keyDestructor)(void *privdata, void *key); //销毁键
void *(*valDestructor)(void *privdata, void *obj); //销毁值
};
一个字典的结构示意图:
3.2 哈希算法:
如何将一个新的键值对插入到字典中:
键key ---> 哈希值 ---> 索引值 ---> 插入dictht中的数组
(1)首先使用哈希函数 根据键值对中的键key计算出哈希值:
(相当于是将用户输入的key值进行散列化,得到具有随机性的散列值,以便将其散列性的插入到哈希表数组中)
hash = dict->type->hashFunction(key);
(2)然后根据哈希值与索引掩码sizemask,计算出索引值:
(第一步中计算出哈希值可能很大,超出哈希表中dictht->table 数组的大小,将哈希值与索引掩码进行 & 计算,得到在数组大小范围内的索引值,以便将新元素插入到数组中)
(“&” 计算相当于是 “%”取余,原因是 “x & sizemask” 的运算会将x超出sizemask的高位值全部用 0 bit 掩盖掉(清0),即为“掩码”,sizemask应取“数组所能表示的最大值”,例如数组长度是8,则数组的下标范围0-7,所以sizemask应取size-1,即8-1=7,这样任何数对sizemask取余后,所得结果都不会超过7(超过7的bit位都被0bit掩盖了))
index = hash & dict->ht[x].sizemask;
例如,假设一个键值对中的key通过哈希函数计算后得出的哈希值是8,那么程序会继续使用语句:
index = hash & dict->ht[0].sizemask = 8 & 3 = 0
3.3 如何解决键冲突:
当有两个或两个以上的键被分配到了哈希表数组的同一个索引上面时,这种现象称为“键冲突”。
Redis的哈希表解决键冲突的方法是 “链地址法”(seperate chaining),每个哈希表都有一个next指针,多个哈希节点可以用next指针构成一个单向链表。
注意:由于 dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置,排在其他已有节点的前面,时间复杂度为 O(1)。
3.4 rehash:
随着哈希表上的操作随着程序运行的时间而逐渐增多 或 减少,哈希表中的节点个数也会相应的变化,为了让哈希表的负载因子维持在合理的范围内,当哈希表中的键值对数量太多或者太少时,需要对其进行扩展或者收缩。
==> 什么是哈希表的“负载因子”(load factor)?
哈希表中已保存的节点个数 与 哈希表大小的比值(ht[0].used / ht[0].size
),例如:对于一个大小为4的哈希表(数组元素个数为4),如果刚好存储了4个元素,则负载因子是:
load_factor = ht[0].used / ht[0].size = 4 / 4 = 1;
==> 什么时候会执行哈希表的扩展或收缩?
(1)当满足以下两个条件中的一个时,程序会自动开始对哈希表执行 扩展 操作:
- 服务器当前没有在执行 BGSAVE 或 BGREWRITEOF 命令,并且哈希表的负载因子 大于等于 1;
- 服务器当前正在执行 BGSAVE 或 BGREWRITEOF 命令,并且哈希表的负载因子 大于等于 5.
(2) 触发哈希表收缩操作的条件:
当哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作。
PS:
为什么当Redis执行BGSAVE或者 BGREWRITEOF 命令时,需要将触发扩展操作的负载因子从1提高到5?
因为在执行BGSAVE时,Redis会fork出一个子进程,而这个子进程只是服务器父进程的内存空间的数据并将其写入磁盘,并不会修改磁盘上的内容(没有写操作)。
按照正常的fork处理,子进程会完全拷贝一份父进程的内存地址空间,数据完全一致,但由于子进程其实只是读取父进程的数据,没有写操作,所以复制父进程的地址空间相当于是对内存的浪费,所以引入“写时复制”技术:子进程复用父进程的地址空间,而知实际指向相同的物理地址(虚拟地址不同),这块地址的属性是 read-only,当父子进程中的某一个有写操作时,则触发系统中断,此时再分配新的内存。
提高负载因子阈值,就是防止在BGSAVE时触发哈希表扩展而导致进程对内存的写操作,导致不必要的内存浪费。
参考内容:关于Redis的“写时复制”(copy-on-write)技术:
https://blog.csdn.net/qq_32131499/article/details/94561780
==> 执行rehash的步骤:
-
为字典 ht[1] 哈希表分配空间,空间大小为:
① 如果执行的是扩展操作,则: ht[1] 的大小是 第一个大于等于 ht[0].used * 2 的 2^n;
例如:ht[0].used = 4, ht[0].used*2 = 8, 而 2^n = 0, 2, 4, 8, 16…,第一个大于8的 2 ^n 是16,所以ht[1]的大小是16;
② 如果执行的是收缩操作,则: ht[1] 的大小是 第一个大于等于 ht[0].used 的 2^n; -
将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上去;(rehash指的是重新计算键的哈希值和索引值,然后放置到ht[1]的哈希数组的UI应位置上);
-
当 ht[0] 上的所有键值对都迁移到了 ht[1] 上后,释放 ht[0] (释放内存),将 ht[1] 设置为 ht[0], 并在 ht[1] 新建一个空白哈希表(申请内存),为下一次rehash做准备。
==> 渐进式rehash:
当哈希表中的元素个数过多时,一次性的将键值对全部rehash 到 ht[1] 上会由于庞大的计算量而导致服务器在一段时间内停止服务,所以服务器采取分多次、渐进式的将 ht[0] 里面的键值对慢慢的rehash 到 ht[1] 上。
在rehash过程中,新插入的节点只会插入到 ht[1] 中,而 ht[0] 则停止插入操作。对于查找、删除、更新 等操作,需要在 ht[0] 和ht[1] 两个表上进行。
在上面描述的 “执行rehash的步骤” 的基础上,渐进式rehash增加的操作是:
将 dict.rehashidx 索引值置为0,表示渐进式rehash开始,每次从 ht[0]向ht[1] 中迁移一个元素后,rehashidx 的值就 +1(指向下一个待迁移的 ht[0] 中的元素位置下标),直至迁移全部完成, 将 rehashidx的值置为 -1,表示rehash执行完毕。
4. 跳跃表(skiplist):
跳跃表支持 平均 O(logN)、 最坏 O(N) 复杂度的节点 查找 时间复杂度,跳跃表的效率可以和平衡树媲美,而跳跃表的实现复杂度比平衡树要更为简单,所以不少程序都用跳跃表来代替平衡树。
常见的数据结构的各种操作时间复杂度比较:
4.1 跳跃表的实现:
// redis.h/zskiplistNode
typedef struct zskiplistNode {
struct zskiplistNode *backward; //后退指针:指向紧邻本节点的前一个节点
double score; //分值:double型浮点数,跳跃表中的节点按照分值从小到大排列,不同的节点分值可以相同,当分值相同时按obj成员对象中的SDS在字典序中的大小进行排列
robj *obj; //成员对象:指向一个字符串对象(SDS)的指针,字符串的值在跳跃表中必须唯一
struct zskiplistLevel {
struct zskiplistNode *forward; //前进指针
unsigned int span; //跨度:用于计算所查找节点在跳跃表中的排位
} level[];
} zskiplistNode;
// redis.h/zskiplist
typedef struct zskiplist {
struct zskiplistNode *header; //表头节点
struct zskiplistNode *tail; //表尾节点
unsigned long length; //表中节点数量:可以以 O(1) 的时间复杂度返回跳跃表的长度
int level; //表中层数最大的节点的层数(不包括表头节点)
} zskiplist;
一个跳跃表结构示意图:
5. 整数集合(intset):
整数集合是集合键的底层实现之一,当一个集合 只包含 整数值 元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
使用 整数集合 可以保存 类型为 int16_t、 int32_t、 int64_t 的整数值,并且保证集合中不会出现重复元素。
5.1 整数集合的实现:
typedef struct intset {
uint32_t encoding; //编码方式:uint16_t、uint32_t、uint64_t。指示这个整数集合存放的数据的类型。
uint32_t length; //元素个数
int8_t contents[]; //保存元素的数组。虽然类型为uint_8,但是会根据encoding字段指示的类型进行修改。
} intset;
一个包含5个int16_t类型整数值的整数集合:
一个包含4个int64_t类型整数值的整数集合:
5.2 整数集合的操作效率:
向 整数集合中 添加元素的时间复杂度是 O(n),因为每次向 整数集合添加新元素都可能引起“升级”,而每次“升级”都需要对底层数组中已有的元素进行类型转换,遍历整个数据集合。
整数结合的“升级”策略提升了灵活性,并节约了内存。
6. 压缩列表(ziplist):
6.1 压缩列表的结构:
压缩列表的各个组成部分:
一个含有3个节点的压缩列表的示例:
一个含有5个节点的压缩列表的示例:
压缩列表中的节点的结构:
一个存 储整数“10086” 的压缩列表节点的示例:
一个存储 字符串“hello world” 的压缩列表节点示例:
其中,压缩列表节点结构中的 previous_entry_length
字段存储的是前一个节点的长度:
(1)如果前一个节点的长度小于 254字节,则 previous_entry_length 需要使用 1 字节的空间来保存;
(2)如果前一个节点的长度大于等于 254字节,则 previous_entry_length 需要使用 5 字节的空间保存。
6.2 压缩列表的操作效率:
由于压缩列表使用的是一块连续的内存存储,所以ziplistPush 等命令 的平均时间复杂度为 O(N),如果触发了连锁更新则时间复杂度将可能退化为 O(N^2),但这种情况发生的概率较低,可以放心的使用这些函数。