Redis 3.0中的数据结构设计:基于gh_mirrors/re/redis-3.0-annotated的字典实现
引言:Redis字典的核心地位
在Redis(Remote Dictionary Server)这款高性能的键值对数据库中,字典(Dictionary)是最为核心的数据结构之一。无论是存储用户数据、实现数据库索引,还是作为其他复杂数据结构的底层支撑,字典都扮演着不可或缺的角色。本文将深入剖析Redis 3.0版本中字典的实现细节,基于带详细注释的源码仓库gh_mirrors/re/redis-3.0-annotated,帮助读者理解其设计思想与高效运作的原理。
字典的整体架构
Redis中的字典,也常被称为哈希表(Hash Table),采用了链地址法(Separate Chaining)来解决哈希冲突,并通过渐进式Rehash(Rehashing)机制来维持其在高负载下的性能。
核心数据结构定义
字典的实现主要涉及以下几个关键的结构体,定义在src/dict.h中:
-
哈希表节点 (
dictEntry): 这是存储键值对的基本单元。typedef struct dictEntry { void *key; // 键 union { // 值(支持多种类型) void *val; uint64_t u64; int64_t s64; } v; struct dictEntry *next; // 指向下一个哈希表节点,形成链表以解决冲突 } dictEntry;可以看到,值
v是一个联合体(Union),这使得字典能够灵活地存储指针、无符号64位整数或有符号64位整数,在src/dict.c的操作函数中会根据具体情况使用不同的成员。 -
哈希表 (
dictht): 哈希表本身是一个数组,数组中的每个元素都是一个指向dictEntry结构的指针,即一个桶(Bucket)。typedef struct dictht { dictEntry **table; // 哈希表数组 unsigned long size; // 哈希表大小(桶的数量) unsigned long sizemask; // 哈希表大小掩码,用于计算索引值,总是等于 size - 1 unsigned long used; // 该哈希表已有节点的数量 } dictht;sizemask的设计非常巧妙,它使得通过哈希值计算索引的操作可以简化为位运算hash & sizemask,这比取模运算hash % size效率更高。 -
字典 (
dict): Redis的字典结构包含了两个哈希表,这是为了支持渐进式Rehash操作。typedef struct dict { dictType *type; // 类型特定函数,用于多态操作 void *privdata; // 私有数据,传给类型特定函数的参数 dictht ht[2]; // 两个哈希表,ht[0] 是主表,ht[1] 用于Rehash int rehashidx; // Rehash索引,当 rehashidx == -1 时表示不在Rehash过程中 int iterators; // 目前正在运行的安全迭代器的数量 } dict;dictType结构体定义了一系列用于操作键值对的函数指针,如哈希函数、键值复制函数、键值比较函数以及键值销毁函数等,这使得字典可以通用地处理不同类型的键和值。
字典的创建与初始化
字典的创建通过dictCreate函数完成,该函数在src/dict.c中实现。它会分配内存,并调用_dictInit函数对字典进行初始化。_dictInit函数会重置两个哈希表的属性,设置类型特定函数和私有数据,并将rehashidx初始化为-1,表示未进行Rehash。
dict *dictCreate(dictType *type, void *privDataPtr) {
dict *d = zmalloc(sizeof(*d));
_dictInit(d, type, privDataPtr);
return d;
}
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;
}
哈希函数与索引计算
哈希函数的质量直接影响哈希表的性能。Redis字典默认使用MurmurHash2算法(由Austin Appleby设计)作为字符串键的哈希函数,定义在src/dict.c的dictGenHashFunction函数中。
unsigned int dictGenHashFunction(const void *key, int len) {
uint32_t seed = dict_hash_function_seed;
const uint32_t m = 0x5bd1e995;
const int r = 24;
uint32_t h = seed ^ len;
const unsigned char *data = (const unsigned char *)key;
while(len >= 4) {
uint32_t k = *(uint32_t*)data;
k *= m;
k ^= k >> r;
k *= m;
h *= m;
h ^= k;
data += 4;
len -=4;
}
// ... 处理剩余字节
h ^= h >> 13;
h *= m;
h ^= h >> 15;
return (unsigned int)h;
}
dict_hash_function_seed是一个静态全局变量,可以通过dictSetHashFunctionSeed函数设置,这为哈希表提供了一定的随机性,有助于避免某些特定类型的攻击或哈希冲突。
计算得到哈希值后,通过 hash & ht->sizemask 即可得到该键在哈希表数组中的索引位置。
冲突解决:链地址法
当两个不同的键通过哈希函数计算得到相同的索引时,就会发生哈希冲突。Redis采用链地址法来解决冲突,即每个哈希表节点都有一个next指针,多个哈希冲突的节点可以通过这个指针连接成一个单向链表。
在src/dict.c的dictAddRaw函数中可以看到,新节点总是被添加到链表的表头:
entry->next = ht->table[index];
ht->table[index] = entry;
这种头插法的好处是插入效率高,时间复杂度为O(1)。
动态扩容与Rehash机制
为了在数据量变化时保持高效的访问性能,Redis字典会根据负载因子(used / size)自动进行扩容(expand)或缩容(shrink)。这一过程主要通过Rehash操作完成。
触发条件
- 扩容:当
dict_can_resize为真且负载因子大于等于1,或者dict_can_resize为假但负载因子大于等于dict_force_resize_ratio(默认值为5)时,触发扩容。dict_can_resize可以通过dictEnableResize和dictDisableResize函数控制,在Redis进行持久化等操作时可能会暂时禁用自动Resize以提高性能。 - 缩容:当负载因子小于0.1时,可能会触发缩容。
这些检查在_dictExpandIfNeeded函数中进行,该函数会在执行dictAdd、dictFind等操作前被调用。
Rehash的过程
Rehash简单来说就是将旧哈希表(通常是ht[0])中的所有键值对迁移到新哈希表(通常是ht[1])中。Redis采用的是渐进式Rehash策略,而不是一次性完成所有迁移,以避免在数据量大时造成服务停顿。
- 准备阶段:当需要Rehash时,字典会为
ht[1]分配足够大的空间(通常是ht[0].used的两倍且为2的幂次方),并将rehashidx设置为0,表示Rehash开始。 - 迁移阶段:在后续的字典操作(如
dictAdd、dictFind、dictDelete等)中,会调用_dictRehashStep函数,每次迁移一个或多个桶(bucket)中的所有节点。rehashidx记录当前正在迁移的桶索引。 - 完成阶段:当
ht[0]中的所有节点都迁移到ht[1]后,释放ht[0]的空间,将ht[1]设置为ht[0],并重置ht[1]和rehashidx。
dictRehash函数(src/dict.c)负责执行实际的Rehash步骤,它接受一个参数n,表示最多迁移n个桶:
int dictRehash(dict *d, int n) {
if (!dictIsRehashing(d)) return 0;
while(n--) {
dictEntry *de, *nextde;
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;
}
// ... 迁移单个桶的节点
d->rehashidx++;
}
return 1;
}
此外,还有dictRehashMilliseconds函数,它会在指定的毫秒数内尽可能多地执行Rehash步骤,这在Redis的定时任务中可能会用到,以确保Rehash过程能够稳步推进。
字典的基本操作
添加键值对 (dictAdd)
dictAdd函数尝试将一个新的键值对添加到字典中。它首先调用dictAddRaw函数来创建并插入新的哈希表节点,dictAddRaw会负责检查键是否已存在、计算索引、处理Rehash等。如果键不存在且插入成功,dictAdd再调用dictSetVal设置节点的值。
int dictAdd(dict *d, void *key, void *val) {
dictEntry *entry = dictAddRaw(d,key);
if (!entry) return DICT_ERR;
dictSetVal(d, entry, val);
return DICT_OK;
}
查找键值对 (dictFind)
dictFind函数根据给定的键查找对应的哈希表节点。它会计算键的哈希值,然后在对应的哈希表(可能是ht[0],如果正在Rehash则还需要检查ht[1])中查找。在查找过程中,如果字典正在Rehash,还会调用_dictRehashStep进行一步Rehash。
dictEntry *dictFind(dict *d, const void *key) {
dictEntry *he;
unsigned int h, idx, table;
if (d->ht[0].size == 0) return NULL;
if (dictIsRehashing(d)) _dictRehashStep(d);
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;
}
if (!dictIsRehashing(d)) return NULL;
}
return NULL;
}
删除键值对 (dictDelete)
dictDelete函数用于从字典中删除指定键的键值对。它会调用dictGenericDelete函数,该函数会遍历可能的哈希表,找到对应的节点并将其从链表中移除,然后释放节点的键、值(如果设置了相应的析构函数)以及节点本身,并更新used计数。
int dictDelete(dict *ht, const void *key) {
return dictGenericDelete(ht,key,0);
}
迭代器 (dictIterator)
为了方便遍历字典中的所有键值对,Redis提供了迭代器机制。有两种类型的迭代器:安全迭代器(safe iterator)和不安全迭代器(unsafe iterator)。
- 不安全迭代器:在迭代过程中,如果字典发生了Rehash或修改操作,可能会导致某些节点被重复遍历或遗漏。
- 安全迭代器:通过设置
iter->safe = 1创建。在安全迭代器活跃期间,字典的iterators计数会增加,这会阻止Rehash操作的进行,从而保证迭代的安全性。
迭代器的使用流程通常是:dictGetIterator或dictGetSafeIterator创建迭代器 -> dictNext获取下一个节点 -> dictReleaseIterator释放迭代器。
总结与启示
Redis 3.0中的字典实现展现了高超的工程设计技巧:
- 高效的哈希函数:MurmurHash2提供了良好的分布性和计算速度。
- 链地址法解决冲突:简单有效,实现方便。
- 渐进式Rehash:巧妙地将大数据量的迁移工作分散到多次操作中,避免了性能抖动。
- 动态扩容/缩容:根据实际数据量自动调整哈希表大小,平衡空间利用率和访问速度。
- 多态设计:通过
dictType结构体,使得字典能够处理不同类型的键值对。
这些设计思想不仅保证了Redis作为数据库的高性能,也为我们在日常开发中设计类似的数据结构提供了宝贵的参考。深入理解这些源码细节(如src/dict.h中的结构定义和src/dict.c中的算法实现),有助于我们更好地使用Redis,并将这些优秀的设计模式应用到自己的项目中。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



