今天咱们来聊聊Redis里一个既基础又核心的组件——字典,也就是大家常说的哈希表。这玩意儿是Redis整个数据结构的基石,像我们常用的HSETHGET这些命令,底层都靠它撑着。理解它的运作机制,尤其是扩容和渐进式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,即已用节点数除以桶的数量。触发扩容主要有两个条件:

  1. 服务器没有在执行BGSAVE或BGREWRITEAOF:如果Redis正在后台持久化(生成RDB或重写AOF),为了尽量减少内存的过度写入(Copy-on-Write机制),此时会提高扩容的阈值。具体是:
    • 没在执行持久化:负载因子 >= 1 时触发扩容。
    • 正在执行持久化:负载因子 >= 5 时触发扩容。
  2. 负载因子达到阈值:如上所述,根据是否在持久化,阈值是1或5。

这个逻辑在_dictExpandIfNeeded函数里。简单来说,就是检查一下现在挤不挤,再看看有没有在干“重活”(持久化),然后决定要不要申请新房子(ht[1])。

三、“蚂蚁搬家”的艺术:渐进式rehash详解

如果一下子把ht[0]里所有数据都搬到ht[1],对于一个大字典来说,可能会造成线程卡顿,服务短暂不可用。Redis可聪明了,它用的是“渐进式rehash”。把搬家这个重活拆分成无数个小步骤,穿插在每次对字典的增删改查操作里,一点点搬。

这个过程怎么控制的?关键就在dict结构里的rehashidx。当它不等于-1时,就表示搬家正在进行中。

核心流程如下:

  1. 分配新房子:为ht[1]分配空间,大小是第一个大于等于ht[0].used * 2的2的n次幂。比如ht[0]用了4个节点,新房子ht[1]的大小就是8。
  2. 设置搬家标识:将rehashidx设置为0,表示rehash正式开始,从ht[0]的第0号桶开始搬。
  3. 边操作边搬家:每次对字典进行dictAdd(增)、dictFind(查)、dictDelete(删)等操作时,除了执行指定操作,还会顺带从ht[0]rehashidx索引指向的桶里,搬1个或多个节点到ht[1]。搬完后rehashidx加1,指向下一个桶。
  4. 循环往复:就这样,随着客户端请求的不断到来,搬家工作悄无声息地进行。所有查找、删除操作都会同时在ht[0]ht[1]上进行(先查ht[0],再查ht[1]),而新增的键值对会直接插入到ht[1]中,保证ht[0]的节点数只减不增。
  5. 搬家完成:当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],从而释放多余内存。逻辑和扩容类似,只是新房子更小了。

基于这些机制,我们可以总结几个实用的内存优化技巧:

  1. 避免使用巨大Hash:尽量不要把一个Hash对象(比如用户信息)塞进成千上万个字段。虽然Redis的Hash结构存储小字段很高效(使用ziplist编码时),但一旦超过配置的阈值(hash-max-ziplist-entrieshash-max-ziplist-value)转成hashtable编码,并且字段数巨大,扩容和rehash的成本会变高。考虑拆分成多个Key。
  2. 监控used_memory和负载因子:通过INFO memoryDEBUG HTSTATS(谨慎使用)等命令,了解字典的实际使用情况。如果发现某个大Hash的负载因子长期很低,可以考虑手动触发缩容(虽然Redis会自动做,但理解原理有助于排查问题)。
  3. 规划键的过期时间:对于缓存场景,给Hash设置一个合理的过期时间,可以让Redis在删除Key时自动回收整个字典的内存,比等它自动缩容更直接。
  4. 理解持久化对扩容的影响:在规划大容量写入时,如果可能,尽量避开BGSAVEBGREWRITEAOF期间,这样扩容策略更积极(阈值=1),可以更早地使用更紧凑的内存布局。

五、实战场景、优缺点与注意事项

应用场景: 字典结构是Redis的万能钥匙。除了最明显的HASH类型,整个Redis的键空间(keyspace)就是一个超大字典,用来存储所有数据库的Key。还有像SET(当使用hashtable编码时)、ZSET(跳跃表+字典)的底层也依赖它。可以说,任何需要快速通过Key查找Value的地方,都有字典的身影。

技术优缺点:

  • 优点
    • 平均O(1)时间复杂度:查找、插入、删除速度极快。
    • 渐进式Rehash:平滑扩容/缩容,避免服务停顿,这是Redis高可用性的关键设计之一。
    • 内存使用灵活:结合ziplist等紧凑编码,在小数据量时非常节省内存。
  • 缺点
    • 哈希冲突:虽然用链地址法解决,但在极端情况下(大量键哈希到同一个桶),会退化成链表,影响性能。
    • Rehash期间额外开销:虽然平滑,但rehash期间的所有读写操作都需要操作两个表,有轻微的性能损耗。如果同时有大量写入,可能会拖慢rehash进程。
    • 内存碎片:频繁的扩容缩容可能加剧内存碎片。

注意事项:

  1. 大Key风险:一个存储了百万字段的Hash,在扩容时创建新ht[1]会一次性分配较大连续内存,可能引发瞬间内存压力甚至OOM。渐进式rehash只迁移数据是分步的,但分配新表内存是瞬间的。
  2. 迭代器安全:在rehash期间,如果有迭代器(例如HSCAN)正在遍历字典,逻辑会变得更复杂以保证能遍历到所有元素。开发客户端库或进行底层操作时需要留意。
  3. 监控Rehash状态:通过redis-cli执行info persistence,查看loading标志和rdb_bgsave_in_progress等,可以间接判断是否可能处于高阈值扩容阶段。

文章总结: Redis的字典结构是一个设计精妙的典范。它通过两个哈希表和渐进式rehash的机制,巧妙地平衡了性能与内存的使用,在提供高效键值存取的同时,保证了服务的平滑运行。理解其扩容缩容的触发条件、渐进式迁移的每一步细节,以及背后负载因子、持久化状态的影响,能够让我们在开发运维中更好地预判Redis的行为。无论是避免创建“大Key炸弹”,还是优化内存配置,亦或是进行深度性能调优,这些知识都是我们手中的利器。下次当你使用HGETALL时,不妨想想背后这个正在默默进行“蚂蚁搬家”的精密系统。