Redis 是一个基于键值对(key-value)的分布式存储系统。它常用的类型主要是 String、List、Hash、Set、ZSet 这5种。通过学习其底层数据类型,来探究其存储过程。
redis底层数据结构主要有:简单动态字符串(SDS),链表,字典,跳跃表,整数集合,压缩列表。
1. 简单动态字符串
先看其底层源码
/*
* 保存字符串对象的结构
*/
struct sdshdr {
// 用于记录buf中已使用的空间长度
int len;
// 用于记录buf中剩余可用空间的长度(初次分配空间时,一般为0,字符串进行修改后会多出空余空间)
int free;
// 数据空间,用于存储字符串
char buf[];
};
补一张结构图
简单的对比一下java的String类型。
//数据空间,用于存储字符串
private final char value[];
//字符串的hash值
private int hash;
简单看这两个实现方式的话,除了sds多出2个字段的,其他并未太大差距。但是我们通过几个方法来看其性能上面的区别
1.1获取字符串长度
java字符串底层采用的是字符数组存储。使用长度为N+1 的字符串数组来表示长度为N 的字符串,所以为了获取一个长度为C字符串的长度,必须遍历整个字符串。而通过查看sds的源码我们可以看到有专门的一个字段len 来进行存储。直接就可以获取字符串长度
1.2 减少修改字符串时带来的内存重分配次数
java的字符串在进行修改时是会进行地址的重新分配的。而sds在字符串进行修改时也会进行分配。但是分配的时候会预留空闲空间。举个例子:我们需要对SDS进行拓展,则需要进行空间的拓展,这时候redis 会将SDS的长度修改为13字节,并且将未使用空间同样修改为1字节,在我们下次修改sds时。如果只需要增加一个字节。那redis则不需要在进行内存分配,就可以修改字符串。通过这种预分配策略,SDS将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次
2. 链表
redis的链表结构和java的linkedlist双端列表结构是相似的。
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
// 链表所包含的节点数量
unsigned long len;
} list;
其特性简单可以总结为:
1.双端:链表节点带有prev 和next 指针,获取某个节点的前置节点和后置节点的时间复杂度都是O(N)
2.无环:表头节点的 prev 指针和表尾节点的next 都指向NULL,对立案表的访问 时以NULL为截止
3.表头和表尾:因为链表带有head指针和tail 指针,程序获取链表头结点和尾节点的时间复杂度为O(1)
4.长度计数器:链表中存有记录链表长度的属性 len
5.多态:链表节点使用 void* 指针来保存节点值,并且可以通过list 结构的dup 、 free、 match三个属性为节点值设置类型特定函数。
3. 字典
其存储方式的本质还是hash散列表。我们查看其源码
//字典类
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privedata;
// 哈希表
dictht ht[2];
// rehash 索引
in trehashidx;
}
//dictht 类
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
}
//dictEntry 哈希表数组
typeof struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}
struct dictEntry *next;
}
其结构图如下所示
可以看出其结构方式和HashMap是集齐相似的。不同的在于其含有2个哈希表的数组。为何它要分配2个数组呢。主要是用于Rehash。我们可以对比java的HashMap的Rehash操作。
void resize(intnewCapacity)
{
Entry[] oldTable = table;
intoldCapacity = oldTable.length;
......
//创建一个新的Hash Table
Entry[] newTable =new Entry[newCapacity];
//将Old Hash Table上的数据迁移到New Hash Table上
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
void transfer(Entry[] newTable)
{
Entry[] src = table;
intnewCapacity = newTable.length;
//下面这段代码的意思是:
// 从OldTable里摘一个元素出来,然后放到NewTable中
for(intj = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if(e != null) {
src[j] =null;
do{
Entry<K,V> next = e.next;
inti = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}while (e != null);
}
}
}
redis分配方式也一样。它是将ht[0]中的数据转移到ht[1]中,在转移的过程中,需要对哈希表节点的数据重新进行哈希值计算。转移完成后将ht[0]释放,然后将ht[1]设置成ht[0],最后为ht[1]分配一个空白哈希表。为何要这样进行呢?,这是因为在实际开发过程中,reids的rehash 操作并不是一次性、集中式完成的,而是分多次、渐进式地完成的:
1、为ht[1] 分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
2、在几点钟维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash 开始
3、在rehash 进行期间,每次对字典执行CRUD操作时,程序除了执行指定的操作以外,还会将ht[0]中的数据rehash 到ht[1]表中,并且将rehashidx加一的
4、当ht[0]中所有数据转移到ht[1]中时,将rehashidx 设置成-1,表示rehash 结束。
采用渐进式rehash 的好处在于它采取分而治之的方式,避免了集中式rehash 带来的庞大计算量。
4. 跳跃表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表是一种随机化的数据,跳跃表以有序的方式在层次化的链表中保存元素,效率和平衡树媲美 ——查找、删除、添加等操作都可以在对数期望时间下完成,并且比起平衡树来说,跳跃表的实现要简单直观得多。
typedef struct zskiplist {
//表头节点和表尾节点
structz skiplistNode *header,*tail;
//表中节点数量
unsigned long length;
//表中层数最大的节点的层数
int level;
}zskiplist;
typedef struct zskiplistNode{
//层 level 数组可以包含多个元素,每个元素都包含一个指向其他节点的指针
struct zskiplistLevel{
// 前进指针:用于指向表尾方向的前进指针
struct zskiplistNode *forward;
//跨度:用于记录两个节点之间的距离
unsigned int span;
} level[];
//后退指针:用于从表尾向表头方向访问节点
struct zskiplistNode *backward;
//分值和成员:跳跃表中的所有节点都按分值从小到大排序。成员对象指向一个字符串,这个字符串对象保存着一个SDS值
double score;
//成员对象
robj *obj;
}
5. 整数集合
其实就是一个特殊的集合,里面存储的数据只能够是整数,并且数据量不能过大。
typedef struct intset{
//编码方式
uint32_t enconding;
// 集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}
intset 在默认情况下会帮我们设定整数集合中的编码方式,但是当我们存入的整数不符合整数集合中的编码格式时,就需要使用到Redis 中的升级策略来解决。根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间,将底层数组现有的所有元素都转换成新的编码格式,重新分配空间。将新元素加入到底层数组中。这样做尽可能的节约了内存资源。但是需要注意整数集合只支持升级操作,不支持降级操作。
6. 压缩列表
压缩列表是列表键和哈希键的底层实现之一
struct ziplist<T> {
int32 zlbytes; // 整个压缩列表占用字节数
int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
int16 zllength; // 元素个数
T[] entries; // 元素内容列表,挨个挨个紧凑存储
int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}
struct entry {
int<var> prevlen; // 前一个 entry 的字节长度
int<var> encoding; // 元素类型编码
optional byte[] content; // 元素内容
}
Redis对于每种数据结构、无论是列表、哈希表还是有序集合,在决定是否应用压缩列表作为当前数据结构类型的底层编码的时候都会依赖一个开关和一个阈值,开关用来决定我们是否要启用压缩列表编码,阈值总的来说通常指当前结构存储的key数量有没有达到一个数值(条件),或者是value值长度有没有达到一定的长度(条件)。任何策略都有其应用场景,不同场景应用不同策略。为什么当前结构存储的数据条目达到一定数值使用压缩列表就不好?压缩列表的新增、删除的操作平均时间复杂度为O(N),随着N的增大,时间必然会增加,他不像哈希表可以以O(1)的时间复杂度找到存取位置,然而在一定N内的时间复杂度我们可以容忍。然而压缩列表利用巧妙的编码技术除了存储内容尽可能的减少不必要的内存开销,将数据存储于连续的内存区域