Redis底层数据结构详解

本文详细解读了Redis中关键的底层数据结构,如SDS(简单动态字符串)、链表、哈希表、字典、跳表、整数集合和压缩列表,以及它们的设计特点和内存管理策略,揭示了Redis高效性能背后的实现原理。
摘要由CSDN通过智能技术生成

Redis底层数据结构详解

简单动态字符串

在Redis中字符串是用简单动态字符串(SDS)存储的,并不是C中的字符串,后面会对比二者的区别。
1. SDS的定义

struct sdsstr{
	int len;   //buf数组已使用字节的数量
	int free;  //buf数组未使用字节的数量
	char buf[];//字节数组,用于保存字符串
}

注:buf数组存的还是C字符串,要有一个空字符’\0’作为结尾。

2. SDS与C字符串的区别

  1. 常数复杂度获取字符串长度
    C字符串获取字符串长度:O(n)
    SDS获取字符串长度:O(1)
    这有什么好处呢?
    Redis把获取字符串长度的复杂度降低到了O(1),这保证了获取字符串长度的操作不会成为Redis的性能瓶颈。
  2. 杜绝缓冲区溢出
    C字符串不会记录自身长度,就会很容易造成缓冲区溢出。
    举个例子:char *strcat(char *dest, const char *src),这个函数是拼接两个字符串用的,例如s1=“pipilong”,s2=“keji”,这两个字符串相邻:
    执行前内存:p i p i l o n g \0 k e j i \0 然后执行strcat(s1," Cluster"),s1没有分配足够的空间,执行后将会导致数据溢出到s2的空间中。
    执行后内存:p i p i l o n g C l u s t e r \0 直接将s2的内容给覆盖掉了,如果程序调用s2,则会出现错误。
    SDS的API中有一个拼接字符串的函数sdscat,它会先检查给的SDS的空间是否足够,如果不够的话进行扩容。扩容的具体操作,会在下一小节具体讲解。
  3. 减少修改字符串时带来的内存重分配次数(SDS的扩容机制)
    首先我们先看下C字符串,如果每次增长或缩减C字符串,程序要对保存的这个C字符串数组进行一次内存重分配操作:
  • 如果是增长字符串的操作,程序会通过内存重分配扩展底层数组空间大小,如果没有这一步,将会产生缓冲区溢出。
  • 如果是缩短字符串的操作,程序会通过内存重分配释放掉字符串不再使用的那部分空间,如果没有这一步,将导致内存泄漏。

内存重分配涉及到复杂的算法,可能要执行系统调用,是一个比较耗时的操作。
Redis中通常会有很频繁的字符串修改操作,那么在频繁的修改场合中,每变一次字符串长度都要执行一次内存重分配操作,这样会对系统性能造成严重影响。
SDS通过实现空间预分配和惰性空间释放两种策略来解决这个问题:

1. 空间预分配:
2. 惰性空间释放:

  1. 二进制安全
    C字符串不能保存像图片、音频这样的二进制数据。C字符串是通过’\0’来分割每一个字符串的,那么程序在读入空字符串时会被误认为字符串结尾。SDS会通过存在len属性的长度读取字符串,就不会产生二进制安全问题了。

链表

链表作为一种很常用的数据结构,在Redis中使用也是很广泛,例如:发布订阅、客户端的状态信息等都用到了链表。Redis中构建了自己的链表实现,我们讨论一下Redis中是怎么实现链表的。
1. 链表和链表节点的定义

  1. 链表节点定义:
typedef struct listNode {
	struct listNode *prev;	//前置节点
	struct listNode *next;	//后置节点
	void *value;			//节点的值
} listNode;
  1. 链表定义:
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;

2. Redis的链表实现的特征

  • 双端:链表节点有prev和next指针。
  • 无环:表头节点的prev指针和表尾节点的next指针都为null,链表的终点都是null。
  • 有表头指针的表尾指针:获取链表的表头节点和表尾节点的复杂度为O(1)。
  • 带链表长度计数器:获取链表中节点数量的复杂度为O(1)。
  • 多态:链表节点用void* 保存节点值,并且dup、free、match这三个函数的参数和返回值都为void * 类型,所以链表能保存各种不同类型的值。

字典

就是我们经常说的映射(map),它是一种保存键值对的数据结构,Redis的数据库底层就是用到了这种数据结构,C中是没有内置这种数据结构的,Redis构建了自己的字典实现。
1. 哈希表节点、哈希表和字典的定义

  1. 哈希表节点定义:
typedef struct dictEntry{
	void *key;				//键
	union{
		void *val;
		uint64_t u64;
		int64_t s64;
	} v;					//值
	struct dictEntry *next; //指向下一个哈希表节点,用来解决hash冲突问题
} dictEntry;
  1. 哈希表定义:
typedef struct dictht{
	dictEntry **table;		//哈希表的数组
	unsigned long size;		//哈希表大小
	unsigned long sizemask;	//哈希表大小掩码,用来计算下标索引用的
	unsigned long used;		//该哈希表已经有的节点数量
} dictht;
  1. 字典定义:
typedef struct dict{
	dictType *type;	//类型特定函数
	void *privdata;	//私有数据
	dictht ht[2];	//哈希表,两个,为什么两个后续会讲
	int trehashidx;	//rehash索引,rehash用的,不在rehash时,为-1,rehash是什么后文会讨论
} dict;
  1. dictType定义:
typedef struct dictType{
	unsigned int (*hashFunction)(const void *key);		//计算hash值的函数
	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 *privdata, void *obj);	//销毁值的函数
}

2. 哈希算法
当往字典中添加一个新的键值对的时候,首先会计算键的哈希值,用字典中自带的函数计算,然后根据计算出的哈希值得出索引。
举个例子:加入要把k0,v0加入到字典中

  1. hash = dict->type->hashFunction(k0);
  2. index = hash&dict->ht[0].sizemask
    字典是作为数据库的底层实现的,Redis使用MurmurHash2算法来计算键的哈希值。

3. 解决键冲突
当有两个及以上的键值对分配到同一个哈希桶上,就会产生哈希冲突问题。Redis的哈希表是通过拉链法来解决哈希冲突问题的。Redis的哈希表中因为没有指向链表表尾的指针,所以采用头插法插入新的节点,复杂度是O(1)

4. rehash
随着操作不断地进行,哈希表中的键值对会逐渐增多或减少,为了让哈希表的负载因子维持在一个合理的范围内,当哈希表保存的键值对太多或太少时,就会对哈希表的大小进行相应的扩展或收缩。
rehash主要有三步:

  1. 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作和ht[0]当前含有的键值对。
  • 扩展操作:ht[1] 的大小等于第一个大于等于ht[0].used*2的2n
  • 收缩操作:ht[1] 的大小等于第一个大于等于ht[0].used的2n
  1. 将保存在ht[0]中的所有键值对rehash到ht[1]上,rehash是重新计算哈希值和索引值。
  2. 当ht[0]的所有键值对都迁移到ht[1]中后,释放ht[0],将ht[1]设置为ht[0],为ht[1]创建一个空的哈希表,为下一次rehash做准备

哈希表扩展或收缩的条件:

  1. 服务器当前没有执行bgsave命令或者bgrewriteaop命令时,并且哈希表的负载因子大于等于1
  2. 服务器当前正在执行bgsave命令或者bgrewriteaop命令时,并且哈希表的负载因子大于等于5

为什么要根据bgsave命令或bgrewriteaop命令是否执行,服务器执行操作所需要的负载因子不同呢?
因为在执行bgsave或bgrewriteaop命令时,Redis会创建一个子进程来执行这些操作,重点是有很多操作系统会使用写时复制技术优化子进程的使用效率,服务器会通过提高负载因子,避免在存在子进程时进行哈希表rehash操作,最大限度的节约内存。
如果哈希表的负载因子小于0.1,程序会自动开始进行收缩操作。
5. 渐进式rehash
哈希表扩展或收缩会将ht[0]中的所有键值对rehash到ht[1]中。如果一次性全部将这些键值对rehash到ht[1]中,这么大的计算量可能会让Redis服务器一段时间内暂停服务,为了避免rehash对服务器带来的影响,这个操作是分多次、渐进式进行。
渐进式rehash操作:

  1. 为ht[1]分配空间
  2. 维护一个索引计数变量rehashidx,将它设置为0,表示rehash正式开始
  3. 在rehash进行期间,每次对字典的操作,程序除了执行这个指定的操作外,还会顺便将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],rehash操作完成后,rehashidx值加一
  4. 随着字典操作不断进行,在某个时间点ht[0]的所有键值对都会rehash到ht[1]上,这时将rehashidx的值设为-1,表示完成

这样就避免了集中式rehash带来的庞大的计算量。
删除、查找、更新操作会在两个表上都进行。而增加操作只在ht[1]上进行。

跳表

跳表是什么?它是一种有序的数据结构,它通过在每个节点中维护多个指向其它节点的指针,从而达到快速访问的目的,它的访问时间复杂度为:O(logn)。
1. 跳表节点和跳表的定义

  1. 跳表节点的定义:
typedef struct zskiplistNode{
	struct zskiplistNode *backward; //后退指针,干嘛用的?稍后讨论
	double score;					//分值
	robj *obj;						//成员对象
	struct zskiplistLevel {
		struct zskiplistNode *forward; //前进指针
		unsigned int span;			   //跨度
	} level[];						   //是一个数组,有多个这样的指针
} zskiplistNode;
  1. 跳表的定义:
typedef struct zskiplist{
	struct zskiplistNode *header, *tail; //表头节点和表尾节点
	unsigned lnog length;				 //表中节点的数量
	int level;							 //表中层数最大的节点的层数
} zskiplist;

2. 对zskiplistNode结构中每个属性详解

  1. zskiplistNode
  • 层:level数组中有多个元素,每个元素都包含了一个指向其它节点的指针,通过这些指针来加快访问速度。层数越多访问其它节点的速度就越快。每次创建一个新的跳表节点的时候,根据幂次定律,随机生成一个1~32大小之间的level数组,这个大小也就是层的高度。
  • 前进指针:每层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点。
  • 跨度:记录两个节点之间的距离。跨度越大,相距的距离就越远。
  • 后退指针:用于从表尾向表头方向访问节点,每个节点只有一个后退指针,每次只能往后退到前一个结点。
  • 分值和成员:是一个double类型的浮点数,跳表中所有节点都按照这个分值从小到大进行排序。成员对象是一个指针,它指向字符串对象,字符串对象保存着一个SDS值。

整数集合

整数集合是集合键的底层实现之一,当一个集合中全部都是整数时,并且这个集合的元素不多时,Redis就会用整数集合这个数据结构当作集合键的底层实现。
1. 整数集合的定义

typedef struct intset {
	uint32_t encoding; //编码方式
	uint32_t length;   //集合包含的元素长度
	int8_t contents[]; //保存元素的数组
} intset;

contents数组是整数集合的底层实现,整数集合的每个元素都是contents数组的一个数据项,各个项在数组中按照值的大小从小到大有序地排列,并且数组中不能有重复项。
2. 升级
我们要添加一个新的元素到整数集合中,并且这个新元素的类型比整数集合中所有元素的类型都要长,整数集合需要进行升级,才能把这个元素放进去。
升级整数集合分为三步进行:

  1. 根据新元素的类型,扩展底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组中所有元素都转换成与新元素相同的类型,将转换后的元素放到正确位置上,并且要保证元素间相对位置不变。
  3. 将新元素添加到底层数组里面。

升级的好处:

  1. 提升灵活性:Redis是用C写的,C中一般一个数组中的元素不允许有不同类型的元素,所以我们只会将一种元素放到数组中。我们通过升级的方式让整数集合适应新元素的类型。
  2. 节约内存:只有在我们需要的时候才进行升级,这样一个数组既能保存16、32位,也能保存64位的数据,保证了没有32、64位数据的情况下,数组只为每个元素分配16位大小就可以了,很好的节省了内存。

3. 降级
整数集合不支持降级,一旦对数据进行了升级,编码就要一直保持升级的状态。

压缩列表

压缩列表是列表键和哈希键的底层实现之一。压缩列表就是为了节省内存而开发的数据结构,是一系列特殊编码的连续内存块组成的顺序型数据结构,它可以有多个节点,每个节点可以是一个字节数组或整数值。
1. 压缩列表和压缩列表节点的定义

  1. 压缩列表节点
    previous_entry_length | encoding | content
  2. 压缩列表
    zlbytes | zltail | zllen | entry1 | entry2 | entryN | zlend

2. 解析压缩列表节点的每个字段

  1. previous_entry_length:
    该字段记录了前一个压缩列表节点的长度。
    这个字段的长度是1字节或5字节:

1字节情况:前一个节点的长度小于254字节时,该字段为1字节。
5字节情况:前一个节点的长度大于等于254字节时,该字段为5字节,第一个字节为0xfe,后面四个字节保存前一个节点的长度。

因为每个节点保存了前一个节点的长度,就很容易计算出前一个节点的起始地址,压缩列表从表尾向表头遍历操作就是基于这个原理的。表尾节点位置很好找到,用zltail属性,然后再根据每个节点的previous_entry_length计算前一个节点的起始位置。
2. encoding:
该属性保存值的编码及长度。
前两位是00、01、10是字节数组编码。前两位是11是整数编码。
3. content:
这个属性负责保存节点 的值。

3. 连锁更新
我们在压缩列表中增加节点或删除节点可能会导致连锁更新。这个连锁更新会涉及到我们前面讨论的previous_entry_length属性。
举个例子:加入我们有一些节点,每个节点的大小为250~253字节,那么当在列表头部插入一个大于等于254字节的节点时,它的下一个节点因为previous_entry_length为一字节,不能保存其长度,所以要扩容为5字节,则扩容后该节点变为254~257字节之间,同理后面会连续扩容,导致连锁更新。
删除也可能产生连锁更新,有这么一种情况:一个大的节点大于等于254,它后面一个小的节点小于254,再后面都是250~253字节之间的节点,那么当删除这个小的节点后,后面因为其previous_entry_length属性都是1字节,所以要扩容,同理一次都要扩容,发生连锁更新。
连锁更新最坏的情况下可能发生n次空间重分配,每次空间重分配最坏复杂度为O(n),所以连锁更新最坏的复杂度为O(n2) 。

对象

上面介绍了Redis中实现的多种数据结构,每种对象都最少用到了一种数据结构。在不同的场景下,不同的对象可以应用不同的数据结构,从而优化了对象在不同场景下的使用效率。
Redis对象基于引用计数技术的内存回收机制。
Redis的对象有访问时间的记录信息,这个信息可以用于计算键的空转时长。
1. 对象的结构

typedef struct redisObject{
	unsigned type:4; 		//类型
	unsigned encoding:4;    //编码
	void *ptr;				//指向底层实现数据结构的指针
}

2. 类型

类型常量对象的名称
pedis_string字符串对象
pedis_list列表对象
pedis_hash哈希对象
pedis_set集合对象
pedis_zset有序集合对象

3. 编码

编码常量编码对应的底层数据结构
pedis_encoding_intlong类型的整数
pedis_encoding_embstrembstr编码的sds
pedis_encoding_raw简单动态字符串sds
pedis_encoding_hashtable字典
pedis_encoding_linkedlist双端链表
pedis_encoding_ziplist压缩列表
pedis_encoding_intset整数集合
pedis_encoding_skiplist跳跃表和字典

字符串

字符串对象的编码可以是int、raw、embstr

  • 如果字符串对象保存是一个整数,则编码为int
  • 如果字符串对象保存是一个字符串且长度大于39字节,则编码为raw
  • 如果字符串对象保存是一个字符串且长度小于等于39字节,则编码为embstr

列表

列表对象的编码可以是ziplist或linkedlist

  • 列表中所有字符串元素长度小于64字节并且数量小于512个,使用ziplist编码
  • 不满足上面两个条件其一,使用linkedlist编码

哈希

哈希对象的编码可以是ziplist或hashtable

  • 哈希对象中所有键值对的键和值的字符串长度都小于64字节,并且数量小于512个,用ziplist编码
  • 不满足上面两个条件其一,使用hashtable编码

集合

集合对象的编码可以是intset或hashtable

  • 所有元素都是整数且数量不超过512个,使用intset编码
  • 不满足上面两个条件其一,使用hashtable编码

有序集合

有序集合的编码可以是ziplist或skiplist

  • 有序集合保存的元素数量小于128个且所有元素长度都小于64字节,使用ziplist编码
  • 不满足上面两个条件其一,使用skiplist编码

参考

  • 《Redis设计与实现》黄键宏 著
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值