Hash是redis中常用的一种无序数据结构。结构类似HashMap。
具体结构如下:key field value
1、优缺点
1.1、优点
- 同类数据归类整合储存,方便数据管理。
- 相比于string操作消耗内存和CPU更小。
- 分字段存储,节省网络流量。
1.2、缺点
- 过期时间无法设置在field上,只能设置在key上
- redis集群下不适合大规模使用
2、Hash底层结构
2.1、ziplist-压缩列表
2.1.1、使用条件
- 哈希对象存储的键值对个数小于512个
- 哈希对象存储的键值对的键和值的字符串长度小于64字节
2.1.2、数据结构
见list
2.1.3、ziplist的优点
- 为什么不直接使用hashtable?
相比于hashtable,ziplist结构少了指针,减少了内存的使用。在redis中内存是非常珍贵的。
- 为什么不使用linkedlist?
ziplist存储时内存地址分配是连续,查询更快。
2.2、dict-字典
字典是在hash存储的数据不满足ziplist中的两个任意一个条件时,使用的数据结构。
由于dict
是一种常用的数据结构,但是c语言并不具备此种数据结构,因此redis
开发人员自己设计和开发了redis
的dict
结构。详细结构如下:
typedf struct dict{dictType *type;//类型特定函数,包括一些自定义函数,这些函数使得key和value能够存储void *private;//私有数据dictht ht[2];//两张hash表 int rehashidx;//rehash索引,字典没有进行rehash时,此值为-1unsigned long iterators; //正在迭代的迭代器数量
}dict;
type
和private
这两个属性是为了实现字典多态而设置额,当字典中存放着不同类型的值,对应的复制、比较函数也是不一样,这两个字段组合起来可以实现多态的方法调用。ht[2]
,两个hash
表rehashidx
,辅助变量,用于记录rehash
过程的进度,以及是否正在进行rehash
等信息。当此值为-1时表示该dict
没有进行rehash
操作。iterators
,记录此时dict
有几个迭代器正在进行遍历过程。
2.2.1、dictht-哈希表
从dict
结构上可以看出,dict
实际上就是对dictht
的操作,dictht
的具体结构如下:
typedf struct dictht{dictEntry **table;//存储数据的数组 二维unsigned long size;//数组的大小unsigned long sizemask;//哈希表的大小的掩码,用于计算索引值,总是等于//size-1unsigned long used; 哈希表中中元素个数
}dictht;
table
是一个dictEntry
类型的数组,用户真正存储数据。size
表示**table
这个数组的大小。sizemask
用于计算索引的位置,总是等于size-1
。used
表示**table
数组中已有的节点个数。
2.2.2、dictEntry
上面分析dictht
实际存储数据的是dictEntry
数组,其结构定义如下:
typedf struct dictEntry{void *key;//键union{void val;unit64_t u64;int64_t s64;double d;}v;//值struct dictEntry *next;//指向下一个节点的指针
}dictEntry;
整个dict
字典的结构示意图如下:
2.2.3、扩容与缩容
当哈希表的数量主键增大时,此时添加数据,产生hash
冲突的概率主键增大,且dict
也是采用拉链法
解决hash冲突
的,因此随着hash冲突
的增加,链表的长度也在逐渐增大。这时查询的速度会随着链表的长度主键变慢。相反,当元素主键减少时,元素占用dict
的空间逐渐减少,处于对内存的极致利用,此时就需要进行缩容操作。
dict
的扩容和缩容操作有一点和Java
中的HashMap
结构类似,都有负载因子。负载因子一般用于表示集合当前被数据填充的程度。在Redis
的字典dict
中,负载因子=哈希表已存节点数量/哈希表长度,即:
load factor=ht[0].used/ht[0].size
Redis
中,关于扩容和缩容有三条规则:
- 没有执行BGSAVE和BGREWRITEAOF指令的情况下,哈希表的负载因子大于等于1时进行扩容。
- 正在执行BGSAVE和BGREWRITEAOF指令的情况下,哈希表的负载因子大于等于5时进行扩容。
- 负载因子小于0.1时,
Redis
自动对哈希表进行缩容操作。
Redis
扩容和缩容的数量规则:
- 扩容后:扩容后的
dictEntry
数组数量为第一个大于等于ht[0].used*2
的2^n
; - 缩容后:缩容后的
dictEntry
数组数量为第一个大于等于ht[0].used
的2^n
;
2.2.4、rehash
Redis
的扩容或者缩容,与Java
中的HashMap
类似都有rehash
过程。Java
中的HashMap
的rehash
过程如下:
- 新建一个哈希表,一次性将当前的数据全部
rehash
,然后复制到新的哈希表上。 - 舍弃掉原来的哈希表,而持有新的
hash
表。这个过程是一个时间复杂度为O(n)
的操作。
对于单线程的Redis
而言很难承受这么高的时间复杂度的操作。因此Redis
的rehash
操作相比较于HashMap
有所不同。Redis
采用渐进式rehash的方式。其过程如下:
- 假设当前数据在
ht[0]
上,那么首先会为ht[1]
分配到足够的空间。如果是扩容ht[1]
就按照扩容规则进行设置。如果是缩容ht[1]
就按照缩容规则设置。 - 在
dict
结构中有个rehashidx
字段,用来记录rehash
的位置。**rehash=0,**表示rehash
开始。 rehash
进行期间,每次对字典进行添加、删除、查找、更新操作时,除了执行指定的操作外,还会顺带将ht[0]
哈希表上的数据rehash
到ht[1]
上,每rehash
一个ht[0]
上的数据到ht[1]
上rehashidx
都会加1。每次顺带的rehash
操作只会搬移少量的数据(100个元素)。- 随着字典操作的不断进行,在某个时刻,
ht[0]
上的所有数据全部被rehash
到ht[1]
上,这时rehashidx
的值为-1,表示rehash
的操作已完成。
以上就是Redis
中的dict
的渐进式rehash过程,但是这个过程存在两个问题:
- 在第三步说了,每次在对字典执行增删改查时才会触发
rehash
过程。万一某一时间段,一直都没有请求怎么办?
A:Redis
中有一个定时器,会定时去判断rehash
是否完成,如果没有完成,则继续进行rehash
操作。
- 在
rehash
过程中维护两个hash
表,是如何对外提供服务的?
A:对于添加操作,会将数据直接添加到ht[1]
上,这样就会保证ht[0]
上的数据只会减少不会增加。而对于**删除、更改、查询操作。**会直接在ht[0]
上进行操作,尤其这三个操作都会涉及到查询,当在**ht[0]**
上查询不到时,会接着去**ht[1]**
上查找,如果在找不到,则表示此key不存在。
2.2.5、渐进式rehash的优缺点
- 优点:采用分而治之的思想,将
rehash
操作分散到每一个对该哈希表的操作上以及定时函数上,避免了集中式的rehash
带来的性能压力。 - 缺点:在
rehash
期间内,需要保存两个hash表,对内存的占用稍大,而且如果在redis服务器内存满了的时候,突然进行**rehash**
操作,会造成大量key被抛弃。
2.2.6、思考题
为什么扩容时需要考虑BGSAVE
的影响,而缩容时不需要?
BGSAVE
时,dict
进行扩容,则此时就需要为ht[1]
分配内存,若是ht[1]
的数据量很大时,就会占用更多的系统内存,造成内存页过多分离,所以为了避免系统耗费更多的开销去回收内存,此时最好不要进行扩容。- 缩容时,结合缩容的条件,此时负载因子<0.1,说明此时的
dict
中的数据很少,就算为ht[1]
分配内存,也消耗不了多少资源。