• 线上 Redis 频繁崩溃?这套大 key 治理方案请收好

线上 Redis 频繁崩溃?这套大 key 治理方案请收好

2025-04-27 10:40:49 栏目:宝塔面板 2 阅读

兄弟们,凌晨两点,手机突然像地震一样狂震,我迷迷糊糊摸到床头一看,运维群里炸了锅:"Redis节点又挂了!内存使用率飙到99%,CPU直接打满!" 顶着黑眼圈爬起来连服务器,刚登录就看到熟悉的报错:OOM killer 又把 Redis 进程干掉了。

那一刻我真想把写代码时随手往 Redis 里塞大集合的同事拎过来——咱就是说,存数据能不能别跟往麻袋里装砖头似的,可劲儿造啊!

一、先搞明白:啥是 Redis 大 key?它凭啥能搞崩服务器?

很多新手可能还不清楚,所谓"大 key"其实分两种情况:一种是单个 key 的值特别大(比如一个字符串类型的值超过1MB),另一种是集合类数据结构(像 hash、list、set、zset)里的元素数量超多(比如一个 zset 存了10万+成员)。别小看这些大块头,它们就像藏在 Redis 里的定时炸弹,主要靠这三招搞破坏:

1. 内存分布不均匀,分片集群秒变"单腿跳"

现在稍微大点的项目都用 Redis 集群,假设你用的是分片集群(比如 Codis、Redis Cluster),一个大 key 会被固定分配到某个分片上。想象一下,其他分片内存使用率才50%,就这个分片像吹气球一样涨到90%,整个集群的负载均衡瞬间失效。更要命的是,当你要删除这个大 key 时,分片节点会经历一段漫长的"卡顿期",因为删除操作需要释放大量连续内存,堪比在市中心拆除一栋摩天大楼,周围的交通都得跟着堵。

2. 网络IO成瓶颈,批量操作直接"卡脖子"

举个真实的例子:之前有个兄弟在项目里用 list 存用户的历史操作记录,一个 key 存了50万条数据。某天运营要导出用户数据,直接用 LRANGE key 0 -1 捞数据,结果 Redis 所在服务器的网卡流量直接飙到峰值,应用服务器这边等了10秒都没拿到响应。为啥?因为 Redis 是单线程模型,处理这种大集合操作时,会把所有元素序列化后通过网络传输,就像用一根水管同时给100户人家供水,水压自然上不去。

3. 内存碎片疯狂增长,好好的内存变成"碎纸片"

Redis 采用jemalloc分配内存,当大 key 被频繁删除和写入时,会产生大量无法利用的小碎片。比如你先存了一个10MB的大字符串,然后删除,再存一堆1KB的小字符串,jemalloc 没办法把这些小碎片合并成大的连续内存,导致实际内存使用率比 INFO memory 里看到的 used_memory 高很多。曾经见过一个线上节点,used_memory 显示8GB,但物理内存已经用了12GB,就是被碎片坑的。

二、检测大 key:别等崩溃了才后悔,提前扫描是王道

1. 最简单的命令:redis-cli --bigkeys

这个命令是 Redis 自带的大 key 扫描工具,原理是对每个数据库的不同数据类型做抽样检查。比如检查 string 类型时,会随机选一些 key 用 STRLEN 查看长度;检查集合类型时,用 HLEN、LLEN、SCARD、ZCOUNT 统计元素数量。注意要加 -i 0.1 参数,这表示每次扫描间隔0.1秒,避免阻塞主线程。不过它有个缺点:只能告诉你每个数据类型的最大 key 是谁,没办法扫描所有大 key,适合做初步排查。

# 扫描所有数据库,每隔0.1秒扫描一次
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.1

2. 更精准的方案:自己写扫描工具(附Python代码)

如果需要全量扫描,就得用 SCAN 命令代替 KEYS *,因为 KEYS 会阻塞主线程,在生产环境用就是"自杀行为"。下面这段 Python 代码可以扫描指定前缀的大 key,支持设置字符串长度阈值和集合元素数量阈值:

import redis
def scan_big_keys(redis_client, prefix, str_threshold=1024*1024, collection_threshold=10000):
   big_keys = []
   cursor = '0'
   while cursor != 0:
       cursor, keys = redis_client.scan(cursor=cursor, match=prefix + '*')
       for key in keys:
           type_ = redis_client.type(key)
           if type_ == 'string':
               length = redis_client.strlen(key)
               if length > str_threshold:
                   big_keys.append((key, 'string', length))
           elif type_ in ['hash', 'list', 'set', 'zset']:
               count = 0
               if type_ == 'hash':
                   count = redis_client.hlen(key)
               elif type_ == 'list':
                   count = redis_client.llen(key)
               elif type_ == 'set':
                   count = redis_client.scard(key)
               elif type_ == 'zset':
                   count = redis_client.zcard(key)
               if count > collection_threshold:
                   big_keys.append((key, type_, count))
   return big_keys
# 使用示例
redis_client = redis.Redis(host='localhost', port=6379, db=0)
big_keys = scan_big_keys(redis_client, 'user:')
for key, type_, size in big_keys:
   print(f"大key: {key}, 类型: {type_}, 大小: {size}")

3. 可视化工具辅助:让大 key 一目了然

如果觉得命令行太麻烦,可以用 RedisInsight(官方可视化工具)或者开源的 RedisDesktopManager,这些工具都有大 key 扫描功能,能生成直观的图表。比如 RedisInsight 的"Memory Analysis"模块,能按数据类型展示内存占用分布,点击某个类型就能看到具体的大 key 列表,适合团队协作时给非技术同学演示。

三、治理大 key:分场景出招,不同类型有不同解法

(一)字符串类型大 key:能压缩就压缩,能拆分就拆分

案例:用户详情存成大 JSON

某电商项目把用户详情(包括收货地址、订单历史、会员信息)存成一个大 JSON,单个 key 大小超过2MB。解决方案分两步:

  • 数据压缩:先用 gzip 压缩 JSON 字符串,压缩后大小能降到500KB左右。Redis 提供了 COMPRESS 和 DECOMPRESS 命令(需要开启 redis-module-recompress 模块),不过更推荐在应用层处理,比如 Java 里用 GZIPOutputStream 和 GZIPInputStream。
// 压缩数据
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream);
gzipOutputStream.write(userJson.getBytes());
gzipOutputStream.close();
byte[] compressedData = byteArrayOutputStream.toByteArray();
redisTemplate.opsForValue().set("user:123", compressedData);

// 解压缩数据
byte[] data = redisTemplate.opsForValue().get("user:123");
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data);
GZIPInputStream gzipInputStream = new GZIPInputStream(byteArrayInputStream);
BufferedReader reader = new BufferedReader(new InputStreamReader(gzipInputStream));
StringBuilder decompressedJson = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
    decompressedJson.append(line);
}
  • 按需拆分:把常用字段(比如用户名、头像)和不常用字段(比如三年前的订单)分开存储。比如用 user:123:base 存基础信息,user:123:order:2023 存2023年的订单,查询时用 MGET 批量获取,虽然多了几个 key,但每次获取的数据量小了,网络传输速度快了很多。

避坑指南:别用 APPEND 命令往大字符串里追加数据

曾经有个项目用 APPEND 记录用户操作日志,每天往一个 key 里追加几MB数据,一个月后这个 key 变成了50MB。APPEND 操作在字符串底层实现是动态扩展数组,当数组需要扩容时,会申请一块更大的内存,把旧数据复制过去,再追加新数据。50MB的字符串每次扩容都要复制大量数据,CPU使用率直接飙升,后来改成按天拆分key,问题立刻解决。

(二)集合类型大 key:分桶存储,别把鸡蛋放一个篮子里

案例:千万级用户的标签集合

某社交APP用 set 存储每个用户的兴趣标签,个别活跃用户的标签数量超过20万。直接遍历这个 set 时,Redis 主线程被阻塞了好几秒。解决方案是"分桶+哈希取模":

  • 确定桶的数量:根据最大元素数量决定,比如每个桶最多存1万条数据,20万条就分20个桶。
  • 计算桶编号:用 CRC32 算法对用户ID取模,保证同一个用户的标签分布在同一个桶里(如果需要保证顺序,用 hash_mod 时要考虑一致性)。
  • 修改数据结构:把 set user:123:tags 改成 set user:123:tags:0 到 set user:123:tags:19,每个桶最多1万条数据。
// 计算桶编号
long userId = 123;
int bucketCount = 20;
int bucketId = (int) (userId % bucketCount);
String bucketKey = "user:" + userId + ":tags:" + bucketId;
// 添加标签
redisTemplate.opsForSet().add(bucketKey, "tag1", "tag2");
// 遍历所有桶
for (int i = 0; i < bucketCount; i++) {
   String key = "user:" + userId + ":tags:" + i;
   Set tags = redisTemplate.opsForSet().members(key);
   // 处理每个桶的数据
}

进阶操作:用分片集群的路由规则优化

如果用的是 Redis Cluster,大 key 会被分配到固定分片上,分桶后可以让不同的桶分布在不同分片,比如每个桶的 key 加上分片标识(user:123:tags:0:shard1),不过这种方法需要和集群架构深度结合,建议在架构设计阶段就考虑大 key 问题。

(三)业务层面优化:从源头减少大 key 的产生

  • 分页处理:比如用户的消息列表,别把所有历史消息都存到一个 list 里,改成按页存储,用 list:user:123:page:1、list:user:123:page:2,每次只取当前页的数据。
  • 时效性控制:给大 key 设置合理的过期时间,比如临时缓存的大集合,用完就自动删除,别让它一直占着内存。
  • 数据归档:像电商的历史订单,超过半年的可以归档到数据库或文件存储,Redis 里只存最近三个月的常用数据。

四、实战案例:从崩溃到稳定,我们是怎么搞定大 key 的

背景:某直播平台的礼物排行榜

直播间的礼物排行榜用 zset 存储,每个直播间一个 key,里面存了所有送礼用户的分数,个别热门直播间的 zset 成员超过50万。每天晚上高峰期,存储排行榜的 Redis 节点频繁触发 OOM,导致整个集群不可用。

治理过程:

  1. 第一步:定位罪魁祸首 用前面提到的 Python 扫描工具,发现 room:123:gifts 这个 zset 有67万成员,ZRANGE 操作平均耗时200ms,远超 Redis 单次操作1ms的正常水平。
  2. 第二步:分桶+冷热分离
  • 按送礼时间分桶:最近1小时的实时数据存在 room:123:gifts:hot,1-24小时的数据存在 room:123:gifts:warm,超过24小时的归档到数据库。
  • 每个桶限制成员数量:hot桶最多存1万条(只保留最新的1万条实时数据),warm桶按小时分桶(room:123:gifts:warm:2025041010 表示2025年4月10日10点的数据)。
  1. 第三步:优化查询逻辑 原来的业务直接查整个 zset 取Top100,现在改成先查 hot 桶和最近24个 warm 桶,合并后再取Top100。虽然多了几次 ZUNIONSTORE 操作,但每个 zset 的成员数量都控制在1万以内,操作耗时降到了10ms以下。
  2. 第四步:监控与预警 用 Prometheus + Grafana 监控每个 zset 的成员数量,设置预警:当单个 zset 成员超过8000时触发报警,同时监控内存碎片率(mem_fragmentation_ratio),当超过1.5时自动触发大 key 扫描。

治理效果:

  • 内存使用率从95%降到60%,OOM 再也没出现过。
  • CPU 负载从平均80%降到20%,因为处理小集合的速度快了很多。
  • 业务查询延迟从200ms降到15ms,用户刷新礼物榜再也不卡顿了。

五、避坑指南:这些大 key 相关的坑,千万别踩!

1. 别迷信"大 key 一定是坏事"

有些场景下,合理的大 key 反而更高效。比如存储一个1MB的图片二进制数据,虽然是大 key,但比拆分成多个小 key 更节省内存(每个 key 本身有元数据开销,Redis 中每个 key 大约占1KB内存)。所以治理大 key 要结合业务场景,不能一刀切。

2. 批量操作时注意"管道"的使用

用 pipeline 批量处理小 key 没问题,但处理大集合时别滥用管道。比如用管道执行100次 HGETALL 一个有10万字段的 hash,会导致客户端内存飙升,因为所有结果会一次性返回。正确的做法是分批次处理,每次处理1000个字段,或者用 HSCAN 渐进式扫描。

3. 集群迁移时的大 key 陷阱

当需要给 Redis 集群扩容时,大 key 的迁移会导致源节点和目标节点之间产生大量网络流量。比如一个10MB的大 key 迁移,需要先在源节点序列化,通过网络传输,再在目标节点反序列化,这个过程可能会阻塞两个节点的主线程。建议在低峰期迁移,并且对大 key 单独处理(比如先删除,迁移后再重新生成)。

4. 监控要关注这几个关键指标

  • used_memory:超过物理内存80%就该警惕了。
  • mem_fragmentation_ratio:大于1.5说明内存碎片太多,需要清理或重启(仅单节点有效,集群节点重启要谨慎)。
  • blocked_clients:如果这个值经常大于0,说明有慢操作阻塞主线程,很可能是处理大 key 导致的。

六、总结:防患于未然,比事后救火更重要

回顾这次治理经历,最大的感悟是:大 key 问题就像房间里的大象,刚开始觉得"存几个大集合没关系",等到出问题时已经积重难返。

最好的办法是在项目初期就建立规范:

  1. 开发阶段:设计数据结构时预估元素数量,超过1万的集合类数据强制分桶。
  2. 测试阶段:用压测工具模拟大 key 场景,比如用 redis-benchmark 测试 LRANGE 10万条数据的耗时。
  3. 上线阶段:部署自动扫描脚本,每天凌晨扫描大 key,发现异常及时报警。
  4. 迭代阶段:每次上线新功能,检查是否引入了潜在的大 key(比如新增的集合类存储)。 

希望这篇文章能让你少走弯路,下次再遇到 Redis 崩溃,记得先查大 key——相信我,十有八九是它在搞事情。 

本文地址:https://www.yitenyun.com/116.html

搜索文章

Tags

Deepseek 宝塔面板 Linux宝塔 Docker JumpServer JumpServer安装 堡垒机安装 Linux安装JumpServer Windows Windows server net3.5 .NET 安装出错 宝塔面板打不开 宝塔面板无法访问 esxi esxi6 root密码不对 无法登录 web无法登录 Windows宝塔 Mysql重置密码 SSL 堡垒机 跳板机 HTTPS 无法访问宝塔面板 HTTPS加密 查看硬件 Linux查看硬件 Linux查看CPU Linux查看内存 修改DNS Centos7如何修改DNS scp Linux的scp怎么用 scp上传 scp下载 scp命令 工具 sqlmock SQL 防火墙 服务器 黑客 Serverless 无服务器 语言 网络架构 网络配置 MySQL B+Tree ID 字段 IT运维 聚簇 非聚簇 索引 Redis 频繁 Codis InnoDB LRU Linux 安全 List 类型 速度 服务器中毒 MVCC 事务隔离 数据库 Caffeine CP Rsync 同城 双活 序列 核心机制 数据备份 MySQL 9.3 部署 开发 API FastAPI 双引擎 优化 开源 PostgreSQL 存储引擎 sftp 服务器 参数 配置 QPS 高并发 虚拟服务器 虚拟机 内存 万能公式 Oracle 处理机制 mini-redis INCR指令 Web 应用 异步数据库 MongoDB 数据结构 悲观锁 乐观锁 StarRocks 数据仓库 OB 单机版 Doris SeaTunnel AI 助手 RocketMQ 长轮询 数据库锁 HexHub SQLite Redka SQLite-Web 数据库管理工具 IT 不宕机 数据 分库 分表 Spring 动态查询 Python Web Calcite 电商系统 缓存 架构 信息化 智能运维 分布式架构 分布式锁​ dbt 数据转换工具 容器 响应模型 缓存方案 缓存架构 缓存穿透 SpringAI Milvus 向量数据库 原子性 线上 库存 预扣 云原生 Entity Netstat Linux 服务器 端口 openHalo 对象 Testcloud 云端自动化 数据集成工具 业务 Ftp 监控 prometheus Alert 单线程 线程