4 字典

felix.shao2025-02-18

4 字典

TIP

 本小节主要介绍以下知识:

  • 字典的定义。

概述

 字典,又称符号表(symbol table)、关联数组(associative array)或者映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。
 在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值),这些关联的键和值就被称为键值对。
 字典中的每个键都是独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除整个键值对,等等。
 字典经常作为一种数据结构内置在很多高级编程语言里面,但 Redis 所使用的 C 语言并没有内置这种数据结构,因此 Redis 构建了自己的字典实现。
 字典在 Redis 中的应用相当广泛,比如 Redis 的数据库就是使用字典来作为底层实现的,对数据库的增、删、查、改操作也是构建在对字典的操作之上的。
 举个例子,当我们执行命令:

redis> SET msg "hello world"
OK

 在数据库中创建一个键为“msg”,值为“hello world”的键值对时,这个键值对就是保存在代表数据库的字典里面的。
 除了用来表示数据库之外,字典还是哈希键的底层实现之一:当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis 就会使用字典作为哈希键的底层实现。  除了用来实现数据库和哈希键之外,Redis 的不少功能也用到了字典,后面会介绍字典在 Redis 中的各种不同应用。

字典的实现

 Redis 的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。

哈希表

 Redis 字典所使用的哈希表由 dict.h/dictht 结构定义:

typedef struct dictht {
    // 哈希表数组
    dictEntry **table;
    // 哈希表大小
    unsigned long size;
    // 哈希表大小掩码,用于计算索引值
    // 总是等于 size - 1
    unsigned long sizemax;
    // 该哈希表已有节点的数量
    unsigned long used;
} dictht;

 table 属性是一个数组,数组中的每个元素都是指向 dict.h/dictEntry 结构的指针,每个 dictEntry 结构保存着一个键值对,size 属性记录了哈希表的大小,也即是 table 数组的大小,而 used 属性则记录了哈希表目前已有节点的数量。 sizemask 属性的值总是等于 size - 1,这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面。

哈希表节点

 哈希表节点使用 dictEntry 结构表示,每个 dictEntry 结构都保存着一个键值对:

typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

 key 属性保存着键值对中的键,而 v 属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个 uint64_t 整数, 又或者是一个 int64_t 整数。
 next 属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对对连接在一起,以此来解决键冲突的问题。

字典

 Redis 中的字典由 dict.h/dict 结构表示:

typedef struct dict {
    // 类型特定函数
    dictType *type;
    // 私有数据
    void *privdata;
    // 哈希表
    dictht ht[2];
    // rehash 索引
    // 当 rehash 不在进行时,值为 -1
    int trehashidx;
} dict;

 type 属性和 privdata 属性是针对不同类型的键值对,为创建多态字典而设置的:

  • type 属性是一个指向 dictType 结构的指针,每个 dictType 结构保存了一簇用于操作特定类型键值对的函数,Redis 会为用途不同的字典设置不同的类型特定函数。
  • privdata 属性则保存了需要传给那些特定函数的可选参数。
typedef struct dictType {
    // hash方法,根据关键字计算哈希值
    unsigned int (*hashFunction)(const void *key);
    // 复制key
    void *(*keyDup)(void *privdata, const void *key);
    // 复制value
    void *(*valDup)(void *privdata, const void *obj);
    // 关键字比较方法
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    //  销毁key
    void (*keyDestructor)(void *privdata, void *key);
    // 销毁value
    void (*valDestructor)(void *privdata, void *obj);
} dictType;
  • ht 属性是一个包含两个项的数组,数组中的每个项都是一个 dictht 哈希表,一般情况下,字典只是用 ht[0] 哈希表,ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。
  • 除了 ht[1] 之外,另一个和 rehash 有关的属性就是 rehashidx,它记录了 rehash 目前的进度,如果目前没有在进行 rehash,那么它的值为 -1。

 字典组成图如下:
dict_structure.png

哈希算法

 将一个新的键值对添加到字典里面的时候,程序需要先根据键值对上面的键来计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希数组的指定索引上面。
 Redis计算哈希值和索引值的方法如下:

// 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
// 使用哈希表的 sizemask 属性和哈希值,计算出索引值
// 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;

TIP

 Redis 使用 MurmurHash2 算法来计算键的哈希值。
 可以参考参考文献的内容理解字典添加 k0 和 v0 的流程,简单来说就是计算哈希值->计算出键

解决键冲突

 当由两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突(collision)。
 Redis 的哈希表使用链地址法来解决键冲突,因为 dictEntry 节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面。

rehash

 随着操作的不断进行,哈希表保存的键值对会逐渐增多或减少,为了让哈希表负载因子维持在一个合理范围之内,当哈希表保存的键值对太多或太少时,程序要对哈希表的大小进行相应的扩展或收缩。
 Redis 对字典的哈希表执行 rehash 的步骤如下:

  1. 为字典的 ht[1] 哈希表分配空间,这个空间大小取决于要执行的操作:
  • 如果执行的是扩展操作,则 ht[1] 的大小为第一个大于等于等于 ht[0].used * 2 的 2^n;
  • 如果执行的收缩操作,则 ht[1] 的大小为第一个大于等于 ht[0].used 的 2^n;
  1. 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面:rehash 指的是重新计算键的哈希值和索引值,然后将键值对放置到 ht[1] 的指定位置上。
  2. 当 ht[0] 包含的所有键值对都迁移到 ht[1] 之后,释放 ht[0],将 ht[1] 设置为 ht[0],并在 ht[1] 新创建一个空白哈希表,为下一次 rehash 做准备。

 rehash 示例略。

哈希表的扩展与收缩

 当以下条件中任意一个被满足时,程序会自动开始对哈希表执行扩展操作:

  1. 服务器目前没有执行BGSAVE或BGREWRITEAOF命令,并且哈希表负载因子大于等于1。
  2. 服务器正在执行BGSAVE或BGREWRITEAOF命令,并且哈希表负载因子大于等于5。

 区分这两种情况的目的在于,因为执行 BGSAVE 与 BGREWRITEAOF 过程中,Redis 都需要创建子进程,而大多数操作系统都采用写时复制技术来优化子进程使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入,最大限度的节约空间。
 另一方面,当哈希表负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作。

渐进式 rehash

 Redis 中的 rehash 动作并不是一次性、集中式完成的,而是分多次、渐进式的完成的。
 这样做的目的是,如果服务器中包含很多键值对,要一次性的将这些键值对全部 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 而带来的庞大计算量。

TIP

 rehashidx 边界条件是什么,如 ht[0] 从 0 开始 rehash,0 rehash 完后,rehashidx 是 0 还是 1。看 Redis 设计与实现上面说的应该是 0 的意思。

渐进式 rehash 执行期间的哈希表操作

 因为在渐进式 rehash 的过程中,字典会同时使用 ht[0] 和 ht[1] 两个哈希表,所以在渐进式 rehash 进行期间,字典的删除、查找、更新等操作都是在两个表上进行的。例如,查找操作会先在 ht[0] 上进行,如果没找到再在 ht[1] 上进行。
 添加操作的键值对会一律保存到 ht[1] 中,这一措施保证 ht[0] 包含的键值对只会减少不会增加。这一措施保证了 ht[0] 包含的键值对数量会只减不增,并随着 rehash 操作的执行而最总变成空表。

字典 API

 见 字典的主要操作 APIopen in new window

参考文献

Last Updated 2/18/2025, 5:05:12 PM