Redis源码探究系列—Redis渐进式rehash机制源码分析

欢迎各位同学关注我哦~
在这个 AI 喧嚣的时代
不忘初心,戒骄戒躁,认真沉淀
Deepincode

在前面的两篇文章中,我们已经详细分析了Redis的双向链表(adlist)和字典(dict,HashTable 实现)这两大基础数据结构。理解了它们的实现原理后,我们可以更深入地探讨Redis字典在实际运行中如何高效地应对扩容和缩容带来的挑战。

在这篇文章中,我们将深入分析Redis渐进式rehash机制的源码实现与设计原理,结合实际代码细节,剖析其如何通过“化整为零”、读写驱动、双表切换等策略,实现高效、无阻塞的哈希表扩容与缩容过程,并探讨其背后的设计和性能的极致优化。

一、为什么需要渐进式rehash?

当哈希表的负载因子(used / size)过高或过低时,需要扩容或缩容。传统的做法是一次性重新分配更大的数组,把所有键值对重新哈希到新数组中。

传统rehash的步骤如下:

  1. 分配新数组
  2. 遍历旧数组所有桶,逐个重新计算哈希并插入新数组
  3. 释放旧数组

如果哈希表中有数百万甚至数十亿个键,一次性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的含义

rehashidxht[0]下一个要迁移的桶索引

  • rehashidx = -1:未在rehash
  • rehashidx = 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;
}

对桶中的每个节点:

  1. 计算在新哈希表中的索引:hash & ht[1].sizemask
  2. 头插法插入 ht[1]
  3. 更新两个哈希表的 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,说明所有键值对已迁移完成:

  1. 释放ht[0]的旧数组
  2. ht[1]提升为ht[0]
  3. 重置ht[1]
  4. 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还通过多层机制控制冲突率:

  1. 高质量哈希函数(SipHash)
    • Redis 4.0+ 默认使用 SipHash,具备密码学安全性和均匀分布,防止哈希碰撞攻击。
  2. 2 的幂次扩容与自动缩容
    • 负载因子 >= 1 时扩容,<10% 时缩容,保证链表不会过长。
    • 扩缩容均通过渐进式rehash实现,无阻塞。
  3. 负载因子与链长表现
    • 正常情况下平均链长 <= 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期间:

  1. 先遍历小表的当前桶
  2. 再遍历大表中所有扩展桶
  3. 通过 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 喧嚣的时代
不忘初心,戒骄戒躁,认真沉淀
Deepincode
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值