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

0 阅读7分钟

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

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

dict 的结构

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 用的是链地址法,冲突的节点挂在同一个桶的链表上:

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 里:

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:

used / size < 0.1   →   缩容

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


渐进式 Rehash

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

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

整个过程分几步:

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:

SET user:1 '{"name":"tom","age":18,"city":"sh"}'

HSET user:1 name tom age 18 city sh
维度StringHash
读写粒度整个对象单个字段
改一个字段读出来、改、整个写回一条 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 那篇。