Redis 数据结构SDS,Intset,Dict,Ziplist,Quicklist详解

大家好 我是积极向上的湘锅锅💪💪💪

Redis 数据类型String,List,Set,ZSet,Hash详解

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

SDS是Redis的string结构主要构成,本质是结构体(java里面是对象)
查看源码

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* buf保存的字节数 */
    uint8_t alloc; /* buf已申请的字节数 */
    unsigned char flags; /* 不同的SDS类型 */
    char buf[];
};

因为sdshdr5太小已经弃用,所以以sdshdr8 举例
当然不止这一种 还有sdshdr16,sdshdr832,sdshdr64,感兴趣的也可以看源码;
区别就是 sdshdr8最大保存为2的8次方-1,依次类推,sdshdr16,sdshdr832可以保存的bit逐渐增大

接下来看个例子 比如说一个“name”的sds结构如下
在这里插入图片描述
可以很清晰的看到长度为4,分配的字节数为4,flags为1(对应sdshdr8 ),buf数组里面存放的是name的字符串数组

那我们知道字符串数组是以\0结尾来表示结束,而这里是以len的长度来读取,也就是说len多长,我就读取多少个字符

如果不这样做,设想如果中间有\0字符,那就直接读取结束了,是不符合一个数据安全性的

SDS的动态扩容

如果想要在字符串后面追加字符串,首先会申请一个新的内存空间

  • 如果新字符串小于1M,则新空间为扩展后的空间*2+1,
  • 如果新字符串大于1M,则新空间为扩展后的空间+1M+1

以上过程称为内存的预分配

也就是说比如一个alloc为2的sds,加入一个长度为4的字符串,则最后alloc变为12,其中+1是最后的\0,alloc是不包含\0的

SDS的优点

  • 获取字符串长度的时间复杂度为O(1);
  • 动态扩容
  • 二进制安全
  • 减少内存分配的次数

2. Intset

看名字就知道是啥了,对没错就是整数类型的set,而且还具备长度可变,内部自动有序

源码:

typedef struct intset {
    uint32_t encoding;/* 编码格式,支持存放16位,32位,64位整数*/
    uint32_t length;/* 元素个数 */
    int8_t contents[];/* 整数数组,保存集合数据 */
} intset;

最为关键的是contents[],那这个难道不是存放整数元素的吗,还真不是,可以看到contents[]是int8_t修饰的,难道contents[]就只能存放int8_t类型的吗,Redis肯定不会那么局限,在这里,contents[]只是充当了数组的首指针,作用是和咱们的编码格式一起基于数组下标进行寻址操作

那又该如何寻址?

因为数组是内存地址连续的,所以根据这个特性,我们已知第一个元素的地址,那找出其他元素就很容易了

比如第一个元素地址为0x001,采用的编码格式为16位,那就是每个数组下标占俩个字节,所以第二个元素地址就是0x003

得出寻址公式为: startPtr+(sizeof(编码格式)* index)= NowPtr

2.1 IntSet升级

如果要存放的元素超过了编码格式规定的字节数,就可以触发IntSet升级,升级到合适的编码大小
注意,每一个元素都要进行升级,也就是说个index下标的字节数都要跟着增大(符合寻址公式)
所以需要倒序将元素扩容到正确位置(正序会覆盖掉其他元素)

可以理解为一个胖子想加进来,但是要保证每一个人所占的空间是一样的,位置的顺序也不能变,所以其他人也跟着变胖

扩容后的新内存空间大小 = (length+1)* 新编码格式大小

IntSet的优点

  • 保证元素唯一,有序
  • 节省内存空间
  • 底层采用二分查找查询

3. Dict(字典)

在我们的Redis当中,需要根据键来进行增删查改的时候,就需要用到Dict来实现,Redis字典dict 的底层实现,其实和Java中的ConcurrentHashMap思想非常相似,在解决哈希冲突的时候,也就是用数组+链表实现了分布式哈希表

Dict由三部分组成:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

  • DictHashTable
    这里的size和sizemask用于计算数组的索引位置
typedef struct dictht {
	// entry数组,数组中保存的是指向entry的数组的指针
	dictEntry **table;
	// 哈希表大小
	unsigned long size;
	// 哈希表大小的掩码,总是等于 size-1
	unsigned long sizemask;
	// 该哈希表已有的节点的数量
	unsigned long used;
} dictht;

  • DictEntry
typedef struct dictEntry {
	// 键
	void *key;
	union {
		void *val;
		uint64_t u64;
		int64_t s64;
		double d;
	} v; // 值:可以为union中的任意类型
	// 下一个 Entry 的指针
	struct dictEntry *next;
}dictEntry;

Dict
这是我们真正所用到的字典dict

typedef struct dict {
	// dict类型,内置不同的hash函数
	dictType *type;
	// 私有数据,在做特殊hash运算时用
	void *privdata;
	// 一个Dict包含两个哈希表,其中一个是当前数据,另一个一般为控,rehash时使用
	dictht ht[2];
	// rehash的进度,-1表示未进行
	long rehashidx;
	// rehash是否暂停,1暂停,0继续
	int16_t pauserehash;
} dict;

3.1 Dict扩容

Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size),满足以下两种情况时会触发哈希表扩容:

  • LoadFactor >=1,并且服务器没有执行BGSAVE或者BGREWRITEAOF 等后台进程;
  • LoadFactor > 5
    扩容后的大小为第一个大与等于 used + 1的2n次方,比如之前是4,则扩容为8(8>5)

3.2 Dict收缩

Dict除了扩容以外,每次删除元素时,也会对负载因子做检查,当LoadFactor (used*100/size) < 0.1时,最小不会小于4,会做哈希表收缩

3.3 Dict的rehash

这也是Dict的一个难点,里面的设计思想也很巧妙,Dict的rehash并不是一次性完成的。他是渐进式的完成,所以又称为渐进式rehash
流程如下:

  1. 计算新hash表的size,值取决于当前要做的是扩容还是收缩:
  • 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的 2^n
  • 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n(不得小于4)
  1. 按照新的size申请内存空间,创建dictht,并赋值给dict.ht[1]
  2. 设置dict.rehashidx = 0,标示开始rehash
  3. 每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx是否大于-1,如果是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1]
  4. 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存
  5. 将rehashidx赋值为-1,代表rehash结束
  6. 在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空

详细可以参考这篇文章:Dict详解

优点:

  • 查询性能好

缺点:

  • 最大的问题就是内存的浪费,由于使用的是指针,内存不连续,可能会造成内存碎片,而且每一个指针也是占据大量的内存,总的来说是以空间换时间的做法

4. Ziplist

Ziplist是一种特殊的双端链表,是由一块连续的内存组成,不需要指针进行寻址,可以实现在任意一端进行压入/弹出操作,而且时间复杂度为O(1)
在这里插入图片描述
由图可以看出只要知道起始地址,就可以很快的找到尾节点的地址

在这里插入图片描述
这里只有entry的长度是不定的,其他都是固定的,4421的规律,为什么entry长度不固定,我们知道在Intset中每一个节点的的大小是根据编码格式固定的,而这里为的就是节省内存,舍弃不必要的内存浪费,内存利用率高

那问题来了,没有指针,大小不固定,怎么遍历?

来看Ziplist的Entry的属性

在这里插入图片描述

  • previous_entry_length:前一个节点的长度,占1个或5个字节(大于254字节用5个字节保存)
  • encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个,2个或5个字节
  • content:负责保证节点的数据

可以看出previous_entry_length记载的是前一个节点总的大小,那这里不就是dict的设计思想,只不过是Ziplis的Entry每一个都不固定,但是后一个Entry是记录了前一个Entry的大小,只要知道了后一个Entry的大小,就可以反推出前一个Entry的地址

总结:

  • 先由起始地址得到尾节点的地址
  • 根据Entry记录的前一个节点的长度,即可遍历整个Entry数组(逆向遍历)

这里encoding感兴趣的可以看看源码

优点:

  • 节省内存

缺点:

  • 遍历时间耗时长

  • ZipList的连锁更新问题
    假设所有的Entry恰恰好都是250字节,也就是在254的临界条件左右,这时在ZipList的首节点插入一个大小为254的Entry,这个时候首节点Entry的下一个节点的previous_entry_length的1字节就不够用了,需要扩容为5字节,那这造成的连锁反应将会是巨大的(新增,删除都可能导致连锁更新的发生)


5. QuickList

其实这是Ziplist的升级版,Ziplist虽然节省空间,但是如果Ziplist的节点较多,申请内存效率很低,所以QuickList诞生了

QuickList的是双端链表,每一个节点都是一个ZipList(经典加一层)

如图:
在这里插入图片描述
优化1:
QuickList可以对每一个Ziplist的长度做限制,Redis提供了一个配置项:list-max-ziplist-size

  • 如果值为正,则代表Ziplist允许的entry的个数最大值
  • 如果值为负,则代表Ziplist的最大内存大小,默认是-2,则表示每个Ziplist的内存占用不能超过8kb

优化2:
QuickList还可以对节点进行压缩,通过配置项list-compress-depth来控制

  • 0:特殊值,代表不压缩
  • 1:表示首尾节点各有一个不压缩,中间节点都压缩
  • 2:表示首尾节点各有俩个不压缩,中间节点都压缩
  • 依次类推

6. SkipList(跳表)

  • 元素按照升序排列存储
  • 节点可能包含多个指针,指针跨度不同

看源码:
在这里插入图片描述
当每一个node扩容,就是level数组新增一个层级,并且下一个指针的地址是指向的Node的地址,而不是层级的地址,找到一个层级的指针,就可以拿到下一个node的地址

所以,一个完整的SkipList如下

在这里插入图片描述

优点:
增删改查效率与红黑树一致,但是实现更简单,跟二分查询相似

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

owensweat

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值