字典,又称符号表、关联数组、映射(map)是一种保存K/V队的抽象数据结构。字典中每个key都是唯一的,而且能在常数时间O(1)获取key对应的值。
1. 定义
- 哈希表结点
- key 保存键值对中的键
- v 保存键值对中的值,类型可以为void *, uint64_t等,使用union节省内存
- next 保存下一个哈希表结点,解决冲突时产生一个链表
1 | typedef struct dictEntry { |
字典类型
一组操作特定类型键值对的函数,Redis会为用途不同的字典设置不同类型,类似于多态的实现
1 | typedef struct dictType { |
- 哈希表
- table 哈希结点数组,类似于桶的概念,每个索引都是一个链表(存在NULL表示没有计算出该索引)
- size 哈希表能够存储结点的大小容量,包括已使用和未使用
- sizemask 哈希表大小掩码,永远为size-1,用于计算哈希值的索引位置(存放在table中的下标)
- used 哈希表已经存储的结点个数
1 | typedef struct dictht { |
- Redis字典
- type 定义了不同字典类型,拥有一组该特定类型的操作函数
- privdata 保存了需要传递给特定类型函数回调的可选参数
- ht[2] 每个字典拥有两张哈希表,用于渐进式rehash替换,一般使用ht[0],rehash过程中才使用ht[1]
- rehashidx 记录目前rehash的进度,-1表示没有进行rehash
- iterators 目前在运行安全迭代器数量
1 | typedef struct dict { |
字典迭代器
不安全迭代器在操作前后会生成指纹,比对两次指纹是否相同说明迭代器是否进行非法操作
1 | /* |
2. 哈希算法
2.1 哈希函数
Jean-Philippe Aumasson和Daniel J. Bernstein能够证明,即使使用随机化种子实施Murmur哈希也容易受到所谓的HashDoS攻击。 通过使用差分密码分析,他们能够生成会导致哈希碰撞的输入。 攻击的作者建议使用他们自己的SipHash代替。
具体有兴趣可以查资料深入研究。
MurmurHash
算法优点在于即使输入键有规律,也能给出一个很好的随机分布,计算速度快
SipHash
为了解决 hash flodding 问题而产生的算法,让输出随机化。
2.2 解决冲突
通过构造性能良好的哈希函数可以减少冲突的产生,但不可能完全避免,常用的冲突解决有以下几种
开放定址法
该方法也称之为再散列法,当key的哈希值p产生冲突时,继续对p进行哈希产生p1,若哈希值p1又产生冲突,则进行哈希,这样直到没有冲突产生为止。
再哈希法
存在多个哈希函数,若H1产生冲突,则使用H2进行哈希,这样直到没有冲突
链地址法
将哈希值相同的元素通过链表的形式关联起来。
对于开放定址法和再哈希法都需要较大的存储空间,而且会存在相当多的空桶问题,导致内存严重浪费,Redis采用链地址法的方式解决冲突,可以方便的修改结点的指针,成本开销很低。
3. 渐进式rehash
当哈希表中的key越来越多后,可能会导致某些桶内元素过多(负载过高),这样操作的速度就会明显下降;当哈希表中的key越来越少,会导致空桶过多(负载过低),浪费内存。为了保证负载因子在合理范围内,当哈希表中的键过多或过少时,Redis会对哈希表进行扩展或收缩操作。
当 (哈希表使用数量/哈希表大小)dict_force_resize_ratio > 5 时,会触发redis强制进行rehash
Redis工作在单线程模式下,一般用做缓存,若在key的数量相当大的情况下触发rehash,那执行时间可想而知,为了解决这个问题,Redis采用渐进式rehash的方式处理,开启rehash后,每次执行某些操作,就会触发小步rehash,这样就平衡了等待时间,以较低的成本完成了Redis的扩容或收缩。
以下是rehash的步骤:
- 为哈希表ht[1]分配内存空间.
- 执行扩展操作,大小为大于等于ht[0].used*2的第一个2^n。
- 执行收缩操作,大小为大于等于ht[0].used(不能小于4)的第一个2^n。
- 将ht[0]中所有的键值对rehash到ht[1]上。
- 每次执行rehash会处理n个桶的数据,有些桶没有任何数据会跳过,但若跳过数量太多,也会导致单步时间过长,所以每次空桶访问的个数不能大于n*10。
- 遍历一个桶中的所有数据,将ht[0]中的数据更新到ht[1]中,重新计算哈希值,计算桶的索引值,放到ht[1]对应的桶中,使用头插入的方法插入到链表
- 当ht[0]中所有键值对都迁移到ht[1]之后,释放ht[0]的内存空间,将ht[1]设置为ht[0],ht[1]初始化为空哈希表,为下一次rehash做准备。
渐进式rehash期间,Redis字典会同时使用ht[0]和ht[1],所以对字典的查找、删除、更新等操作会先在ht[0]中进行,再在ht[1]中进行。
4. 字典扫描
字典扫描用于SCAN,HSCAN,SSCAN命令,若Redis的哈希表不做修改,那么遍历元素就比较简单,多次调用即可,但由于Redis可能正在进行rehash,导致遍历可能存在重复或纰漏。
Redis使用了一种算法,由Pieter Noordhuis设计,我们可以叫他反向二进制进位迭代法,该方法主要思想是利用模相关按照反向二进制进位的顺序迭代哈希表中的每个桶,算法保证了不会漏掉元素的同时尽可能少的重复。
例如:
1 | 哈希表长度分别为8和16的迭代顺序如下: |
假设某结点哈希值为11(0b1011):
哈希表长度为 8,sizemask = 7(0b111),结点计算出索引值为11 & 7 = 3(0b011)
哈希表长度为16,sizemask = 15(0b1111),结点计算出索引值为 11 & 15 = 11(0b1011)
由于哈希值不变,所以当哈希表长度变化时,索引值的后几比特位也不会变化,利用该性质可以得出以下结论
扩展情况:
哈希表长度8,当迭代完1号桶(001)时,发生扩容(长度16),新cursor为1(0001),此时还未迭代的桶分别是5(101)、3(011)、7(111),这些桶内结点产生的新索引为5(0101)、13(1101)、3(0011)、11(1011)、7(0111)、15(1111),从上表可知新的索引都在cursor(0001)之后,保证所有元素都会被迭代到,但存在5号桶的重复迭代。
收缩情况:
哈希表长度16,当迭代完cursor(0001)之后,发生收缩(长度8),新cursor为(001),此时还未迭代的桶分别是9(1001)、5(0101)、13(1101)、3(0011)、11(1011)、7(0111)、15(1111),这些桶内结点产生的新索引为1(001)、5(101)、3(011)、7(111),从上表可知新的索引都在cursor(001)之后,这样保证所有元素都会被迭代到,但存在1号桶部分元素存放迭代。
5. 源码剖析
- N步rehash
1 | /** |
- 字典扫描
1 | unsigned long dictScan(dict *d, |
- 迭代器查找
1 | /** |