关于redis:从零手写缓存框架14redis渐进式rehash详

3次阅读

共计 5554 个字符,预计需要花费 14 分钟才能阅读完成。

redis 的 rehash 设计

本文思维导图如下:

HashMap 的 rehash 回顾

读过 HashMap 源码的同学,应该都晓得 map 在扩容的时候,有一个 rehash 的过程。

没有读过也没有关系,能够花工夫浏览下 从零开始手写 redis(13) HashMap 源码详解 简略理解下整个过程即可。

HashMap 的扩容简介

这里简略介绍下:

扩容 (resize) 就是从新计算容量,向 HashMap 对象里不停的增加元素,而 HashMap 对象外部的数组无奈装载更多的元素时,对象就须要扩充数组的长度,以便能装入更多的元素。

当然 Java 里的数组是无奈主动扩容的,办法是应用一个新的数组代替已有的容量小的数组,就像咱们用一个小桶装水,如果想装更多的水,就得换大水桶。

redis 中的扩容设计

HashMap 的扩容须要对汇合中大部分的元素进行从新计算,然而对于 redis 这种企业级利用,特地是单线程的利用,如果像传统的 rehash 一样把所有元素来一遍的话,预计要十几秒的工夫。

十几秒对于常见的金融、电商等绝对高并发的业务场景,是无法忍受的。

那么 redis 的 rehash 是如何实现的呢?

实际上 redis 的 rehash 动作并不是一次性、集中式地实现的,而是 分屡次、渐进式地实现的

这里补充一点,不单单是扩容,缩容也是一样的情理,二者都须要进行 rehash。

只增不降就是对内存的节约,节约就是立功,特地是内存还这么贵。

ps: 这种思维和 key 淘汰有殊途同归之妙,一口吃不了一个大瘦子,一次搞不定,那就 1024 次,慢慢来总能解决问题。

Redis 的渐进式 rehash

这部分间接选自经典入门书籍《Redis 设计与实现》

为什么要渐进式解决?

实际上 redis 外部有两个 hashtable,咱们称之为 ht[0] 和 ht[1]。传统的 HashMap 中只有一个。

为了防止 rehash 对服务器性能造成影响,服务器不是一次性将 ht[0] 外面的所有键值对全副 rehash 到 ht[1],而是分屡次、渐进式地将 ht[0] 外面的键值对缓缓地 rehash 到 ht[1]。

具体步骤

哈希表渐进式 rehash 的具体步骤:

(1)为 ht[1] 调配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表。

(2)在字典中维持一个索引计数器变量 rehashidx,并将它的值设置为 0,示意 rehash 工作正式开始。

(3)在 rehash 进行期间,每次对字典执行增加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1],当 rehash 工作实现之后,程序将 rehashidx 属性的值增 1。

(4)随着字典操作的一直执行,最终在某个工夫点上,ht[0] 的所有键值对都会被 rehash 至 ht[1],这时程序将 rehashidx 属性的值设为 -1,示意 rehash 操作已实现。

渐进式 rehash 的益处在于它采取分而治之的形式,将 rehash 键值对所需的计算工作均滩到对字典的每个增加、删除、查找和更新操作上,从而防止了集中式 rehash 而带来的宏大计算量。

rehash 间的操作怎么兼容呢?

因为在进行渐进式 rehash 的过程中,字典会同时应用 ht[0] 和 ht[1] 两个哈希表,那这期间的操作如何保障失常进行呢?

(1)查问一个信息

这个相似于咱们的数据库信息等迁徙,先查问一个库,没有的话,再去查问另一个库。

ht[0] 中没找到,咱们去 ht[1] 中查问即可。

(2)新数据怎么办?

这个和数据迁徙一样的情理。

当咱们有新旧的两个零碎时,新来的用户等信息间接落在新零碎即可,

这一措施保障了 ht[0] 蕴含的键值对数量会只减不增,并随着 rehash 操作的执行而最终变成空表。

一图胜千言

咱们来看图:

(1)筹备 rehash

(2)rehash index=0

(3)rehash index=1

(4)rehash index=2

(5)rehash index=3

(6)rehash 实现

缩容扩容的思考

看完了下面的流程,不晓得你对 rehash 是否有一个大略了思路呢?

上面让咱们来一起思考下几个缩扩容的问题。

什么时候扩容呢?

什么时候判断?

redis 在每次执行 put 操作的时候,就能够查看是否须要扩容。

其实也很好了解,put 插入元素的时候,判断是否须要扩容,而后开始扩容,是间接的一种思路。

留一个思考题:咱们能够在其余的时候判断吗?

redis 判断是否须要扩容的源码

/* Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d)
{
    /* Incremental rehashing already in progress. Return. */
    // 如果正在进行渐进式扩容,则返回 OK
    if (dictIsRehashing(d)) return DICT_OK;

    /* If the hash table is empty expand it to the initial size. */
    // 如果哈希表 ht[0]的大小为 0,则初始化字典
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    /* If we reached the 1:1 ratio, and we are allowed to resize the hash
     * table (global setting) or we should avoid it but the ratio between
     * elements/buckets is over the "safe" threshold, we resize doubling
     * the number of buckets. */
    /*
     * 如果哈希表 ht[0]中保留的 key 个数与哈希表大小的比例曾经达到 1:1,即保留的节点数曾经大于哈希表大小
     * 且 redis 服务以后容许执行 rehash,或者保留的节点数与哈希表大小的比例超过了平安阈值(默认值为 5)* 则将哈希表大小扩容为原来的两倍
     */
    if (d->ht[0].used >= d->ht[0].size &&
        (dict_can_resize ||
         d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
    {return dictExpand(d, d->ht[0].used*2);
    }
    return DICT_OK;
}

扩容的条件总结下来就是两句话:

(1)服务器目前没有在执行 BGSAVE/BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1;

(2)服务器目前正在执行 BGSAVE/BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 5;

这里其实体现了作者的一种设计思维:如果负载因子超过 5,阐明信息曾经很多了,管你在不在保留,都要执行扩容,优先保障服务可用性。如果没那么高,那就等长久化实现再做 rehash。

咱们本人在实现的时候能够简化一下,比方只思考状况 2。

扩容到原来的多少?

晓得了什么时候应该开始扩容,然而要扩容到多大也是值得思考的一个问题。

扩容的太小,会导致频繁扩容,节约性能。

扩容的太大,会导致资源的节约。

其实这个最好的计划是联合咱们理论的业务,不过这部分对用户是通明的。

个别是扩容为原来的两倍。

为什么须要扩容?

咱们在实现 ArrayList 的时候须要扩容,因为数据放不下了。

咱们晓得 HashMap 的底层是数组 + 链表(红黑树)的数据结构。

那么会存在放不下的状况吗?

集体了解实际上不会。因为链表能够始终加下去。

那为什么须要扩容呢?

实际上更多的是处于性能的思考。咱们应用 HashMap 就是为了晋升性能,如果始终不扩容,能够了解为元素都 hash 到雷同的 bucket 上,这时就进化成了一个链表。

这会导致查问等操作性能大大降低。

什么时候缩容呢?

何时判断

看了后面的扩容,咱们比拟直观地形式是在用户 remove 元素的时候执行是否须要缩容。

不过 redis 并不齐全等同于传统的 HashMap,还有数据的淘汰和过期,这些是对用户通明的。

redis 采纳的形式实际上是一个定时工作。

集体了解内存缩容很重要,然而没有那么紧急,咱们能够 1min 扫描一次,这样能够节俭机器资源。

理论工作中,个别 redis 的内存都是逐渐回升的,或者稳固在一个范畴内,很少去大批量删除数据。(除非数据搞错了,我就遇到过一次,数据同步错中央了)。

所以数据删除,个别几分钟内给用户一个反馈就行。

知其然,知其所以然。

咱们懂得了这个情理也就懂得了为什么有时候删除 redis 的几百万 keys,内存也不是间接降下来的起因。

缩容的条件

/* If the percentage of used slots in the HT reaches HASHTABLE_MIN_FILL
 * we resize the hash table to save memory */
void tryResizeHashTables(int dbid) {if (htNeedsResize(server.db[dbid].dict))
        dictResize(server.db[dbid].dict);
    if (htNeedsResize(server.db[dbid].expires))
        dictResize(server.db[dbid].expires);
}

/* Hash table parameters */
#define HASHTABLE_MIN_FILL        10      /* Minimal hash table fill 10% */
int htNeedsResize(dict *dict) {
    long long size, used;

    size = dictSlots(dict);
    used = dictSize(dict);
    return (size > DICT_HT_INITIAL_SIZE &&
            (used*100/size < HASHTABLE_MIN_FILL));
}

/* Resize the table to the minimal size that contains all the elements,
 * but with the invariant of a USED/BUCKETS ratio near to <= 1 */
int dictResize(dict *d)
{
    int minimal;

    if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
    minimal = d->ht[0].used;
    if (minimal < DICT_HT_INITIAL_SIZE)
        minimal = DICT_HT_INITIAL_SIZE;
    return dictExpand(d, minimal);
}

和扩容相似,不过这里的缩容比例不是 5 倍,而是当哈希表保留的 key 数量与哈希表的大小的比例小于 10% 时须要缩容。

缩容到多少?

最简略的形式是间接变为原来的一半,不过这么做有时候也不是那么好用。

redis 是 缩容后的大小为第一个大于等于以后 key 数量的 2 的 n 次方。

这个可能不太好了解,举几个数字就懂了:

keys 数量 缩容大小
3 4
4 4
5 8
9 16

次要保障以下 3 点:

(1)缩容之后,要大于等于 key 的数量

(2)尽可能的小,节约内存

(3)2 的倍数。

第三个看过 HashMap 源码解说的小伙伴应该深有体会。

当然也不能太小,redis 限度的最小为 4。

实际上如果 redis 中只放 4 个 key,切实是杀鸡用牛刀,个别不会这么小。

咱们在实现的时候,间接参考 jdk 好了,给个最小值限度 8。

为什么须要缩容?

最外围的目标就是为了节约内存,其实还有一个起因,叫 small means fast(小即是快——老马)。

渐进式 ReHash 实现的思考

好了,扩容和缩容就聊到这里,那么这个渐进式 rehash 到底怎么一个渐进法?

扩容前

不须要扩容时应该有至多须要初始化两个元素:

hashtable[0] = new HashTable(size);
hashIndex=-1;

hashtable[1] = null;

hashtable 中存储着以后的元素信息,hashIndex=-1 标识以后没有在进行扩容。

扩容筹备

当须要扩容的时候,咱们再去创立一个 hashtable[1],并且 size 是原来的 2 倍。

hashtable[0] = new HashTable(size);

hashtable[1] = new HashTable(2 * size);

hashIndex=-1;

次要是为了节约内存,应用惰性初始化的形式创立 hashtable。

扩容时

调整 hashIndex=0…size,逐渐去 rehash 到新的 hashtable[1]

新的插入全副放入到 hashtable[1]

扩容后

扩容后咱们应该把 hashtable[0] 的值更新为 hashtable[1],并且开释掉 hashtable[1] 的资源。

并且设置 hashIndex=-1,标识曾经 rehash 实现

hashtable[0] = hashtable[1];
hashIndex=-1;

hashtable[1] = null;

这样整体的实现思路就曾经差不多了,光说不练假把式,咱们下一节就来本人实现一个渐进式 rehash 的 HashMap。

至于当初,先让 rehash 的思路飞一会儿~

小结

本节咱们对 redis rehash 的原理进行了解说,其中也退出了不少本人的思考。

文章的结尾,也增加了简略的实现思路,当然理论实现还会有很多问题须要解决。

下一节咱们将一起手写一个渐进式 rehash 的 HashMap,感兴趣的搭档能够关注一波,即便获取最新动静~

开源地址:https://github.com/houbb/cache

感觉本文对你有帮忙的话,欢送点赞评论珍藏关注一波。你的激励,是我最大的能源~

不晓得你有哪些播种呢?或者有其余更多的想法,欢送留言区和我一起探讨,期待与你的思考相遇。

正文完
 0