未命名
Redis 字典与渐进式 rehash:为什么“慢慢搬”是合理的选择
如果你用过 Redis,大概率知道它“快”。但“快”从来不是偶然的。Redis 字典的渐进式 rehash,就是一个典型的例子:它为了不阻塞,主动选择了一种“看起来更慢”的搬迁方式。
这篇文章不贴源码,而是回答几个具体问题:一次性 rehash 到底会阻塞多久?迁移过程中增删改查怎么处理?为什么是“每次搬一点”,而不是“分批定时搬”?以及——这个设计牺牲了什么。
一、字典的基本结构:两个哈希表
Redis 的字典 dict 内部有两个哈希表:ht[0] 和 ht[1]。
正常情况下,数据都在 ht[0] 里,ht[1] 是空的。只有当需要扩容或缩容时,ht[1] 才会被分配空间,然后数据从 ht[0] 逐渐迁移到 ht[1]。
为什么要两个表?
因为 Redis 是单线程的。如果只用一张表,扩容时你得原地把所有元素重新计算位置——这在单线程下意味着什么都干不了。两张表 + 一个 rehashidx 索引,让迁移可以“边干活边搬”。
每个哈希表底层是数组 + 链表,用链地址法解决冲突。dictEntry 就是链表节点,同一个桶里的元素用 next 指针串起来。
二、一次性 rehash 到底会阻塞多久?
这是核心问题。要回答它,先看数据量。
假设:一个字典有 1 亿个键值对。
关键数字:Redis 迁移一个键值对的典型耗时,保守估计在 100 纳秒左右。
那么:
1 | 1 亿 × 100 ns = 10,000,000,000 ns = 10 秒 |
10 秒。 在这 10 秒里,Redis 的主线程完全被占住。所有客户端请求全部排队等待。 对于一个标榜 P99 延迟在毫秒级的服务来说,这是灾难性的。
更极端的场景:如果 value 是大 key(比如一个几百 MB 的 List),单次迁移的耗时可能以秒为单位。有测试数据显示,迁移一个 600MB 的 list 类型 key 需要 6.7 秒。这还只是一个 key。
所以答案很明确:一次性 rehash 的阻塞时间与数据量成正比,在百万级以上的字典中,阻塞时间会达到秒级甚至更长。 对 Redis 来说,这等于服务中断。
三、渐进式 rehash 到底“渐进”在哪里?
Redis 的解法是:不一次性搬完,而是把搬迁工作分摊到每一次操作里。
具体机制:
- 扩容时,为
ht[1]分配新空间(通常是ht[0].used * 2向上取最近的 2 的幂); - 把
rehashidx设为0,表示 rehash 正式开始; - 每次对字典执行增删改查时,顺带把
ht[0]中rehashidx位置上的整个桶(链表上所有节点)迁移到ht[1]; - 迁移完成后,
rehashidx加一,指向下一个桶; - 当所有桶都迁移完毕,把
ht[1]设为新的ht[0],清空旧表,rehashidx重置为-1。
关键细节:一次迁移的是一个“桶”,不是“一个键值对”。因为一个桶可能是一个链表,上面挂着多个节点。但从实际数据看,由于负载因子控制在合理范围,平均每个桶通常只有 1 个节点,最多一般不超过 5 个。所以单次迁移的开销很小。
四、迁移过程中,增删改查怎么处理?
这是设计中最精妙的部分。两个表同时存在,但操作规则不同。
| 操作 | 规则 |
|---|---|
| 查找 | 先在 ht[0] 找,找不到再去 ht[1] 找 |
| 删除 | 两个表都要操作(因为 key 可能在任意一个表里) |
| 更新 | 两个表都要操作(先找到,再更新) |
| 新增 | 只往 ht[1] 里加,绝不往 ht[0] 加 |
新增只写 ht[1] 的原因很直接:保证 ht[0] 的节点数量只减不增。 如果新增还往旧表里写,那 ht[0] 永远清不空,rehash 永远结束不了。
这个规则带来的一个副作用是:迁移期间的查找可能比平时稍慢,因为最坏情况下要找两张表。但这是可接受的代价。
五、为什么是“每次操作顺带搬”,而不是“分批定时搬”?
Redis 其实两种都有。
- 被动搬迁:每次增删改查时顺带迁移一个桶(
_dictRehashStep); - 主动搬迁:服务器定时任务(
databasesCron)中调用dictRehashMilliseconds,在1 毫秒内尽可能多地迁移。
为什么以被动为主、主动为辅?
因为纯定时搬迁有一个问题:如果客户端一直不发请求,定时任务也不一定能保证及时搬完。 而且定时任务的执行时机依赖于 Redis 的事件循环,在高负载时可能被推迟。
被动搬迁的好处是:搬迁的开销天然地跟随负载分布。 请求越多,搬迁越快;请求越少,系统本身也不忙,晚一点搬完也无所谓。这是一种自适应的节奏。
主动搬迁则作为兜底:当客户端闲下来了,没有后续指令来触发被动搬迁时,Redis 不会“置之不理”,而是通过定时任务主动推进。dictRehashMilliseconds 每次只花 1ms 的 CPU 时间做搬迁,然后让出控制权。
所以准确地说:不是“不做定时”,而是“被动为主、定时为辅”。 两者结合,既保证了有请求时搬迁不拖后腿,也保证了没请求时不会永远卡在半途。
六、这个设计牺牲了什么,换来了什么?
牺牲的:
- 单次操作略慢。 迁移期间,每次增删改查都可能附带一次搬迁,或者需要查两张表。但开销被控制在单个桶的粒度,通常只有 1~5 个节点,对响应时间的影响很小。
- 内存占用翻倍。 rehash 期间两个哈希表同时存在,内存使用量会上升。这是“空间换时间”的典型体现。
- 实现复杂度上升。 所有字典操作都必须考虑“是否处于 rehash 状态”,代码路径分叉,更容易出 bug。
换来的:
- 不阻塞。 这是最核心的收益。无论字典多大,Redis 主线程永远不会因为 rehash 而停下来。
- 平滑的延迟曲线。 一次性 rehash 会导致 P99.9 和 max 延迟飙升;渐进式 rehash 把开销摊薄,让延迟保持稳定。
- 自适应节奏。 搬迁进度跟随负载,忙时搬得快,闲时搬得慢,不浪费 CPU。


















