本篇主要目的是理解Redis单机数据库中键空间dict的实现机理。
Redis的持久化技术建立在操作系统的COW机制上,Redis如何权衡rehash的时机?rehash时间会产生堵塞服务器的问题,Redis是如何应对这个问题的?渐进式rehash的原理是什么?诸如此类问题将通过本篇文章得到解答。
结构一览
首先看看Redis单机数据库的结构:
|
|
下面将解析redisDb内各属性。
dict
这个属性中文翻译成“键空间”,是Redis内部的数据结构字典
实现的,字典的结构如下:
|
|
了解Java HashMap的对这个结构不会陌生,只不过这里拥有两个哈希表:
- ht[0]->table的空间分配将在第一次往字典添加键值对时进行;
- ht[1]->table的空间分配将在rehash开始时进行;
新的键值添加进字典前会经过三个步骤:
- 字典0号哈希表的table属性为空,ht[0]->table分配空间(本人当前使用版本初始大小为4);
- 如果发生碰撞,处理碰撞;
- 插入新元素若满足rehash条件,则进行rehash;
这里分析一下rehash过程,其它步骤可以参考本站HashMap的源码解析,原理相同。
rehash时机
哈希表的性能依赖一个比率 ratio:used / size,used和size是dictht中的属性:
|
|
rehash分扩展(expand)和收缩(shrink)。本篇文章主要介绍扩展(收缩的原理和扩展基本相同):
什么时候扩展?这个问题需要提两个变量:
- static int dict_can_resize = 1;
- static unsigned int dict_force_resize_ratio = 5;
它们都是触发扩展的条件变量,为什么有两个触发条件?这就需要对它们使用的环境进行分析。
dict_can_resize
设置dict_can_resize的方法有两个:
- dictEnableResize方法用来置1;
- dictDisableResize方法用来置0;
这两个方法只在一处调用:
|
|
还没有RDB持久化服务的时候,server.rdb_child_pid == -1;还没有AOF持久化服务的时候,server.aof_child_pid == -1。
即调用方法后,若没有子进程的持久化服务存在,dictEnableResize()就调用,将dict_can_resize置1,反之置0。
在扩容的方案中,dict_can_resize作为条件变量时被引用在_dictExpandIfNeeded中:
|
|
dict_force_resize_ratio
这个时候,我们看到了另一个条件变量dict_force_resize_ratio。这也是整个源代码该变量唯一被引用的地方。
暂时先搁置_dictExpandIfNeeded的内部实现,看看它在整个源代码中的调用链:
dictAdd() / dictReplaceRaw()可能调用 / sentinelObjectiveLeaderIncr()可能调用 ->
dictAddRaw() ->
_dictKeyIndex() ->
_dictExpandIfNeeded()
暂时考虑添加键值对的情况:dictAdd(),它是最普遍的操作。最终它会调用_dictExpandIfNeeded方法,添加键值对后,d->ht[0].used >= d->ht[0].size如果成立,则继续判断 &&
后的条件语句,也就是:
|
|
dict_can_resize为1,直接执行rehash扩展方法dictExpand();如果dict_can_resize为0,但是只要used / size大于5,程序仿佛抱怨“对不起,实在太大了,必须马上rehash”,不论dict_can_resize是什么值,都执行rehash扩展方法。
现在一切都清楚了,dict_can_resize是一个用来降低rehash优先级的标志位。而如果比率太大,大于5,那么rehash的优先级就摆在了前面。
为什么要这样处理?used / size >= 1
比起used / size > dict_force_resize_ratio
的“门槛”低很多,如果不设置一个dict_can_resize标志位,那么当子进程进行持久化服务的时候,可能会面临频繁的rehash,造成过大的资源开销,Redis的持久化服务运用了操作系统的写时复制(COW)技术,关于父子进程,国外网友回答到:“All processes are sharing the same set of pages and each one gets its own private copy when it wants to modify a page.”
rehash基本原理
先确定扩容大小。前文的代码中,一旦确定要扩容,返回的是dictExpand(d, d->ht[0].used*2)
的值。在该函数内部实际上调用了_dictNextPower方法,并赋值给realsize,这就是扩容后的哈希表的大小。在Java的HashMap容器的源码中有一个和_dictNextPower类似的方法,该方法返回不小于传入参数的最小2的次方数。
接下来简单叙述rehash流程
- 按照前文确定的扩容大小给ht[1]分配空间;
- ht[0]->table的节点迁移到ht[1]->table;
- 执行迁移后的“善后”工作;
“善后”用代码描述:
|
|
这个过程用代码描述还是比较清晰的。
渐进式rehash
ht[0]->table的节点迁移到ht[1]->table不是一次性、集中性的完成,主要是因为如果数据量太大,服务器会阻塞等待,所以它是一种渐进式的方式来进行。怎么渐进式进行?就是把rehash的整个过程分散到多个步骤中去。
我个人觉得代码中比较有新意的地方
:作者明白复制一个哈希表的内容到另一个扩容的哈希表不是目的,目的是减少增删改查数据的开销,以及不造成服务器长时间的阻塞。那么每次可以只将一个表的内容转移一部分到扩容的表,此时两个表可以同时使用。当然,后续客户端命令产生的新的数据,直接保存在扩容表上。
rehash的核心操作都封装在dictRehash方法里,但它的调用主要又是依靠_dictRehashStep和dictRehashMilliseconds方法。
前面提到的多个步骤,对于_dictRehashStep()来说,指的是:dictAddRaw、dictGenericDelete、dictFind、dictGetRandomKey四个方法的调用步骤,只要dictIsRehashing方法返回true(该方法主要判断rehash是否正在进行),就会执行_dictRehashStep()。每次迁移ht[0]->table索引从0递增第一个不为空的链表的所有节点到ht[1]->table。
dictRehashMilliseconds则需要传入一个参数时常的阻塞时间,在这个规定时间内迁移尽可能多的数据。
参考
Redis 设计与实现:国内解析Redis的开源资料;
fork()后copy on write的一些特性:介绍了操作系统内COW实现原理;
How does copy-on-write in fork() handle multiple fork?:国外问答网站关于fork处理中COW技术的精简回答;
[OS] fork() 和 vfork() [copy on write]:痞客邦的网友回答;
解密Redis持久化:该内容来源于Redis作者博文;
安装附录
用HomeBrew安装:brew isntall redis
启动命令:redis-server;
启动客户端:redis-cli;
如果有中文乱码,输入如下命令:
|
|