Redis 的整个 keyspace,底层就是一个巨大的哈希表(dict)。哈希表装的元素多了要扩容,扩容意味着把所有元素从旧表搬到新表——如果几百万个 key 一次性从头搬到尾,单线程的 Redis 会在这几十毫秒甚至更久的时间里完全没法响应任何请求。Redis 的解法是渐进式 rehash:把这次大搬家拆成无数个小碎步,分散在之后的每一次操作里悄悄完成。
本机真实运行的 Redis 8.10.1,在批量插入 20 万个 key 的过程中,真实抓到了两张哈希表同时存在的瞬间。
哈希表的经典扩容方式是:申请一张更大的新表,把旧表里所有元素重新计算哈希、逐个搬过去。
几百万个 key 一次性重新计算哈希、搬到新表,单线程的 Redis 在这段时间里没法处理任何其他请求——对一个号称"高性能"的系统这是不可接受的。
同时保留新旧两张表,每次读写操作顺手搬一两个桶,再加上后台定时任务见缝插针地多搬一些——直到旧表搬空。
查找一个 key 得先查旧表、没找到再查新表;新写入的 key 直接放进新表——多了一点判断逻辑,换来的是"没有一次性卡顿"。
本机真实运行的 Redis,用 --pipe 批量插入 20 万个 key,同时每 50 毫秒用 DEBUG HTSTATS 查一次字典状态。
中间那次抓拍里,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 个桶)。
演示里出现了两轮扩容(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。