0. 序幕:周五下午的惊魂时刻
那是周五下午 4 点,离下班还有一个小时,我正准备提交最后一行代码。突然,运维群里的报警机器人疯了。
[CRITICAL] Redis Cluster-02 Memory Usage > 90% (8.1GB / 8.5GB)
[CRITICAL] Redis Cluster-02 OOM Killer invoked
我心里“咯噔”一下。这台 Redis 实例平时主要做用户 Session 和一些简单的配置缓存,常年稳定在200M左右。怎么可能在短短两小时内飙升到8G?
这不是简单的流量突增,这是一次典型的内存“泄露”(或者说是业务层面的逻辑泄露)。如果不立刻解决,整个用户登录系统将全面瘫痪。
本文将带你还原这场惊心动魄的排查过程,从现象分析、工具使用到源码定位,揭示那个差点毁了我们周末的“罪魁祸首”。
1. 案发现场:第一轮常规体检
连上 VPN,通过堡垒机登录生产环境。我的第一反应是:是不是内存碎片?
1.1 排除内存碎片
Redis 的内存占用不仅仅是数据大小,还包括内存分配器的开销。如果碎片率过高,操作系统看到的内存占用(RSS)会远大于 Redis 实际存储的数据(used_memory)。
我迅速敲下命令:
redis-cli -h192.168.x.x -p6379INFO memory返回的关键指标如下:
# Memoryused_memory:8152940210# 约 8.15 GB (Redis 认为它存的数据量)used_memory_rss:8421005120# 约 8.42 GB (操作系统实际分配的量)mem_fragmentation_ratio:1.03分析:
mem_fragmentation_ratio只有 1.03,说明内存碎片极少(通常 > 1.5 才需要担心碎片问题)。used_memory实打实地达到了 8.15 GB。
结论:这不是操作系统的问题,是真的有数据把 Redis 撑爆了。
1.2 排除客户端缓冲区积压
有时候,如果消费端处理太慢,Redis 的输出缓冲区(Output Buffer)会积压大量数据。
redis-cli -h192.168.x.x -p6379CLIENT LIST我检查了omem(Output Buffer Memory) 一列,发现所有连接的占用都为 0 或很小。
结论:排除客户端堆积问题。
2. 抽丝剥茧:寻找那个胖子
既然是数据问题,那么只有两种可能:
- 海量小 Key:突然写入了千万级的微小 Key。
- 超级大 Key:某一个或几个 Key 像黑洞一样在吞噬内存。
2.1 尝试一:dbsize里的猫腻
我先看了一下 Key 的总数:
127.0.0.1:6379>dbsize(integer)15203疑点出现了!
只有 1.5 万个 Key?
如果 1.5 万个 Key 占用了 8G 内存,平均每个 Key 大约500KB。这在 Redis 里绝对属于“巨型”数据了。平时的 Session Key 只有几 KB。
这强烈暗示:问题不在于 Key 的数量,而在于 Key 的体积。
2.2 尝试二:--bigkeys的扫描
Redis 自带的--bigkeys参数是排查神器,它会扫描整个 keyspace,找到每种数据类型中最大的那个 Key。
redis-cli -h192.168.x.x -p6379--bigkeys终端飞速滚动,最后输出了摘要:
... [Summary] Sampled 15203 keys in the keyspace! Total key length in bytes is 420123 (avg len 27.63) Biggest string found 'user:session:9921' has 1024 bytes Biggest list found 'system:notification:queue' has 25012 items Biggest hash found 'config:app:settings' has 5 fields ...困惑:
- 最大的 String 只有 1KB。
- 最大的 List 只有 2.5 万个元素(即便每个元素 1KB,也才 25MB)。
- 这就奇怪了,
--bigkeys竟然没扫出来?
排查盲区:--bigkeys是基于采样的(虽然它扫描所有 Key,但如果是巨大的 Set 或 Hash,它只统计元素个数,不一定能精准反映内存占用)。或者,这个 Key 可能刚刚过期了?或者是在某些隐藏的 DB 索引里?
不,不对。内存依然是 8G。说明 Key 还在。
3. 深度取证:RDB 离线分析法
在线上执行DEBUG OBJECT或者遍历所有 Key 是极其危险的,会阻塞主线程。为了不影响业务(虽然已经快挂了),我决定采用离线分析。
3.1 导出 RDB
利用 Redis 的 BGSAVE 功能(注意:如果内存快满了,BGSAVE 的fork操作可能会导致 OOM,需确认sysctl vm.overcommit_memory设置为 1)。
redis-cli bgsave等待 dump.rdb 生成后,我将其下载到本地分析服务器。
3.2 使用rdb-tools验尸
这是一个 Python 编写的强大工具,能将 RDB 解析成 CSV 或 JSON。
pipinstallrdbtools# 解析 RDB,按内存大小排序,取前 3 名rdb -c memory dump.rdb --bytes128-l3几分钟的漫长等待后,屏幕上跳出了结果。看到结果的那一刻,我差点一口老血喷出来。
database,type,key,size_in_bytes,encoding,num_elements,len_largest_element 0,list,global:error:log:2023-10-27,8102394812,quicklist,45020120,512 0,string,user:session:102,4096,raw,1024,1024 0,hash,app:config,2048,ziplist,10,200凶手找到了!
- Key 名称:
global:error:log:2023-10-27 - 类型:
List - 大小:8.1 GB
- 元素个数:4500 万个
一个用来记录“全局错误日志”的 List,竟然在一天之内膨胀到了 8G!
4. 案情还原:一行代码的蝴蝶效应
拿到 Key 名字后,我迅速在代码库中全局搜索。
终于,在一个角落的GlobalExceptionHandler.java(全局异常处理类)里发现了这段逻辑:
// 伪代码示例publicvoidhandleException(Exceptione){StringlogKey="global:error:log:"+LocalDate.now().toString();StringerrorMsg=e.getMessage()+StackTraceUtils.toString(e);// 记录错误日志到 Redis,方便后台查看redisTemplate.opsForList().rightPush(logKey,errorMsg);}致命缺陷分析:
- 无限追加:使用
RPUSH写入 Redis List,原本是想做一个简单的日志队列。 - 无 TTL:没有设置过期时间!
- 无长度限制:没有执行
LTRIM!这意味着 List 可以无限增长。 - 触发条件:平时系统稳定,错误很少,这个 Key 也就几 MB。但今天下午,某个第三方 API 服务挂了,导致系统内部抛出了海量的
ConnectionTimeoutException。 - 死循环:每次异常都触发
handleException,写入 Redis;如果 Redis 慢了或者满了,可能引发新的异常,再次写入…
这就是 200M 飙升到 8G 的真相:一次外部服务的抖动,配合一段没有边界限制的代码,制造了一个吞噬内存的黑洞。
5. 紧急救援:如何安全删除 8G 的大 Key?
找到了凶手,现在的任务是“排雷”。
绝对不能做的事:
直接执行DEL global:error:log:2023-10-27。
为什么?
Redis 是单线程的。删除一个 8G 的大 Key,涉及 4500 万次内存释放操作。这会导致 Redis 主线程阻塞几十秒甚至几分钟。在这期间,所有应用请求都会超时,导致真正的服务雪崩。
5.1 正确方案:UNLINK (Lazy Free)
如果你的 Redis 版本 >= 4.0,请务必使用UNLINK命令。
UNLINK global:error:log:2023-10-27原理:UNLINK只是把这个 Key 从元数据(Keyspace)中摘除,告诉 Redis “这个 Key 不可见了”。真正的内存释放操作会交给后台线程(Bio Thread)异步执行,不会阻塞主线程。
5.2 兼容方案(Redis 4.0 以下)
如果还在用老版本,只能用脚本一点点地删(Scan + LTRIM/LPOP)。
# Python 渐进式删除脚本示例defsafe_delete_list(redis_conn,key,batch_size=10000):whileredis_conn.llen(key)>0:# 每次只修剪 10000 个元素redis_conn.ltrim(key,batch_size,-1)time.sleep(0.1)# 甚至可以睡一会,让出 CPUredis_conn.delete(key)执行完UNLINK后,INFO memory监控图表瞬间出现了一个漂亮的断崖式下跌。内存回到了 200M。
6. 总结与反思:如何避免下一次悲剧?
虽然事故解决了,但这次教训必须深刻吸取。作为架构师,我们需要从规范层面堵住漏洞。
6.1 开发规范三原则
- 所有 Key 必须有过期时间:
这是 Redis 使用的第一铁律。特别是对于日志、缓存类数据,必须设置 TTL(Time To Live)。
redisTemplate.expire(logKey,1,TimeUnit.DAYS);- 容器类数据结构必须有容量限制:
使用 List, Set, Hash, ZSet 时,必须考虑“最大包含多少元素”。
redisTemplate.opsForList().rightPush(logKey,msg);// 每次 push 后,保留最近 1000 条redisTemplate.opsForList().trim(logKey,-1000,-1);- 禁止在 Redis 存放巨型对象:
如果需要存储日志,请使用 ELK (Elasticsearch, Logstash, Kibana) 或文件系统。Redis 是内存数据库,寸土寸金,不是垃圾桶。
6.2 运维监控兜底
- **设置
maxmemory-policy**:
生产环境必须设置内存淘汰策略。例如volatile-lru或allkeys-lru。如果设置了策略,当内存满时,Redis 会自动踢掉旧数据,而不是直接 OOM 宕机(虽然这可能会丢日志,但保住了服务)。 - 大 Key 预警:
利用云厂商的 Redis 分析服务,或者自建脚本定期扫描(在低峰期),一旦发现超过 50MB 的 Key,立即报警。 - 流量监控:
监控 Redis 的output_buffer和input_buffer,以及网络流量。异常的流量增长往往是大 Key 的前兆。
7. 结语
技术债总是要还的,通常还是在周五下午。
那个不起眼的List,因为缺乏边界控制,在特定条件下变成了吞噬系统的怪兽。这次排查经历告诉我们:敬畏每一行代码,特别是那些看似无害的日志逻辑。
希望这篇文章能放入你的“避坑指南”里。如果你也遇到过类似的 Redis 奇葩问题,欢迎在评论区留言分享!
互动环节 (Hook)
👀你遇到过最离谱的 Redis 故障是什么?
- 开发把 100MB 的 HTML 存进了 String?
- 循环里忘了关 Redis 连接导致连接数耗尽?
- 或者是和我一样,被日志撑爆了内存?
在评论区晒出你的“炸库”经历,点赞最高的,我下期专门写一篇关于 Redis 性能优化的硬核代码解析!别忘了关注我,获取更多后端实战干货!👇