Redis数据结构总结一
一.前言
对Redis基本数据结构的浅显理解。
参考书籍与引用来源:《Redis设计与实现》
二.动态字符串
当Redis需要一个简单的字符串,而且这个字符串可能需要频繁的修改时。Redis会使用动态字符串储存字符串,简称SDS。
1.数据结构
struct sdshdr{
int len; //记录字符串长度,并不包括字符串结束符的长度
int free; //记录SDS中的未使用空间,即分配的所有空间减去已经被占用的空间
char buf[]; //字节数组,保存字符串
}
假如有一个SDS储存了字符串 Hello World 。
因为buf指针指向的内存已全部使用所以free是0,len是11(字符串最后一个字符是‘\0’,用于标识字符串的结束)
如果此时修改字符串 Hello World 为 Hello。则SDS变为:
此时free变为6,len变为5 。
值得注意的是,字符串结束符所占空间是不会从free或len参数体现出来的。
2.较之传统字符串的优势
1.常数复杂度获取字符串长度
传统C语言函数获取字符串长度时,要遍历整个字符串直至读到‘\0’停止。时间复杂度为O(n)。但SDS因为内置了len参数,所以获取字符串长度的时间复杂度为O(1),只需要读取len的值即可获取字符串长度。
2.避免缓冲区溢出
通过SDS内置的free与len参数可在进行字符串操作时提前检测以分配足够的空间避免缓冲区溢出。
3.减少因修改字符串时造成的内存重分配
因为分配内存需要执行系统调用,而系统调用涉及到用户态与内核态的切换,一般比较耗时。所以为了减少内存分配的次数,往往会预先分配比需要的内存大的内存。其实这种思路在很多地方有用到。比如在内存池中,内存池会预先分配一块较大内存,需要的时候再从大内存中取;还有C++ Vector的扩容机制。
Redis使用两种方法减少内存分配次数。
一是空间预分配:
即当SDS需要扩容时,会额外多分配内存,以避免下一次SDS因为内存不够造成的内存分配。
这里具体分为两种情况,一是当SDS的长度需扩容超过1MB时,会额外多分配1MB的未使用空间。二是SDS的长度不超过1MB时,会额外分配与len一样大小的未使用空间,此时len与free相等。
二是惰性释放:
即当字符串缩短时,并不立即释放不需要的空间。而是留着以便以后要扩容时可以利用空闲内存。
3.二进制安全
SDS的API都是二进制安全的。不会因为数据中有特殊字符而出现异常。数据读入时是什么样,读出时就是什么样。
三.链表
当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现
Redis实现了自己的链表作为底层数据结构之一。Redis实现的是一个双向链表,通过list结构持有链表。
1.数据结构
链表节点:
typedef struct listNode{
struct listNode* prev; //前置节点
struct listNode* next; //后置节点
void* value; //节点储存的值
}
2.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;
四.整数集合
当一个集合只包含整数元素,且包含的整数元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
1.数据结构
typedef struct intset{
uint32_t encoding; //编码方式,即contents数组的类型,如int16_t或int32_t.
uint32_t length; //集合包含元素的数量
int8_t contents[]; //保存元素的数组,数组中的元素都是有序的,从小到大依次排列,且不包含重复项
}
2.升级策略
如果往一个已经把储存数组初始化为int16_t类型的整数集合中插入一个大小为32位的整数。那么就会引起该整数集合的升级操作。同理,往int32_t类型的数组中插入64位的整数时也一样。
具体操作为先进行空间的重分配,以便能储存32位的整数。比如原来一个数占16位,一共三个数就是16 * 3为48位,插入一个占32位的数,空间重分配则一共要分配32 * 4=128位。然后将原来int16_t类型的整数全部转化为int32_t类型储存在新分配的空间里。
使用升级策略的好处是能够提高整数集合的灵活性,使之不会成为只能储存单一类型的数据集合。二是能够节约内存,不用一开始就初始化为int64_t类型的数组,当int16_t类型能储存所有数据时,便只会使用int16_t类型的数组。
五.跳跃表
当一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,Redis就会使用跳跃表来作为有序集合的底层实现。在很多情况下,跳跃表的效率可以和平衡树相媲美。
跳跃表查找元素平均的时间复杂度为O(logn),最坏情况下为O(n)。
1.数据结构
跳跃表节点数据结构:
typedef struct zskiplistNode{
struct zskiplistNode* backward; //后退指针
double score; //分值
robj* obj; //成员对象
struct zskiplistLevel{
struct zskiplistNode* forward; //前进指针
unsigned int span; //跨度
}level[]; //层,一个节点可以有很多层
}zskiplistNode; //跳跃表节点
通过zskiplist结构持有跳跃表:
typedef struct zskiplist{
struct zskiplistNode *header,*tail; //表头节点和表尾节点
unsigned long length; //表中节点的数量
int level; //表中层数最大的节点的层数
}
2.跳跃表图解
如图为一个跳跃表:
其中L1,L2,L3等为节点的层;1,2,3等数字为层的跨度;跨度下的箭头表示前进指针(前进指针与跨度都是层的成员属性);1.0,2.0,3.0为节点分值;o1,o2,o3为节点成员对象;BW为后退指针。
- 每个节点的层的个数通过某种算法确定,范围在1到32之间
- 节点的成员对象是一个指针,它指向一个字符串对象
- 跳跃表节点根据分值大小在跳跃表中排序,分值大小相同的根据成员对象指向的字符串的字典序确定
- 跨度可以用于确定一个节点在跳跃表中的排位,从头节点出发通过层的前进指针访问到某个节点时,通过各个层时累加的跨度即是该节点在跳跃表中的位置
- 因为zskiplist结构有header,tail,length等属性,所以通过zskiplist持有跳跃表时要访问跳跃表的头节点与尾节点,获取跳跃表节点数量的时间复杂度都是O(1)。