转载请声明出处哦~,本篇文章发布于luozhiyun的博客:https://www.luozhiyun.com/archives/667

本文使用的Redis 5.0源码

概述

我们在学习 Redis 的 Hash 表的时候难免脑子里会想起其他 Hash 表的实现,然后进行一番对比。通常我们如果要设计一个 Hash 表,那么我们需要考虑这几个问题:

  1. 有没有并发操作;
  2. Hash冲突如何解决;
  3. 以什么样的方式扩容。

对 Redis 来说,首先它是单线程的工作模式,所以不需要考虑并发问题,这题 pass。

对于 Hash 冲突的解决,通常来说有,开放寻址法、再哈希法、拉链法等。但是大多数的编程语言都用拉链法实现哈希表,它的实现复杂度也不高,并且平均查找的长度也比较短,各个用于存储节点的内存都是动态申请的,可以节省比较多的存储空间。

所以对于 Redis 来说也是使用了拉链法来解决 hash 冲突,如下所示,通过链表的方式把一个个节点串起来:

至于为什么没有向 JDK 的 HashMap 一样红黑树来解决冲突,我觉得其实有两方面,一方面是链表转红黑数其实也是需要时间成本的,会影响链表的操作效率;另一方面就是红黑树其实在节点比较少的情况下效率是不如链表的。

再来看看扩容,对于扩容来说,一般要新起一块内存,然后将旧数据迁移到新的内存块中,这个过程中因为是单线程,所以在扩容的时候,不能阻塞主线程很长时间,在 Redis 中采用的是渐进式 rehash + 定时 rehash

渐进式 rehash 会在执行增删查改前,先判断当前字典是否在执行rehash。如果是,则rehash一个节点。这其实是一种分治的思想,通过通过把大任务划分成一个个小任务,每个小任务只执行一小部分数据,最终完成整个大任务。

定时 rehash 如果 dict 一直没有操作,无法渐进式迁移数据,那主线程会默认每间隔 100ms 执行一次迁移操作。这里一次会以 100 个桶为基本单位迁移数据,并限制如果一次操作耗时超时 1ms 就结束本次任务,待下次再次触发迁移

Redis 在结构体中设置两个表 ht[0]ht[1],如果当前 ht[0]的容量是 0 ,那么第一次会直接给4个容量;如果不是 0 ,那么容量会直接翻倍,然后将新内存放入到ht[1]中返回,并设置标记0表示在扩容中。

迁移 hash 桶的操作会在增删改查哈希表时每次迁移 1 个哈希桶从ht[0] 迁移到ht[1],在迁移拷贝完所有桶之后会将ht[0] 空间释放,然后将ht[1]赋值给ht[0] ,并把ht[1]大小重置为0 ,并将表示设置标记1表示 rehash 结束了。

对于查找来说,在 rehash 的过程中,因为没有并发问题,所以查找 dict 也会依次先查找 ht[0] 然后再查找 ht[1]

设计与实现

Redis 的 hash 实现主要在 dict.h 和 dict.c 这两个文件中。

hash 表的数据结构大致如下所示,我就不贴出结构体的代码了,字段都标注在图上了:

从上面的图上也可以看到 hash 表中有一个空间为2的 dictht 数组,这个数组就是用来做 rehash 时交替保存数据用的,其中 dict 里面的 rehashidx 用来表示是否在进行 rehash 。

何时触发扩缩容?

很多 hash 表都只有扩容,但是 dict 在 Redis 中是既有扩容,也有缩容。

扩容

扩容其实就是一般是在 add 元素的时候校验一下是否达到某个阈值,然后决定要不要进行扩容。所以经过搜索可以看到添加元素会调用 dictAddRaw 这个函数,我们通过函数的注释也可以知道它是 add 或查找的底层的函数。

Low level add or find:

This function adds the entry but instead of setting a value returns the dictEntry structure to the user, that will make sure to fill the value field as he wishes.

dicAddRaw 函数会调用到 _dictKeyIndex 函数,这个函数会调用 _dictExpandIfNeeded 判断是否需要扩容。

 ┌─────────────┐     ┌─────────┐      ┌─────────────┐      ┌─────────────────────┐
│ add or find ├────►│dicAddRaw├─────►│_dictKeyIndex├─────►│ _dictExpandIfNeeded │
└─────────────┘ └─────────┘ └─────────────┘ └─────────────────────┘

_dictExpandIfNeeded 函数判断了大致有三种情况会进行扩容:

  1. 如果 hash 表的size为0,那么创建一个容量为4的hash表;
  2. 服务器目前没有在执行 rdb 或者 aof 操作, 并且哈希表的负载因子大于等于 1
  3. 服务器目前正在执行 rdb 或者 aof 操作, 并且哈希表的负载因子大于等于 5

其中哈希表的负载因子可以通过公式:

// load ratio = the number of elements / the buckets
load_ratio = ht[0].used / ht[0].size

比如说, 对于一个大小为 4 , 包含 4 个键值对的哈希表来说, 这个哈希表的负载因子为:

load_ratio = 4 / 4 = 1

又比如说, 对于一个大小为 512 , 包含 256 个键值对的哈希表来说, 这个哈希表的负载因子为:

load_ratio = 256 / 512 = 0.5

为什么要根据 rdb 或者 aof 操作联合负载因子来判断是否应该扩容呢?其实源码的注释中也有提到:

as we use copy-on-write and don't want to move too much memory around when there is a child performing saving operations.

也就是说在 copy-on-write 时提高执行扩展操作所需的负载因子, 可以尽可能地避免在子进程存在期间进行哈希表扩展操作, 这可以避免不必要的内存写入操作, 最大限度地节约内存,提高子进程的操作的性能。

逻辑我们说完了, 下面我们看看源码:

static int _dictExpandIfNeeded(dict *d)
{
// 正在扩容中
if (dictIsRehashing(d)) return DICT_OK;
// 如果 hash 表的size为0,那么创建一个容量为4的hash表
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE); // hash表中元素的个数已经大于hash表桶的数量
if (d->ht[0].used >= d->ht[0].size &&
//dict_can_resize 表示是否可以扩容
(dict_can_resize ||
// hash表中元素的个数已经除以hash表桶的数量是否大于5
d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
{
return dictExpand(d, d->ht[0].used*2); // 容量扩大两倍
}
return DICT_OK;
}

通过上面的源码我们可以知道,如果当前表的已用空间大小为 size,那么就将表扩容到 size*2 的大小。新的 dict hash 表是通过 dictExpand 来进行创建的。

int dictExpand(dict *d, unsigned long size)
{
//正在扩容,直接返回
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR; dictht n;
// _dictNextPower会返回 size 最接近的2的指数值
// 也就是size是10,那么返回 16,size是20,那么返回32
unsigned long realsize = _dictNextPower(size); // 校验扩容之后的值是否和当前一样
if (realsize == d->ht[0].size) return DICT_ERR;
// 初始化 dictht 成员变量
n.size = realsize;
n.sizemask = realsize-1;
n.table = zcalloc(realsize*sizeof(dictEntry*)); // 申请空间是 size * Entry的大小
n.used = 0; //校验hash 表是否初始化过,没有初始化不应该进行rehash
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
//将新的hash表赋值给 ht[1]
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}

这一段代码还是比较清晰的,可以跟着上面的注释稍微看一下就好了。

缩容

讲完了扩容,那么来看一下缩容。熟悉 Redis 的同学都知道,在 Redis 里面对于清理过期数据一个是惰性删除,另一个是定期删除,缩容其实也是在定期删除里面做的。

Redis 的定时器会每100ms调用一次 databasesCron 函数,它会调用到 dictResize 函数进行缩容:

 ┌─────────────┐   ┌──────────────────┐   ┌──────────┐   ┌──────────┐
│databasesCron├──►│tryResizeHashTable├──►│dictResize├──►│dictExpand│
└─────────────┘ └──────────────────┘ └──────────┘ └──────────┘

同样的 dictResize 函数中也会判断一下是否正在执行 rehash 以及校验 dict_can_resize 是否在进行 copy on write操作。然后将 hash 表的 bucket 大小缩小为和被键值对同样大小:

int dictResize(dict *d)
{
int minimal; if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
minimal = d->ht[0].used; // 将bucket 缩小为和被键值对同样大小
if (minimal < DICT_HT_INITIAL_SIZE)
minimal = DICT_HT_INITIAL_SIZE;
return dictExpand(d, minimal);
}

最后同样调用 dictExpand 创建新的空间赋值给 ht[1]。

数据迁移如何进行?

上面我们也提到了,无论是扩容还是缩容,创建的新的空间都会赋值给 ht[1] 以便进行数据迁移。然后在两个地方分别执行数据迁移,一个是增删改查哈希表时触发,另一个是定时触发

增删改查哈希表时触发

增删改查操作的时候都会检查 rehashidx 参数,校验是否正在迁移,如果正在迁移那么会调用 _dictRehashStep 函数,然后会调用到 dictRehash 函数。

但是需要注意的是,这里调用 dictRehash 函数传入的大小是 1 ,也就意味着每次只迁移 1 个 bucket。下面我们来看看 dictRehash 函数,这是整个迁移过程中最重要的函数。这个函数主要做了以下几件事:

  1. 校验当前迁移的bucket数量是否已达上线,并且ht[0]是否还有元素;
  2. 判断当前的迁移的bucket槽位是否为空,最大访问的空槽数量不能超过 n*10,n是本次迁移bucket数量;
  3. 获取到非空槽位里面 entry 链表进行循环迁移;
    1. 首先获取ht[1]新槽位的index;
    2. 一个个节点放置到新bucket的头部;
    3. 直到全部迁移完毕;
  4. 迁移完了将旧的hash表ht[0]对应的bucket置空;
  5. 检查如果已经rehash完了,那么需要free掉内存占用,并将ht[1]赋值给ht[0];

感兴趣的可以看看下面源码,已标注好注释:

int dictRehash(dict *d, int n) {
// 最大的空bucket访问次数
int empty_visits = n*10; /* Max number of empty buckets to visit. */
if (!dictIsRehashing(d)) return 0;
// 校验当前迁移的bucket数量是否已达上线,并且ht[0]是否还有元素;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde; assert(d->ht[0].size > (unsigned long)d->rehashidx);
// 判断当前的迁移的bucket槽位是否为空
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
// 获取到槽位里面 entry 链表
de = d->ht[0].table[d->rehashidx];
// 从老的bucket迁移数据到新的bucket中
while(de) {
uint64_t h;
nextde = de->next;
// hash之后获取新hash表的bucket槽位
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
// 一个个节点放置到新bucket的头部
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
// 迁移完了将旧的hash表对应的bucket置空
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
// 如果已经rehash完了,那么需要free掉内存占用,并将ht[1]赋值给ht[0]
if (d->ht[0].used == 0) {
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;// 返回0表示迁移已完成
}
return 1; // 返回1表示迁移未完成
}

定时触发

定时触发是由 databasesCron 函数进行定时触发,这个函数会每100ms 运行一次,最终会通过 dictRehashMilliseconds 函数调用到我们上面提到的 dictRehash 函数。

  ┌─────────────┐   ┌───────────────────┐   ┌──────────────────────┐   ┌──────────┐
│databasesCron├──►│incrementallyRehash├──►│dictRehashMilliseconds├──►│dictRehash│
└─────────────┘ └───────────────────┘ └──────────────────────┘ └──────────┘

dictRehashMilliseconds 函数传入的 ms 参数表示可以运行多长时间,默认传入的是1,也就是运行1ms就会退出这个函数:

int dictRehashMilliseconds(dict *d, int ms) {
long long start = timeInMilliseconds();
int rehashes = 0;
// 每次会迁移 100 个 bucket
while(dictRehash(d,100)) {
rehashes += 100;
if (timeInMilliseconds()-start > ms) break;
}
return rehashes;
}

调用 dictRehash 函数的时候每次会迁移 100 个 bucket。

总结

之所有要讲 hash 表的实现是因为 Redis 中凡是需要 O(1) 时间获取 kv 数据的场景,都使用了 dict 这个数据结构,而 Redis 用的最多的也就是这种 kv 获取的场景,所以通过这篇文章我们可以清楚的了解到 Redis 的 kv 存储是怎么存放数据的,何时扩容,以及扩容是如何迁移数据的。

看这篇文章的时候不妨对比一下自己所使用的语言中 hash 表是如何实现的。

Reference

https://tech.meituan.com/2018/07/27/redis-rehash-practice-optimization.html

http://redisbook.com/preview/dict/rehashing.html

https://juejin.cn/post/6986102133649063972#heading-1

https://tech.youzan.com/redisyuan-ma-jie-xi/

https://time.geekbang.org/column/article/400379

透过Redis源码探究Hash表的实现的更多相关文章

  1. 透过Redis源码探究字符串的实现

    转载请声明出处哦~,本篇文章发布于luozhiyun的博客:https://www.luozhiyun.com 本文使用的Redis 5.0源码 概述 最近在通过 Redis 学 C 语言,不得不说, ...

  2. Redis源码研究--跳表

    -------------6月29日-------------------- 简单看了下跳表这一数据结构,理解起来很真实,效率可以和红黑树相比.我就喜欢这样的. typedef struct zski ...

  3. Redis 源码简洁剖析 03 - Dict Hash 基础

    Redis Hash 源码 Redis Hash 数据结构 Redis rehash 原理 为什么要 rehash? Redis dict 数据结构 Redis rehash 过程 什么时候触发 re ...

  4. Redis源码解析之跳跃表(三)

    我们再来学习如何从跳跃表中查询数据,跳跃表本质上是一个链表,但它允许我们像数组一样定位某个索引区间内的节点,并且与数组不同的是,跳跃表允许我们将头节点L0层的前驱节点(即跳跃表分值最小的节点)zsl- ...

  5. Redis源码研究--字典

    计划每天花1小时学习Redis 源码.在博客上做个记录. --------6月18日----------- redis的字典dict主要涉及几个数据结构, dictEntry:具体的k-v链表结点 d ...

  6. [Redis源码阅读]dict字典的实现

    dict的用途 dict是一种用于保存键值对的抽象数据结构,在redis中使用非常广泛,比如数据库.哈希结构的底层. 当执行下面这个命令: > set msg "hello" ...

  7. Redis源码剖析--源码结构解析

    请持续关注我的个人博客:https://zcheng.ren 找工作那会儿,看了黄建宏老师的<Redis设计与实现>,对redis的部分实现有了一个简明的认识.在面试过程中,redis确实 ...

  8. Redis源码阅读(四)集群-请求分配

    Redis源码阅读(四)集群-请求分配 集群搭建好之后,用户发送的命令请求可以被分配到不同的节点去处理.那Redis对命令请求分配的依据是什么?如果节点数量有变动,命令又是如何重新分配的,重分配的过程 ...

  9. Redis源码分析:serverCron - redis源码笔记

    [redis源码分析]http://blog.csdn.net/column/details/redis-source.html   Redis源代码重要目录 dict.c:也是很重要的两个文件,主要 ...

随机推荐

  1. 【DIY】【CSAPP-LAB】深入理解计算机系统--datalab笔记

    title: 前言 <深入理解计算机系统>一书是入门计算机系统的极好选择,从其第三版的豆瓣评分9.8分可见一斑.该书的起源是卡耐基梅龙大学 计算机系统入门课(Introduction to ...

  2. Web安全学习笔记 SQL注入中

    Web安全学习笔记 SQL注入中 繁枝插云欣 --ICML8 权限提升 数据库检测 绕过技巧 一.权限提升 1. UDF提权 UDF User Defined Function,用户自定义函数 是My ...

  3. 国产开源优秀新一代MPP数据库StarRocks入门之旅-数仓新利器(上)

    概述 背景 Apache Doris官方地址 https://doris.apache.org/ Apache Doris GitHub源码地址 https://github.com/apache/i ...

  4. zabbix5.0报错PHP时区未设置(配置参数"date.timezone")

    解决办法 : #1.编辑文件/etc/opt/rh/rh-php72/php-fpm.d/zabbix.conf,取消注释并设置为所在地时区 vim /etc/opt/rh/rh-php72/php- ...

  5. 【java并发编程】Lock & Condition 协调同步生产消费

    一.协调生产/消费的需求 本文内容主要想向大家介绍一下Lock结合Condition的使用方法,为了更好的理解Lock锁与Condition锁信号,我们来手写一个ArrayBlockingQueue. ...

  6. 【算法】希尔排序(Shell Sort)(四)

    希尔排序(Shell Sort) 1959年Shell发明,第一个突破O(n2)的排序算法,是简单插入排序的改进版.它与插入排序的不同之处在于,它会优先比较距离较远的元素.希尔排序又叫缩小增量排序. ...

  7. DirectX11--CPU与GPU计时器

    前言 GAMES104的王希说过: 游戏引擎的世界里,它的核心是靠Tick()函数把这个世界驱动起来. 本来单是一个CPU的计时器是不至于为其写一篇博客的,但把GPU计时器功能加上后就不一样了.在这一 ...

  8. 虚拟环境与django版本与视图层相关知识

    目录 虚拟环境 django版本区别 视图函数返回值 JsonResponse对象 form表单上传文件 request方法 FBV与CBV CBV源码剖析 模板语法传值 传值方式 传值范围 虚拟环境 ...

  9. Tarjan入门

    Tarjan系列!我愿称Tarjan为爆搜之王! 1.Tarjan求LCA 利用并查集在一遍DFS中可以完成所所有询问.是一种离线算法. 遍历到一个点时,我们先将并查集初始化,再遍历完一个子树之后,将 ...

  10. Node.js安装与环境配置

    废话不多少直接上干货.坐车扶稳, 当然你要知道Node.js 是一个基于Chrome JavaScript 运行时建立的一个平台.其次Node.js是一个事件驱动I/O服务端JavaScript环境, ...