Redis 已是你们耳熟能详的东西了,平常工做也都在使用,面试中也是高频的会涉及到,那么咱们对它究竟了解有多深入呢?面试
我读了几本 Redis 相关的书籍,尝试去了解它的具体实现,将一些底层的数据结构及实现原理记录下来。redis
本文将介绍 Redis 中底层的 dict(字典) 的实现方法。 它是 Redis 中哈希键和有序集合键的底层实现之一。算法
能够看到图中,当我给一个 哈希结构中放了两个短的值,此时 哈希的编码方式是 ziplist, 而当我插入一个比较长的值,哈希的编码方式成为了 hashtable.编程
注:本文默认读者对于 hashtable 这一数据结构有基本的了解,所以不会详细讲解这块内容后端
字典做为一种经常使用的数据结构,也被内置在不少编程语言中,好比 Java 的 HashMap 和 Python 的 dict. 然而 C 语言又没有(知道为何你们更喜欢写 Java,Python 等高级语言了吧).数组
因此 Redis 本身实现了一个字典:服务器
typedef struct dict{ // 类型特定函数 dictType *type; // 私有数据 void *private; // 哈希表 dictht ht[2]; // rehash 索引,当当前的字典不在 rehash 时,值为-1 int trehashidx; } 复制代码
这两个属性是为了实现字典多态而设置的,当字典中存放着不一样类型的值,对应的一些复制,比较函数也不同,这两个属性配合起来能够实现多态的方法调用。微信
这是一个长度为 2 的 dictht
结构的数组,dictht
就是哈希表。markdown
这是一个辅助变量,用于记录 rehash 过程的进度,以及是否正在进行 rehash 等信息。数据结构
看完字段介绍,咱们发现,字典这个数据结构,本质上是对 hashtable的一个简单封装,所以字典的实现细节主要就来到了 哈希表上。
哈希表的定义以下:
typedef struct dictht{ // 哈希表的数组 dictEntry **table; // 哈希表的大小 unsigned long size; // 哈希表的大小的掩码,用于计算索引值,老是等于 size-1 unsigned long sizemasky; // 哈希表中已有的节点数量 unsigned long used; } 复制代码
其中哈希表中的节点的定义以下:
typedef struct dictEntry{ // 键 void *key; // 值 union { void *val; uint64_tu64; int64_ts64; }v; // 指向下一个节点的指针 struct dictEntry *next; } dictEntry; 复制代码
若是你看过 Java 中 HashMap 的源码,你会发现这一切是如此的熟悉。所以我不对其中的每一个属性进行详细的解释了。
上图是一个没有处在 rehash 状态下的字典。能够看到,字典持有两张哈希表,其中一个的值为 null, 另一个哈希表的 size=4, 其中两个位置上已经存放了具体的键值对,并且没有发生 hash 冲突。
哈希表添加一个元素首先须要计算当前键值的 hash 值,以后根据 hash 值来定位即将它即将被放入的槽。因为 hash 值可能冲突,所以 hash 算法的选择尤为重要,要将 key 值打散的足够均匀。
Redis 选用了业内的一些算法来实现 hash 过程。
在 Redis 5.0 以及 4.0 版本,都使用了 siphash 哈希算法。siphash 能够在输入的 key 值很小的状况下,产生随机性比较好的输出。
在 Redis 3.2, 3.0 以及 2.8 版本,使用 Murmurhash2 哈希算法,Murmurhash 能够在输入值是有规律时,也能给出比较好的随机分布。
固然以上两个算法,都有一个共同点,就是计算性能很好,这才符合 Redis 的产品特性。
hash 结束以后,会根据当前哈希表的长度,来肯定当前键值所在的 index, 而因为长度有限,那么早晚会产生两个键值要放到同一个位置的问题,也就是常说的 hash 冲突问题。
既然是哈希表,那么就也有 hash 冲突问题。
Redis 的哈希表处理 Hash 冲突的方式和 Java 中的 HashMap 同样,选择了分桶的方式,也就是常说的链地址法。Hash 表有两维,第一维度是个数组,第二维度是个链表,当发生了 Hash 冲突的时候,将冲突的节点使用链表链接起来,放在同一个桶内。
因为第二维度是链表,咱们都知道链表的查找效率相比于数组的查找效率是比较差的。那么若是 hash 冲突比较严重,致使单个链表过长,那么此时 hash 表的查询效率就会急速降低。
当哈希表过于拥挤,查找效率就会降低,当 hash 表过于稀疏,对内存就有点太浪费了,此时就须要进行相应的扩容与缩容操做。
想要进行扩容缩容,那么就须要描述当前 hasd 表的一个填充程度,总不能靠感受。这就有了 负载因子
这个概念。
负载因子
是用来描述哈希表当前被填充的程度。计算公式是:负载因子=哈希表以保存节点数量 / 哈希表的大小
.
在 Redis 的实现里,扩容缩容有三条规则:
负载因子>1
的时候进行扩容。负载因子>5
的时候,强行进行扩容。负载因子<0.1
的时候,进行缩容。根据程序当前是否在进行 BGSAVE 相关操做,扩容须要的负载因子条件不相同。
这是由于在进行 BGSAVE 操做时,存在子进程,操做系统会使用 写时复制 (Copy On Write) 来优化子进程的效率。Redis 尽可能避免在存在子进程的时候进行扩容,尽可能的节省内存。
熟悉 hash 表的读者们应该知道,扩容期间涉及到到 rehash 的问题。
由于须要将当前的全部节点挪到一个大小不一致的哈希表中,且须要尽可能保持均匀,所以须要将当前哈希表中的全部节点,从新进行一次 hash. 也就是 rehash.
在 Java 的 HashMap 中,实现方式是 新建一个哈希表,一次性的将当前全部节点 rehash 完成,以后释放掉原有的 hash 表,而持有新的表。
而 Redis 不是,Redis 使用了一种名为渐进式 hash 的方式来知足本身的性能需求。
这是一个我亲历的面试原题:Redis 的字典结构,在 rehash 时和 Java 的 HashMap 的 Rehash 有什么不一样?
rehash 须要从新定位全部的元素,这是一个 O(N) 效率的问题,当对数据量很大的字典进行这一操做的时候,比较耗时。
对于单线程的 Redis 来讲,表示很难接受这样的延时,所以 Redis 选择使用 一点一点搬的策略。
Redis 实现了渐进式 hash. 过程以下:
rehashindex
位置上的值 rehash 到 ht[1] 上。将 rehashindex 递增一位。在上面的过程当中有两个问题没有提到:
解决办法是:在 redis 的定时函数里,也加入帮助 rehash 的操做,这样子若是服务器空闲,就会比较快的完成 rehash.
解决办法:对于添加操做,直接添加到 ht[1] 上,所以这样才能保证 ht[0] 的数量只会减小不会增长,才能保证 rehash 过程能够完结。而删除,修改,查询等操做会在 ht[0] 上进行,若是得不到结果,会去 ht[1] 再执行一遍。
渐进式 hash 带来的好处是显而易见的,他采用了分而治之的思想,将 rehash 操做分散到每个对该哈希表的操做上以及定时函数上,避免了集中式 rehash 带来的性能压力。
与此同时,渐进式 hash 也带来了一个问题,那就是 在 rehash 的时间内,须要保存两个 hash 表,对内存的占用稍大,并且若是在 redis 服务器原本内存满了的时候,忽然进行 rehash 会形成大量的 key 被抛弃。
咱们学习渐进式 hash 是为了面试吗?若是不是为了面试,那么咱们又不用去设计一个 Redis, 为啥要知道这个?
我我的以为,咱们是为了理解它的思想。在我学习完渐进式 hash 以后的某一天,在某论坛回答了一位网友的问题。
他的问题是这样一个场景:
有两张表,一张工做量表,一张积分表,积分=工做量*系数。
系数是有可能改变的,当系数发生变化以后,须要从新计算全部过往工做量的对应新系数的积分状况。
而工做量表的数据量比较大,若是在系数发生变化的一瞬间开始从新计算,能够会致使系统卡死,或者系统负载上升,影响到在线服务。
怎么解决这个问题?
复制代码
我我的的理解是,这个能够用 redis 渐进式 rehash 的思路来解决。
原数据(原有的工做量表), 负载因子达到某个值(系数改变), 进行 rehash(从新计算全部值)
全部的元素都齐活了。
咱们只须要额外记录一个标志着正在进行从新计算过程当中的变量便可。以后的思路就彻底和 Redis 一致了。
这样完美的解决了性能压力,代码层面只是加一个记录参数以及给一个接口加个"触发器"而已,也算不上麻烦~.
这是我看的《Redis 深度历险:核心原理和应用实践》中的一个思考问题,我在这里写下我的的一点理解。
扩容时考虑 BGSAVE 是由于,扩容须要申请额外的不少内存,且会从新连接链表(若是会冲突的话), 这样会形成不少内存碎片,也会占用更多的内存,形成系统的压力。
而缩容过程当中,因为申请的内存比较小,同时会释放掉一些已经使用的内存,不会增大系统的压力。所以不用考虑是否在进行 BGSAVE 操做。
Redis 的字典数据结构,和下一篇文章要将的跳跃表数据结构同样,是面试中的高频问题。
Redis 字典中,用 table[2] 的数组保存着两张 hash 表,正常状况下只使用其中一张,在 rehash 的时候使用另一张表。
Redis 为了提升本身的性能,rehash 过程不是一次性完成的,而是使用了渐进式 hash 的策略,逐步的将原有元素 rehash 到新的哈希表中,直到完成。
至于其余方面,和其余语言中的哈希表区别不是特别大,好比 hash 算法以及如何解决哈希冲突。
《Redis 的设计与实现(第二版)》
《Redis 深度历险:核心原理和应用实践》
完。
最后,欢迎关注个人我的公众号【 呼延十 】,会不按期更新不少后端工程师的学习笔记。 也欢迎直接公众号私信或者邮箱联系我,必定知无不言,言无不尽。
以上皆为我的所思所得,若有错误欢迎评论区指正。
欢迎转载,烦请署名并保留原文连接。
联系邮箱:huyanshi2580@gmail.com
更多学习笔记见我的博客或关注微信公众号 < 呼延十 >------>呼延十