字典结构
redis字典结构是整个数据库的底层实现,也是hash类型对象和set类型对象的底层实现之一,是一种用于保存键值对的数据结构,redis中一个未处于扩容状态且含有两个键值对的完整的字典结构图如下:
dict即字典结构,它包含:
- type:一个指向dictType结构的指针,dictType记录了一组操作特定类型键值对的函数;
- privdata:记录字典私有数据,需要作为参数传递给特定函数;
- ht:指向两个哈希表,通常情况下只使用ht[0]一个哈希表,ht[1]是为扩容/收缩时准备的;
- rehashidx:记录渐进式rehash的进度,若没有进行rehash,值为-1。
dictht是哈希表,它包含:
- table:指向一个键值对数组,使用链地址法解决hash冲突;
- size:哈希表大小,也就是当前的数组长度;
- sizemask:哈希表大小掩码,永远等于size-1,用于计算键值对索引,添加一个新的键值对时它的索引等于hash(key)&sizemask;
- used:该哈希表已有键值对数量。
集中式rehash
在介绍渐进式rehash之前,先来介绍一下集中式rehash,从目的和结果出发来看这两种rehash方式都是一样的。
rehash时机
负载因子=哈希表已有键值对数量 / 哈希表大小 = used / size;
扩容:
1)如果Redis当前没有执行BGSAVE或BGREWRITEAOF命令,会在负载因子大于等于1时rehash;
2)如果Redis当前正在执行BGSAVE或BGREWRITEAOF命令,会在负载因子大于等于5时rehash。
收缩:
负载因子小于0.1时进行rehash。
之所以依据是否在执行BGSAVE或BGREWRITEAOF命令,rehash的门限会不同,在《Redis设计与实现》中是这样解释的:
这是因为在执行BGSAVE或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能避免在子进程存在期间对哈希表进行扩展操作,这可以避免不必要的内存写入操作,尽最大限度地节约内存。
Redis在执行BGSAVE或BGREWRITEAOF命令时,会fork出一个子进程来生成rdb文件或重写AOF文件,采用写时复制技术,父子进程共享同一片内存区域,当任一进程企图对这片内存区域进行写入操作时,会把将要写入的那一部分内存页复制一份进行写入,其他内存页依旧共享。在字典扩容期间,父进程要迁移数据,不可避免的会有大量内存写入操作,为了减少内存页过多的复制,而提高了扩容的门限,这是出于节省内存考虑的。
至于为什么Redis字典收缩时不用考虑写时复制?我认为原因有两个:一是收缩条件是键值对个数小于哈希表长度的10%,有意设置的这么低,就是为了不会造成过多的页分离;二是收缩操作完成会释放一部分内存,本身目的就是节省内存的。所以两个原因综合起来,不用考虑写时复制对收缩的影响。
rehash过程
①首先在ht[1]的位置初始化一个键值对数组,数组长度取决于执行扩容还是收缩操作:
- 扩容:键值对数组长度为第一个大于等于ht[0].used*2的2^n
- 收缩:键值对数组长度为第一个大于等于ht[0].used的2^n
②将ht[0]的元素迁移到ht[1]上,重新计算hash值和索引;
③释放ht[0],将ht[1]设置为ht[0],在ht[1]的位置新建一个空的哈希表,以备下次rehash。
渐进式rehash
rehash的过程可能会行当耗时,所以Redis采用的是分多次地、渐进式地rehash,即渐进式rehash。
渐进式rehash过程
①为ht[1]初始化一个新数组,并将dict字典结构的rehashidx属性的值由-1设为0,标志rehash开始;
②在rehash期间,每一次接收到对字典的增删改查命令时,除了执行相应操作,还会把ht[0]上索引为rehashidx位置的所有元素(整个链表)rehash到ht[1]的相应索引上,每执行一次上述操作,rehashidx的值就加一;
③ht[0]所有键值对都被rehash到ht[1]上之后,释放ht[0],将ht[1]设置为ht[0],在ht[1]的位置新建一个空的哈希表,以备下次rehash。
其他注意事项
- rehash过程中会使用两个哈希表执行查找、更新、删除命令,如先在ht[0]中查找元素,没有找到再去ht[1]中找,以此类推;
- rehash过程中的所有添加命令都在ht[1]上执行;
- 如果一段时间内Redis没有接收到命令触发渐进式rehash,Redis会在定时任务中对字典进行rehash。
参考资料
《Redis设计与实现》