今天咱们来聊聊Redis里一个既基础又核心的组件——字典,也就是大家常说的哈希表。这玩意儿是Redis整个数据结构的基石,像我们常用的HSET、HGET这些命令,底层都靠它撑着。理解它的运作机制,尤其是扩容和渐进式rehash,对我们用好Redis、优化内存和性能至关重要。咱们就掰开揉碎了,看看源码里是怎么玩的。
一、Redis字典的“骨架”:哈希表与字典结构
Redis的字典,内部是由两个哈希表(dictht)构成的,通常我们叫它们ht[0]和ht[1]。为啥要两个?这就跟咱们搬家一样,ht[0]是老房子,ht[1]是新买的大房子,扩容的时候数据得一点点从老房子搬到新房子去,这个过程就是rehash。
先看看它们的“身份证”长啥样。在dict.h文件里,定义得明明白白。下面我用C语言的伪代码风格,结合注释给大家展示一下。
/* 技术栈:Redis (C语言) */
/* 哈希表节点,代表字典里的一个键值对 */
typedef struct dictEntry {
void *key; // 键
union {
void *val; // 值(可以是任意类型指针)
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; // 指向下一个节点,形成链表(解决哈希冲突)
} dictEntry;
/* 哈希表结构 */
typedef struct dictht {
dictEntry **table; // 哈希表数组,俗称“桶”(bucket)
unsigned long size; // 哈希表大小,即桶的数量(总是2的n次幂)
unsigned long sizemask; // 哈希掩码,用于计算索引,等于 size-1
unsigned long used; // 哈希表已有节点的数量
} dictht;
/* 字典的主结构,包含两个哈希表 */
typedef struct dict {
dictType *type; // 类型特定函数(用于操作特定类型键值)
void *privdata; // 私有数据
dictht ht[2]; // 两个哈希表,ht[0]是主表,ht[1]只在rehash时使用
long rehashidx; // rehash进度索引,-1表示不在进行rehash
int iterators; // 当前正在运行的迭代器数量
} dict;
看这个结构,重点在ht[2]和rehashidx。平时ht[1]是空着的,rehashidx是-1。一旦开始扩容搬家,ht[1]就派上用场了,rehashidx会从0开始,记录老房子ht[0]里已经搬了多少个桶的数据到新房子。
二、何时“换大房子”:触发扩容的时机
Redis不会等到老房子挤得水泄不通了才想着换房。它有个负载因子(load factor)的概念,就是ht[0].used / ht[0].size,即已用节点数除以桶的数量。触发扩容主要有两个条件:
- 服务器没有在执行BGSAVE或BGREWRITEAOF:如果Redis正在后台持久化(生成RDB或重写AOF),为了尽量减少内存的过度写入(Copy-on-Write机制),此时会提高扩容的阈值。具体是:
- 没在执行持久化:负载因子 >= 1 时触发扩容。
- 正在执行持久化:负载因子 >= 5 时触发扩容。
- 负载因子达到阈值:如上所述,根据是否在持久化,阈值是1或5。
这个逻辑在_dictExpandIfNeeded函数里。简单来说,就是检查一下现在挤不挤,再看看有没有在干“重活”(持久化),然后决定要不要申请新房子(ht[1])。
三、“蚂蚁搬家”的艺术:渐进式rehash详解
如果一下子把ht[0]里所有数据都搬到ht[1],对于一个大字典来说,可能会造成线程卡顿,服务短暂不可用。Redis可聪明了,它用的是“渐进式rehash”。把搬家这个重活拆分成无数个小步骤,穿插在每次对字典的增删改查操作里,一点点搬。
这个过程怎么控制的?关键就在dict结构里的rehashidx。当它不等于-1时,就表示搬家正在进行中。
核心流程如下:
- 分配新房子:为
ht[1]分配空间,大小是第一个大于等于ht[0].used * 2的2的n次幂。比如ht[0]用了4个节点,新房子ht[1]的大小就是8。 - 设置搬家标识:将
rehashidx设置为0,表示rehash正式开始,从ht[0]的第0号桶开始搬。 - 边操作边搬家:每次对字典进行
dictAdd(增)、dictFind(查)、dictDelete(删)等操作时,除了执行指定操作,还会顺带从ht[0]的rehashidx索引指向的桶里,搬1个或多个节点到ht[1]。搬完后rehashidx加1,指向下一个桶。 - 循环往复:就这样,随着客户端请求的不断到来,搬家工作悄无声息地进行。所有查找、删除操作都会同时在
ht[0]和ht[1]上进行(先查ht[0],再查ht[1]),而新增的键值对会直接插入到ht[1]中,保证ht[0]的节点数只减不增。 - 搬家完成:当
rehashidx从0递增到ht[0].size,意味着所有桶都搬完了。此时,释放ht[0]的空间,将ht[1]设置为新的ht[0],并在ht[1]处创建一个新的空哈希表,最后将rehashidx重置为-1。整个扩容过程圆满结束。
我们来看一个模拟渐进式rehash步骤的简化示例,虽然真正的源码更复杂,但这个例子能帮你理解核心思想。
/* 技术栈:Redis (C语言) - 渐进式rehash单步模拟 */
/* 假设这是dict结构的一个实例 */
dict myDict;
/* 初始化后,myDict.rehashidx = -1, myDict.ht[1] 为空 */
/* 假设某个条件触发了扩容,我们开始了rehash */
void beginRehash(dict *d) {
// 1. 为ht[1]分配新空间,大小是ht[0].used * 2 左右的2的n次幂
unsigned long newSize = ... // 计算新大小
d->ht[1].table = malloc(newSize * sizeof(dictEntry*));
d->ht[1].size = newSize;
d->ht[1].sizemask = newSize - 1;
d->ht[1].used = 0;
// 2. 设置rehash标识,从第0号桶开始搬
d->rehashidx = 0;
printf("Rehash started! We'll move from bucket 0 of ht[0].\n");
}
/* 这是每次字典操作时被调用的函数,执行一步rehash */
void dictRehashStep(dict *d) {
if (d->rehashidx == -1) return; // 不在rehash状态,直接返回
// 本次只搬一个桶(实际Redis可能一次搬多个,比如n*10个桶)
int empty_visits = 10; // 最多检查10个空桶,避免耗时过长
while(d->ht[0].table[d->rehashidx] == NULL && empty_visits--) {
d->rehashidx++; // 跳过空桶
if (d->rehashidx >= d->ht[0].size) {
// 所有桶都检查/搬完了
free(d->ht[0].table);
d->ht[0] = d->ht[1]; // ht[1] 晋升为新的主表ht[0]
// 重置ht[1]为空表
d->ht[1].table = NULL;
d->ht[1].size = 0;
d->ht[1].sizemask = 0;
d->ht[1].used = 0;
d->rehashidx = -1; // 关闭rehash标识
printf("Rehash completed!\n");
return;
}
}
// 搬运当前 rehashidx 指向的桶里的所有节点
dictEntry *de, *nextDe;
de = d->ht[0].table[d->rehashidx];
while(de) {
nextDe = de->next;
// 计算该节点在新表ht[1]中的桶索引
unsigned int 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--;
d->ht[1].used++;
de = nextDe;
}
d->ht[0].table[d->rehashidx] = NULL; // 清空老桶
d->rehashidx++; // 准备处理下一个桶
printf("Moved bucket %lu to new table.\n", d->rehashidx - 1);
}
/* 客户端执行一个HSET命令,底层会调用类似下面的逻辑 */
void dictAddWrapper(dict *d, void *key, void *val) {
// 先尝试执行一步rehash
dictRehashStep(d);
// 然后执行真正的添加操作
// 注意:如果正在rehash,新键值对直接添加到ht[1]
if (d->rehashidx != -1) {
// 正在rehash,直接插入ht[1]
_dictAddToHashTable(d->ht[1], key, val);
} else {
// 不在rehash,插入ht[0]
_dictAddToHashTable(d->ht[0], key, val);
}
}
通过这个模拟,你可以看到,rehashidx就像个指针,一点点扫过ht[0]的所有桶。而客户端的每次请求,都像是推了这个指针一把,让它前进一小步。这就是“渐进”的精髓。
四、给内存“瘦身”:缩容与内存优化技巧
有扩容就有缩容。如果大量删除操作导致负载因子过低(比如小于0.1),Redis也会启动渐进式rehash来缩容,把数据从ht[0]搬到更小的ht[1],从而释放多余内存。逻辑和扩容类似,只是新房子更小了。
基于这些机制,我们可以总结几个实用的内存优化技巧:
- 避免使用巨大Hash:尽量不要把一个Hash对象(比如用户信息)塞进成千上万个字段。虽然Redis的Hash结构存储小字段很高效(使用
ziplist编码时),但一旦超过配置的阈值(hash-max-ziplist-entries和hash-max-ziplist-value)转成hashtable编码,并且字段数巨大,扩容和rehash的成本会变高。考虑拆分成多个Key。 - 监控
used_memory和负载因子:通过INFO memory和DEBUG HTSTATS(谨慎使用)等命令,了解字典的实际使用情况。如果发现某个大Hash的负载因子长期很低,可以考虑手动触发缩容(虽然Redis会自动做,但理解原理有助于排查问题)。 - 规划键的过期时间:对于缓存场景,给Hash设置一个合理的过期时间,可以让Redis在删除Key时自动回收整个字典的内存,比等它自动缩容更直接。
- 理解持久化对扩容的影响:在规划大容量写入时,如果可能,尽量避开
BGSAVE或BGREWRITEAOF期间,这样扩容策略更积极(阈值=1),可以更早地使用更紧凑的内存布局。
五、实战场景、优缺点与注意事项
应用场景:
字典结构是Redis的万能钥匙。除了最明显的HASH类型,整个Redis的键空间(keyspace)就是一个超大字典,用来存储所有数据库的Key。还有像SET(当使用hashtable编码时)、ZSET(跳跃表+字典)的底层也依赖它。可以说,任何需要快速通过Key查找Value的地方,都有字典的身影。
技术优缺点:
- 优点:
- 平均O(1)时间复杂度:查找、插入、删除速度极快。
- 渐进式Rehash:平滑扩容/缩容,避免服务停顿,这是Redis高可用性的关键设计之一。
- 内存使用灵活:结合
ziplist等紧凑编码,在小数据量时非常节省内存。
- 缺点:
- 哈希冲突:虽然用链地址法解决,但在极端情况下(大量键哈希到同一个桶),会退化成链表,影响性能。
- Rehash期间额外开销:虽然平滑,但rehash期间的所有读写操作都需要操作两个表,有轻微的性能损耗。如果同时有大量写入,可能会拖慢rehash进程。
- 内存碎片:频繁的扩容缩容可能加剧内存碎片。
注意事项:
- 大Key风险:一个存储了百万字段的Hash,在扩容时创建新
ht[1]会一次性分配较大连续内存,可能引发瞬间内存压力甚至OOM。渐进式rehash只迁移数据是分步的,但分配新表内存是瞬间的。 - 迭代器安全:在rehash期间,如果有迭代器(例如
HSCAN)正在遍历字典,逻辑会变得更复杂以保证能遍历到所有元素。开发客户端库或进行底层操作时需要留意。 - 监控Rehash状态:通过
redis-cli执行info persistence,查看loading标志和rdb_bgsave_in_progress等,可以间接判断是否可能处于高阈值扩容阶段。
文章总结:
Redis的字典结构是一个设计精妙的典范。它通过两个哈希表和渐进式rehash的机制,巧妙地平衡了性能与内存的使用,在提供高效键值存取的同时,保证了服务的平滑运行。理解其扩容缩容的触发条件、渐进式迁移的每一步细节,以及背后负载因子、持久化状态的影响,能够让我们在开发运维中更好地预判Redis的行为。无论是避免创建“大Key炸弹”,还是优化内存配置,亦或是进行深度性能调优,这些知识都是我们手中的利器。下次当你使用HGETALL时,不妨想想背后这个正在默默进行“蚂蚁搬家”的精密系统。
评论