Redis 哈希表如何边扩容边服务?讲透渐进式 Rehash

在 第 1 篇的编码表里,Hash 有两种形态,字段少的时候是 listpack,字段一多或者某个 value 一长就掉到 hashtable。掉下去之后,底下就是 Redis 自己实现的一个哈希表,代码里叫 dict。

dict 表面上就是个普通的哈希表,真正值得讲的是它怎么扩容。如果表里有几百万个节点,一次性搬完会把 Redis 主线程卡住,所以它把搬迁拆散了做,这就是渐进式 Rehash。

dict 的结构

c 复制代码
typedef struct dict {
    dictType *type;      // 一组操作函数
    void *privdata;
    dictht ht[2];        // 注意是两个哈希表
    long rehashidx;      // 不在 rehash 时是 -1
} dict;

typedef struct dictht {
    dictEntry **table;        // 桶数组,每个元素是一条链表的头
    unsigned long size;       // 桶的数量,一定是 2 的幂
    unsigned long sizemask;   // size - 1
    unsigned long used;       // 已有的节点数
} dictht;

typedef struct dictEntry {
    void *key;
    union { void *val; uint64_t u64; int64_t s64; double d; } v;
    struct dictEntry *next;   // 拉链,指向下一个冲突节点
} dictEntry;

三个东西要留意:

  • ht 是长度 2 的数组,ht[0] 平时用,ht[1] 只在 rehash 的时候才分配。有第二个表,就是为了 rehash
  • size 一定是 2 的幂,这样算下标可以用 hash & sizemask 代替取模,位运算快
  • rehashidx 是进度指针,-1 表示没在 rehash

哈希冲突

两个 key 算出来的下标一样,就叫冲突。Redis 用的是链地址法,冲突的节点挂在同一个桶的链表上:

text 复制代码
table[5] ──► entry(K1) ──► entry(K7) ──► entry(K3) ──► NULL

新节点插在链表头部,不是尾部。原因是 dictEntry 只有 next 没有 prev,也没有尾指针,头插一步搞定是 O(1),尾插得先走到链表末尾,是 O(n)。

链表长了会退化。如果攻击者能构造出一堆哈希值相同的 key,所有节点挤在一条链上,查找就从 O(1) 变成 O(n)。所以 Redis 4.0 之后哈希函数换成了 SipHash,它对这种碰撞攻击不敏感。

拉链法的桶数够多、哈希函数够散,链就不会长,每次查找差不多就是算一次哈希、比一次 key。真正麻烦的不是冲突,是冲突多到需要扩容。

扩容和缩容

负载因子(load factor)就是 used / size,用来衡量桶的拥挤程度。

扩容的触发条件在 _dictExpandIfNeeded 里:

c 复制代码
if (d->ht[0].used >= d->ht[0].size &&
    (dict_can_resize ||
     d->ht[0].used / d->ht[0].size > dict_force_resize_ratio))  // 5
{
    dictExpand(d, d->ht[0].used * 2);
}

翻译一下:只要用掉的节点数不小于桶数(负载因子 ≥ 1),正常情况下就扩容。但有个前提 dict_can_resize,它在有子进程跑 BGSAVE 或 BGREWRITEAOF 的时候是 0。

为什么这时候要压住?BGSAVE 会 fork 一个子进程,父子进程共享内存页,父进程任何写操作都会触发写时复制。扩容要改 table 指针、搬大量数据,会搅动很多内存页,把共享的页复制成两份,内存占用一下子涨上去。所以有子进程在跑的时候,负载因子要涨到 5 才会强行扩容(这就是 dict_force_resize_ratio 的作用)。

扩容到多大:used * 2 之后往上取最近的 2 的幂。比如 used 是 1000,目标就是 2048 个桶。

缩容的触发条件不一样,是负载因子小于 0.1:

text 复制代码
used / size < 0.1   →   缩容

缩容到第一个不小于 used 的 2 的幂。扩容在插入路径上顺手检查,缩容则是靠 serverCron 定时任务里的 tryResizeHashTables 定期看一眼,因为删除操作不像插入那么频繁。


渐进式 Rehash

问题出在搬迁的量上。假设 ht[0] 有 400 万个节点,现在要扩到 800 万个桶,把 400 万个节点一条条重新算哈希、挂到新表上,这个操作可能要几百毫秒。Redis 是单线程处理命令的,这几百毫秒里所有请求都得等,线上就是一次明显的卡顿。

Redis 的解法是把这次搬迁摊到后续的每一次操作里,每次搬一点。

整个过程分几步:

text 复制代码
1. 给 ht[1] 分配空间,大小是第一个 >= used*2 的 2 的幂
   rehashidx 置 0

2. 每次对 dict 做增删改查,干完正事顺手搬一个桶:
   把 ht[0].table[rehashidx] 这条链上的所有节点搬到 ht[1]
   然后 rehashidx++

3. 期间写入的新节点,一律进 ht[1],ht[0] 只减不增

4. 查找时先在 ht[0] 找,找不到再去 ht[1] 找

5. ht[0] 搬空了,rehashidx = -1
   把 ht[1] 设成 ht[0],原来的 ht[1] 清零

第 3 步是关键。rehash 期间如果有新节点往 ht[0] 写,ht[0] 就永远搬不完。所以规则定死了:rehash 期间 ht[0] 只读不写,所有新增都进 ht[1]。

第 4 步是查找要查两个表的代价。一个 key 可能在老表里还没搬走,也可能已经在新表里了,两边都得找一遍。不过这个代价是有界的,最多查两次。

光靠操作驱动不够

如果某个 dict 建完之后长期没被访问,就没人触发第 2 步,rehash 停在半路,ht[0] 和 ht[1] 两个表同时占着内存,等于内存翻倍还多。

所以还有一条兜底路径。serverCron 会周期性调用 databasesCron,里面有个 incrementallyRehash,它调用 dictRehashMilliseconds(1),意思是主动搬 1 毫秒的活。这样即使没有请求,rehash 也会往前走。

搬运的最小单位是一个桶。dictRehash 每次最多扫 n * 10 个空桶就停下,避免连续碰到一大片空桶(这在缩容后很常见)时卡在原地太久。

大 Hash 的代价

回到 Hash 的编码选择。一个 Hash 字段少的时候是 listpack,省内存;一旦超过阈值变成 hashtable,代价就上来了。

两个地方会显内存:

  • hashtable 编码下,每个字段都是一个独立的 dictEntry,加上 key、value 各自的 robj,光指针和对象头就是几十字节。同样的数据,listpack 里是挨着存的一大块,没有这些开销
  • rehash 期间 ht[0] 和 ht[1] 同时在,两个桶数组一起占内存,峰值能到平时的两倍

所以别把一个 Hash 当成大集合用。字段到了几万、几十万,HGET 是快,但内存和遇到扩容时的压力都不划算,这种场景要拆 key,或者换更合适的结构。

存对象用 String 还是 Hash

这个选择经常要做。同一个用户对象,可以整体序列化成 JSON 存 String,也可以拆成字段存 Hash:

shell 复制代码
SET user:1 '{"name":"tom","age":18,"city":"sh"}'

HSET user:1 name tom age 18 city sh
维度 String Hash
读写粒度 整个对象 单个字段
改一个字段 读出来、改、整个写回 一条 HSET
字段级过期 做不到(除了拆 key) 最近才支持,见下
字段多时内存 拆成多 key 的话,每字段都有 key 开销 listpack 下字段挨着存,省
字段少时内存 一个 key,直接 多一层 robj,略多

判断的逻辑:

  • 对象整个读、整个写,不怎么改单个字段,用 String。缓存场景大部分是这种
  • 要频繁改某个字段,或者要按字段读,用 Hash。不然每次改一个字段都要把整个对象反序列化、改完再序列化写回,白白浪费 CPU
  • 别用 user:1:name、user:1:age 这样把一个对象拆成一堆 String key。每个 key 都是一个 dictEntry 加一个 robj,字段一多内存就上去了。这种情况应该用一个 Hash
  • 反过来,字段只有两三个、又不需要字段级操作,直接存一个 String 更省事

字段级过期是个例外情况。Redis 7.4 之前,Hash 不能给单个字段设 TTL,只能整个 key 过期。7.4 加了 HEXPIRE、HTTL 这一组命令才支持。如果你用的是 7.4 之前的版本,又需要"对象里某个字段单独过期",那就只能把它拆成独立的 String key。

ZSet 也同时用了跳表和 dict,不过那里 dict 是为了按 member 查 score,见ZSet 那篇。

相关推荐
仍然.1 小时前
Redis---主从复制
java·数据库·redis
Flynt11 小时前
Redis Cluster主节点挂了,为什么"高可用"还全员掉线?我把三次kill的记录翻出来了
数据库·redis·分布式
写后端的胖头鱼14 小时前
一文详解Cache Aside(旁路缓存模式)
redis·缓存·延迟双删·缓存一致
Web3&Basketball14 小时前
用FastAPI+Redis 复刻常驻Agent
redis·bootstrap·fastapi
ly768919 小时前
Redis 主从复制全解析:从 PSYNC 协议到复制积压缓冲区的故障切换边界
java·数据库·redis·主从复制·故障切换·psync
hweiyu0019 小时前
Redis命令:HPEXPIREAT
redis·缓存
ShineWinsu1 天前
对于Redis:ZSet类型的解析
数据库·c++·redis·缓存·面试·有序集合·zset
新鲜势力呀1 天前
PHP 定时任务系统实战:从 Cron 混乱执行到任务调度中心 + Redis队列 + 失败重试
android·java·redis
hweiyu001 天前
Redis命令:HPTTL
redis·缓存