|
欢迎各位同学关注我哦~
在这个 AI 喧嚣的时代 不忘初心,戒骄戒躁,认真沉淀 |
|
在前面的两篇文章中,我们已经详细分析了Redis的双向链表(adlist)和字典(dict,HashTable 实现)这两大基础数据结构。理解了它们的实现原理后,我们可以更深入地探讨Redis字典在实际运行中如何高效地应对扩容和缩容带来的挑战。
在这篇文章中,我们将深入分析Redis渐进式rehash机制的源码实现与设计原理,结合实际代码细节,剖析其如何通过“化整为零”、读写驱动、双表切换等策略,实现高效、无阻塞的哈希表扩容与缩容过程,并探讨其背后的设计和性能的极致优化。
一、为什么需要渐进式rehash?
当哈希表的负载因子(used / size)过高或过低时,需要扩容或缩容。传统的做法是一次性重新分配更大的数组,把所有键值对重新哈希到新数组中。
传统rehash的步骤如下:
- 分配新数组
- 遍历旧数组所有桶,逐个重新计算哈希并插入新数组
- 释放旧数组
如果哈希表中有数百万甚至数十亿个键,一次性rehash会导致Redis在这段时间内无法响应任何请求。对于一个号称单线程、低延迟的内存数据库来说,这是不可接受的。
对于这个问题,Redis的解决方案:渐进式rehash——将一次性的大规模迁移,拆分成无数次小步骤,分摊到后续的每次增删改查操作中,以及服务器的定时任务中。
二、渐进式rehash的核心机制
2.1 两个哈希表
// dict.h:76-82
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2]; // 两个哈希表
long rehashidx; // rehash进度索引,-1表示未在rehash
unsigned long iterators;
} dict;
dict结构体内嵌了两个dictht:
| 状态 | ht[0] | ht[1] | rehashidx |
|---|---|---|---|
| 正常 | 当前哈希表 | 空(table=NULL) | -1 |
| rehash 中 | 旧哈希表 | 新哈希表 | 0 ~ size-1 |
| rehash 完成 | 新哈希表 | 空 | -1 |
2.2 rehashidx的含义
rehashidx是ht[0]中下一个要迁移的桶索引:
rehashidx = -1:未在rehashrehashidx = 0:从第0个桶开始迁移rehashidx = k:第0~k-1个桶已经迁移完成,下一个迁移第k个桶- 所有桶迁移完成后:
rehashidx重置为-1
ht[0] 迁移进度(rehashidx = 3):
桶: [0] [1] [2] [3] [4] [5] [6] [7]
状态: NULL NULL NULL →迁移← 待迁移 待迁移 待迁移 待迁移
↑
rehashidx
[0]-[2] 已迁移到 ht[1],[3]正在迁移,[4]-[7]等待迁移
三、rehash的三个触发时机
3.1 操作触发—— _dictRehashStep
每当对字典执行增删改查操作时,Redis都会检查当前字典是否处于rehash阶段。如果正在rehash,则会在本次操作的同时顺带迁移一个桶的数据到新表中。这样,rehash的成本被巧妙地分摊到每一次普通操作中,避免了集中迁移带来的阻塞和性能抖动:
// dict.c:260-262
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
这个函数被以下操作调用:
| 操作 | 调用位置 |
|---|---|
| 添加 | dictAddRaw (dict.c:298) |
| 删除 | dictGenericDelete (dict.c:364) |
| 查找 | dictFind (dict.c:482) |
| 随机获取 | dictGetRandomKey (dict.c:617) |
| 批量获取 | dictGetSomeKeys (dict.c:685) |
前提条件:d->iterators == 0,即没有安全迭代器正在运行。如果有安全迭代器,rehash会被暂停,因为迁移可能导致迭代器遗漏或重复元素。
3.2 定时任务触发 —— dictRehashMilliseconds
当服务器处于空闲状态时,操作触发的rehash机会很少。Redis在serverCron定时任务中主动推进rehash:
// server.c:755-767
int incrementallyRehash(int dbid) {
if (dictIsRehashing(server.db[dbid].dict)) {
dictRehashMilliseconds(server.db[dbid].dict, 1);
return 1;
}
if (dictIsRehashing(server.db[dbid].expires)) {
dictRehashMilliseconds(server.db[dbid].expires, 1);
return 1;
}
return 0;
}
// dict.c:241-250
int dictRehashMilliseconds(dict *d, int ms) {
long long start = timeInMilliseconds();
int rehashes = 0;
while(dictRehash(d,100)) {
rehashes += 100;
if (timeInMilliseconds()-start > ms) break;
}
return rehashes;
}
这个函数的设计核心在于“限时批量迁移”,每次调用最多只消耗1毫秒的CPU时间,并以每轮最多迁移100个桶为单位,超时即停,保证不会对主线程造成明显阻塞。这样即使哈希表极大,也能通过多次短暂的后台推进,逐步完成rehash过程。
这种机制通常在Redis主线程空闲时,由databasesCron定时任务自动触发调用。它确保即使业务操作很少、rehash无法靠普通命令推进时,后台也能持续推进rehash进度,避免rehash长期滞留,保证内存和性能的健康。
在databasesCron中被调用:
// server.c:1035-1048
if (server.activerehashing) {
for (j = 0; j < dbs_per_call; j++) {
int work_done = incrementallyRehash(rehash_db);
if (work_done) {
break; // 某个db正在rehash,本轮只处理这一个
} else {
rehash_db++; // 这个db不需要rehash,检查下一个
rehash_db %= server.dbnum;
}
}
}
注意:每次databasesCron只对一个db做rehash(返回1就break),避免同时占用过多CPU。
3.3 RDB/AOF加载时触发
Redis在加载RDB或AOF文件时,会在rdbLoadRio中主动触发rehash:
// rdb.c:1418
if (len > DICT_HT_INITIAL_SIZE)
dictExpand(o->ptr, len);
加载时直接调用dictExpand分配足够大的空间,然后数据会自然地插入到新表中。
四、dictRehash —— 核心迁移函数
// dict.c:188-231
int dictRehash(dict *d, int n) {
int empty_visits = n * 10; // 最多允许跳过的空桶数,防止极端情况下卡住
if (!dictIsRehashing(d)) return 0; // 没有在rehash直接返回
// 每次最多迁移n个桶(不是n个元素)
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
// rehashidx始终小于ht[0].size,防止越界
assert(d->ht[0].size > (unsigned long)d->rehashidx);
// Step 1: 跳过空桶(没有元素的桶)
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++; // 推进rehashidx到下一个桶
if (--empty_visits == 0) return 1; // 跳空桶次数超限,提前返回,避免阻塞
}
// Step 2: 迁移当前非空桶的所有节点(链表头插到新表)
de = d->ht[0].table[d->rehashidx];
while(de) {
uint64_t h;
nextde = de->next; // 记录下一个节点
// 重新计算在新表中的桶索引
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;
}
// Step 3: 清空旧表当前桶,rehashidx推进
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
// Step 4: 检查是否迁移完成(旧表已空)
if (d->ht[0].used == 0) {
zfree(d->ht[0].table); // 释放旧表内存
d->ht[0] = d->ht[1]; // 新表升级为主表
_dictReset(&d->ht[1]); // 重置备用表
d->rehashidx = -1; // 标记rehash结束
return 0; // 返回0表示迁移已完成
}
// 还有未迁移的元素,返回1
return 1;
}
4.1 逐步分析
参数n:本次最多迁移n个桶(不是n个键值对,是n个桶)。
Step 1 — 跳过空桶
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
空桶没有数据需要迁移,只需推进rehashidx。但空桶可能非常多(特别是缩容后),所以设置empty_visits = n * 10的上限,防止在一个全是空桶的哈希表中卡住太久。
Step 2 — 迁移整个桶
de = d->ht[0].table[d->rehashidx];
while(de) {
nextde = de->next;
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;
}
对桶中的每个节点:
- 计算在新哈希表中的索引:
hash & ht[1].sizemask - 头插法插入
ht[1] - 更新两个哈希表的
used计数
Step 3 — 清理旧桶
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
旧桶置空,rehashidx推进。
Step 4 — 检查是否完成
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;
}
当ht[0].used降为0,说明所有键值对已迁移完成:
- 释放
ht[0]的旧数组 - 将
ht[1]提升为ht[0] - 重置
ht[1] rehashidx重置为-1
4.2 迁移过程
初始状态:rehashidx = 0, ht[0].size = 4, ht[1].size = 8
ht[0]:
桶: [0] → A → B
[1] → NULL
[2] → C
[3] → D → E → F
ht[1]:
桶: [0]~[7] → 全部NULL
===== Step 1: rehashidx=0, 迁移桶[0] =====
ht[0]:
桶: [0] → NULL ← 已清空
[1] → NULL
[2] → C
[3] → D → E → F
ht[1]:
桶: [0] → NULL
[1] → NULL
...
[h_A] → A → B ← A 和 B 根据新 sizemask 重新定位
...
rehashidx = 1
===== Step 2: rehashidx=1, 空桶跳过 =====
rehashidx = 2 (桶[1]为空,直接跳过)
===== Step 3: rehashidx=2, 迁移桶[2] =====
ht[0]:
桶: [0] → NULL
[1] → NULL
[2] → NULL ← 已清空
[3] → D → E → F
ht[1]:
... C也迁移进来了 ...
rehashidx = 3
===== Step 4: rehashidx=3, 迁移桶[3] =====
ht[0]:
桶: 全部NULL, used = 0
ht[1]:
D, E, F也迁移完成
→ ht[0].used == 0
→ 释放 ht[0].table
→ ht[0] = ht[1]
→ 重置ht[1]
→ rehashidx = -1
→ rehash完成!
五、rehash期间的操作行为
5.1 查找 —— 查两个表
// dict.c:476-495 (dictFind)
for (table = 0; table <= 1; table++) {
idx = h & d->ht[table].sizemask;
he = d->ht[table].table[idx];
while(he) {
if (key == he->key || dictCompareKeys(d, key, he->key))
return he;
he = he->next;
}
if (!dictIsRehashing(d)) return NULL; // 不在rehash,只查ht[0]
}
- 正在rehash:先查
ht[0],再查ht[1],保证不会遗漏 - 未在rehash:只查
ht[0]
5.2 添加 —— 只加新表
// dict.c:306
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
rehash期间,新键值对只插入ht[1]。这保证了ht[0]的键值对只会减少不会增加,rehash一定能收敛。
5.3 删除/更新 —— 查两个表
删除和更新操作与查找类似,需要在两个表中定位目标,然后操作。
5.4 操作行为汇总
| 操作 | rehash期间行为 |
|---|---|
| 查找 | 先查ht[0],再查ht[1] |
| 添加 | 只添加到ht[1] |
| 删除 | 先查ht[0],再查ht[1],在哪找到就删哪个 |
| 更新 | 先查ht[0],再查ht[1],在哪找到就更新哪个 |
| 遍历 | 遍历ht[0]和ht[1]所有桶 |
六、扩容与缩容的触发条件
6.1 扩容条件
// dict.c:922-941
static int _dictExpandIfNeeded(dict *d) {
if (dictIsRehashing(d)) return DICT_OK;
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
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;
}
| 条件 | 负载因子 | 说明 |
|---|---|---|
size == 0 | — | 首次插入,扩到4 |
used >= size && dict_can_resize | >= 1 | 正常扩容 |
used / size > 5 | > 5 | 强制扩容,无论是否在BGSAVE |
6.2 dict_can_resize的控制
// dict.c:62-63
static int dict_can_resize = 1;
static unsigned int dict_force_resize_ratio = 5;
// server.c:769-781
void updateDictResizePolicy(void) {
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1)
dictEnableResize();
else
dictDisableResize();
}
当有BGSAVE或BGREWRITEAOF子进程运行时,dict_can_resize = 0,禁止正常扩容。但负载因子超过5时,仍然强制扩容——因为过高的负载因子会导致性能严重下降,风险比COW更大。
6.3 缩容条件
// server.c:730-737
int htNeedsResize(dict *dict) {
long long size, used;
size = dictSlots(dict);
used = dictSize(dict);
return (size > DICT_HT_INITIAL_SIZE &&
(used * 100 / size < HASHTABLE_MIN_FILL));
}
// server.h:201
#define HASHTABLE_MIN_FILL 10 // 10%
当负载因子<10%且大小超过初始值4时,触发缩容。缩容在databasesCron定时任务中检查:
// server.c:1029-1032
for (j = 0; j < dbs_per_call; j++) {
tryResizeHashTables(resize_db % server.dbnum);
resize_db++;
}
七、哈希冲突与解决方案
哈希表的高效运行不仅依赖于扩容与缩容机制,更离不开对哈希冲突的妥善处理。下面系统梳理 Redis 哈希冲突的原理、解决方案及工程优化。
7.1 什么是哈希冲突?
哈希函数将任意键映射到有限大小的数组索引。当两个不同的键被映射到同一个索引时,就发生了哈希冲突(Hash Collision)。
键 "name" → hash → 3
键 "type" → hash → 3 ← 冲突!两个键落在同一个桶
哈希冲突是哈希表的固有问题,解决冲突的策略决定了哈希表的性能上限。
7.2 Redis的方案:链地址法
Redis采用链地址法(Separate Chaining),即每个桶存放一个链表,所有哈希到同一桶的键值对以链表节点方式串联。
// dict.h:47-56
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; // 指向下一个节点,形成链表
} dictEntry;
插入新节点时采用头插法:
// dict.c:306-310
ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0];
entry = zmalloc(sizeof(*entry));
entry->next = ht->table[index]; // 新节点指向原链表头
ht->table[index] = entry; // 新节点成为新的链表头
头插法优势:O(1)插入、局部性好、实现简单。
7.3 冲突查找与删除
查找和删除操作均需遍历桶内链表:
// dict.c:476-495 查找
for (table = 0; table <= 1; table++) {
idx = h & d->ht[table].sizemask;
he = d->ht[table].table[idx];
while(he) {
if (key == he->key || dictCompareKeys(d, key, he->key))
return he;
he = he->next;
}
if (!dictIsRehashing(d)) return NULL;
}
删除时需维护链表前后关系,支持头节点和中间节点的高效删除。
7.4 冲突率控制与工程优化
链地址法只解决了冲突的存储问题,Redis还通过多层机制控制冲突率:
- 高质量哈希函数(SipHash):
- Redis 4.0+ 默认使用 SipHash,具备密码学安全性和均匀分布,防止哈希碰撞攻击。
- 2 的幂次扩容与自动缩容:
- 负载因子 >= 1 时扩容,<10% 时缩容,保证链表不会过长。
- 扩缩容均通过渐进式rehash实现,无阻塞。
- 负载因子与链长表现:
- 正常情况下平均链长 <= 1,极端情况下(如BGSAVE期间)最长链也有限。
7.5 其他实现对比与选择理由
| 方案 | 代表实现 | 优点 | 缺点 |
|---|---|---|---|
| 链地址法 | Redis, Java HashMap | 删除简单,无聚集问题 | 指针开销,缓存不友好 |
| 开放寻址法 | Python dict, Ruby Hash | 缓存友好,无指针开销 | 删除需墓碑,聚集问题 |
Redis选择链地址法,因其实现简单、删除高效、对渐进式rehash友好、负载因子可大于1且指针开销可接受。
7.6 冲突相关源码关键路径
插入、查找、删除、SCAN遍历等操作均需遍历桶内链表,相关源码见dict.c的dictAddRaw、dictFind、dictGenericDelete、dictScan等。
八、dictScan —— rehash期间的迭代
SCAN命令使用dictScan无状态迭代器。在rehash期间,迭代需要特殊处理。
8.1 非rehash时的迭代
// dict.c:850-871
if (!dictIsRehashing(d)) {
t0 = &(d->ht[0]);
m0 = t0->sizemask;
de = t0->table[v & m0];
while (de) {
fn(privdata, de);
de = de->next;
}
v |= ~m0;
v = rev(v);
v++;
v = rev(v);
}
只遍历 ht[0],游标通过逆序递增算法计算下一个桶。
8.2 rehash时的迭代
// dict.c:872-914
} else {
t0 = &d->ht[0];
t1 = &d->ht[1];
if (t0->size > t1->size) {
t0 = &d->ht[1];
t1 = &d->ht[0];
}
m0 = t0->sizemask;
m1 = t1->sizemask;
// 先遍历小表当前桶
de = t0->table[v & m0];
while (de) { fn(privdata, de); de = de->next; }
// 再遍历大表中对应的所有扩展桶
do {
// 遍历大表中当前扩展桶的所有节点
de = t1->table[v & m1];
while (de) { fn(privdata, de); de = de->next; }
// 推进游标:只遍历属于当前小表桶的所有扩展桶
// 通过逆序递增游标,确保不会遗漏或重复
v |= ~m1; // 设置未被大表掩码覆盖的高位为1
v = rev(v); // 位反转
v++; // 递增
v = rev(v); // 再次位反转,得到下一个扩展桶索引
} while (v & (m0 ^ m1)); // 只要扩展位还没遍历完就继续
}
从上面的代码可以看出,在rehash过程中,小表的每个桶在大表中会对应多个扩展桶。例如:
- 小表size=4(掩码0b11),桶0b10
- 大表size=8(掩码0b111),对应的扩展桶是0b010和0b110
所以rehash期间:
- 先遍历小表的当前桶
- 再遍历大表中所有扩展桶
- 通过
v & (m0 ^ m1)判断扩展位是否遍历完毕
8.3 逆序递增游标示例
size=4, sizemask=0b11
正常递增: 00 → 01 → 10 → 11 → (回到 00)
逆序递增: 00 → 10 → 01 → 11 → (回到 00)
过程:
v=00: rev(00)=00, ++ → 01, rev(01) → 10
v=10: rev(10)=01, ++ → 10, rev(10) → 01
v=01: rev(01)=10, ++ → 11, rev(11) → 11
v=11: rev(11)=11, ++ → 00, rev(00) → 00 (完成)
高位先变——当哈希表从4扩到8时,新增的高位不会与已遍历的低位模式重叠,保证不遗漏。
九、rehash与迭代器的互斥
9.1 安全迭代器阻止rehash
// dict.c:260-262
static void _dictRehashStep(dict *d) {
if (d->iterators == 0) dictRehash(d,1);
}
当有安全迭代器运行时(d->iterators > 0),_dictRehashStep不会执行rehash。这避免了迭代过程中桶的迁移导致元素遗漏或重复。
9.2 哪些操作使用安全迭代器?
// db.c —— SCAN 命令
di = dictGetSafeIterator(dict);
// expire.c —— 主动过期
di = dictGetSafeIterator(db->expires);
// server.c —— DEBUG SEGFAULT
di = dictGetSafeIterator(server.commands);
9.3 非安全迭代器的指纹保护
非安全迭代器(dictGetIterator)不阻止rehash,但在创建和释放时检查指纹:
// dict.c:562-595
// 创建时:
iter->fingerprint = dictFingerprint(iter->d);
// 释放时:
assert(iter->fingerprint == dictFingerprint(iter->d));
如果迭代期间字典被修改(rehash也算),指纹会变化,断言失败。这是开发阶段的调试手段。
十、完整生命周期

通过对Redis渐进式rehash机制的源码分析,我们可以看到其在高性能与高可用性之间的巧妙权衡。Redis通过“化整为零”的渐进式迁移、读写驱动与定时任务协同推进、双表切换、迭代器安全保护等一系列设计,实现了哈希表扩容与缩容过程的无阻塞和极致效率。这些机制不仅保证了在大规模数据场景下的低延迟响应,也为内存和CPU资源的合理利用提供了坚实基础。
至此,Redis字典从哈希冲突处理、负载因子控制到渐进式rehash的整体运行脉络就比较清晰了。理解了这一套围绕哈希表展开的设计之后,接下来我们可以把视角转向Redis的另一类核心数据结构:跳表。后续几篇文章中,我们会一起去继续分析跳表(skiplist)的源码实现、Redis为什么选择跳表而不是红黑树,以及有序集合ZSet的底层实现解析。
|
欢迎各位同学关注我哦~
在这个 AI 喧嚣的时代 不忘初心,戒骄戒躁,认真沉淀 |
|

323

被折叠的 条评论
为什么被折叠?



