文章目录
动态字符串(SDS)
概念
Redis
是使用C
语言实现的,C语言字符串底层是字符数组,结束使用/0
来表示,但Redis
并没有直接使用C
语言的字符串,因为C
语言字符串存在多个问题:
- 获取字符串长度需要运算
- 非二进制安全
- 不可修改(保存在常量池中,是不能修改的)
所以Redis
创建了一种新的字符串结构,称之为简单动态字符串
Redis
是使用C
语言实现的,其中SDS
是一个结构体
Redis
源码:
几个重点:
uint8_t
:无符号8
比特位整型,所以buf
字符数组中最多保存2^8-1=254
个字符(此处是sdshdr8
类型的SDS
)Unsigned char flags
:指代SDS
的五种类型—SDS_TYPE_5
,SDS_TYPE_8
,SDS_TYPE_16
,SDS_TYPE_32
,SDS_TYPE_64
所以,一个SDS
字符串的结构如下:
SDS
特点
SDS
之所以叫做动态字符串,是因为它具备动态扩容的能力
SDS
的优势
- 获取字符串长度的时间复杂度为
O(1)
- 支持动态扩容
- 减少内存分配次数(因为做了内存的预分配)
- 二进制安全(不以
\0
为结束标志,而是以len
属性来维护一个字符串)
IntSet
概念
IntSet
是Redis
中set
集合的一种实现方式,基于整数数组来实现,并具备长度可变,有序等特征
uint32_t length
:contents[]
数组中的元素个数;int8_t contents[]
:存放整数数据encoding
包含三种模式,表示存储整数的大小;
IntSet的特点
升序
为了方便查找,Redis
会将intset
中所有的整数按照升序依次保存在contents
数组中,结构如图:
统一的编码格式
为了快速定位数据,统一编码格式后,可以根据索引下标和第一个数据的内存地址,算出偏移量.
这里的理解方式如下:
我们获得了第一个数据的内存地址之后,由于编码方式是统一的,那么这里的每一个整数数据都是占有2个字节,那么第三个数据的偏移量为2*2
个字节,这样我们就可以直接得出第三个数据的地址,而不需要进行遍历(这也就是我们的索引要从0
开始的原因)
计算公式:
startptr+(sizeof(encoding)*index)
刚才的例子中,所有数据可以使用16Byte
来表示,那么如果这时加入一个数据,无法使用16Byte
来表示了,例如:5000
,该怎么办?这就涉及我们的IntSet
自动升级了
IntSet
自动升级
升级的流程:
- 升级编码为
INTSET_ENC_INT32
,每个整数占4字节,并按照新的编码方式以及元素个数扩容数组 - 倒序依次将数组中的元素拷贝到扩容后的正确位置
- 将待添加的元素放到数组末尾
- 最后将
intset
的encoding
属性改为INTSET_ENC_INT32
,将length
属性改为4
Dict
概念
我们知道Redis
是一个键值型(Key-Value Pair
)的数据库,我们可以根据键实现快速的增删改查.而键与值的映射关系正是通过Dict
来实现的.
Dict
是由三部分组成:哈希表(DictHashTable
),哈希节点(DicEntry
),字典(Dict
)
当我们向Dict
添加键值对的时候,Redis
首先根据Key
计算出hash
值(h
),然后利用h&sizemask
(由于size=sizemask-1
与h%size
的效果是一样的)来计算元素应该存储到数组中哪个索引的位置
下图是这三部分结构体的设计:
这三者的关系是:
Dict特点
Dict的伸缩
Dict的扩容
Dict
的扩容:Dict
中的HashTable
就是数组结合单向链表来实现的,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低.
Dict
在每次新增键值对时都会检查负载因子(LoadFactor=used/size
),满足以下两种情况时会触发哈希表扩容:
- 哈希表的
LoadFactor>=1
,并且服务器没有执行bgsave
或者bgrewriteaof
等后台进程(后台进程的对CPU
的消耗是很大的); - 哈希表的
LoadFactor>5
;
扩容的源码:
注意:这里的扩容大小为used+1
,但并不是代表就是扩容used+1
,而是扩容到第一个大于used+1
的2^n
Dict
收缩
Dict
收缩:每次删除元素时,也会对负载因子做检查,当LoadFactor<0.1
时,会做哈希收缩.
Dict
执行哈希收缩的原码:
Dict
的rehash
不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size
和sizemask
发生变化,而key
的查询与sizemask
有关.因此必须对哈希表中的每一个key
重新计算索引,插入新的哈希表,这个过程称为rehash
.过程是这样的:
- 计算新
hash
表的realeSize
,值取决于当前要做的是扩容还是收缩:
- 如果是扩容,则新的
size
为第一个大于等于dict.ht[0].used+1
的2^n
- 如果是收缩,则新的
size
为第一个的大于等于dict.ht[0].uesd
的2^n
(不得小于4
)
- 按照新的
realeSize
申请内存空间,创建dictht
,并赋值给dict.ht[1]
- 设置
dict.rehashidx=0
,标示开始rehash
- 将
dict.ht[0]
中的每一个dictEntry
都rehash
到dict.ht[1]
- 将
dict.ht[1]
赋值给dict.ht[0]
,给dict.ht[0]
初始化为空哈希表,释放原来的dict.ht[1]
内存
如图所示:
渐进式哈希
因为新增或者删除操作一般来说都是在**Redis
主线程中进行的,所以如果在主线程中触发了rehash
操作,在数据量很大的情况下,可能会导致出现主线程阻塞的情况.
所以在Redis
当中,rehash
是分多次,渐进式**完成的,因此称之为渐进式rehash
.流程如下:
- 计算新
hash
表的size
,值取决于当前要做的是扩容还是收缩:
- 如果是扩容,则新的
size
为第一个大于等于dict.ht[0].used+1
的2^n
- 如果是收缩,则新的
size
为第一个的大于等于dict.ht[0].uesd
的2^n
(不得小于4
)
- 按照新的
size
申请内存空间,创建dictht
,并赋值给dict.ht[1]
- 设置
dict.rehashidx=0
,标示开始rehash
(这里标记成0
的原因是进行数据迁移的时候,下标就是从0
开始的) - 将
dict.ht[0]
中的每一个dictEntry
都rehash
到dict.ht[1]
每次执行到新增,查询 ,修改,删除操作时,都检查一下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[0]
初始化为空哈希表,释放原来的dict.ht[0]
内存 - 将
rehashidx
赋值为-1
,代表rehash
结束 - 在
rehash
过程中.新增操作,则直接写入ht[1]
,查询,修改和删除则会在dict.ht[0]
和dict.ht[1]
依次查找并且执行.这样可以确保ht[0]
的数据只减不增,随着rehash
最终为空.
总结
Dict
的结构
- 类似
java
的HashTable
,底层是数组加链表来解决哈希冲突
2.Dict
包含两个哈希表,ht[0]
平常用.ht[1]
用来rehash
Dict
的伸缩
- 当
LoadFactor
大于5
或者LoadFactor
大于1
并且没有子进程任务时,Dict
扩容 - 当
LoadFactor<0.1
时,Dict收缩 - 扩容大小为第一个大于等于
user+1
的2^n
- 收缩大小为第一个大于等于
user
的2^n
5.Dict
采用渐进式has
h,每次访问Dict
时执行一次rehash
rehash
时ht[0]
只减不增,新增操作只在ht[1]
执行,其他操作在两个哈希表