Redis底层数据结构介绍

一:Redis中的五大对象(Object)以及底层数据结构实现

类型 编码 对象
REDIS_STRING REDIS_ENCODING_INT 使用整数值实现的字符串对象
REDIS_STRING REDIS_ENCODING_EMBSTR 使用embstr编码的简单动态字符串实现的字符串对象
REDIS_STRING REDIS_ENCODING_RAW 使用简单动态字符串实现的字符串对象
REDIS_LIST REDIS_ENCODING_ZIPLIST 使用压缩列表实现的 列表对象
REDIS_LIST REDIS_ENCODING_LINKEDLIST 使用双端链表实现的 列表对象
REDIS_HASH REDIS_ENCODING_ZIPLIST 使用压缩列表实现的哈希对象
REDIS_HASH REDIS_ENCODING_HT 使用字典实现的哈希对象
REDIS_SET REDIS_ENCODING_INTSET 使用整数集合实现的集合对象
REDIS_SET REDIS_ENCODING_HT 使用字典实现的集合对象
REDIS_ZSET REDIS_ENCODING_ZIPLIST 使用压缩列表实现的有序集合对象
REDIS_ZSET REDIS_ENCODING_SKIPLIST 使用跳跃表和字典实现的有序集合对象
  • 注:根据实际情况,Redis的对象会采用不同的底层数据结构实现。

二:Redis数据结构介绍

1. SDS(简单动态字符串)

1.1 数据结构

struct sdshdr {
	//记录buf数组中已使用字节数量
	//也就是sds中保存的字符串长度
	int len;
	//buf数组中未使用字节的数量
	int free;
	//字节数组,保存字符串
	char buf[];
};

Redis底层数据结构介绍_第1张图片
如上图:SDS中保存的字符串长度为5,未使用空间(可用空间)为0。且SDS遵循C字符串以’\0’结尾的惯例,保存‘\0’的1字节空间不计算在SDS的len属性里面,并为其额外分配1字节空间,以及添加其到字符串末尾等操作都是SDS函数自动完成,这样做可以直接重用C中的字符串库函数。

1.2 优化策划
由于SDS中的free属性,SDS中的数组长度不一定就是字符数量加一(’\0’),数组里面可以包含未使用的空间,由free记录,通过未使用空间SDS实现了空间预分配与惰性空间释放两种优化策略。

  • 空间预分配:如果需要对SDS进行空间扩展,程序不仅会为其分配修改所必须要的空间还会为其分配额外的未使用空间,额外分配的未使用空间大小由以下公式决定。
    (1) 如果对SDS修改后,SDS长度(即len大小)将小于1MB,那么程序会分配与len属性同样大小的未使用空间,即free与len大小相同。例,修改后的SDS长度(len)为13,那么程序也会多分配13字节的未使用空间(free),SDS的buf实际长度为13+13+1=27字节(额外的1字节用来存储‘\0’)
    (2)如果对SDS修改后,SDS长度将大于等于1MB,那么程序会分配1MB的未使用空间。例如,修改后SDS长度(len)变为30MB,那么程序会多分配1MB的未使用空间(free),SDS的buf实际长度为30MB+1MB+1byte。

  • 惰性空间释放:SDS的API需要缩短保存的字符串时,程序不会立即使用内存重分配来回收缩短后多出来的空间,而是使用free记录这些未使用的空间,这样避免了缩短字符串时所需的内存重分配操作,并未将来可能有的增长操作提供了优化,同时,SDS也提供了相应的API让我们再有需要时真正地释放未使用的空间。

1.3 二进制安全
由于C字符串是以空字符来表示结尾,所以C字符串中不能包含该字符,这个限制使C字符串只能保存文本数据,而不能保存图片、音频、视频、压缩文件这样的二进制数据,而由于SDS中的len属性,从而没有这些限制,Redis可以用其保存一系列的二进制数据。
1.4 兼容部分C字符串函数
虽然SDS的API都是二进制安全的,但他们一样遵循C字符串以空字符结尾的惯性,这样就可以让那些保存文本数据(其他类型的数据中可能含有空字符)的SDS可以重用一部分库定义的库函数。

1.5 对比C字符串SDS的优点

  • 常数复杂度获取字符长度
  • 杜绝缓冲区溢出
  • 减少修改字符串长度时所需的内存重分配次数
  • 二进制安全
  • 兼容部分C字符串函数

2.链表(双端链表)

2.1 数据结构

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底层数据结构介绍_第2张图片
如图:Redis的链表实现的特性可以总结如下:

  • 双端:Redis的列表为双端链表,每个节点中由prev和next指针分别指向前置节点和后置节点
  • 无环:表头节点的prev指针指向NULL,表尾节点的next指针也指向NULL
  • 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表表头结点和表尾节点的复杂度都为O(1)
  • 带链表长度计数器:list结构中的len属性表示链表长度,程序获取链表长度复杂度为O(1)
  • 多态:链表节点使用void*指针保存节点的值,并且可以通过list结构的dup、free、match三个属性(函数指针)为节点值设置类型特定函数 (不同类型值函数实现可能不同-多态),所以链表可以用于保存各种不同类型的值。

3.字典(dict)

Redis字典使用哈希表作为底层实现,采用链地址法解决哈希冲突,以下分别为哈希表节点,哈希表以及字典的数据结构。
3.1 数据结构

//哈希表节点
typedef struct dictEntry{
	//键
	void *key;
	//值
	union{
		void *val;
		uint64_t u64;
		int64_t s64;
	}v;
	//指向下个哈希表节点,形成链表
	struct dictEntry *next; 
}dictEntry;
//哈希表
typedef struct dictht{
	//哈希表数组
	dictEntry **table;
	//哈希表大小
	unsigned long size;
	//哈希表大小掩码,用于计算索引
	//总是等于size-1
	unsigned long sizemask;
	//该哈希表已有节点的数量
	unsigned long used;
}dictht;
//字典
typedef struct dict{
	//类型特定函数
	dictType *type;
	//私有数据
	void *privdata;
	//哈希表
	//ht数组包含两个哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]只会在对ht[0]进行rehash时使用
	dictht ht[2]; 
	//rehash索引,他记录了rehash目前的进度
	//当没有在进行rehash时,值为-1
	int trehashidx;
}dict;
  • 注:type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的,type属性是一个指向dictType结构的指针,每个该结构保存了一组用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数,而privdata属性则保存了需要传给这些函数的可选参数。
typedef struct dictType{
	//计算哈希值的函数
	unsigned int (*hashFunction)(const void *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 *pravdata, void *obj):
};

Redis底层数据结构介绍_第3张图片
如上图所示,为一个普通状态下(没有进行rehash)的字典,size表示哈希表的数组大小为4,used表示共有两个元素。

3.2 哈希算法
当需要将一个新的键值对添加到字典里或根据键查找相应的值,就需要根据键计算出哈希值和索引值,然后在进行后续操作。
Redis计算哈希值和索引值的方法如下:

  • hash = dict->type->hashFunction(key); //使用字典设置的哈希函数计算key的哈希值
  • index = hash & dict->ht[x].sizemask;//使用哈希表的sizemask属性和哈希值计算出索引值,情况不同,ht[x]可以是ht[0]或ht[1]

3.3 rehash(重新散列)
随着操作不断进行,哈希表保存的键值对会逐渐的增多或减少,为了使哈希表的负载因子维持在一个合理的范围,当哈希表保存的键值对过多或过少时,就需要对哈希表的大小进行相应的扩展或者收缩。
扩展和收缩通过rehash操作来完成,步骤如下:

  • 为字典的ht[1]哈希表分配空间,空间大小取决于要执行的操作以及ht[0]当前包含的键值对数量(ht[0].used属性)

    • 如果执行扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used2的2^n.(例:ht[0].used为4 42 = 8,而8(2^3)恰好是第一个大于等于4*2的n次方)
    • 如果执行收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2^n
  • 将保存在ht[0]中的所有键值对rehash到ht[1]上面,即重新计算哈希值与索引值,然后根据索引值将键值对放到指定位置。

  • 当将所有的键值对都迁移到ht[1]之后,释放ht[0],将ht[1]置为ht[0],并在ht[1]新创建一个新的哈希表,为下一次rehash做准备。

注: 负载因子 = 键值对个数/哈希表大小

3.4 渐进式rehash
因为扩展和收缩哈希表需要将ht[0]中保存的所有键值对rehash到ht[1]中,但是,这个rehash动作并不是一次性,集中式的完成,而是分多次、渐进式的完成,因为如果保存的键值对数量过多,如果一次性rehash,庞大的计算量可能会导致服务器在一段时间内停止服务。
步骤如下:

  • 在开始rehash时,字典中维持一个索引计数器变量rehashidx,并将其置为0,表示rehash正式开始
  • 在rehash期间,每次对字典执行添加、删除、查找或者更新操作时,除了完成指定操作,还会将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当完成后++rehashidx。
  • 随着字典操作不断进行,最终ht[0]上所有键值对都会rehash到ht[1],这是将rehashidx置为-1,表示rehash操作完成。

注: 在rehash期间,字典会同时使用两个哈希表ht[0]和ht[1],删除、查找、更新等操作在两个哈希表进行,而新添加到字典的键值对一律保存到ht[1]上。

优点: 采取分而治之的方式,将所需计算工作均摊到对字典的每个添加、删除、查找、更新操作,从而避免集中式rehash而带来的庞大计算量。

4.跳跃表(skiplist)

4.1 简介: 跳跃表是一种有序数据结构,实质是一种可以进行二分查找的有序链表。它通过在每个节点中维持多个指向其他节点的指针(多级索引),从而达到快速访问节点的目的。其查找的平均时间复杂度为O(logN),最坏O(N)。还可以通过顺序操作来批量处理节点,在大部分情况下,他的效率可以媲美平衡树且实现更简单。
4.2 数据结构

//跳跃表节点
typedef struct zskiplistNode{
	//后退指针
	struct zskiplistNode *backward;
	//分值
	double score;
	//成员对象
	robj *obj;
	//层
	struct zskiplistLevel {
		//前进指针
		struct zskiplistNode *forward;
		//跨度
		unsigned int span;
	}level[];
}zskiplistNode;

注: 每个节点都包含一个level数组,数组大小只有当创建新节点的时候才会根据幂次定律随机生成一个介于1和32之间的值作为level数组的大小,加入大小为4,表示这个节点在前四层都出现。C语言数组索引从0开始,所以第一层为level[0]。跨度表示两个节点之间的距离。可以用来计算rank排名。

//跳跃表
typedef struct zskiplist{
	//表头节点和表尾节点
	struct  zskiplistNode *header;
	struct zskiplistNode *tail;
	//节点数量
	unsigned long long length;
	//表中层数最大的节点的层数
	int level;
}zskiplist;

Redis底层数据结构介绍_第4张图片
如上图所示为一个包含三个节点的跳跃表(头节点不算,类似于负无穷),其中第一个节点层数组大小为为4,第二个节点大小为2,第三个大小为5,分别表示第一个节点在前四层都有,第二个节点在前两层,而第三个节点在前5层。可表示为下图:
Redis底层数据结构介绍_第5张图片
跳跃表最大的特点就是在链表上可以使用二分查找,假设现在要找分值为2的节点,从最大层开始查找(跳跃表中记录了最大层数(当前为5)),从第五层开始找,先从头节点开始,如果后置节点存在且分数小于等于想要查找的,那么当前节点等于后置节点继续,代码如下:

zskiplistNode *pcur = zsl->header;
for(i = zsl->level - 1; i >= 0; --i)
{
	while(pcur->level[i].forward && pcur->level[i].forward->score <= score)
	{
		if(pcur->level[i].forward->score == score)
			//找到了
		else
			pCur = pCur->level[i].forward;
	}
}

查找过程简述如下:

  • 从五层开始,由于pCur->level[4].forward.score > 2,所以来到第四层,
  • 第四层,满足条件,进入while循环,pCur = 1(score为1的节点),继续,不满足条件,来到第三层
  • 第三层,不满足条件,来到第二层
  • 第二层,满足条件,且pcur->level[i].forward->score == 2,查找结束

5.整数集合(intset)

5.1 简介:整数集合就是Redis用来保存整数值的集合抽象的数据结构,底层实现其实就是一个动态有序(从小到大)的字节数组(根据编码将几个字节视为一个元素)。它可以存储int_16、int_32、int_64的整数值,并且不会有重复元素。
5.2 数据结构

typedef struct intset{
	//编码方式
	uint32_t encoding;
	//元素数量
	uint32_t length;
	//保存元素的数组
	int8_t contents[];
};

5.3 升级

  • 每当向整数集合添加一个新元素时,如果新元素的类型长度比现有的元素类型长时,整数集合就需要先进行升级,然后才能添加新元素到整数集合。

升级整数集合并添加新元素分为三步:

  • 根据新元素类型,扩展整数集合底层数组的空间,并为新元素分配空间。
  • 将底层数组中的元素全部转换为新元素类型,并将转换后的元素放在正确的位置,且保证有序性质不变。
  • 将新元素添加到底层数组中

:如果添加新元素导致升级,那么新元素一定比现有所有元素大或者小,也就是说,新元素一定是放在底层数组第一个或最后一个。整数集合并不支持降级。

5.4 整数集合的优点

  • 提升灵活性
  • 节约内存:假如要让一个数组可以同时保存int_16、int32、int_64最简单的就是使用int_64类型的数组,但是存储的元素不管是int16还是32都使用int_64类型的空间保存,从而出现浪费内存的情况,而整数集合是在有需要时才去升级,这可以尽量节省内存。

6. 压缩列表(ziplist)

6.1 简介:

  • 压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构(知道起始地址,就可以根据偏移量找到所有元素)。

下面的表记录了各个部分的类型长度以及用途:
在这里插入图片描述

属性 长度 作用
zlbytes 4字节 记录压缩列表所占字节总数
zltail 4字节 记录尾节点距起始地址有多少字节,可以通过该属性不用遍历确定尾节点地址
zllen 2字节 当该值小于65535时,表示压缩列表的节点数量,等于时,数量需要遍历压缩列表才能得出
entryX 不定 压缩列表的节点,节点长度由保存内容决定
zlend 1字节 特殊值0xff(十进制255),标记压缩列表的末端

6.2 压缩列表节点的构成
在这里插入图片描述

  • 如上图,压缩节点都由三部分构成, 其中previous_entry_length表示前一个节点的长度(如果长度小于254,那么该字段用1个字节保存,如果大于254,该字段用5个字节保存上一个节点的长度),encoding表示当前节点的编码(根据这个可以知道当前节点保存的具体是什么),conten用来保存节点的内容,可以保存一个字节数组或者一个整数值。

:一个节点的长度包含这三个部分。
6.3 连锁更新

  • 假设现在有这样一种情况,在一个压缩列表中,有多个连续的、长度介于250~253字节的节点e1 ~eN,因为这些节点长度都小于254,所以他们的previous_entry_length字段长度为1字节,即用一个字节保存前一个节点的长度,如果此时,我们将一个长度大于等于254字节的新节点设置为表头节点,那么新节点将是e1的前置节点,由于新节点大于等于254字节,所以e1需要用5字节保存前置节点的长度,就需要扩容4字节,这样自己的长度250 ~253 + 4 字节也将大于等于254,他的下一个节点e2也需要扩容,e2的扩容将会引发e3扩容,e3也会引发e4,为了让每个节点的privious_entry_length属性都符合压缩列表对节点的要求,程序需要不断地对压缩列表执行空间重分配操作,直到eN为止,将这种在特殊情况下产生的连续多次空间扩展操作称为“连锁更新”。除了增加节点,删除节点可能也会引发连锁更新。
    Redis底层数据结构介绍_第6张图片

如上图,big节点大于等于254字节,e1到eN都小于254,且small也小于,当删除small后,也会引发连锁更新。

你可能感兴趣的:(redis)