「腾讯云 NoSQL」技术之 Redis 篇:精准围剿 rehash 时延毛刺实践方案揭秘

在互联网时代,数据存储与访问的效率直接决定着用户体验的好坏。Redis 作为当今最流行的内存数据库之一,凭借其出色的性能表现,已成为支撑亿万用户在线服务的核心基础设施。然而,随着业务规模的快速增长,Redis 内部的 rehash 机制却可能在关键时刻引发严重的时延毛刺,导致整个系统性能急剧下降,甚至引发大规模的服务故障。如何精准识别并有效解决 rehash 带来的性能问题,已成为云 Redis 产品必须面对的重要挑战。本文将通过一个真实的线上故障案例,深入剖析 rehash 机制的工作原理及其对时延的影响,并详细介绍腾讯云 NoSQL 团队针对这一问题的体系化优化实践。
1 故障“案发现场”:一次惊心动魄的线上事故
这是一个寒冷的冬夜,你拖着疲惫的身躯回到家中,盛满一杯热牛奶,躺在松软的沙发上。“终于到周末啦!”,你一边庆祝着,一边郑重地打开了你最爱的娱乐软件,准备狠狠 happy 一波。但天有不测风云,平时能轻松登录的应用软件,今天竟然显示“登录失败”。你以为是网络问题,于是熟练地刷新页面、切换网络,但那个刺眼的提示依旧顽固地占据着屏幕,直到各种方法用尽,却依然无法登录后,你恍然大悟——应用服务崩了!
对于腾讯云 NoSQL 团队的工程师们来说,这个不平静的夜晚,始于监控屏幕上一条急剧拉升的曲线。这条曲线对应着负责该应用软件身份校验服务的云 Redis 集群——一个存储着海量用户登录凭证的关键系统。监控图上,P99 延迟从稳如磐石的 18ms 飙升至 390ms,暴涨超过 20 倍,平均延迟也翻了个倍!相应地,身份校验服务的成功率骤降,毫不知情的用户面对着无法登录的应用,下意识地刷新、重试。这股巨大的“重试风暴”带来了海量的建连请求,如潮水般涌向同一个 Redis 分片,瞬间将其 CPU 打满,服务近乎瘫痪。
在紧张的故障原因调查中,一个关键线索浮出水面:业务的 key 总量在短短半个多月内暴涨 4 倍。而发生故障的那天,故障分片的 key 数量不多不少,正好增长到了 6710 万,这与 2 的 26 次方十分接近。我们将时延激增的时间点与 key 数量增长曲线一对齐,揪出了这次事故的“幕后黑手”——Redis 的 rehash 机制。
2 揭秘“真凶”:rehash 为何会引发时延毛刺?
在介绍 rehash 如何引发时延上升之前,我们需要先了解 Redis rehash 的基本原理。接下来,我们从 Redis 的基本数据结构——字典(dict)开始说起。
2.1 dict 数据结构
dict 的数据结构如图一所示。在 Redis 这样的 key-value 数据库中,数据都是以 key-value 这样的键值对形式存储的,而 dict 则是存储键值对的核心数据结构。dict 由两个哈希表组成,在普通状态下,dict 只使用 table0,table1 为空。当用户写入一对 key-value 时,Redis 首先会计算 key 的 hash 值,将 key-value 存入 hash 值对应的哈希桶里。每个哈希桶都是一个链表,新来的 key-value 对会插入到链表的头部。以图一为例,当写入 k3-v3 时,Redis 首先计算出 k3 的 hash 值为 2,然后将 k3-v3 插入到 2 号哈希桶的链表头部。
图 1 dict 数据结构示意图
在写数据时,数据是直接写到链表头部的,因此写数据的时间复杂度可以稳定在 O(1),但读数据就没有这么幸运了。当一个哈希桶里有多对 key-value 时,Redis 会从链表头部开始遍历,依次比较每个链表节点的 key 与目标 key 是否一致:如果不一致,则比较下一个节点;如果一致,则返回该节点的 value。
图 2 过长的哈希桶链表导致读性能下降
不难想到,这样的读取方式会带来下述问题:如果哈希桶中的 key-value 对太多,并且我们要查询的 key 非常不幸地位于链表尾部,那么我们就不得不遍历链表的所有节点才能读取到目标 value,本次查询就会偏慢。如图二所示,如果用户执行“GET k0”命令,那么它需要进行 6 次比较才能完成查询。可以想象,如果哈希表永远只有 4 个桶,元素个数为 N,那么每个桶的平均 key-value 对数量为 N/4,每次查询平均需要做 N/8 次比较。在数据量 N 很大时,这样的查询性能是无法接受的。如何解决以上问题呢?很简单,给哈希表分配更多的哈希桶就行了。
2.2 dict 的扩容与 rehash
在上一小节我们得知,当 dict 中的数据量较大时,为了避免读性能下降,应该给哈希表分配更多的哈希桶,这就是 dict 的扩容。怎样决定何时扩容呢?接着上一节的分析,记哈希表中的实际元素个数为 N,哈希表的桶数为 M,那么每个桶的平均链表长度为 N/M。平均链表长度直接影响哈希表的读取性能,因此 Redis 把 N/M 作为决定 dict 何时扩容的指标,并将其称为“负载因子”(load factor)。当负载因子大于 1 时,dict 就决定进行扩容。
扩容的时机定好了,那么扩容的大小怎么定呢?首先,Redis 中 dict 哈希表的桶数都是 2 的整数次幂(初始为 4)。每次扩容时,新表的桶数是“第一个大于或等于当前元素个数两倍的 2 的整数次幂”。例如当前哈希表的桶数为 4,元素个数为 4,那么再写入一个新 key 时,负载因子大于 1,会触发扩容,扩容出的新表大小是第一个大于或等于 5 的 2 的整数次幂,也就是 8。
dict 扩容的机制使得当 key 的数量增长至 2 的整数次幂时,dict 就会进行扩容。还记得我们在文章开头提到的定位问题的关键线索——“故障分片的 key 数量不多不少,正好增长到了约 2 的 26 次方”吗?现在我们便明白了,这个数字说明故障分片在当时正好进行了一次扩容,将问题指向了扩容后续的 rehash。
扩容的大小也定好了,那么新表在哪里分配呢?诶,还记得第一章节图一中空出来的 table 1 吗?它就是新表分配的地点。
最后,将新表分配出来后,还需要将旧表的元素搬到新表中,新表才能正式生效。将元素从旧表搬迁到新表的过程就是本文的主角——rehash。当元素数量较多时,rehash 是一个耗时较大的任务,但 Redis 的运行又是单线程的,如果等到 rehash 完成后才执行下一步指令,那么用户的读写等请求会长时间得不到响应,这肯定是不能接受的。对此,Redis 的解决方案是渐进式 rehash。
想象你是一个管理大师,正在经营一家图书馆。随着图书馆的书籍(键值对)越来越多,馆内的书架(哈希桶)变得十分拥挤,顾客们需要找很久才能在书架上找到自己想看的书(查询请求处理缓慢)。深知“用户为本”理念的你为了解决顾客的痛点,决定新建一个更大的图书馆。新馆建造完毕后,你还需要把旧图书馆的大量书籍搬到新馆(rehash)。粗暴的方式是闭馆一周,搬完所有书籍后再开放,但这势必会影响顾客体验,等到书搬完后,黄花菜都凉了。但聪明的你立马想到了一个更好的方式:新图书馆和旧图书馆同时保持开放,在此期间,所有新到的书籍都直接放入新馆,而查找书籍时则会先看旧馆,再看新馆,以确保万无一失。在图书馆开放期间,也要时不时把书从旧馆搬往新馆,当所有书都转移完毕后,关闭旧馆。
Redis 的渐进式 rehash 就是遵循这样的思路。table 1 分配出来后,同时使用 table 0 和 table 1 两个哈希表。新写入的数据直接写入 table 1,查询数据则会先查询 table 0,再查询 table 1。在此期间将元素渐进式地从 table 0 转移到 table 1,转移完毕后释放掉空的 table 0,然后将 table 1 设置为新的 table 0。
综上所述,可以把哈希表扩容的全流程总结为以下几个步骤:
(1)初始 dict 如图三所示,此时 dict 的负载因子已经为 1,扩容一触即发。
图 3 即将扩容的 dict
(2)用户写入新的元素 k4-v4,导致负载因子大于 1,扩容触发。dict 在 table1 中创建新表,分配 8 个哈希桶,新到来的 k4-v4 直接写到 table1 中。如图四所示。
图 4 分配新表
(3)开始渐进式 rehash。逐渐将元素从 table0 搬迁到 table1,期间正常处理读写请求。图五显示的是搬迁全部完成的场景,rehash 期间用户还写入了新数据 k5-v5,被直接插入到了 table1 中。
图 5 rehash 完成
(4)rehash 完成后释放 table0,把释放后的 table0 和拥有完整数据的 table1 交换一下(可以理解为交换名字),扩容完成。扩容完成后的 dict 如图六所示。
图 6 释放旧表
这里做一些补充说明:Redisrehash 的最小单位是一个哈希桶,一次 rehash 至少会把一个哈希桶里的所有元素全部搬到新表。Redis 的 dict 维护了一个索引计数器变量 rehashidx,用于记录渐进式 rehash 进行到了哪个位置,或者说 rehashidx 就是下一个要被迁移的桶的桶号。rehashidx 从 0 开始,每次搬完一个桶后就加 1。如图七所示,table0 中的 0 号桶最先被迁移;1 号桶是空桶,被跳过;目前 rehash 进行到了 2 号桶,在下一次迁移时,2 号桶中的两个元素都会被迁移。
图 7 单步 rehash 过程
2.3 渐进式 rehash 的进行方式
在上一小节中,我们讲到 Redis 的 rehash 不是一次性完成的,而是在正常处理读写请求时渐进完成。那么 Redis 具体是如何把元素从旧表搬到新表的呢?本小节将会回答这个问题。
Redis 的渐进式 rehash 分为被动 rehash 和主动 rehash 两种方式。
2.3.1 被动 rehash
在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作外,还会顺带进行单步 rehash,将 table0 在 rehashidx 索引上的所有元素 rehash 到 table1(只搬一个桶)。被动 rehash 巧妙地化整为零,将一个大的整体 rehash 分摊到多个增删查改操作上,从而避免了一次性 rehash 的庞大工作量。
2.3.2 主动 rehash
了解了被动 rehash 之后,我们会想,如果 Redis 太空闲了,长时间都没有收到增删查改的请求,那么 rehash 岂不是就停滞了吗?这时候就要靠主动 rehash 了。Redis 设置了一个服务器定时任务 serverCron,它会在 Redis 运行期间周期性执行,每次 serverCron 执行都会分配出 1ms 的时间片专门用于字典 rehash。
被动和主动两种 rehash 方式的结合,使得整个字典的 rehash 是分摊到每一次对字典的读写请求中,并且在被动模式低效时(客户端读写请求少)也可以通过主动 rehash 最终将字典 rehash 完成。
2.4 Rehash 对时延的影响
通过前面的分析,我们已经了解了 Redis rehash 的基本原理和执行方式。现在让我们回到文章开头那个惊心动魄的故障现场:P99 延迟从 18ms 暴涨到 390ms,平均延迟翻倍,海量用户无法正常使用服务。究竟是 rehash 过程中的哪些环节导致了如此严重的时延毛刺?让我们深入剖析 rehash 过程中的三个时延"杀手"。
2.4.1 被动 rehash 的影响
还记得被动 rehash 的工作原理吗?每当用户执行一次增删查改操作时,Redis 不仅要完成用户请求的操作,还要"顺便"搬迁一个哈希桶的数据。这就像你去图书馆,本来只是借个书,但工作人员说:"顺便帮我们把这个书架上的书搬到我们的新馆去吧。"
这种"顺便"的代价可不小。被动 rehash 虽然将迁移成本分摊到每个用户请求中,但也意味着用户的每一次读写请求都会被强行增加一个额外的 rehash 步骤。在高 QPS 下,这会显著拉低系统整体的吞吐量和性能表现。正如在文章开头提到的线上事故中,Redis 分片的平均时延翻倍,这其中就离不开被动 rehash 的“贡献”。
更糟糕的是,如果某个哈希桶中恰好存储了大量的 key-value 对,那么搬迁这个桶就需要花费更长的时间。如图八所示,图中的 rehash 正进行到了一个元素数量很多的桶,此时恰好有一个用户向 Redis 发送了读请求,该请求必须等待 rehashidx 指向的桶中所有元素完成迁移后才能继续执行,从而导致响应延迟显著增加。
图 8 被动 rehash 时,若搬迁到元素数量较多的桶会造成较高时延
我们无法确定某个请求“顺带”的被动 rehash 需要搬迁的元素数量是多还是少,从而无法控制每次被动 rehash 带来的额外耗时。从这个角度来看,被动 rehash 是不可控的,且当 QPS 越大时,被动 rehash 对整体时延的影响就会越大。
2.4.2 主动 rehash 的影响
主动 rehash 的问题在于其固定的时间分配策略。Redis 的 serverCron 定时任务每次执行时,都会固定分配 1ms 的时间来进行字典 rehash。这听起来似乎不多,但如果把 serverCron 任务的运行频率考虑在内的话,主动 rehash 也可能占用较大比例的 CPU 时间。
在 Redis 中,周期性任务 serverCron 的执行频率由系统配置 server.hz 决定,server.hz 是 serverCron 在 1s 内的执行次数。server.hz 默认配置为 10,也就是说 serverCron 默认每秒执行 10 次。在默认配置下,rehash 的 CPU 使用率是,这的确是一个不高的值。但是,server.hz 配置是可以更改的,若 server.hz 配置为 100,那么 rehash 的 CPU 使用率就上升到了。如果珍贵的 CPU 资源有 10%都用于 rehash,那么用于处理读写请求的 CPU 资源就会减少,从而不可避免地导致读写请求的响应时延上升。
2.4.3 旧表释放的“致命一击”
经过了读写请求顺带的被动 rehash 和 serverCron 定期的主动 rehash 后,旧表终于被搬空,只剩释放旧表这最后一步,dict 扩容就大功告成了。但就是这最后一个看似不起眼的步骤,可能会给系统最致命的一击。
释放旧表实际就是释放旧表中的每个哈希桶,每个哈希桶指向的空间大小为 8 个 byte,因此释放一个大小为的哈希表一共需要释放 bytes 的空间。当旧表很大时,释放内存需要花费较长的时间,在这期间 Redis 的主线程被完全占用,无法处理任何用户请求。
回到我们开头的故障案例,当故障分片的 key 数量达到 6710 万时触发了 rehash,最后待释放空间大小将是 512MB。监控数据显示,正是在旧表释放的瞬间,P99 延迟从 18ms 暴涨到到 390ms。
被动 rehash、主动 rehash、释放旧表这三种操作带来的影响相互交织,共同增加了 rehash 期间 Redis 的响应时间。被动 rehash 带来了不可控的请求延迟,主动 rehash 消耗了宝贵的 CPU 资源,而旧表释放则在关键时刻给出了"致命一击"。理解了这些问题的根源,我们就可以有针对性地制定解决方案了。
3 驯服“怪兽”:腾讯云 Redis 针对 rehash 的体系化优化实践
针对本次故障暴露的 rehash 问题,腾讯云 NoSQL 团队从全局视角出发,成功将 rehash 这一不可控的"性能怪兽"纳入可控范围,实现了 rehash 过程的体系化管理。
3.1 被动 rehash 开关
在 2.4.1 小节中,我们讲到被动 rehash 会给每一个请求强加一个额外的 rehash 步骤,并且由于哈希桶的大小具有随机性,因此该过程实际上是不可控的。我们在想,能否仅通过主动 rehash 的方式去搬迁元素呢?为此,我们做了如下实验。
我们使用 Redis5,在线上环境进行测试。关闭 dynamic-hz 使 server.hz 始终保持在固定的 10,观察在没有读写流量的情况下,redis-server 单纯依靠主动 rehash 时的搬迁效率。具体实验步骤如下:
从实例监控图像中可以看到,触发 rehash 后,CPU 资源平均只会消耗 2% 左右。约 3300W 个 key 搬迁耗时是 15 分钟,平均 1 分钟可以 rehash 搬迁约 220W 个 key。
图 9 主动 rehash 期间 CPU 使用率
经过上述分析,我们认为主动 rehash 的效率实际上已经足够了,因此我们加入了一个开关来控制被动 rehash。具体而言,我们在 Redis 中增加了配置项 passive-rehash-enabled 来控制被动 rehash 是否开启,且该配置默认为 no。
3.2 被动 rehash 开关
2.4.2 小节中提到,主动 rehash 的 CPU 使用率会根据 server.hz 的变化而变化,这一点不利于 rehash 期间系统读写性能的稳定。为了解决这一问题,我们将 Redis 中每次 serverCron 中花费在 rehash 的时间,从固定的 1ms 更改为根据 server.hz 动态修正。
具体而言,我们增加了配置项 active-rehash-cycle 表示 serverCron 中用于 rehash 的 CPU 使用率,该配置默认值为 1。
用于 rehash 的时间根据 server.hz 和 server.active-rehash-cycle 实时计算,每次用时为
例如:
● hz = 10, active-rehash-cycle = 1,上面计算结果为 1000us 即 1ms;
● hz = 10, active-rehash-cycle = 10,上面计算结果为 10000us 即 10ms;
● hz = 100, active-rehash-cycle = 1,上面计算结果为 100us 即 0.1ms。
上述方式有效地固定了主动 rehash 的 CPU 使用率,根据 server.hz 动态修正每次主动 rehash 的用时,消除了 server.hz 对主动 rehashCPU 使用率的影响,使得主动 rehash 期间系统读写性能更加稳定。
3.3 空间异步释放
正如 2.4.3 小节所讲,当 dict 大小已经很大时,rehash 结束时的旧表释放会造成非常致命的系统阻塞,所有请求需要等待旧表释放完成才可继续进行。其实这个问题的解决方法十分简单,只需要将旧表释放异步进行,使其不干扰主线程的正常运行即可。
Redis 的内存分配与释放使用的是 jemalloc 分配器。经过我们的调研,jemalloc5 已经支持在 backgroundthread 中异步释放内存了。因此我们消除 Redis 释放大块内存抖动的方式非常直接:将 Redis 使用的 jemalloc 分配器升级到版本 5。
3.4 rehash 维护时间窗口
从前文到许多分析中我们都能感受到,尽管我们通过各种方式将 rehash 控制得更加稳定,但其始终是一个成本较大的操作。rehash 或多或少都会对用户请求的时延产生负面影响,尤其是在 QPS 较高的场景下,这一影响更加显著。
面对这一问题,我们将视角从系统内部转移至业务场景,采取了一个更加“无感”的策略——为 rehash 设置一个时间窗口,只有在该时间窗口内才允许 dict 正常扩容。
我们为 Redis 新增了 maintence-time 配置项,它的值是一个时间区间。
● 维护时间窗口内:按照社区逻辑正常走,即当添加元素时发现负载因子 >= 1 时,正常进行扩容。
● 维护时间窗口外:禁止扩容。但为了避免哈希冲突带来的性能损失,会设置一个最大负载因子。当添加元素时,若发现负载因子 >= 1.618,即使不在维护时间窗口内,dict 依旧正常进行扩容。
maintence-time 可以依据实际业务需求自由配置,通常设置为业务 QPS 较低的时间段,例如凌晨某时间段,让 rehash 在深夜“悄悄”进行。
4 总结
从文章开头的故障现场出发,我们剖析了 Redis rehash 引发时延毛刺的三大原因:被动 rehash 的额外负担、主动 rehash 的 CPU 占用不可控,以及旧表释放引起的线程阻塞。针对这些问题,腾讯云 NoSQL 团队构建了完整的 rehash 优化体系:关闭被动 rehash,主动 rehash 时间控制让 CPU 使用率可控,空间异步释放消除内存释放阻塞,维护时间窗口让 rehash 在业务低峰期进行。这套优化体系成功驯服了 Redis 这头"怪兽",并持续保障线上用户体验的稳定与流畅。
版权声明: 本文为 InfoQ 作者【腾讯云数据库】的原创文章。
原文链接:【http://xie.infoq.cn/article/a09aea37d015103ebabd85ad2】。文章转载请联系作者。







评论