目录
前言
Redis 如何实现链式哈希?
什么是哈希冲突?
链式哈希如何设计与实现?
Redis 如何实现 rehash?
什么时候触发 rehash?
rehash 扩容扩多大?
渐进式 rehash 如何实现?
-
前言
- Hash 表是一种非常关键的数据结构,在计算机系统中发挥着重要作用
- 比如在Memcached 中,Hash 表被用来索引数据
- 在数据库系统中,Hash 表被用来辅助 SQL 查询
- 而对于 Redis 键值数据库来说,Hash 表既是键值对中的一种值类型,同时,Redis 也使用一个全局 Hash 表来保存所有的键值对,从而既满足应用存取 Hash 结构数据需求,又能提供快速查询功能
- 那么,Hash 表应用如此广泛的一个重要原因,就是从理论上来说,它能以 O(1) 的复杂度快速查询数据
- Hash 表通过 Hash 函数的计算,就能定位数据在表中的位置,紧接着可以对数据进行操作,这就使得数据操作非常快速
- Hash 表这个结构也并不难理解,但是在实际应用 Hash 表时,当数据量不断增加,它的性能就经常会受到哈希冲突和 rehash 开销的影响
- 而这两个问题的核心,其实都来自于 Hash 表
- 要保存的数据量,超过了当前 Hash 表能容纳的数据量
- 那么要如何应对这两个问题呢?
- Redis 为我们提供了一个经典的 Hash 表实现方案
- 针对哈希冲突,Redis 采用了链式哈希,在不扩容哈希表的前提下,将具有相同哈希值的数据链接起来,以便这些数据在表中仍然可以被查询到
- 对于 rehash 开销,Redis 实现了渐进式 rehash 设计,进而缓解了 rehash 操作带来的额外开销对系统的性能影响
- 通过学习 Redis 中针对 Hash 表的设计思路和实现方法
- 帮助掌握应对哈希冲突和优化 rehash 操作性能的能力,并以此支撑你在实际使用 Hash 表保存大量数据的场景中,可以实现高性能的 Hash 表
- 接下来先来聊聊链式哈希的设计与实现
-
Redis 如何实现链式哈希?
- 在开始学习链式哈希的设计实现之前,还需要明白 Redis 中 Hash 表的结构设计是啥样的,以及为何会在数据量增加时产生哈希冲突,这样也更容易理解链式哈希应对哈希冲突的解决思路
-
什么是哈希冲突?
- 实际上,一个最简单的 Hash 表就是一个数组,数组里的每个元素是一个哈希桶(也叫做Bucket)
- 第一个数组元素被编为哈希桶 0,以此类推
- 当一个键值对的键经过 Hash 函数计算后,再对数组元素个数取模,就能得到该键值对对应的数组元素位置,也就是第几个哈希桶
- 如下图所示,key1 经过哈希计算和哈希值取模后,就对应哈希桶 1,类似的,key3 和 key16分别对应哈希桶 7 和桶 4
- 从图上还可以看到,需要写入 Hash 表的键空间一共有 16 个键,而 Hash 表的空间大小只有 8 个元素,这样就会导致有些键会对应到相同的哈希桶中
- 在实际应用 Hash 表时,其实一般很难预估要保存的数据量
- 如果一开始就创建一个非常大的哈希表,当数据量较小时,就会造成空间浪费
- 所以,通常会给哈希表设定一个初始大小,而当数据量增加时,键空间的大小就会大于 Hash 表空间大小了
- 也正是由于键空间会大于 Hash 表空间,这就导致在用 Hash 函数把键映射到 Hash 表空间时,不可避免地会出现不同的键被映射到数组的同一个位置上
- 而如果同一个位置只能保存一个键值对,就会导致 Hash 表保存的数据非常有限,这就是常说的哈希冲突
- 比如下图中,key3 和 key100 都被映射到了 Hash 表的桶 5 中,这样,当桶 5 只能保存一个key 时,key3 和 key100 就会有一个 key 无法保存到哈希表中了
- 那么该如何解决哈希冲突呢?
- 可以考虑使用以下两种解决方案:
- 第一种方案,就是接下来要介绍的链式哈希
- 这里需要先知道,链式哈希的链不能太长,否则会降低 Hash 表性能
- 第二种方案,就是当链式哈希的链长达到一定长度时,可以使用 rehash
- 不过,执行rehash本身开销比较大,所以就需要采用稍后会给你介绍的渐进式 rehash 设计
-
链式哈希如何设计与实现?
- 所谓的链式哈希,就是用一个链表把映射到 Hash 表同一桶中的键给连接起来
- 下面就来看看 Redis 是如何实现链式哈希的,以及为何链式哈希能够帮助解决哈希冲突
- 首先,需要了解 Redis 源码中对 Hash 表的实现
- Redis 中和 Hash 表实现相关的文件主要是 dict.h 和 dict.c
- 其中,dict.h 文件定义了 Hash 表的结构、哈希项,以及 Hash 表的各种操作函数
- 而 dict.c 文件包含了 Hash 表各种操作的具体实现代码
- 在 dict.h 文件中,Hash 表被定义为一个二维数组(dictEntry **table),这个数组的每个元素是一个指向哈希项(dictEntry)的指针
- 下面的代码展示的就是在 dict.h 文件中对 Hash表的定义:
- 那么为了实现链式哈希, Redis 在每个 dictEntry 的结构设计中,除了包含指向键和值的指针,还包含了指向下一个哈希项的指针
- 如下面的代码所示,dictEntry 结构体中包含了指向另一个 dictEntry 结构的指针 *next,这就是用来实现链式哈希的
- 除了用于实现链式哈希的指针外,这里还有一个值得注意的地方,就是在 dictEntry 结构体中,键值对的值是由一个联合体 v 定义的
- 这个联合体 v 中包含了指向实际值的指针 *val
- 还包含了无符号的 64 位整数、有符号的 64 位整数,以及 double 类的值
- 之所以要提醒注意这里,其实是为了说明,这种实现方法是一种节省内存的开发小技巧,非常值得学习
- 因为当值为整数或双精度浮点数时,由于其本身就是 64 位,就可以不用指针指向了
- 而是可以直接存在键值对的结构体中,这样就避免了再用一个指针,从而节省了内存空间
- 那么到这里,应该就了解了 Redis 中链式哈希的实现,不过现在你可能还是不太明白,为什么这种链式哈希可以帮助解决哈希冲突呢?
- 就拿刚才的例子来说明一下,key3 和 key100 都被映射到了 Hash 表的桶 5 中
- 而当使用了链式哈希,桶 5 就不会只保存 key3 或 key100
- 而是会用一个链表把 key3 和key100 连接起来,如下图所示
- 当有更多的 key 被映射到桶 5 时,这些 key 都可以用链表串接起来,以应对哈希冲突
- 这样,当要查询 key100 时,可以先通过哈希函数计算,得到 key100 的哈希值被映射到了桶 5 中
- 然后,再逐一比较桶 5 中串接的 key,直到查找到 key100
- 如此一来,就能在链式哈希中找到所查的哈希项了
- 不过,链式哈希也存在局限性,那就是随着链表长度的增加,Hash 表在一个位置上查询哈希项的耗时就会增加,从而增加了 Hash 表的整体查询时间,这样也会导致 Hash 表的性能下降
- 那么,有没有什么其他的方法可以减少对 Hash 表性能的影响呢?
- 当然是有的,这就是接下来要介绍的 rehash 的设计与实现了
-
Redis 如何实现 rehash?
- rehash 操作,其实就是指扩大 Hash 表空间
- 而 Redis 实现 rehash 的基本思路是这样的:
- 首先,Redis 准备了两个哈希表,用于 rehash 时交替保存数据
- 在前面介绍过,Redis 在 dict.h 文件中使用 dictht 结构体定义了 Hash 表
- 不过,在实际使用 Hash 表时,Redis 又在 dict.h 文件中,定义了一个 dict 结构体
- 这个结构体中有一个数组(ht[2]),包含了两个 Hash 表 ht[0]和 ht[1]
- dict 结构体的代码定义如下所示:
- 其次,在正常服务请求阶段,所有的键值对写入哈希表 ht[0]
- 接着,当进行 rehash 时,键值对被迁移到哈希表 ht[1]中
- 最后,当迁移完成后,ht[0]的空间会被释放,并把 ht[1]的地址赋值给 ht[0],ht[1]的表大小设置为 0
- 这样一来,又回到了正常服务请求的阶段,ht[0]接收和服务请求,ht[1]作为下一次 rehash 时的迁移表
- 在了解了 Redis 交替使用两个 Hash 表实现 rehash 的基本思路后
- 还需要明确的是:在实现 rehash 时,都需要解决哪些问题?
- 我认为主要有以下三点:
- 什么时候触发 rehash?
- rehash 扩容扩多大?
- rehash 如何执行?
-
什么时候触发 rehash?
- 首先要知道,Redis 用来判断是否触发 rehash 的函数是 _dictExpandIfNeeded
- 所以接下来就先看看,_dictExpandIfNeeded 函数中进行扩容的触发条件
- 然后,再来了解下 _dictExpandIfNeeded 又是在哪些函数中被调用的
- 实际上,_dictExpandIfNeeded 函数中定义了三个扩容条件:
- 条件一:ht[0]的大小为 0
- 条件二:ht[0]承载的元素个数已经超过了 ht[0]的大小,同时 Hash 表可以进行扩容
- 条件三:ht[0]承载的元素个数,是 ht[0]的大小的 dict_force_resize_ratio 倍,其中,dict_force_resize_ratio 的默认值是 5
- 那么,对于条件一来说,此时 Hash 表是空的,所以 Redis 就需要将 Hash 表空间设置为初始大小,而这是初始化的工作,并不属于 rehash 操作
- 而条件二和三就对应了 rehash 的场景
- 因为在这两个条件中,都比较了 Hash 表当前承载的元素个数(d->ht[0].used)和 Hash 表当前设定的大小(d->ht[0].size),这两个值的比值
- 一般称为负载因子(load factor)
- 也就是说,Redis 判断是否进行 rehash 的条件,就是看load factor 是否大于等于 1 和是否大于 5
- 实际上,当 load factor 大于 5 时,就表明 Hash 表已经过载比较严重了,需要立刻进行库扩容
- 而当 load factor 大于等于 1 时,Redis 还会再判断 dict_can_resize 这个变量值,查看当前是否可以进行扩容
- 你可能要问了,这里的 dict_can_resize 变量值是啥呀?
- 其实,这个变量值是在dictEnableResize 和 dictDisableResize 两个函数中设置的,它们的作用分别是启用和禁止哈希表执行 rehash 功能,如下所示:
- 然后,这两个函数又被封装在了 updateDictResizePolicy 函数中
- updateDictResizePolicy 函数是用来启用或禁用 rehash 扩容功能的
- 这个函数调用dictEnableResize 函数启用扩容功能的条件是:当前没有 RDB 子进程,并且也没有 AOF 子进程
- 这就对应了 Redis 没有执行 RDB 快照和没有进行 AOF 重写的场景
- 可以参考下面的代码
- 到这里就了解了 _dictExpandIfNeeded 对 rehash 的判断触发条件
- 那么现在,再来看下 Redis 会在哪些函数中,调用 _dictExpandIfNeeded 进行判断
- 首先,通过在dict.c文件中查看 _dictExpandIfNeeded 的被调用关系,可以发现,_dictExpandIfNeeded 是被 _dictKeyIndex 函数调用的,而 _dictKeyIndex 函数又会被dictAddRaw 函数调用,然后 dictAddRaw 会被以下三个函数调用:
- dictAdd:用来往 Hash 表中添加一个键值对
- dictRelace:用来往 Hash 表中添加一个键值对,或者键值对存在时,修改键值对
- dictAddorFind:直接调用 dictAddRaw
- 因此,当往 Redis 中写入新的键值对或是修改键值对时,Redis 都会判断下是否需要进行rehash
- 这里可以参考下面给出的示意图:
- 简而言之,Redis 中触发 rehash 操作的关键,就是 _dictExpandIfNeeded 函数和updateDictResizePolicy 函数
- _dictExpandIfNeeded 函数会根据 Hash 表的负载因子以及能否进行 rehash 的标识,判断是否进行 rehash
- 而 updateDictResizePolicy 函数会根据 RDB 和 AOF 的执行情况,启用或禁用 rehash
- 接下来,继续探讨 Redis 在实现 rehash 时,要解决的第二个问题:rehash 扩容扩多大?
-
rehash 扩容扩多大?
- 在 Redis 中,rehash 对 Hash 表空间的扩容是通过调用 dictExpand 函数来完成的
- dictExpand 函数的参数有两个,一个是要扩容的 Hash 表,另一个是要扩到的容量
- 下面的代码就展示了 dictExpand 函数的原型定义:
- 那么,对于一个 Hash 表来说,就可以根据前面提到的 _dictExpandIfNeeded 函数,来判断是否要对其进行扩容
- 而一旦判断要扩容,Redis 在执行 rehash 操作时,对 Hash 表扩容的思路也很简单,就是如果当前表的已用空间大小为 size,那么就将表扩容到 size*2 的大小
- 如下所示,当 _dictExpandIfNeeded 函数在判断了需要进行 rehash 后,就调用 dictExpand进行扩容
- 这里可以看到,rehash 的扩容大小是当前 ht[0]已使用大小的 2 倍
- 而在 dictExpand 函数中,具体执行是由 _dictNextPower 函数完成的
- 以下代码显示的Hash表扩容的操作,就是从 Hash 表的初始大小(DICT_HT_INITIAL_SIZE),不停地乘以2,直到达到目标大小
- 下面再来看看 Redis 要解决的第三个问题,即 rehash 要如何执行?而这个问题,本质上就是 Redis 要如何实现渐进式 rehash 设计
-
渐进式 rehash 如何实现?
- 那么这里要先搞清楚一个问题,就是为什么要实现渐进式 rehash?
- 其实这是因为,Hash 表在执行 rehash 时,由于 Hash 表空间扩大,原本映射到某一位置的键可能会被映射到一个新的位置上,因此,很多键就需要从原来的位置拷贝到新的位置
- 而在键拷贝时,由于 Redis 主线程无法执行其他请求,所以键拷贝会阻塞主线程,这样就会产生rehash开销
- 而为了降低 rehash 开销,Redis 就提出了渐进式 rehash 的方法
- 简单来说,渐进式 rehash 的意思就是 Redis 并不会一次性把当前 Hash 表中的所有键,都拷贝到新位置,而是会分批拷贝,每次的键拷贝只拷贝 Hash 表中一个 bucket 中的哈希项
- 这样一来,每次键拷贝的时长有限,对主线程的影响也就有限了
- 那么,渐进式 rehash 在代码层面是如何实现的呢?
- 这里有两个关键函数:dictRehash 和_dictRehashStep
- 先来看 dictRehash 函数,这个函数实际执行键拷贝,它的输入参数有两个,分别是全局哈希表(即前面提到的 dict 结构体,包含了 ht[0]和 ht[1])和需要进行键拷贝的桶数量(bucket 数量)
- dictRehash 函数的整体逻辑包括两部分:
- 首先,该函数会执行一个循环,根据要进行键拷贝的 bucket 数量 n,依次完成这些bucket 内部所有键的迁移
- 当然,如果ht[0]哈希表中的数据已经都迁移完成了,键拷贝的循环也会停止执行
- 其次,在完成了 n 个 bucket 拷贝后,dictRehash 函数的第二部分逻辑,就是判断 ht[0]表中数据是否都已迁移完
- 如果都迁移完了,那么 ht[0]的空间会被释放
- 因为 Redis 在处理请求时,代码逻辑中都是使用 ht[0],所以当 rehash 执行完成后,虽然数据都在 ht[1]中了,但 Redis 仍然会把 ht[1]赋值给 ht[0],以便其他部分的代码逻辑正常使用
- 而在 ht[1]赋值给 ht[0]后,它的大小就会被重置为 0,等待下一次 rehash
- 与此同时,全局哈希表中的 rehashidx 变量会被标为 -1,表示 rehash 结束了(这里的 rehashidx 变量用来表示 rehash 的进度,稍后会给具体解释)
- 下面这张图,展示了 dictRehash 的主要执行流程:
- 同时也可以通过下面代码,来了解 dictRehash 函数的主要执行逻辑:
- 在了解了 dictRehash 函数的主体逻辑后,再看下渐进式 rehash 是如何按照 bucket粒度拷贝数据的,这其实就和全局哈希表 dict 结构中的 rehashidx 变量相关了
- rehashidx 变量表示的是当前 rehash 在对哪个 bucket 做数据迁移
- 比如,当 rehashidx 等于 0 时,表示对 ht[0]中的第一个 bucket 进行数据迁移
- 当 rehashidx 等于 1 时,表示对ht[0]中的第二个 bucket 进行数据迁移,以此类推
- 而 dictRehash 函数的主循环,首先会判断 rehashidx 指向的 bucket 是否为空,如果为空,那就将 rehashidx 的值加 1,检查下一个 bucket
- 那么,有没有可能连续几个 bucket 都为空呢?
- 其实是有可能的,在这种情况下,渐进式rehash 不会一直递增 rehashidx 进行检查
- 这是因为一旦执行了 rehash,Redis 主线程就无法处理其他请求了
- 所以,渐进式 rehash 在执行时设置了一个变量 empty_visits,用来表示已经检查过的空bucket,当检查了一定数量的空 bucket 后,这一轮的 rehash 就停止执行,转而继续处理外来请求,避免了对 Redis 性能的影响
- 下面的代码显示了这部分逻辑,可以看看:
- 而如果 rehashidx 指向的 bucket 有数据可以迁移,那么 Redis 就会把这个 bucket 中的哈希项依次取出来,并根据 ht[1]的表空间大小,重新计算哈希项在 ht[1]中的 bucket 位置,然后把这个哈希项赋值到 ht[1]对应 bucket 中
- 这样每做完一个哈希项的迁移,ht[0]和 ht[1]用来表示承载哈希项多少的变量 used,就会分别减一和加一
- 当然,如果当前 rehashidx 指向的 bucket 中数据都迁移完了,rehashidx就会递增加 1,指向下一个 bucket
- 下面的代码显示了这一迁移过程:
- 到这里就已经基本了解了 dictRehash 函数的全部逻辑
- 现在知道,dictRehash 函数本身是按照 bucket 粒度执行哈希项迁移的,它内部执行的bucket 迁移个数,主要由传入的循环次数变量 n 来决定
- 但凡 Redis 要进行 rehash 操作,最终都会调用 dictRehash 函数
- 接下来,来学习和渐进式 rehash 相关的第二个关键函数 _dictRehashStep,这个函数实现了每次只对一个 bucket 执行 rehash
- 从 Redis 的源码中可以看到,一共会有 5 个函数通过调用 _dictRehashStep 函数,进而调用 dictRehash 函数,来执行 rehash
- 它们分别是:dictAddRaw,dictGenericDelete,dictFind,dictGetRandomKey,dictGetSomeKeys
- 其中,dictAddRaw 和 dictGenericDelete 函数,分别对应了往 Redis 中增加和删除键值对,而后三个函数则对应了在 Redis 中进行查询操作
- 下图展示了这些函数间的调用关系:
- 但要注意,不管是增删查哪种操作,这 5 个函数调用的 _dictRehashStep 函数,给dictRehash 传入的循环次数变量 n 的值都为 1,下面的代码就显示了这一传参的情况
- 这样一来,每次迁移完一个 bucket,Hash 表就会执行正常的增删查请求操作,这就是在代码层面实现渐进式 rehash 的方法