简单动态字符串SDS
Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组,以下简称C字符串),而是自己构
建了一种名为简单动态字符串(simple dynamic string, SDS)的抽象类型,并将SDS用作Redis的默认字符
串表示。
SDS 的实现
struct sdshdr{
// 记录buf中已使用字节数量等于sds保存字符串的长度
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,保存字符串
char buf[];
}
SDS 与 C字符串的区别
- 常数复杂度获取字符串的长度。sds中记录了字符串长度可以直接获取
- 杜绝缓存区溢出。sds的空间分配完全杜绝了发生缓冲区溢出的可能性,当sds API需要对SDS进行修改的时候,API会先检查SDS的空间是否满足修改的需要,如果不满足,会自动将SDS的空间扩展至执行修改所需的大小。
- 减少修改字符串时带来的内存冲分配次数。
- 空间预分配。优化SDS字符串增长操作,当SDS需要进行空间扩展的时候,程序不仅会为SDS分配修改锁必须要的空间,还会为SDS分配额外的未使用空间。分配公式
- 如果对SDS进行修改之后,SDS的长度小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS的len属性的值和free相同
- 如果对SDS进行修改之后,SDS的长度大于1MB,那么程序会分配1MB的未使用空间。
- 惰性空间的释放。用于优化SDS的字符串缩短操作,当SDS缩短的时候,程序不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来。等待将来使用。SDS API提供的有真正释放空间的方法。
- 空间预分配。优化SDS字符串增长操作,当SDS需要进行空间扩展的时候,程序不仅会为SDS分配修改锁必须要的空间,还会为SDS分配额外的未使用空间。分配公式
- 二进制安全。C字符串必须符合某种编码,并且除字符串末尾外不能包含空字符,只能保存文本数据。SDS通过len判断字符串末尾。SDS不是保存字符而是保存二进制,所以SDS不仅可以保存文本数据还可以保存二进制。
链表
链表的实现
链表节点的结构:
typedef struct listNode{
struct listNode *prev;
struct listNode *next;
void *value;
}listNode;
持有链表的结构:
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;
Redis 链表的特点
- 双向:双向列表。
- 无环:表头的prev指针和表尾的next指针都执行null。
- 长度计数:获取节点数量的复杂度为O(1)
- 多态:链表节点使用void*指针来保存节点值,可以通过list结构的dup,free,match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
字典(map)
字典的实现
Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。
哈希表的结构定义:
typedef struct dictht{
//哈希表数组
dictEntry **table;
// 哈希表的大小
unsigned long size;
// 哈希表大小掩码,用于计算索引,总是等于size- 1
unsigned long sizemask;
// 哈希表已有节点的数量
unsigned long used;
}
table属性是一个数组,数组中的每一个元素都是一个指向dict.h/dictEntry结构,每个dictEntry结构保存一个键值对。
哈希表节点结构定义:
typedef struct dictEntry{
void *key;
union{
void *val;
unit64_tu64;
int64_ts64;
}v;
// 指向下一个哈希表节点,形成链表
struct dictEntry *next;
}
字典结构的定义:
typedef struct dict{
// 类型特定函数
dictType *type;
// 私有数据
void *privadata;
// 哈希表
dictht ht[2];
// rehash 索引 当rehash不再进行时,值为-1
int trehashidx;
}
- type 是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
- privdata保存了需要传给那些类型特定函数的可选参数。
- ht[2] 保存了两个hash表,一般情况下只使用h[0], 在进行rehash的时候会使用到h[1]
- trehashidx 记录了rehash的进度。
解决hash冲突
使用链地址法解决hash冲突。
rehash
哈希表保存的键值对数量太多或者太少的时候将要执行rehash(重新散列)
rehash的步骤:
- 为字典的ht[1] 哈希表分配空间,这个哈希表的大小决定于要执行的操作以及ht[0] 当前包含的键值对数量(即ht[0].used属性的值)、
- 如果执行的扩展操作,ht[1].size = 第一个大于等于ht[0].used * 2的2的n次幂
- 收缩操作,ht[1] = 第一个大于等于ht[0].used * 2的2的n次幂
- 保存在ht[0]中的键值对重新rehash到ht[1]上。
- 迁移完成之后,释放ht[0] 将 ht[1] 设置为ht[0],并在ht[1]新创建一个空白哈希表。
什么时候进行扩展与收缩呢?
程序自动对哈希表扩展的条件(满足一条即可):
- 服务器目前没有在执行BGSAVE或者BGREWRITEAOF命令,并且哈希表负载因子大于等于1
- 服务器在执行BGSAVE或者BGREWRITEAOF命令,并且哈希表负载因子大于等于5
负载因子(factor) = 哈希表以保存节点数量 / 哈希表大小。(ht[0].used / ht[0].size)
自动收缩的条件:负载因子小于0.1
渐进式rehash
在字典中维持一个索引计数器变量rehashidx,通过这个变量进行rehash。
首先将rehashidx设置为0,表示rehash开始。在rehash期间,每次对字典进行添加,删除,查找或者更新操作时,顺带进行rehash 即将ht[0]哈希表在rehashidx索引上的键值对rehash到ht[1]上 ,rehashidx逐渐增加,所有的rehash之后将rehashidx设置为-1表示结束。
这种方式避免了集中式的rehash带来的庞大工作量。在进行使用中逐渐的rehash完成。
在渐进式rehash过程中,字典会同时使用ht[0]和ht[1]两个哈希表。例如查找回去两个哈希表中进行。新添加的只保存到ht[1]中。最终ht[0]变为空表。
跳跃表
跳跃表支持平均O(logN),最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。在Redis中用于实现有序集合。
跳跃表的实现
跳跃表由zskiplistNode和zskiplist两个结构定义。
header指向的节点是表头节点,表头节点只有层,其它属性不会使用。
跳跃表节点的实现:
typedef struct zskiplistNode{
// 层
struct zskiplistLevel{
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
} level[];
// 后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj;
}zskiplistNode;
- 层 level[]。通过层加快访问其它节点的速度。数组中的每个元素包含一个指向其它节点的指针和与指向节点的跨度。每次创建一个新跳跃表节点的时候,程序根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的高度。
- **前进指针。**每个层都有一个指向表尾方向的前进指针。
- **层的跨度:**用于记录两个节点之间的距离。
- **后退指针。**用于表尾向表头方向访问节点。每次只能回退至前一个节点。
- **分值和成员。**节点的分值是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。节点的成员对象是一个指针,它指向一个字符串对象保存着一个SDS值。成员对象必须是唯一的,分值是可以相同的。
跳跃表结构定义:
typedef struct zskiplist{
//表头节点和表尾节点
struct zskiplistNode *header, *tail;
//表中节点的数量
unsigned long length;
//表中层数最大的节点层数(不包括表头节点)
int level;
}zskiplist;
通过这个结构持有跳跃表节点。
整数集合
整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
整数集合的实现
整数集合是Redis用于保存整数数值的集合抽象数据结构,它可以保存int16_t,int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。
intset结构表示一个整数集合:
typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}intset;
contents 数组是整数集合的底层实现,整数集合的每个元素都是contents数组的一个数组项(item)各个项在数组中按值的大小从小到大有序地排列,并且数组中不会包括任何重复项。contents属性声明为int8_t类型的数组,但实际上contents数组不保存任何int8_t类型的数组,contents数组的真正类型取决于encoding属性的值。
升级
每当将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现在所有元素的类型都要长时,整数集合需要先进行升级(upgrade)然后才能将新元素添加到整数集合里面。
升级整数集合并添加新元素分为三步进行:
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确位置上。
- 将新元素加到底层数组里面。
升级的好处:(1)提升整数集合的灵活性(使用者不用考虑整数的类型,底层会自动升级类型,不用担心类型错误)。(2)尽可能的节约内存。
整数集合不支持降级,升级后,编码就会一直保持升级后的状态。
压缩列表
压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。
压缩列表的实现
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。
压缩列表节点的构成:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。
压缩列表从表尾向表头遍历操作就是通过这一原理实现的。
encoding属性记录了节点的content属性所保存数据的类型以及长度。
content复制保存节点的值,可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。
对象
上面介绍了Redis用到的所有主要数据结构,Redis并没有字节使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串,列表,哈希,集合,有序集合这五种类型的对象,每一种对象都至少用到了一种前面介绍的数据结构。
Redis对象系统还是先了**基于引用计数技术的内存回收机制。**对象的引用计数属性还带有对象共享的作用。
集合对象底层实现可以是intset(整数集合)或者hashtable
有序集合对象底层实现可以是ziplist或者skiplist。还包含一个用字典为有序集合创建了一个从成员到分值的映射。
哈希对象底层实现可以是ziplist或者hashtable
列表对象底层实现可以是ziplist或者linkedlist
对象的空转时长,对象结构包含一个lru属性记录对象最后一次被命令程序访问的时间。
服务器打开了maxmemory选项时,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,空转时长较高的那部分键会优先被释放,从而回收内存。
参考《Redis设计与实现》