Redis 大 key 排查实战:从内存暴涨到凌晨三点紧急扩容

🔑 关键词:Redis大key, redis-cli --bigkeys, UNLINK, 大key拆分, Redis内存优化

📖 摘要:记录一次线上 Redis 内存暴涨的排查过程,分享大 key 的发现、定位与拆分方案,附带具体命令和参数。

那个周五晚上,Redis 内存炸了

图片

先交代背景。我们那个服务大概每天 200 万 UV,用的 Redis 4.0 单机主从,maxmemory 设的 8G,淘汰策略是 volatile-lru。晚上 9 点多,监控突然报警:内存使用率 95%。我当时正在吃火锅,手机响个不停,打开 Grafana 一看,used_memory 从 2.3G 直接飙到 7.8G,QPS 从 1.2 万掉到 3000。第一反应是有人写了死循环?还是缓存穿透?登录服务器,redis-cli info memory 看到 used_memory_human:7.8G,mem_fragmentation_ratio:1.8,碎片率有点高。然后 redis-cli info stats 看到 expired_keys 没怎么涨,evicted_keys 倒是涨了 200 多万。说明内存是被新写入的数据撑爆的,而且淘汰了很多 key。但为什么淘汰了还涨?肯定是有大 key 没被淘汰掉,或者淘汰策略没覆盖到。

用 --bigkeys 揪出那个 120 万的 List

图片

我一开始想用 KEYS * 看看,但马上被自己蠢哭了,这玩意儿谁敢在生产上跑?后来想起官方有个 redis-cli --bigkeys。命令是 redis-cli -h 10.0.0.1 -p 6379 --bigkeys -i 0.1。注意 -i 0.1 是每扫描 100 个 key 休息 0.1 秒,降低对线上影响。跑了一遍,输出里赫然出现一个:Sampled 1234567 keys in the keyspace!,然后 Biggest list found 'user:feed:12345' has 1200000 items。120 万个元素的 List,每个元素大概 200 字节的 JSON,算下来就是 240MB。这只是一个 key 啊。而且 --bigkeys 只统计元素个数,不统计实际内存。我又用了 redis-cli --memkeys -i 0.1,它调用 MEMORY USAGE,输出显示这个 key 占用了 280MB。另外还发现几个 Hash 类型的 key,user:profile:67890 有 50 万个 field,占了 180MB。加起来快 500MB,难怪内存下不去。这里要提醒:--bigkeys 和 --memkeys 都是基于 SCAN 的,会遍历整个 keyspace,如果 key 数量上千万,跑一次可能要几分钟,最好在从节点上执行,或者低峰期搞。

图片

不要用 DEL,用 UNLINK 也治标不治本

找到大 key 后,我第一反应是删掉。但直接 DEL user:feed:12345 会阻塞 Redis,因为删除 120 万个元素是同步的。Redis 4.0 引入了 UNLINK,这是异步删除,命令是 UNLINK user:feed:12345。它先把 key 从 keyspace 里摘掉,然后后台线程去释放内存。我试了一下,UNLINK 返回 1,内存从 7.8G 降到 7.2G,但过了一分钟又涨到 7.6G。为什么?因为后台线程释放内存也需要时间,而且那个 List 太大,释放过程中其他写入还在继续。更关键的是,这个 key 还会被重新写入,因为业务代码里 LPUSH user:feed:12345 还在跑。所以删了没用。真正的解决方案是拆分。我把这个 List 按分片键拆成 10 个 List:user:feed:12345:0 到 user:feed:12345:9。每次 LPUSH 时,根据 user_id % 10 选择分片。读取时,用 LRANGE 分别读 10 个分片再合并。每个分片控制在 12 万以内,内存占用降到 30MB 左右。对于 Hash 大 key,我用 HSCAN 分批迁移,把 50 万个 field 拆成 10 个 Hash,每个 5 万 field。

图片

大 key 的排查不能只靠运维,开发阶段就要卡住

图片

说个真实感受。这次事故后,我们复盘发现,代码里有个地方在用户每次刷新 feed 流时都会 LPUSH 进去,而且没有限制长度。产品经理说“用户最多也就看几百条”,但实际线上有个机器人用户刷了 120 万条。这种问题,运维工具只能事后发现,真正要解决得在代码层面。我现在的做法是:所有 List、Hash、Set、ZSet 类型的 key,在业务层强制加长度限制。比如 List 超过 10000 就 LTRIM,Hash 超过 5000 就报警。另外,用 Redis 做缓存时,value 大小最好控制在 1KB 以内,超过就压缩。我们后来加了一个拦截器,在 LPUSH 之前检查当前长度,如果超过阈值就拒绝写入并打日志。还有一个坑:大 key 如果设置了过期时间,Redis 的定期删除会随机抽查,如果大 key 很多,删除时可能阻塞。所以大 key 最好手动清理,或者用 UNLINK 配合后台任务。

几个具体命令和参数,你直接抄

图片

最后列一下我常用的排查命令,你可以直接复制。1)redis-cli -h host -p port --bigkeys -i 0.1:找每种类型最大的 key,基于元素个数。2)redis-cli -h host -p port --memkeys -i 0.1:找内存占用最大的 key,基于 MEMORY USAGE,需要 Redis 4.0+。3)redis-cli MEMORY USAGE key:查单个 key 的内存占用,返回字节数。4)redis-cli SCAN 0 MATCH user:feed:* COUNT 100:游标遍历,注意 COUNT 只是提示,可能返回空。5)redis-rdb-tools:离线分析 RDB 文件,命令 rdb -c memory dump.rdb --bytes 10240 -f memory.csv,找出大于 10KB 的 key。6)redis-cli --latency -h host -p port:看延迟,如果 LLEN 一个 key 花了 200ms,那就说明有问题。另外,DEBUG OBJECT key 可以看序列化后的长度,但会阻塞,慎用。还有,别用 KEYS *,别用 FLUSHALL,别用 DEL 删大 key。记住:Redis 是单线程的,大 key 就是定时炸弹。

🏷️ 标签: