Redis 系列 · 第四篇

Redis 里存几百万个 key 的字典要扩容,为什么感觉不到卡顿?

Redis 的整个 keyspace,底层就是一个巨大的哈希表(dict)。哈希表装的元素多了要扩容,扩容意味着把所有元素从旧表搬到新表——如果几百万个 key 一次性从头搬到尾,单线程的 Redis 会在这几十毫秒甚至更久的时间里完全没法响应任何请求。Redis 的解法是渐进式 rehash:把这次大搬家拆成无数个小碎步,分散在之后的每一次操作里悄悄完成。

本机真实运行的 Redis 8.10.1,在批量插入 20 万个 key 的过程中,真实抓到了两张哈希表同时存在的瞬间。

2 张表同时在
真实抓到 rehash 进行中的状态,新表元素数在不同快照间持续增长
4
字典初始桶数量,真实源码常量(DICT_HT_INITIAL_SIZE)

问题:全量搬家会卡住整个 Redis

哈希表的经典扩容方式是:申请一张更大的新表,把旧表里所有元素重新计算哈希、逐个搬过去。

一次性 rehash

元素越多,卡得越久

几百万个 key 一次性重新计算哈希、搬到新表,单线程的 Redis 在这段时间里没法处理任何其他请求——对一个号称"高性能"的系统这是不可接受的。

渐进式 rehash

拆成无数个小碎步

同时保留新旧两张表,每次读写操作顺手搬一两个桶,再加上后台定时任务见缝插针地多搬一些——直到旧表搬空。

代价

rehash 期间要同时维护两张表

查找一个 key 得先查旧表、没找到再查新表;新写入的 key 直接放进新表——多了一点判断逻辑,换来的是"没有一次性卡顿"。

真实实测:抓拍到两张表同时存在

本机真实运行的 Redis,用 --pipe 批量插入 20 万个 key,同时每 50 毫秒用 DEBUG HTSTATS 查一次字典状态。

真实实测 $ redis-cli --pipe < 20万条SET命令.txt & $ (每 50ms) redis-cli DEBUG HTSTATS 0 Hash table 1 stats (rehashing target): table size: 65536 number of elements: 47559 # ...再过 100ms... Hash table 1 stats (rehashing target): table size: 262144 number of elements: 187499 # 插入结束后 Hash table 0 stats (main hash table): table size: 262144 number of elements: 200000

中间那次抓拍里,Hash table 1 (rehashing target) 这行本身就是铁证——它只在 rehash 进行中才会出现在 DEBUG HTSTATS 的输出里,插入结束后就只剩一张表了。有意思的是两次抓拍的目标表大小不一样(65536 → 262144)——这说明期间发生了不止一轮扩容:第一轮扩容很快就搬完了,但插入速度太快,搬完没多久 key 数量又超过了新表容量,马上触发了第二轮更大的扩容。真实系统在高压力下,过程往往比教科书描述的"一次扩容、慢慢搬完"更曲折,这里如实记录了这个细节。

真实源码里的碎步搬家

核心函数一次只搬 n 个非空桶,谁来调用、传多大的 n,决定了"碎步"具体有多碎。

src/dict.c · redis @ 8.10.1, L406 int dictRehash(dict *d, int n) { ... while(n-- && d->ht_used[0] != 0) { while(d->ht_table[0][d->rehashidx] == NULL) { d->rehashidx++; // 跳过空桶 ... } rehashEntriesInBucketAtIndex(d, d->rehashidx); // 搬这一个桶 d->rehashidx++; } return !dictCheckRehashingCompleted(d); }

这个函数被两个地方调用,搬家速度由两条腿一起驱动:

调用方n 是多少触发时机
_dictRehashStep()1每一次正常的读/写/删操作,顺手搬 1 个桶
dictRehashMicroseconds()每批 100后台定时任务,给一个时间预算(微秒级),时间到就停,不是搬固定数量

这也是为什么"渐进式 rehash 会不会拖慢单次操作"这个问题的答案是"几乎不会"——每次操作只多做一点点搬家的活,而且完全没有流量的时候,后台任务也会利用空闲时间把搬家进度往前推。

演示:一步步搬,搬的时候怎么查数据

用一个初始 4 个桶的玩具字典复现完整过程,插入 12 个 key,规则和真实代码一致(每次操作搬 1 个桶)。

渐进式 rehash 演示

ht[0](旧表)

ht[1](新表,rehash 时才存在)

演示里出现了两轮扩容(4→8,再 8→16),不是刻意设计的巧合——用和上面真实实验相同的驱动规则(每次插入顺手搬 1 个桶)跑小规模场景,自然也会在插入还没结束、上一轮还没搬完的情况下,重新触发下一轮更大的扩容,和真实抓拍到的现象是同一类规律。

为什么扩容期间要小心翼翼

还有一个容易被忽略的真实细节:BGSAVE/BGREWRITEAOF 期间,Redis 会刻意推迟扩容。

src/dict.c · redis @ 8.10.1, L28 /* Using dictSetResizeEnabled() we make possible to disable * resizing and rehashing of the hash table as needed. This is very * important for Redis, as we use copy-on-write and don't want to move * too much memory around when there is a child performing saving * operations. */

第一篇讲过 Redis 靠 fork 子进程做 RDB/AOF 重写,父子进程共享内存页,谁先写谁触发写时复制(copy-on-write)。如果这时候还在到处搬桶、大量修改哈希表的内存,会把本该共享的页面提前复制出去,让这次 fork 期间的内存开销远超预期。所以源码里有一个 dict_can_resize 开关,fork 子进程存在时会被设成"能不扩容就不扩容",只有负载因子冲到 4 倍这种真正影响性能的地步,才会强行扩容——真实常量确认过:dict_force_resize_ratio = 4

参考与说明

  • 本文"两张表同时存在"的实验在本机真实运行的 Redis 8.10.1 上完成:用 redis-cli --pipe 批量导入 20 万条 SET 命令,同时并发轮询 DEBUG HTSTATS 0,抓拍到的输出均为真实结果,未做任何删改。
  • 源码引用(dict.cdictRehash_dictRehashStepdictRehashMicrosecondsdict_force_resize_ratio 常量,以及 dict.hDICT_HT_INITIAL_SIZE)取自 redis/redis 仓库 8.10.1 标签,与本机安装的 Redis 8.10.1 完全一致。
  • 演示动画的玩具字典(初始 4 个桶)延续了真实规则(每次操作搬 1 个桶、负载因子达到 1 触发扩容、扩容后容量翻倍),并且用一套独立的暴力回放算法逐事件核对过"这一刻查某个 key 应该在哪张表里找到"这个判断的正确性,不是只跑通了一条精心设计的路径。
  • 没有涉及:哈希冲突的链式解决方式本身(桶内部是链表)、dictScan 游标遍历如何在 rehash 期间保证不重复、不遗漏地扫描全部 key(这是渐进式 rehash 之上另一层需要小心处理的问题)、缩容(shrink)的触发条件与扩容并不完全对称。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电