我追着 Redis 超时查了一圈,最后是 1200 万 field 的 Hash 卡住了实例
不是连接池先坏了。是这个 Hash 先把 Redis 实例堵住,连接池和慢接口只是后面跟着出现的受害者。
我追着 Redis 超时查了一圈,最后是 1200 万 field 的 Hash 卡住了实例
接口开始一批批变慢,Redis 超时告警也跟着冒出来。
这种时候,人很容易把锅先扣给连接池:连接是不是借不出来了,应用是不是堆了一堆等待 Redis 的 goroutine。接着再去翻慢查询,怀疑是不是哪条业务请求突然把数据库拖住了。
这两步没有错。但它们都在应用侧找“谁等得太久”。这次真正该问的问题是:Redis 在那段时间里,究竟被哪一条命令占住了?
最后钉住问题的,是一个 Hash。它有 12,102,218 个 field。
不是连接池先坏了。是这个 Hash 先把 Redis 实例堵住,连接池和慢接口只是后面跟着出现的受害者。
Redis 的 Key 设计,不能只问“能不能放进去”;还要问“任何一次读、删、迁移它时,实例要被占住多久”。

先别急着扩容,先看谁把实例占住了
排查一开始还是按常规路径走:连接池、调用链、慢查询。
连接池图能告诉你“有很多请求正在等”;慢查询能告诉你“业务链路某一段变长了”。但两张图都不等于根因。尤其当超时集中指向同一组 Redis 实例时,继续在应用侧猜,会把排查带偏。
应该把视线收回 Redis 自己的延迟。
公开案例里,业务侧先观察到访问 Redis 超时,HKEYS 平均响应约 200 ms,最大超过 500 ms。实例监控则显示,平时响应约 0.1 ms,异常时出现约 70 ms 的突刺,最高约 100 ms。[1]
这两组数字放在一起,事情就清楚了:不是所有请求都慢,而是某些时刻有命令占住了实例。后面的命令排队,客户端看到的时间就被拉长。
这也是 Redis 超时最容易被误判的地方。你看到的是调用方等了 500 ms;真正消耗实例的,可能只是一段 70 ms 到 100 ms 的执行窗口。对一个本来以亚毫秒响应为常态的实例来说,这已经够把后面一串请求堵住。
别把“平均延迟还可以”当成没事。对在线服务更危险的,往往是那根偶尔冒出来、却把队列压长的尖刺。
慢的不是 Redis,是你让它一次做得太多
HKEYS 的语义很直接:取出一个 Hash 的全部 field。问题不在命令名字,而在“全部”两个字。
当 Hash 只有几百、几千个 field,这个“全部”可能还只是一个正常业务动作。可一旦数据没有边界地往同一个 Key 里累积,原来轻巧的读取会变成一次长时间扫描和返回;Redis 要在处理这条命令的期间,把其他命令先晾在一边。
Redis 官方的延迟排查清单把“会阻塞服务端的慢命令”列为优先检查项;而 HKEYS 的时间复杂度是 O(N),N 就是 Hash 的大小。[3]
所以,BigKey 不是一个单纯的内存告警。
一个不断变大的 Hash,同时在埋三颗雷:
- 读取“全部 field”的代价会跟着元素数量长大;
- 删除、过期和迁移这类动作,可能在某个时刻把长时间工作集中塞给实例;
- 大结果集还会把网络传输和客户端反序列化一起拖进事故现场。
很多团队只给 Redis 配了内存水位告警。这个 Key 在内存还有余量时,看起来完全健康;直到某个业务动作刚好需要遍历它、搬动它或清掉它,延迟才突然露头。
这不是“容量没规划好”这么轻。它是把一个没有上限的数据集合,塞进了一个要求低延迟响应的执行模型里。
那个 1200 万 field 的 Hash,是怎么长出来的
定位到异常实例后,BigKey 分析给出的结果很难忽略:一个 Hash 有 12,102,218 个 field。[1]
它连续保存了 30 天业务数据。设计时,这样做看上去很省事:一类数据一个 Hash,写入时追加,查询时按 field 取。需求刚上线时,数据不多,读取也没问题。时间一长,“一个 Hash”就悄悄变成了“把 30 天全部业务数据绑在一起”。
这类 Key 往往不是谁故意写坏的。它通常来自一个很合理、但漏了时间维度的问题建模:
数据按照什么归类?按业务对象。
数据会积累多久?没有答案。
只回答了第一句,Key 就很容易无限生长。
微博在大规模 Redis 使用中也提到过类似取舍:面向计数、关系判断这类不断增长的数据,不能只把“Redis 很快”当作设计前提;容量、扩展性和访问模式必须一起考虑。[2] 业务规模变大后,最先失效的通常不是某个命令,而是原先默认“数据不会这么多”的 Key 边界。
所以排查 BigKey 时,不要只问“它多大”。再补两句:
- 它为什么不会停?是按天滚动、按用户隔离,还是只会一直追加?
- 最坏情况下,谁会对它做全量读取、删除、迁移或重建?
前一句看增长,后一句看爆发点。两句答不出来,这个 Key 迟早会给你一个难看的答案。
[正文插图 2:错误与正确的 Key 边界。左侧:单个 business:records 聚合 30 天、1200 万 field;右侧:business:records:{date}:{bucket},按日期 + 二次哈希分桶,单 Key 有明确元素上限。]
修复不是“把 Hash 拆小”,而是补上数据边界
这个案例最后的处理方向是拆分 BigKey:把连续 30 天的数据拆开,可以按分钟级时间范围拆分;也可以进行二次哈希。公开材料给出的目标,是让每个 Key 的元素数量控制在 5000 以内,优化后延迟尖刺消失。[1]
这里最容易把修复做成另一种事故:今天手工拆成十个 Key,三个月后十个 Key 又重新长成十个 BigKey。
拆分应该落在业务边界上,而不是落在一次临时运维操作上。
如果数据天然有时间窗口,就让时间进入 Key:按天、小时或更短窗口滚动;如果单个时间窗口仍然可能倾斜,再加一层稳定的分桶。读取时多一次定位,换来的是每个 Key 的规模可估算、过期可控、迁移可承受。
要付出的代价也别藏着:跨窗口查询会复杂一点,聚合可能要放到应用层或异步任务里做。但这是值得付的复杂度。因为你把复杂度从 Redis 主线程里挪到了可以并行、可以限流、可以重试的地方。
别拿“一次取完最方便”做理由。线上系统里,方便只是把成本延期;没有边界的数据结构,最后总会挑一次高峰期结算。
这次事故该留下的,不是一条 BigKey 告警
只加一条“单 Key 超过多少 MB”告警,下一次仍然可能出事。Hash 的风险有两个维度:内存大小和 field 数量。前者关系到容量,后者直接关系到某些命令要处理多少元素。两个维度都要看。
更实用的监控组合是:
- 实例维度:延迟最大值和延迟尖刺,而不是只盯平均值;把异常实例和命令耗时放到同一张排障视图里。
- Key 维度:按类型统计 Top N 大 Key;Hash、List、Set、ZSet 除了字节数,也统计元素数及其增长率。
- 业务维度:给“持续追加”的数据模型标明保留周期、单 Key 上限和拆分规则;这些不是 Redis 运维细节,而是表结构设计的一部分。
- 变更维度:扩缩容、批量删除、过期策略调整前,先识别大 Key。它们平时不一定出声,却最擅长在迁移和清理时放大风险。
Redis 的 --bigkeys 能帮助发现每种数据类型中的大 Key,但它不是一张免死金牌:它基于扫描,结果和执行方式都需要结合线上负载评估。更重要的是,发现之后要有人能回答这个 Key 为什么会长、何时该滚动、谁负责改模型。[1]
最后回看这次超时,真正该警惕的不是“Redis 也会慢”,而是一个更常见的错觉:把 Redis 当成没有边界的临时仓库。
它当然能装下很多数据。但线上 Redis 最宝贵的不是能装多少,而是每条命令都能尽快让出执行权。
那个 1200 万 field 的 Hash,不是因为 Redis 太脆弱才出事。是我们把“以后再说”的数据增长,塞进了今天每个请求都要经过的路径里。
数据与参考
- 环信转载的 vivo 互联网数据库团队 BigKey 案例:异常实例监控、12,102,218-field Hash、按时间与二次哈希拆分建议。https://www.easemob.com/news/9908
- 微博 Redis 优化经验(2019 年演讲整理):大规模业务中的容量、扩展性与数据模型取舍。https://pdai.tech/md/db/nosql-redis/db-redis-y-weibo.html
- Redis 官方文档:延迟诊断与
HKEYS的命令复杂度说明。https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/latency/ ;https://redis.io/docs/latest/commands/hkeys/