聊聊redis中的字典的实现

news2024/10/7 5:30:52

写在文章开头

redis作为非关系数据库,其底层采用了字典(也称为映射)保存键值对。本文会基于源码分析的方式带你了解redis中这一常见数据结构的精巧设计,希望对你有帮助。

在这里插入图片描述

Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili

因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

在这里插入图片描述

详解redis中的字典的设计与实现

哈希表的基本数据结构

字典用table管理当前存储键值对的数组,这个数组的大小可有size这个字段知晓,每当我们有一个键值对要存储到字典时就会通过sizemask进行按位与运算得到table数组的某个索引位置将当前键值对存储,然后自增一个used标识当前有一个节点加入。

可能上文说的比较抽象,我们不妨举个例子,假设我们现在键入如下指令:

HSET student  xiaoming 18

redis完成命令解析后,定位到student这个key对应的字段空间的字典,找到当前正在使用的哈希表,按照如下步骤完成键值对存储:

  1. 计算xiaoming的哈希值。
  2. 将计算出的哈希值和sizemask即3,也就是数组的索引范围进行按位与运算,得到对应的数组索引位置。
  3. 查看该位置是否有元素,如果没有则直接添加,反之追加到该dictEntry的后面,这也就是我们常说的链地址法
  4. used字段自增一下,表示当前哈希表有一个元素。

在这里插入图片描述

我们可以在dict.h看到上文所提及的哈希表和字典中每一个元素的数据结构:

typedef struct dictht {
	//存储键值对的哈希表
    dictEntry **table;
    //当前哈希表的大小
    unsigned long size;
    //计算哈希值的掩码值
    unsigned long sizemask;
    //当前哈希表的节点数
    unsigned long used;
} dictht;

//记录键值对的数据结构dictEntry 
typedef struct dictEntry {
	//指向键的指针
    void *key;
    
    //通过共用体存储值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    //next指针指向下一个dictEntry 
    struct dictEntry *next;
} dictEntry;

字典的数据结构

哈希表在极端算法情况下会造成大量键值对冲突碰撞的情况,导致查询效率由原来的O(n)变为O(1),所以为了保证针对冲突的数组进行优化,redis的字典采用的双数组的方式管理键值对,如下图所示,可以看到dict的数据结构定义了大小为2的哈希表数组,当某个哈希表碰撞激烈需要进行调整时就调整算法将对应的键值对存到dictht[1],并通过rehashidx标志为-1表示当前处于渐进式哈希阶段:

在这里插入图片描述

对应的我们也可以在dict.h看到dict 的定义:

typedef struct dict {
  //.......
  	//定义2个哈希表
    dictht ht[2];
    //-1时表示当前哈希表处于渐进式哈希
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */
    //.......
} dict;

字典的创建

进行键值对创建时,dictCreate会进行必要的内存分配,然后进入初始化工作:

  1. 初始化两个哈希表空间。
  2. 设置类型特定函数type ,这个type 包含了各种类型哈希值计算、值复制以及键比对等各种方法的指针。
  3. 设置私有数据privdata
  4. 初始化rehashidx 为-1表示未进行渐进式再哈希。

对应的我们可以在dict.c中看到这段源代码:

/* Create a new hash table */
dict *dictCreate(dictType *type,
        void *privDataPtr)
{
	//内存分配
    dict *d = zmalloc(sizeof(*d));
	//字典初始化
    _dictInit(d,type,privDataPtr);
    return d;
}

/* Initialize the hash table */
int _dictInit(dict *d, dictType *type,
        void *privDataPtr)
{
	//重置哈希表
    _dictReset(&d->ht[0]);
    _dictReset(&d->ht[1]);
    //设置类型特定函数和私有数据
    d->type = type;
    d->privdata = privDataPtr;
    //初始化渐进式哈希标识
    d->rehashidx = -1;
    d->iterators = 0;
    return DICT_OK;
}

元素的插入

字典的插入操作大体流程也很市面上常见的哈希表实现差不多,通过哈希算法(MurmurHash2)定位元素插入的位置再进行插入操作,唯一有所区别的是,redis版本字典的链地址法解决冲突的上的优化,为了保证哈希定位的位置存在元素时能够快速插入,redis字典的插入采用的是头插法,即将最新的元素作为链表头元素插入:

在这里插入图片描述

与之对应的我们给出代码的入口,也就是dict.c下的dictAdd方法,可以看到其内部是通过完成键的添加,只有key插入成功后才会通过setVal方法维护插入的entry的值:

int dictAdd(dict *d, void *key, void *val)
{
	//通过dictAddRaw完成key的插入
    dictEntry *entry = dictAddRaw(d,key);
	//如果插入成功再维护value
    if (!entry) return DICT_ERR;
    dictSetVal(d, entry, val);
    return DICT_OK;
}

dictAddRaw逻辑也比较简单,先检查当前的字典表是否因为大量冲突而处理渐进式哈希(关于渐进式哈希后文会详细讲解,这里也补充一些简单的概念),通过_dictKeyIndex定位到当前元素插入的索引位置,采用头插法将其插入到对应索引位置的链表首部:

dictEntry *dictAddRaw(dict *d, void *key)
{
    int index;
    dictEntry *entry;
    dictht *ht;
	//是否处于渐进式哈希阶段
    if (dictIsRehashing(d)) _dictRehashStep(d);

   //定位索引位置
    if ((index = _dictKeyIndex(d, key)) == -1)
        return NULL;

   //定位要存储元素的哈希表位置
    ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
    //分配内存空间
    entry = zmalloc(sizeof(*entry));
    //采用头插法将元素插入到对应哈希表的索引位置上
    entry->next = ht->table[index];
    ht->table[index] = entry;
    //当前插入元素数加一
    ht->used++;

    /* Set the hash entry fields. */
    dictSetKey(d, entry, key);
    return entry;
}

哈希冲突及其对应解决方案

随着我们不断的新增键值对,当前的哈希算法得到的索引位置很大概率会出现哈希冲突,即每次定位到的索引位置都很大概率存在元素,这也就是我们的常说的哈希冲突,这就是redis的字典默认会初始化两张哈希表的原因所在。
符合以下两个条件时,字典就会触发扩容机制:

  1. 未进行BGSAPE命令或者BGREWRITEAOF持久化操作,且当前哈希表元素数何哈希表空间大小一样。
  2. 正进行BGSAPE命令或者BGREWRITEAOF持久化操作,当且哈希表元素数是哈希表空间的5倍。

触发扩容时,字典会将rehashidx设置为0意为当前因为大量冲突碰撞而从0索引开始渐进式再哈希,ht[1]就会开辟一个ht[0]当前正在使用的哈希表空间大小*2的空间,后续的新插入的元素也都会根据哈希算法将元素插入到ht[1]中。
对于旧有存在的元素,考虑到整个哈希表可能存在不可预估数量的键值对,redis的字典会通过渐进式哈希的方式在元素每次进行增删改查操作时将旧有元素迁移到ht[1]中,一旦所有元素全部迁移到ht[1]后,哈希表就会将ht[1]指向的哈希表指针赋值给ht[0],并将ht[0]原有哈希表释放。

在这里插入图片描述

了解整体的设计之后,我们就可以从源码角度印证这个问题了,可以看到字典在每次进行哈希索引定位时都会调用_dictKeyIndex方法,而该方法内部则有一个_dictExpandIfNeeded操作,其内部就会根据我们上文所说的阈值判断当前哈希表是否需要进行扩容:

static int _dictKeyIndex(dict *d, const void *key)
{
    unsigned int h, idx, table;
    dictEntry *he;

   	//判断当前哈希表是否需要进行扩容操作
    if (_dictExpandIfNeeded(d) == DICT_ERR)
        return -1;
   //获取当前key的哈希值
    h = dictHashKey(d, key);
    //计算哈希值
    for (table = 0; table <= 1; table++) {
    	//计算索引
        idx = h & d->ht[table].sizemask;
		
        he = d->ht[table].table[idx];
        while(he) {
            if (dictCompareKeys(d, key, he->key))
                return -1;
            he = he->next;
        }
        //如果不处于渐进式哈希阶段,则直接将该索引值返回,后续元素直接存入ht[0]表中,反之进入下一个循环计算当前元素在ht[1]表的索引
        if (!dictIsRehashing(d)) break;
    }
    return idx;
}

我们继续步入_dictExpandIfNeeded即可看到扩容判断的逻辑,也就是我们上文所说的符合两个扩容条件之一时就会触发扩容:

static int _dictExpandIfNeeded(dict *d)
{
	//如果正处于渐进式哈希则直接返回
    if (dictIsRehashing(d)) return DICT_OK;

   //......
   //如果当前有子进程进行持久化操作则dict_can_resize为false,所以当字典元素数大于等于哈希表大小且未进行持久化,或进行持久化操作且元素数是哈希表的5倍时才会进行扩容操作,dictExpand会将rehashidx设置为0,告知当前哈希表进行扩容需要进行渐进式再哈希
    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;
}

此时我们再回看之前的键值对插入操作,它会根据dictIsRehashing判断rehashidx是否为0,从而调用_dictRehashStep进入渐进式哈希操作在键值对维护:

dictEntry *dictAddRaw(dict *d, void *key)
{
    int index;
    dictEntry *entry;
    dictht *ht;
	//如果处于再哈希阶段,若符合要求则进行一次ht[0]哈希表元素驱逐操作
    if (dictIsRehashing(d)) _dictRehashStep(d);

   //保存键值对操作
   //......
    return entry;
}

我们直接查看_dictRehashStep内部的实现就可以看到一个dictRehash的方法,它就是渐进式哈希的核心实现,可以看到该方法会从0开始每次驱逐10个元素到ht[1]中:

int dictRehash(dict *d, int n) {
	//驱逐10个元素
    int empty_visits = n*10; 
    if (!dictIsRehashing(d)) return 0;
    
	//将当前rehashidx中的元素驱逐到ht[1]中
    while(n-- && d->ht[0].used != 0) {
        dictEntry *de, *nextde;

        
        assert(d->ht[0].size > (unsigned long)d->rehashidx);
        //当前索引位置为空,说明驱逐完成返回1,下次继续
        while(d->ht[0].table[d->rehashidx] == NULL) {
            d->rehashidx++;
            if (--empty_visits == 0) return 1;
        }
        de = d->ht[0].table[d->rehashidx];
        //计算当前元素在ht[1]中的位置并驱逐过去
        while(de) {
            unsigned int h;

            nextde = de->next;
            /* Get the index in the new hash table */
            h = dictHashKey(d, de->key) & d->ht[1].sizemask;
            de->next = d->ht[1].table[h];
            d->ht[1].table[h] = de;
            d->ht[0].used--;
            d->ht[1].used++;
            de = nextde;
        }
        d->ht[0].table[d->rehashidx] = NULL;
        d->rehashidx++;
    }

	 //used 为0说明所有元素驱逐完成,将ht[1]指向的哈希表赋值给ht[0],重置rehashidx ,并返回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;
    }

    /* More to rehash... */
    return 1;
}

查询操作

有了上述的基础后,我们查看查询操作就比较简单了,其步骤比较固定:

  1. 计算key的哈希值。
  2. 计算对应索引位置到ht[0]定位,如果找到了直接返回。
  3. 如果没找到,查看当前是否处于扩容阶段,若是则到ht[1]进行哈希定位,若找到直接返回。
  4. 上述操作都未找到该元素,直接返回null
dictEntry *dictFind(dict *d, const void *key)
{
    //......
    //计算哈希值
    h = dictHashKey(d, key);
    //通过哈希算法定位索引,到哈希表进行查询
    for (table = 0; table <= 1; table++) {
        idx = h & d->ht[table].sizemask;
        he = d->ht[table].table[idx];
        //遍历当前索引位置的元素,找到比对一致的返回
        while(he) {
            if (dictCompareKeys(d, key, he->key))
                return he;
            he = he->next;
        }
        //上一步没找到则判断是否处于扩容,若处于扩容则进入下一个循环到ht[1]表找,反之直接返回null
        if (!dictIsRehashing(d)) return NULL;
    }
    return NULL;
}


删除操作

同理我们最后给出删除操作的源码,也查询操作一样,定位到元素后,将其从索引位置中解除该元素和前驱节点关系即可:

static int dictGenericDelete(dict *d, const void *key, int nofree)
{
	//......
	
   	//定位元素
    h = dictHashKey(d, key);
	
    for (table = 0; table <= 1; table++) {
        idx = h & d->ht[table].sizemask;
        he = d->ht[table].table[idx];
        prevHe = NULL;
        while(he) {
        	//找到比对一致的键值对
            if (dictCompareKeys(d, key, he->key)) {
               //解除该元素和前驱节点的关系
                if (prevHe)
                    prevHe->next = he->next;
                else
                    d->ht[table].table[idx] = he->next;
                //释放当前节点
                if (!nofree) {
                    dictFreeKey(d, he);
                    dictFreeVal(d, he);
                }
                zfree(he);
                //元素数减去1
                d->ht[table].used--;
                return DICT_OK;
            }
            prevHe = he;
            he = he->next;
        }
        if (!dictIsRehashing(d)) break;
    }
    return DICT_ERR; /* not found */
}

小结

本文笔者从字典的数据结构和常见操作的角度对redis中字典的设计思想和优化思路进行深入的剖析,希望对你有帮助。

我是 sharkchiliCSDN Java 领域博客专家开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。

在这里插入图片描述

参考

《Redis设计与实现》

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1830723.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

需求虽小但是问题很多,浅谈JavaScript导出excel文件

最近我在进行一些前端小开发&#xff0c;遇到了一个小需求&#xff1a;我想要将数据导出到 Excel 文件&#xff0c;并希望能够封装成一个函数来实现。这个函数需要接收一个二维数组作为参数&#xff0c;数组的第一行是表头。在导出的过程中&#xff0c;要能够确保避免出现中文乱…

【单片机】DS2431芯片,读写128个字节,程序

ds2431pt&r stm32读写程序&#xff1a; 部分程序&#xff1a; #include "sys.h" #include "delay.h" #include "usart.h"#include <stdio.h> #include <stdlib.h> #include <string.h>#include "sys.h" #incl…

使用代理IP常见问题及解答

代理IP在互联网数据收集和业务开展中发挥着重要作用&#xff0c;它充当用户客户端和网站服务器之间的“屏障”&#xff0c;可以保护用户的真实IP地址&#xff0c;并允许用户通过不同的IP地址进行操作。然而&#xff0c;在使用代理IP的过程中&#xff0c;用户经常会遇到一些问题…

车载学习:UDS诊断、ECU刷写、OTA升级、Tbox测试、CANoe实操

每天的直播时间&#xff1a; 周一至周五&#xff1a;20&#xff1a;00-23&#xff1a;00 周六与周日&#xff1a;9&#xff1a;00-12&#xff1a;00&#xff0c;14&#xff1a;00-17&#xff1a;00 TBOX 深圳 涉及过T-BOX测试吗Ota升级涉及的台架环境是什么样的&#xff1f;上…

全球潮流集结:海外盲盒小程序引领消费新风尚

随着全球化的加速和互联网的普及&#xff0c;消费者的购物选择不再局限于本土市场&#xff0c;而是越来越多地关注国际潮流和时尚趋势。在这个背景下&#xff0c;海外盲盒小程序应运而生&#xff0c;以其独特的购物模式和全球化的商品选择&#xff0c;迅速成为引领消费新风尚的…

Elasticsearch:智能 RAG,获取周围分块(二)

在之前的文章 “Elasticsearch&#xff1a;智能 RAG&#xff0c;获取周围分块&#xff08;一&#xff09; ” 里&#xff0c;它介绍了如何实现智能 RAG&#xff0c;获取周围分块。在那个文章里有一个 notebook。为了方便在本地部署的开发者能够顺利的运行那里的 notebook。在本…

522. 最长特殊序列 II(Rust)

题目 给定字符串列表 strs &#xff0c;返回其中 最长的特殊序列 的长度。如果最长特殊序列不存在&#xff0c;返回 -1 。 特殊序列 定义如下&#xff1a;该序列为某字符串 独有的子序列&#xff08;即不能是其他字符串的子序列&#xff09;。 s 的 子序列可以通过删去字符串…

Linux中nginx.conf如何配置【搬代码】

Nginx 是一个独立的软件。 它是一款高性能的 Web 服务器、反向代理服务器和负载均衡器等&#xff0c;具有强大的功能和广泛的应用场景。它通常需要单独进行安装和配置来发挥其作用。 下载网址&#xff1a;http://nginx.org/en/download.html nginx.conf写法&#xff1a; #配置…

怎么样为您的步进电机设计选择正确的驱动器

步进电机已成为从车辆气候控制到工业剂量泵和剧院舞台照明等应用的动态定位解决方案。更好的性能、更小的尺寸和更低的成本都有助于它们进入主流应用。 正如电机及其应用的发展一样&#xff0c;控制电机所需的驱动电子设备也在不断发展。特别是&#xff0c;汽车设计人员已经实…

宋仕强论道之华强北假货

宋仕强论道之华强北假货&#xff0c;在华强北发展的初期&#xff0c;假货是华强北一大特点&#xff0c;一般分为翻新&#xff08;拆机&#xff09;货、散新货、换标货、虚标货、国产替换货等几种。金航标kinghelm&#xff08;www.kinghelm.com.cn&#xff09;和萨科微slkor&…

【Docker】解决访问难题:搭建私有的Docker镜像代理

什么是Nexus 3 Nexus 3是由Sonatype公司开发的一款强大的包管理和仓库服务工具&#xff0c;它广泛应用于自动化的构建系统和持续集成/持续部署(CI/CD)流程中。Nexus 3支持多种包格式&#xff0c;包括但不限于Maven、npm、Docker、NuGet等&#xff0c;能够为软件开发中的依赖管…

【Android】安Android Studio环境搭建注意点

人不走空 &#x1f308;个人主页&#xff1a;人不走空 &#x1f496;系列专栏&#xff1a;算法专题 ⏰诗词歌赋&#xff1a;斯是陋室&#xff0c;惟吾德馨 目录 &#x1f308;个人主页&#xff1a;人不走空 &#x1f496;系列专栏&#xff1a;算法专题 ⏰诗词歌…

切割游戏介绍

简介 上大学时&#xff0c;在学校实验室里玩过一个貌似使用VC写的小游戏&#xff0c;一个小球在界面上四处游荡&#xff0c;玩家使用鼠标切割背景&#xff0c;将背景切割剩余到一定的百分比后&#xff0c;就胜利了&#xff0c;后边的背景图会全部展示出来。 使用qt的qml技术&a…

超级底层:10WQPS/PB级海量存储HBase/RocksDB,底层LSM结构是什么?

一次穿透&#xff1a;10WQPS/PB级海量存储HBase/RocksDB的底层LSM结构 LSM tree 是很多数据库内部的核心数据结构&#xff0c;包括BigTable,ClickHouse、Cassandra, Scylla&#xff0c; RocksDB&#xff0c;HBase。 ClickHouse基于Log-Structured Merge-Tree 结构&#xff08…

【Mac】CrossOver 24 for Mac(windows 虚拟机)软件介绍及与PD的比较

软件介绍 Crossover for Mac是一款由CodeWeavers开发的软件&#xff0c;它允许用户在Mac操作系统上运行Windows程序&#xff0c;而无需安装Windows操作系统。 这个工具使用了Wine项目的技术&#xff0c;它通过创建一个虚拟的Windows环境来实现在Mac上运行Windows程序。用户可…

CoppeliaSim机器人模拟器与Matlab Simulink环境

一、CoppeliaSim机器人模拟器 CoppeliaSim&#xff08;原名V-REP&#xff0c;Virtual Robot Experimentation Platform&#xff09;是一款基于物理引擎的动力学机器人模拟器。它提供了一个集成的开发环境&#xff0c;支持传感器、机械、机器人、环境的系统建模与仿真。Coppeli…

404错误页面源码,简单实用的html错误页面模板

一款404错误页面源码&#xff0c;简单实用的html错误页面模板&#xff0c;简单大气的页面布局&#xff0c;可以使用到不同的网站中&#xff0c;相信大家一定会喜欢的&#xff0c;快来下载使用吧&#xff01; 源码下载&#xff1a;https://download.csdn.net/download/m0_66047…

安全漏洞周报(2024.06.10-2024.06.17)

漏洞速览 ■ SolarWinds Serv-U 目录遍历漏洞(CVE-2024-28995) 漏洞详情 1.SolarWinds Serv-U 目录遍历漏洞(CVE-2024-28995) 影响组件&#xff1a; SolarWinds 是一家提供广泛的 IT 管理和网络管理软件解决方案的公司。SolarWinds 的产品被设计用于监控和管理网络设备、服…

串扰(一)

一、说明 串扰应该算比较常见的信号完整性问题了&#xff0c;一般是指由于走线较近&#xff0c;传输信号时在临线上产生耦合噪声的现象。串扰的原因是由于电场和磁场的耦合&#xff0c;我们经常用耦合电容和耦合电感模型进行问题分析。 本文是基于被攻击线阻抗匹配的情形下计…

SpringBoot+Vue小区物业管理系统 附带详细运行指导视频

文章目录 一、项目演示二、项目介绍三、运行截图四、主要代码1.用户登录代码2.查询小区信息代码3.保存缴费信息代码 一、项目演示 项目演示地址&#xff1a; 视频地址 二、项目介绍 项目描述&#xff1a;这是一个基于SpringBootVue框架开发的小区物业管理系统。首先&#xf…