写点什么

【Redis 技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(字典)

作者:洛神灬殇
  • 2025-03-15
    江苏
  • 本文字数:7072 字

    阅读完需:约 23 分钟

【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(字典)

【专栏简介】

随着数据需求的迅猛增长,持久化和数据查询技术的重要性日益凸显。关系型数据库已不再是唯一选择,数据的处理方式正变得日益多样化。在众多新兴的解决方案与工具中,Redis 凭借其独特的优势脱颖而出。

【技术大纲】

为何 Redis 备受瞩目?原因在于其学习曲线平缓,短时间内便能对 Redis 有初步了解。同时,Redis 在处理特定问题时展现出卓越的通用性,专注于其擅长的领域。深入了解 Redis 后,您将能够明确哪些任务适合由 Redis 承担,哪些则不适宜。这一经验对开发人员来说是一笔宝贵的财富。



在这个专栏中,我们将专注于 Redis 的 6.2 版本进行深入分析和介绍。Redis 6.2 不仅是我个人特别偏爱的一个版本,而且在实际应用中也被广泛认为是稳定性和性能表现都相当出色的版本

【专栏目标】

本专栏深入浅出地传授 Redis 的基础知识,旨在助力读者掌握其核心概念与技能。深入剖析了 Redis 的大多数功能以及全部多机功能的实现原理,详细展示了这些功能的核心数据结构和关键算法思想。读者将能够快速且有效地理解 Redis 的内部构造和运作机制,这些知识将助力读者更好地运用 Redis,提升其使用效率。


将聚焦于 Redis 的五大数据结构,深入剖析各种数据建模方法,并分享关键的管理细节与调试技巧。

【目标人群】

Redis 技术进阶之路专栏:目标人群与受众对象,对于希望深入了解 Redis 实现原理底层细节的人群

1. Redis 爱好者与社区成员

Redis 技术有浓厚兴趣,经常参与社区讨论,希望深入研究 Redis 内部机制、性能优化和扩展性的读者。

2. 后端开发和系统架构师

在日常工作中经常使用 Redis 作为数据存储和缓存工具,他们在项目中需要利用 Redis 进行数据存储、缓存、消息队列等操作时,此专栏将为他们提供有力的技术支撑。

3. 计算机专业的本科生及研究生

对于学习计算机科学、软件工程、数据分析等相关专业的在校学生,以及对 Redis 技术感兴趣的教育工作者,此专栏可以作为他们的学习资料和教学参考。


无论是初学者还是资深专家,无论是从业者还是学生,只要对 Redis 技术感兴趣并希望深入了解其原理和实践,都是此专栏的目标人群和受众对象


让我们携手踏上学习 Redis 的旅程,探索其无尽的可能性!



字典

字典,作为一种抽象数据结构,常常被我们程序员称之为 Hash 或者映射,主要用于存储键值对。在字典中,每个键都是唯一的,与之对应着一个值,这种键与值的配对关系被称为键值对。



凭借这种键值对的关系,程序能够轻松地在字典中根据键查找对应的值,或者通过键来更新值,甚至删除整个键值对。这种灵活的操作方式使得字典在编程中发挥着重要的作用。



虽然许多高级编程语言内置了字典这种数据结构,但 Redis 所使用的 C 语言却并未提供内置支持。因此,Redis 为了实现其高效的数据存储和操作需求,自行构建了字典数据结构。



这种设计旨在优化数据存储和访问效率,确保在复杂数据场景下,Redis 依然能够保持高效稳定的性能表现。通过深度整合字典数据结构,Redis 成功实现了对大量键值对的高效管理,进一步提升了其在各种应用场景中的实用价值。

字典和 Hash 的结构关系

字典作为哈希键的底层实现之一,在特定情况下发挥着关键作用。当哈希键中包含的键值对数量庞大,或者键值对中的元素为较长字符串时,Redis 会选择采用字典作为其底层实现。

Hash 结构的实现

Redis 成功构建了一个强大而灵活的 Hash 表实现,为存储和检索键值对提供了高效的支持。无论是简单的数据操作还是复杂的查询任务,Redis 的 Hash 源码都能够满足需求,展现出其出色的性能和稳定性。

源码分析

  • https://github.com/redis/redis/blob/unstable/src/dict.h

  • https://github.com/redis/redis/blob/unstable/src/dict.c


Hash 源码主要由两部分组成:dict.hdict.c



  • dict.h 文件主要负责定义 Hash 表的结构、哈希项,以及声明了一系列针对 Hash 表的操作函数。这些定义和声明为开发者提供了一个清晰的接口,使他们能够了解和使用 Hash 表的功能。

  • dict.c 文件则是对这些函数的具体实现。它包含了 Hash 表创建、查找、插入、删除等操作的实现细节,确保 Hash 表能够高效、稳定地运行。

Hash 数据结构

dict.h 文件中,Hash 表是通过一个(dictEntry **table)来构建的,这种设计使得哈希表的实现更加高效且灵活。总体个人认为分为数据结构模型+行为操作模型两个大部分:



Redis 字典结构定义


dict 结构通过 dictType 指针抽象键值对类型及操作,实现多样化处理。采用双哈希表实现渐进式 rehashing,确保扩容缩容平稳进行,减少性能损耗。下面是对应的 dict 的源码,我在其中加入了注释,进行介绍对应的含义:


struct dict {      // 指向 dictType 结构体的指针,包含与字典相关的类型特定函数和操作      dictType *type;      // 字典的哈希表数组,用于支持渐进式 rehashing,包含两个哈希表      dictEntry **ht_table[2];      // 哈希表已使用的槽位数量数组      unsigned long ht_used[2];      // rehashing 索引,如果 rehashing 未进行,则值为 -1      long rehashidx;       // 暂停rehashing的标志位,大于0时暂停rehashing,小于0表示代码错误      int16_t pauserehash;       // 哈希表大小的指数(size = 1 << exp),包含两个哈希表的大小指数      signed char ht_size_exp[2];       // 暂停自动调整大小的标志位,大于 0 时禁用自动调整大小,小于 0 表示代码错误      int16_t pauseAutoResize;       // 柔性数组,用于存储与字典相关的元数据,在标准的 Redis 实现中可能并未使用      void *metadata[];  };
复制代码


  • dictEntry **ht_table[2]:dictEntry **table 是个二维数组,其中第一维是 bucket,每一行就是 bucket 指向的元素列表(因为键哈希冲突,Redis 采用了链式哈希)。

  • ht_used[2]:这是一个数组,记录了每个哈希表当前已使用的槽位数量。这对于管理哈希表的容量和进行 rehashing 操作非常重要。提供字典了解当前哈希表的使用情况,以便在必要时进行扩容或缩容。

  • rehashidx:这是一个索引值,用于指示当前 rehashing 操作的进度。如果 rehashing 未进行,则值为 -1。在渐进式 rehashing 过程中,该值会逐渐从 0 增加到旧哈希表的长度,表示已经迁移了多少个键值对。使得字典可以在多个操作之间保持 rehashing 的状态。


dictType 结构体的指针


dictType 与字典紧密相关的特定类型函数和操作,这些函数和操作定义了字典的行为,确保它能够有效地处理各种与字典相关的任务。支持的功能如下图所示:



下面展示的是 Redis 的源代码片段,我结合个人理解,为其添加了详尽的注释,旨在帮助大家更好地把握其深层含义,促进代码的解读与理解。


typedef struct dictType {      /* 回调函数组 */      /* 哈希函数,用于将键(key)转化为哈希值 */      uint64_t (*hashFunction)(const void *key);      /* 键的复制函数,用于在字典中创建键的副本 */      void *(*keyDup)(dict *d, const void *key);      /* 值的复制函数,用于在字典中创建值的副本 */      void *(*valDup)(dict *d, const void *obj);      /* 键的比较函数,用于比较两个键是否相等 */      int (*keyCompare)(dict *d, const void *key1, const void *key2);      /* 键的析构函数,用于释放键所占用的内存 */      void (*keyDestructor)(dict *d, void *key);      /* 值的析构函数,用于释放值所占用的内存 */      void (*valDestructor)(dict *d, void *obj);      /* 是否允许调整字典大小的函数,基于更多的内存需求和当前使用比率 */      int (*resizeAllowed)(size_t moreMem, double usedRatio);      /* 在字典初始化或重新哈希开始时调用的函数(旧的和新的哈希表已经创建) */      void (*rehashingStarted)(dict *d);      /* 在字典初始化或所有条目从旧哈希表到新哈希表重新哈希完成时调用的函数。两个哈希表都还存在,       * 并在此回调函数之后被清理。 */      void (*rehashingCompleted)(dict *d);      /* 允许字典携带额外的调用者定义的元数据。当分配字典时,额外的内存被初始化为0。 */      size_t (*dictMetadataBytes)(dict *d);      /* 数据 */      /* 用户自定义数据,可以在字典中使用 */      void *userdata;      /* 标志位 */      /* 'no_value'标志位,如果设置,表示不使用值,即字典是一个集合(set)。       * 当此标志位设置时,无法访问dictEntry的值,也无法使用dictSetKey()。       * 同样,也无法使用条目元数据。 */      unsigned int no_value:1;      /* 如果no_value = 1且所有键都是奇数(最低有效位=1),设置keys_are_odd = 1       * 可以启用另一个优化:在不分配dictEntry的情况下存储键。      */      unsigned int keys_are_odd:1;      /* TODO: 添加一个'keys_are_even'标志位,并在设置该标志位时使用类似的优化。 */  } dictType;
复制代码


希望这些注释能为大家的学习和工作提供便利,大家对于源码的头文件的理解,主要要集中于有哪些方法以及支持哪些操作即可。


dictEntry 二维数组


dictEntry **table 作为一个二维数组,其第一维代表的是不同的 bucket(桶)。每个 bucket 内部则通过指针链接了一系列元素,形成链表结构。



如上图所示,对应的 dicEntity 数组中每个元素都是一个指向 dictEntry 链表的指针。


dictEntry 模型


Redis 的 dictEntry 结构体不仅包含了指向键和值的指针,还巧妙地设计了一个指向下一个哈希项的指针 next。这个指针 next 的存在,使得当多个键的哈希值发生冲突时,Redis 能够将这些键以链表的形式连接在一起,从而有效地解决了哈希冲突问题。



  • key:用于存储键值对中键的部分

  • value:承载着键值对中对应的值。既可以是一个指向其他数据结构的指针,也可以是一个 uint64_t 类型的无符号 64 位整数,或是一个 int64_t 类型的有符号 64 位整数。

  • next:扮演着链接哈希表节点的角色,它是一个指向另一个哈希表节点的指针。当多个键值对的哈希值相同时,即发生了所谓的键冲突,这些具有相同哈希值的键值对会通过 next 指针串联起来,形成一个链表结构。


通过设计哈希函数和链表的维护策略,哈希表能够在平均情况下实现近乎 O(1)的查找、插入和删除操作。


dictEntry 的结构体源码


dictEntry 结构体表示字典中的一个键值对,源码如下所示:


struct dictEntry {    void *key;    union {        void *val;        uint64_t u64;        int64_t s64;        double d;    } v;    /* Next entry in the same hash bucket. */        struct dictEntry *next;    };typedef struct {    void *key;    dictEntry *next;} dictEntryNoValue;
复制代码


dictEntry **ht_table[2]


dictEntry **ht_table[2]:一个包含两个项的数组,其中每个项都代表一个dict哈希表结构。



在正常情况下,我们主要使用ht[0]这个哈希表进行数据存储和检索操作。而ht[1]哈希表的存在,主要是为了在需要对ht[0]进行 rehash 操作时提供一个临时的存储空间。


两个哈希表支持 rehashing


在 Redis 中,当哈希表的大小需要调整时(例如,因为哈希表已满或空闲空间太多),它不会一次性重新分配整个哈希表,而是会同时使用两个哈希表:一个旧的和一个新的,类似于 COW 模式进行处理,Copy And Write 机制。


随着键值对的插入和删除,旧的哈希表中的数据会逐渐迁移到新的哈希表中,直到旧的哈希表为空,然后旧的哈希表会被释放,新的哈希表成为主哈希表。接下来我们要开始分析和研究 hash 的机制和原理、最后到了对应的 rehashing 能力。

哈希算法

我们先来看一下哈希算法,当需要将一个新的键值对添加至字典时,程序会首先根据该键值对的键进行哈希计算,得出相应的哈希值。随后,利用这个哈希值,进一步计算出在哈希表数组中的具体索引位置。

计算哈希值和索引值

使用字典设置的哈希函数,计算键 key 的哈希值,使用哈希表的 mask 值和 size 值,计算出素引值,根据情况不同,ht[x]可以是 ht[0]或者 ht[1]。


#define DICTHT_SIZE(exp) ((exp) == -1 ? 0 : (unsigned long)1<<(exp))#define DICTHT_SIZE_MASK(exp) ((exp) == -1 ? 0 : (DICTHT_SIZE(exp))-1)
复制代码


上面定义的 C 语言的两个宏通常一起使用。


#define dictHashKey(d, key) ((d)->type->hashFunction(key))#define dictBuckets(d) (DICTHT_SIZE((d)->ht_size_exp[0])+DICTHT_SIZE((d)->ht_size_exp[1]))#define dictSize(d) ((d)->ht_used[0]+(d)->ht_used[1])
复制代码


当想要计算一个键值对的哈希值对应的哈希表索引时,会先使用哈希函数计算出一个原始的哈希值,然后使用 DICTHT_SIZE_MASK(exp) 宏将这个哈希值限制在哈希表大小的范围内。这样,就可以确保计算出的索引不会超出哈希表的边界。最终,将包含新键值对的哈希表节点精准地放置在哈希表数组指定索引的位置上。

案例分析

举个例子,对于下图所示的字典来说,如果我们要将一个键值对 w 添加到字典里面:



那么程序会先使用语句:#define dictHashKey(d, key) ((d)->type->hashFunction(key)),计算键 w 的哈希值。假设计算得出的哈希值为 100,那么程序会继续使用语句:#define dictBuckets(d) (DICTHT_SIZE((d)->ht_size_exp[0])+DICTHT_SIZE((d)->ht_size_exp[1])),计算出键 w 的索引值 6,这表示包含键值对 w 的节点应该被放置到哈希表数组的索引 6 位置上。

解决键冲突

当多个键被哈希函数映射到哈希表数组的同一索引位置时,这种现象被称为键冲突。

链地址法

在 Redis 的哈希表实现中,采用了链地址法来有效处理这种冲突。每个哈希表节点都包含一个next指针,使得多个哈希表节点能够通过next指针串联成一个单向链表。因此,当多个键被分配到相同的索引位置时,这些节点可以通过这个单向链表相互连接,从而巧妙地解决了键冲突问题。



以图示为例,假设我们拥有一个哈希表,并且程序需要将键值对w插入其中。经过哈希函数的计算,我们得知w的索引值为 4。然而,在这个特定的索引位置,已存在其他键(如cd),这就引发了键冲突问题。

Rehash 操作和处理执行

为了确保哈希表的负载因子维持在一个适宜的水平,程序会根据哈希表当前的键值对数量来灵活调整其大小,这种动态调整的策略有助于确保哈希表始终保持在高效运行的状态。

扩展和收缩

扩展和收缩哈希表的工作可通过执行 rehash(重新散列)操作得以实现。在此过程中,字典巧妙地利用 ht[0]和 ht[1]这两个哈希表来共同存储键值对,确保了操作的顺畅进行。当满足以下任一条件时,程序将自动触发哈希表的扩展操作:


  • 【键值对数量过多】导致负载因子偏高时,程序会执行扩展操作,增大哈希表容量,以提高查询效率并避免过多的冲突。

  • 【键值对数量过少】负载因子偏低,程序则会进行收缩操作,减小哈希表的大小,以节省内存资源。

负载因子

负载因子 = 即键值对数量/哈希表大小。


load_factor = ht[0/1].used / ht[0/1].size
复制代码


案例分析


  • 哈希表的大小为 6,包含 6 个键值对的哈希表来说,这个哈希表的负载因子为:6/6 =1,结果为 1。

  • 哈希表的大小为 100,包含 200 个键值对的哈希表来说,这个哈希表的负载因子为:200 / 100=2

  • 哈希表的大小为 100,包含 50 个键值对的哈希表来说,这个哈希表的负载因子为:50 / 100=0.5

RDB 和 AOF 与 Rehash 的关系

Redis 在执行 BGSAVE 或 BGREWRITEAOF 命令时,会根据子进程的存在与否调整哈希表扩展操作的负载因子阈值。这主要是为了避免在子进程运行时进行不必要的哈希表扩展,进而减少内存写入,提高内存利用效率。


  • 未执行 BGSAVE 或 BGREWRITEAOF 命令,并且哈希表的负载因子达到或超过 1 时,程序会自动启动哈希表的扩展操作。

  • 正在执行 BGSAVE 或 BGREWRITEAOF 命令,且哈希表的负载因子不低于 5,程序将自动触发哈希表的扩展流程。

哈希表执行 rehash 的步骤

  1. 分配 h1 的哈希表空间:当哈希表需要扩容时,为 ht[1]分配新的存储空间,其大小是经过精确计算的,确保至少是当前 ht[0]中键值对数量(即 ht[0].used 的值)的两倍,并且是一个 2 的 n 次方幂。

  2. Rehash 重新散列,这一过程涉及到将 ht[0]中所有的键值对,按照新的哈希算法计算出的哈希值和索引值,精确无误地迁移至 ht[1]中。

  3. 数据键值转移:所有键值对成功从 ht[0]转移至 ht[1]

  4. 释放原有哈希表空间:立即释放 ht[0]占用的内存空间,以优化内存使用。


最后,将 ht[1]提升为新的 ht[0],并初始化一个新的空白哈希表作为新的 ht[1],以备将来可能再次触发的重新散列操作。


执行 rehash 的时候业务操作

在进行 rehash 操作时,字典的删除、查找和更新等操作都需要在这两个哈希表上进行,注意没有新增哦!具体来说,当我们需要在字典中查找一个键时,程序会首先在 ht[0]中进行搜索。如果未能在 ht[0]中找到相应的键,程序则会继续转向 ht[1]进行查找,以确保不会遗漏任何可能的键值对。



注意,在 rehash 操作进行的过程中,所有新添加的键值对都会被统一存储于 ht[1]哈希表中,而 ht[0]则不再承担新键值对的添加任务

最后总结

字典是 Redis 实现多样化功能的核心组件,尤其在数据库和哈希键的构造中发挥着至关重要的作用。Redis 精心设计了其字典结构,以哈希表作为基石,确保高效且稳健的数据存取。


  • 双哈希表:每个 Redis 字典都巧妙地配备了两个哈希表,一个负责日常运作,而另一个则专门用于 rehash 操作,这种双表机制显著提升了字典在数据变动时的性能表现。

  • 解决冲突:在哈希表中,为了解决这一冲突,Redis 巧妙地运用了next指针机制。它将新键值对w对应的节点通过next指针链接到已存在的d节点之前,从而构建了一个链表结构。通过这种方式,Redis 不仅有效地解决了键冲突问题,还保证了哈希表在数据插入时的灵活性和高效性。

  • Rehash 控制:当哈希表需要进行扩展或收缩以适应数据量的变化时,Redis 并非一蹴而就地完成整个 rehash 过程。相反,它采取了渐进式的策略,逐步将旧哈希表中的键值对迁移到新表中,从而确保了 rehash 操作对系统性能的影响最小化。

用户头像

洛神灬殇

关注

🏆 InfoQ写作平台-签约作者 🏆 2020-03-25 加入

👑 优酷资深工程师 | INTJ | 狮子座 | 高洞察力理性自律小i人 📕 个人著作《深入浅出Java虚拟机—JVM原理与实战》 💻 10年开发经验,参与过多个大型互联网项目,定期分享技术干货和项目经验

评论

发布
暂无评论
【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(字典)_redis_洛神灬殇_InfoQ写作社区