探秘Redis哈希表扩容与缩容:渐进式Rehash的巧妙设计与实现
1. 哈希表:Redis的基石与挑战
哈希表是Redis中最核心、最基础的数据结构。不仅是HSET、HGET等命令的直接载体,Redis本身就是一个巨大的键值对数据库,其所有键值对(无论String, List, Set)都存储在一个全局的哈希表中(在Redis源码中称为redisDb的dict结构)。因此,哈希表的性能直接决定了Redis的整体性能。
哈希表面临的核心挑战是负载因子(Load Factor),即已存储键值对数量与哈希桶数组大小的比值。当负载因子过高时,哈希冲突加剧,查询效率从理想的O(1)退化为O(n);当负载因子过低时,则造成内存浪费。因此,扩容(Expand)与缩容(Shrink)是维持哈希表高效运作的生命线。
然而,对于一个像Redis这样处理海量请求的单线程服务,传统的“一次性”Rehash(创建新哈希表,将所有键值对一次性迁移过去)会导致巨大的延迟毛刺。在迁移数千万个键值对时,主线程可能被阻塞数秒甚至更久,这对于高并发服务是致命的。
Redis的解决方案是渐进式Rehash(Incremental Rehash)。它巧妙地将一次性的、高强度的计算开销,*摊到多次客户端请求中,实现了*滑、无感的哈希表迁移,这正是Redis高性能神话的重要组成部分。
2. 核心数据结构:深入dict与dictht
要理解Rehash,首先必须理解Redis中字典的实现。核心结构定义在src/dict.h中。
2.1 哈希表节点:dictEntry
这是存储键值对的基本单位。
typedef struct dictEntry {
void *key; // 键
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; // 值(柔性存储)
struct dictEntry *next; // 指向下一个节点的指针(形成链表,解决哈希冲突)
} dictEntry;
每个dictEntry不仅包含key和v,还有一个next指针。当发生哈希冲突时(两个不同的键被哈希到同一个桶),Redis使用链地址法,将冲突的节点通过next指针连接成一个单向链表。
2.2 哈希表本体:dictht
这是真正存储数据的结构。
typedef struct dictht {
dictEntry **table; // 指针数组,每个元素指向一个链表(一个桶)
unsigned long size; // 哈希表大小(桶的数量,总是2的幂)
unsigned long sizemask; // 掩码,等于size-1,用于计算索引
unsigned long used; // 哈希表中已有的节点数量
} dictht;
- table:一个二级指针,指向一个dictEntry*数组。数组的每个位置称为一个“桶”(bucket)。
- size:桶数组的长度。为了使用位运算快速计算索引,它总是2的幂(如4, 8, 16, …, 2^n)。
- sizemask:大小掩码,值为size - 1。计算键的哈希值在哪个桶时,使用hash & sizemask,这比取模运算%高效得多。
- used:当前哈希表中实际存储的键值对数量。
2.3 字典:dict
dict结构封装了两个哈希表,并包含了Rehash所需的核心状态。
typedef struct dict {
dictType *type; // 类型特定函数(用于实现多态,如哈希函数,键比较函数)
void *privdata; // 私有数据
dictht ht[2]; // 两个哈希表,ht[0]是主表,ht[1]仅在Rehash时使用
long rehashidx; // Rehash的进度索引,-1表示未进行Rehash
int16_t pauserehash; // Rehash暂停计数器,>0时暂停
} dict;
dict是理解渐进式Rehash的关键:
- ht[2]:正常情况下,所有数据都存储在ht[0]中,ht[1]为空。当需要Rehash时,才会为ht[1]分配一个更大(或更小)的新数组。
- rehashidx:这是Rehash的进度指示器。它的值在非Rehash状态下为-1。在Rehash过程中,它记录当前正在迁移的ht[0]的桶的索引(从0到ht[0].size-1)。
3. 触发时机:何时扩容与缩容?
Redis根据负载因子动态决策何时启动Rehash。
3.1 扩容(Expand)
扩容发生在元素数量过多,导致哈希表过于拥挤时。条件相对复杂,旨在*衡内存和性能。
扩容触发条件(dict.c中的_dictExpandIfNeeded函数):
static int _dictExpandIfNeeded(dict *d) {
if (dictIsRehashing(d)) return DICT_OK; // 已在Rehash,直接返回
// 条件1: 哈希表为空,需要初始化
if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);
// 条件2: 负载因子 >= 1,且允许调整大小或者负载因子 > 5(强制调整)
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize || d->ht[0].used/d->ht[0].size > 5))
{
return dictExpand(d, d->ht[0].used*2); // 新大小为已用节点的2倍
}
return DICT_OK;
}
- DICT_HT_INITIAL_SIZE:哈希表的初始大小,默认为4。
- dict_can_resize:一个全局标志,通常为1。在后台持久化(如RDB或AOF重写)时,为了减少内存页的写入(Copy-on-Write),会临时设置为0,禁止扩容。
- 强制扩容:当负载因子>5时,说明哈希表已经非常拥挤,性能严重下降,此时即使dict_can_resize为0,也会强制扩容。
3.2 缩容(Shrink)
缩容发生在元素数量减少,造成内存浪费时。Redis非常注重内存效率。
缩容触发条件(src/redis.c的databasesCron函数中):
// 在serverCron定期任务中会调用
void databasesCron(void) {
// ... 遍历所有数据库 ...
if (server.activerehashing) { // 允许主动Rehash
for (int j = 0; j < dbs_per_call; j++) {
// 尝试对数据库字典进行Resize
int workdone = incrementallyRehash(TARGET_DB_DICTS);
if (workdone) break;
}
}
// ...
}
// 在尝试Resize时,会判断是否需要缩容
int dictResize(dict *d) {
int minimal;
if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
minimal = d->ht[0].used; // 最小所需大小就是当前元素数量
if (minimal < DICT_HT_INITIAL_SIZE) // 但不能小于初始值
minimal = DICT_HT_INITIAL_SIZE;
return dictExpand(d, minimal);
}
关键点:缩容的目标大小并不是used的一半,而是used本身。因为used就是容纳所有元素所需的最小桶数量。由于桶数量必须是2的幂,最终分配的大小会是大于等于used的最小的2的幂次方。
4. 渐进式Rehash的源码实现:步步为营
这是最核心的部分。让我们深入dictRehash函数,看Redis如何一步步完成迁移。
4.1 单步迁移:dictRehash
dictRehash函数负责执行一步迁移,即迁移ht[0]中rehashidx指示的桶里的所有节点。
int dictRehash(dict *d, int n) { // n表示本次最多迁移多少个桶
int empty_visits = n * 10; // 最大允许访问的空桶数量,防止长时间阻塞
if (!dictIsRehashing(d)) return 0; // 未在Rehash,直接返回
while (n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
// 1. 找到ht[0]中下一个非空的桶
while (d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1; // 避免在空桶上浪费太多时间
}
// 2. de指向当前要迁移的桶的链表头
de = d->ht[0].table[d->rehashidx];
// 3. 迁移整个链表到ht[1]
while (de) {
uint64_t h;
nextde = de->next; // 保存下一个节点的地址
// 计算此键在ht[1]中的新索引
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
// 将节点插入到ht[1]的链表头部
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--; // ht[0]节点数减1
d->ht[1].used++; // ht[1]节点数加1
de = nextde; // 处理下一个节点
}
// 4. 当前桶迁移完毕,将ht[0]对应桶置为NULL
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++; // 进度索引加1,指向下一个待迁移的桶
}
// 5. 检查Rehash是否完成
if (d->ht[0].used == 0) {
zfree(d->ht[0].table); // 释放旧的哈希表数组
d->ht[0] = d->ht[1]; // 将ht[1]赋值给ht[0]
_dictReset(&d->ht[1]); // 重置ht[1]为空表
d->rehashidx = -1; // 关闭Rehash标志
return 0; // 返回0表示Rehash已完成
}
return 1; // 返回1表示Rehash仍在进行中
}
关键步骤解析:
- 寻找非空桶:从rehashidx开始,跳过空桶,找到第一个有数据的桶。
- 迁移整个链表:将该桶下的所有节点(即整个链表)迁移到ht[1]。注意,节点在ht[1]中的新索引是重新计算的,因为sizemask已经改变。
- 更新指针:迁移后,将节点插入ht[1]对应链表的头部。这是一个O(1)操作。
- 更新状态:清空ht[0]的当前桶,rehashidx加1。
- 完成检查:当ht[0]的used为0时,表示所有元素已迁移。此时用ht[1]替换ht[0],并重置ht[1]和rehashidx。
4.2 驱动迁移的“引擎”
单步迁移函数dictRehash需要被驱动。Redis有三个主要的“引擎”来驱动它:
- 定时驱动(主动Rehash):在serverCron(Redis的定时任务函数)中,如果检测到字典正在Rehash,会调用incrementallyRehash函数,执行指定时长(默认为1毫秒)的Rehash。
// 在serverCron中
if (server.activerehashing) { // 由配置项`activerehashing`控制,默认开启
for (int j = 0; j < dbs_per_call; j++) {
// 对数据库字典和过期键字典进行一步Rehash
int workdone = incrementallyRehash(TARGET_DB_DICTS | TARGET_EXPIRES_DICTS);
if (workdone) break; // 如果已经做了实际工作,就跳出
}
}
- 请求驱动(懒惰Rehash):这是保证Rehash最终完成的关键。在对字典进行增删改查操作时(如dictAdd, dictFind, dictDelete),都会先尝试执行一步Rehash。
// 在dictAddRaw等函数开头
if (dictIsRehashing(d)) _dictRehashStep(d); // 执行一步Rehash
// _dictRehashStep定义
static void _dictRehashStep(dict *d) {
if (d->pauserehash == 0) { // 如果Rehash未被暂停
dictRehash(d, 1); // 只迁移1个桶
}
}
这意味着,只要有客户端访问正在Rehash的字典,就会推动Rehash前进一点点。
- 强制完成:在少数情况下,需要确保Rehash立即完成,例如在BGSAVE(RDB持久化)开始前,Redis会调用dictRehashMilliseconds,在指定毫秒数内持续进行Rehash,尽可能多地完成迁移。
5. 操作在Rehash期间的智慧:双表查询
在Rehash进行期间,字典同时存在ht[0]和ht[1]两张表。那么,增删改查操作如何正确进行呢?
核心原则:顺水推舟,协助迁移。
- 查询(Find):需要同时查找ht[0]和ht[1]。因为一个键可能还在旧表,也可能已迁到新表。查找顺序是从ht[0]到ht[1]。
dictEntry *dictFind(dict *d, const void *key) {
// ...
if (dictIsRehashing(d)) _dictRehashStep(d); // 先协助一步迁移
unsigned long h = dictHashKey(d, key);
for (int table = 0; table <= 1; table++) { // 遍历两张表
unsigned long idx = h & d->ht[table].sizemask;
dictEntry *de = d->ht[table].table[idx];
while (de) {
if (dictCompareKeys(d, key, de->key))
return de;
de = de->next;
}
// 如果不在Rehash,或者Rehash完成,就不需要查第二张表
if (!dictIsRehashing(d)) break;
}
return NULL;
}
- 插入(Add):新插入的键值对一律被放入ht[1]。这保证了ht[0]的节点数量只减不增,最终一定会变为空。
int dictAdd(dict *d, void *key, void *val) {
// ...
if (dictIsRehashing(d)) _dictRehashStep(d); // 协助迁移
int index = _dictKeyIndex(d, key, dictHashKey(d, key)); // 计算索引
// ... 检查键是否存在 ...
dictEntry *entry = dictAllocEntry(key);
// 插入到ht[1](如果正在Rehash)或ht[0](如果未在Rehash)
int index = dictIsRehashing(d) ? 1 : 0;
dictEntry *entry = ...;
entry->next = d->ht[index].table[idx];
d->ht[index].table[idx] = entry;
d->ht[index].used++;
// ...
}
- 删除(Delete):与查询类似,也需要在两张表中查找并删除。
6. 实战启示与调优要点
理解Rehash机制对生产环境的稳定性和性能至关重要。
- 监控Rehash状态:使用INFO stats命令,关注migrate_cached_sockets字段并非直接相关。更有效的方法是监控latency,因为Rehash可能导致延迟微小上升。或者,通过DEBUG HTSTATS 命令(调试命令,生产环境慎用)可以查看哈希表的状态。
- 避免大Key导致的阻塞:虽然Rehash是渐进的,但迁移一个存储了百万个字段的Hash大Key所在的桶,仍然可能是一个耗时的操作,因为需要遍历整个链表。务必避免使用大Key。
- 配置activerehashing:redis.conf中的activerehashing配置项(默认yes)控制是否启用定时驱动Rehash。在延迟极其敏感的场景,可以设置为no,完全依靠请求驱动。但这可能导致Rehash进程缓慢,内存回收不及时。
- 理解内存使用:在Rehash期间,由于同时存在ht[0]和ht[1],内存占用会短暂地接**时的两倍。在内存紧张的环境中,需要预留这部分开销,并确保最大内存限制(maxmemory)设置合理,以触发Key的淘汰。
总结
Redis的渐进式Rehash是一项精妙绝伦的工程设计。它通过:
- 分而治之:将庞大的迁移任务分解为一个个小步骤。
- 化整为零:将迁移成本*摊到每次客户端请求和系统定时任务中。
- 双表协作:通过rehashidx指针和双表查询机制,在迁移过程中无缝地维持服务的可用性。
这种设计完美体现了Redis在高性能、低延迟与资源利用率之间的高超权衡艺术,是其能够胜任高性能缓存和数据存储中间件角色的基石之一。它不仅解决了哈希表扩容/缩容时的延迟问题,更是一种普适的、用于处理高开销后台任务的经典范式。
浙公网安备 33010602011771号