大家好 我是积极向上的湘锅锅💪💪💪
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
流程如下:
- 计算新hash表的size,值取决于当前要做的是扩容还是收缩:
- 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的 2^n
- 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n(不得小于4)
- 按照新的size申请内存空间,创建dictht,并赋值给dict.ht[1]
- 设置dict.rehashidx = 0,标示开始rehash
- 每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx是否大于-1,如果是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1]
- 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存
- 将rehashidx赋值为-1,代表rehash结束
- 在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如下
优点:
增删改查效率与红黑树一致,但是实现更简单,跟二分查询相似