Redis学习笔记(一)SDS、链表、字典
写在前面
本文完全参考黄健宏所做《Redis设计与实现》。为了方便日后回顾,故写学习笔记。处于自身熟悉程度考虑,对于书中某些知识点不予记录。对于Redis,参考当时的设计原因,虽然现在还没读完全书,但是我认为Redis的每个设计细节都在尽可能的实现高效率与高安全性。带着这个印象去阅读,应该会更好的理解细节。
简单动态字符串SDS
1. 简介
与c语言不同的是,redis内部采用自己的一种抽象类型,简单动态字符串(simple dynamic string,SDS)来表示字符串。
在使用字符串常量时才会使用C语言字符串,如打印log。
在redis中,使用key-value模式进行存储。
如 SET msg "hello world"
,Redis将在数据库中创建一个新的键值对, 其中:键值对的键是一个字符串对象, 对象的底层实现是一个保存着字符串“msg”的SDS,键值对的值也是一个字符串对象, 对象的底层实现是一个保存着字符串“hello world”的SDS。
又如 RPUSH fruits "apple" "banana" "cherry"
Redis将在数据库中创建一个新的键值对, 其中:键值对的键是一个字符串对象, 对象的底层实现是一个保存了字符串“fruits”的SDS。键值对的值是一个列表对象, 列表对象包含了三个字符串对象,这三个字符串对象分别由三个SDS实现: 第一个SDS保存着字符串“apple”, 第二个SDS保存着字符“banana”, 第三个SDS保存着字符串“cherry”
除用来保存数据库字符串值外,SDS也可被用作缓冲区,如AOF模块中的AOF缓冲区,客户端状态中的输入缓冲区等
2. 实现
SDS定义在sds.h下的sdshdr结构体下,请注意,len+free+1才等于buf的长度,因为要以/0结尾
struct sdshdr
{
//记录buf数组中已使用字节的数量,等于SDS所保存字符串的长度
int len;
//记录buf数组中未使用字节的数量
int free;
//字节数组, 用于保存字符串
char buf[];
};
SDS遵循C字符串以空字符结尾的惯例, 保存空字符的1字节空间不计算在SDS的len属性里面, 并且为空字符分配额外的1字节空间, 以及添加空字符到字符串末尾等操作, 都是由SDS函数自动完成的。遵循空字符结尾这一惯例可以让SDS·直接重用一部分C字符串函数库里面的函数,如printf。
SDS与C语言字符串区别
-
获取长度更快。由于SDS直接用len记录了已写入的字符个数,所以可以在O(1)时间内得到长度数据。而C语言遍历数组直至查找到 \0 ,需要O(n)时间。len与free的更改由相应api自动完成,无需手动设置。这使得获取字符串长度不会成为性能瓶颈。
-
尽可能避免空间的重分配(空间换时间)。拼接字符串时,SDS的api会先对free和将要拼接的字符串长度进行比较,当不足时会自动分配新地址存储,无需C字符串一样人工控制。此外,SDS还使用了空间预分配和惰性空间释放策略尽可能避免重分配空间(为了效率考虑)。
空间预分配:拼接字符串且当前free<待拼接字符串长度时,SDS的api会自动为拼接后的字符串多分配一定的空间(C语言api strcat()只会分配刚好的空间),具体值由下列规则确定:if(拼接后字符串.length()<1MB) free=len;//分配登长长度 else free=1MB;
惰性空间释放
在移除字符串的某些部分后,只会更改free和len的值,并不会重新分配一块新空间,预防将来可能的拼接情况。 -
可存储二进制数据。C语言字符串中 /0 不能直接存储在数组中,这导致C语言数组只能保存文本文件不能保存二进制文件,而SDS无所谓,因为len中记录了长度。也正因这个特性,结构体中的buf数组被称为字节数组。
-
兼容部分C函数。SDS依然遵守 /0 结尾原则,可以使用一部分C语言函数。
-
总结。相比较于C字符串,SDS是一个被封装过的,尽可能实现安全和速度的结构体。
链表
1.简介
C语言中并没有封装链表,所以Redis自己实现了。链表主要用于存储列表键元素较多、列表键元素为比较长字符串、发布与订阅、 慢查询、 监视器等功能,以及保存多个客户端的状态信息、构建客户端输出缓冲区等场景下。
2.实现
node:
typedef struct listNode {
//前置节点
struct listNode * prev;
//后置节点
struct listNode * next;
//节点的值
void * value;
}listNode;
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;
需要注意的是,相较于普通的链表,有如下特点:
- 双向。有prev和next双向指针。
- 无环。头结点prev和尾结点next均为null。
- 有长度计数器。O(1)时间内即可获取链表长度。
- 多态。每个node都用void*指针去标识value,这就可以让一个链表中存在多个value类型不同的node。
字典
1. 简介
字典中存储key-value的映射关系,底层使用哈希表作为实现方式。
2. 底层哈希表实现
哈希冲突时,使用链地址法(相同哈希值采用链表链接)解决冲突。且因为没有尾结点指针,链表节点的插入采用头插入法,所以插入复杂度为O(1)。每个链表node是一个key-value键值对。
3. 字典实现
typedef struct dict
{
//type属性和privdata属性是针对不同类型的键值对, 为创建多态字典而设置
//类型特定函数,type属性是一个指向dictType结构的指针, 每个dictType结构保存了一簇用于操作特定类型键值对的函数, Redis会为用途不同的字典设置不同的类型特定函数。
dictType *type;
//privdata属性则保存了需要传给那些类型特定函数的可选参数
void *privdata;
//两个哈希表,ht[0]正常使用,ht[1]只有在rehash时候使用
dictht ht[2];
//当rehash不在进行时, 值为-1
in trehashidx;
}
dict;
哈希表内部实现:
typedef struct dictht
{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码, 用于计算索引值,总是等于size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
} dictht;
4. 计算方法
首先,哈希值!=索引值。
计算时,首先计算哈希值通过type找到对应func,计算哈希值hash=func(key);func一般为Murmurhash算法。
然后将hash与sizemark按位与,得到索引值idx,idx即为哈希表中存放的地址。
5. rehash
为了维持哈希表的负载因子在一个合理的范围内,有些时候需要rehash,对哈希表的长度进行拓展或者收缩。
5.1执行条件
拓展:服务器没有正在执行BGSAVE命令或者BGREWRITEAOF命令,且负载因子大于等于1。或者正在执行这两天命令中的一条或多条,且负载因子大于等于5(负载因子过高,已经严重影响效率,需要立即rehash)
收缩:负载因子小于0.1
5.2 执行过程
首先是分配空间。
当执行拓展操作时,ht[1]大小被设置成第一个大于等于当前ht[0]所载键值对个数的二倍的2^n。
当执行收缩操作时,ht[1]大小被设置成第一个大于等于当前ht[0]所载键值对个数的2^n。
然后执行迁移,对于每个存在于ht[0]中的键值对,重新计算其哈希值和索引值,放入ht[1]中。字典中设置一个值rehashidx,值0时表示正在迁移,值为-1时表示已迁移完成。整个rehash过程中,为避免长时间大量占用算力,采取渐进式rehash的方式,即一次迁移一部分。这种策略不可避免的会带来数据可能存在于两个表中某一个表上的情况。对于查找、更新、删除操作,首先查找ht[0],然后是ht[1]。而插入操作会直接作用到ht[1],保证ht[0]只减不增。
rehash完成后,释放ht[0],将ht[1]转为ht[0],同时在新的ht[1]创建一个新的空白哈希表,为下次rehash做准备。