一个 Redis 大 Key,如何让 生产 宕机 30 分钟------缓存设计反思
首发:CSDN(2026-09-19)· 脱敏改写
背景: PROD 生产事故&缓存设计反思
提示:命名均已脱敏为逻辑命名,代码为伪代码 / 精简版;线上数据按量级描述,非精确实测值。
一、事故回顾:30 分钟的"幽灵故障"
某天,生产PROD 突然大面积不可用------白屏报错、无法登录、接口超时。运维同学看到的现象是:
- 接口API 层 6 个 Pod 在 3 分钟内反复重启(K8s 存活探针失败 → Pod 被重建 → 再失败 → 再重建......)
- 认证服务 持续刷
UnavailableSecurityManagerException(Shiro SecurityManager 不可用)和oauthAccessToken.find.fail(Token 查不到) - 生产 接口API服务 所有 Pod 持续报
oauthAccessToken.find.fail,用户带 Token 来校验,认证服务查不到 - 认证服务本身是活着的------登录接口的日志显示 Token 正常签发
这一切看起来像是认证服务出了问题。重启认证服务?重启了,新 Pod 启动后同样的异常立即复现。
排查陷入僵局。直到有人看了当天上午的部署记录,才发现真正的"凶手"不是认证服务,而是 生产环境 当天部署的一个"性能优化"版本------一次性新增了 5 个 Redis 缓存服务 ,其中一个在 Redis 里写入了一个 约 600 KiB 的大 Key,每次用户进首页都去读它,最终拖垮了共享 Redis,连锁反应波及认证服务,导致 App 全线不可用。
持续了接近 30 分钟,直到回滚代码。
二、代码变更:一次"善意"的缓存批量优化
2.1 变更全貌
事故版本相对于上一个稳定版本(以下称"基线版本"),一共变更了 54 个文件、新增约 2600 行代码。其中最关键的变更是一次性新增了 5 个 Redis 缓存服务 ,全部使用 StringRedisTemplate 读写共享 Redis:
| 缓存服务 | 新增行数 | 缓存内容 | Key 设计 | 大 Key 风险 |
|---|---|---|---|---|
| PosEnhanceService | 267 行 | 首页增强逻辑(调用方) | --- | --- |
| PosEnhanceCacheService | 140 行 | 在售商品树 | biz:onsale:detail(固定单 Key) |
极高 |
| BankCacheService | 133 行 | 品牌列表 | biz:bank:brand-list(固定单 Key) |
中 |
| VConfigCacheService | 199 行 | 商品系列配置 | biz:v:s-list:ALL(code 为空时) |
中 |
| XxxxCacheService | 74 行 | 商品列表 | mall:mallItem:{code}(按code分片) |
低 |
核心问题出在 PosEnhanceCacheService ------它的 listGoodDetail() 方法用了一个固定的单 Key,Value 是全量产品系列的 JSON 序列化,约 600KB。
2.2 基线版本 vs 事故版本对比
基线版本 :首页页的 enhance() 方法直接 Feign 调下游服务获取数据,完全不碰 Redis:
java
// 基线版本:直接 Feign 调用,独立线程池
private Map<Long, List<VersionVO>> loadOnsaleMap() {
Response<List<XyyyVO>> response =
assemblerReadApi.listDetail(TERMINAL, IS_NEW); // Feign 调用
// ... 处理响应
}
事故版本 :新增 PosEnhanceCacheService,改为读写 Redis:
java
// 事故版本:新增缓存服务,改为读写 Redis
public List<XyyyVO> listsDetail() {
String cacheKey = "biz:onsale:detail"; // 固定单 Key,无分片
String cached = redis.get(cacheKey); // 每次请求都读这个大 Key
if (cached != null) {
return JSON.parseArray(cached, XyyyVO.class); // 反序列化 ~600KB
}
// 缓存未命中 → 回源
Response<List<XyyyVO>> response = assemblerReadApi.listDetail(1, 1);
List<XyyyVO> data = response.getData();
redis.set(cacheKey, JSON.toJSONString(data), 1, TimeUnit.DAYS); // 写入 ~600KB 大 Key
return data;
}
同时,PosController 也被修改,在返回响应前新增了 enhance() 调用:
java
// 基线版本:直接返回,无增强
Response<List<PosDTO>> response = posService.getRedisKeysValue(pageCode, ...);
return response;
// 事故版本:返回前先做 enhance(会触发大 Key 读取)
Response<List<PosDTO>> response = posService.getRedisKeysValue(pageCode, ...);
if (response.success()) {
posEnhanceService.enhance(response.getData(), pageCode); // ← 每次进页都读大 Key
}
return response;
2.3 调用链
每次用户进入首页都会触发以下调用链:
text
用户进入首页页(高频)
→ GET /pos/detail
→ PosController.posDetails()
→ enhanceService.enhance()
→ loadOnsaleMap()
→ cacheService.listDetail()
→ redis.get("biz:onsale:detail") ← 读 ~600KB 大 Key
→ JSON.parseArray(cached, XyyVO.class) ← 反序列化 ~600KB
而首页是 生产 的核心高频页面,用户每次进页都会触发。
2.4 Key 设计问题对比
text
┌────────────────────────────────────────────────────────────────────┐
│ 5 个缓存服务的 Key 设计对比 │
├──────────────────────┬──────────┬──────────┬───────────────────────┤
│ 缓存服务 │ Key 粒度 │ 预估体积 │ 大 Key 风险 │
├──────────────────────┼──────────┼──────────┼───────────────────────┤
│ onsale:detail │ 固定单Key │ ~600KB │ ⛔ 极高 --- 全量商品树 │
│ onsale:title:{code} │ 按code分片 │ < 1KB │ ✅ 安全 │
│ all-carousel │ 固定单Key │ ~50KB │ ⚠️ 中等 │
│ bank:brand-list │ 固定单Key │ ~10KB │ ⚠️ 中等 │
│ x:y:ALL │ code空时全量│ ~100KB │ ⚠️ 中等 │
│ smallXy:{code} │ 按code分片 │ < 5KB │ ✅ 安全 │
└──────────────────────┴──────────┴──────────┴───────────────────────┘
→ onsale:detail 是唯一的固定单Key + 大体积组合,成为雪崩的导火索
三、雪崩过程:一个大 Key 如何拖垮整条链路
3.1 Redis 单线程的致命弱点
Redis 是单线程架构------所有命令在主线程串行执行。一条命令耗时越长,排在后面的命令等待越久。
读取一个 600KB 的 Value,Redis 主线程需要:
- 从内存中找到对应 Value(可能跨多页内存)
- 序列化为 RESP 协议格式
- 通过网络 socket 发送给客户端
这个过程在低并发下可能只需几毫秒,但高并发下会严重积压 。假设每次读取耗时 20ms,QPS 500 的情况下,Redis 主线程每秒要花 10 秒来处理这个 Key 的读取------远超 1 秒的处理能力,后面的命令全部排队等待。
3.2 雪崩链路
text
用户进首页(高频)
↓
enhance() → redis.get("biz:onsale:detail") ← 600KB 大 Key
↓
Redis 主线程被大 Key 读取阻塞
↓
共享 Redis 实例的认证服务 token 查询也被阻塞 → 超时
↓
生产 拿 token 校验 → 认证服务查不到 → AuthenticationException
↓
prod 收到 401 → 请求超时 → Broken pipe → 500 服务器异常
↓
prod 的 Tomcat 线程被 Redis 读取 + JSON 反序列化占满
↓
线程池耗尽 → 新请求排队 → 响应超时 → 存活探针失败
↓
K8s 杀 Pod → 重建 → 立即又被打满 → 反复重启
↓
prod 大面积不可用(白屏、无法登录=报错)
3.3 认证服务为什么也报错?
这是排查时最容易踩的坑------认证服务的异常是连锁反应,不是根因。
text
prod 大 Key 拖住 Redis
↓
认证服务的 Token 存储、Shiro Session 也用同一个 Redis
↓
Token 查询超时 → oauthAccessToken.find.fail
↓
Shiro Session 获取失败 → UnavailableSecurityManagerException
↓
看起来像是认证服务的 Shiro 配置问题
重启认证服务无效,因为 Redis 仍被阻塞,新 Pod 同样受影响。
四、排查过程:从误判到定位
第一阶段:误判为认证服务故障(约 10 分钟)
看到 UnavailableSecurityManagerException 和 oauthAccessToken.find.fail,第一反应是认证服务的 Shiro 配置有问题。尝试重启认证服务,故障立即复现------说明不是应用自身的问题,而是外部依赖被阻塞。
第二阶段:查看部署时间线(约 10 分钟)
搜索日志中的 Tomcat started 关键字,还原当天的部署记录:
| 时间 | 部署服务 |
|---|---|
| 2:11 | prod(最先部署) |
| 2:14 | 用户业务服务 |
| 2:17 | 认证服务开始报错(prod 部署后 6 分钟) |
| 2:28 | 认证服务尝试重启(无效) |
关键发现:prod 最先部署,认证服务的异常是在 prod 部署 6 分钟后才出现的。
第三阶段:代码 diff 定位根因(约 15 分钟)
拿事故版本与上一个稳定版本做全量 diff,发现事故版本一次性新增了 5 个 Redis 缓存服务(约 800 行新增代码)。其中 PosEnhanceCacheService 的 listDetail() 方法用了一个固定单 Key,Value 约 600KB。
关键代码:
biz:onsale:detail是一个固定单 Key,存储全量在售商品- Value 是 JSON 明文,体积约 600KB(远超 Redis 建议的 10KB 大 Key 阈值)
- 每次用户进页都会读取并反序列化这个大 Key
- 该 Redis 实例被认证服务的 Token 存储共享
验证
| 假设 | 结果 |
|---|---|
| 删除缓存能解决? | 不能。 下一个请求会回源并重新写入同样的大 Key |
| 重启认证服务能解决? | 不能。 Redis 仍被阻塞 |
| 回滚 prod缓存逻辑? | 可以。 去掉 Redis 读写后恢复正常 |
五、为什么"删除缓存"不能解决问题
这是很多人会想到的第一个"快速止血"方案:
bash
DEL biz:onsale:detail
删了之后会发生什么?
text
DEL biz:onsale:detail
↓
下一个请求 redis.get() 返回 null(缓存没了)
↓
回源 assemblerReadApi.listDetail()
↓
redis.set("biz:onsale:detail", JSON.toJSONString(data), 1, DAYS)
↓
600KB 大 Key 重新写入 → 问题立即复现
更糟糕的是,高并发下删除缓存会导致缓存击穿:多个请求同时发现缓存不存在,同时回源下游服务,可能把下游也打挂。
正确做法:回滚代码 + 等待大 Key 自然过期。
六、Redis 大 Key 的危害
6.1 什么是大 Key
Redis 单个 Key 的 Value 体积过大,通常建议:
| Value 大小 | 风险等级 |
|---|---|
| < 1 KB | 安全 |
| 1 ~ 10 KB | 正常 |
| 10 ~ 100 KB | 需关注 |
| 100 KB ~ 1 MB | 高危 |
| > 1 MB | 危险 |
本案例的大 Key 约 600KB,属于高危级别。
6.2 大 Key 的四大危害
┌──────────────────────────────────────────────────────┐
│ │
│ 1. 阻塞主线程 │
│ Redis 单线程,大 Key 读写耗时长,阻塞所有命令 │
│ │
│ 2. 网络带宽占用 │
│ 600KB × 500 QPS = 300MB/s 带宽,可能打满网卡 │
│ │
│ 3. 内存分配抖动 │
│ 大 Key 频繁分配/释放,导致内存碎片和 GC 压力 │
│ │
│ 4. 删除时阻塞 │
│ DEL 一个大 Key 会阻塞主线程数秒甚至更久 │
│ │
└──────────────────────────────────────────────────────┘
6.3 为什么会影响认证服务
本案例中,prod的业务缓存和认证服务的 Token 存储共用同一个 Redis 实例。大 Key 阻塞 Redis 主线程后,认证服务的 Token 查询也排队等待,导致超时 → oauthAccessToken.find.fail。
这就是共享基础设施的连带故障------一个服务的"优化"拖垮了另一个毫不相关的服务。
七、正确的缓存设计
7.1 大 Key 拆分
如果要恢复缓存功能,将 600KB 大 Key 按维度拆分:
java
// 改前:一个 600KB 大 Key
String cacheKey = "biz:onsale:detail";
// 改后:按code拆分,每个 Key 几 KB
String cacheKey = "biz:onsale:detail:" + Code;
每个一个 Key,Value 几 KB,读取和写入都是毫秒级。
对比同版本的 SmallItemCacheService,它就做对了------按 Code 分片,每个 Key 只存一个code的商品列表,体积几 KB,风险极低。
7.2 本地缓存兜底
高频读 + 低频变更的数据应优先使用本地缓存:
text
请求 → L1 Caffeine(本地, 亚毫秒) → miss → L2 Redis(共享, ~1ms) → miss → 回源
本地缓存命中时不走 Redis,彻底避免大 Key 读取对 Redis 主线程的影响。
7.3 Value 压缩
大列表缓存不应存明文 JSON,应使用压缩:
| 方案 | 压缩比 | 读取性能 | 适用场景 |
|---|---|---|---|
| 明文 JSON | 1x | 最快 | < 10KB |
| GZIP + JSON | ~5-10x | 稍慢(需解压) | 10KB ~ 1MB |
| Protobuf | ~3-5x | 快(二进制解析) | > 100KB |
7.4 Redis 实例隔离
Token 存储等核心链路不应与业务缓存共用 Redis 实例。
text
改前(共用):
Redis 实例 A ← prod缓存 + 认证服务 Token
改后(隔离):
Redis 实例 A ← 认证服务 Token(核心链路,专用)
Redis 实例 B ← prod业务缓存(可降级)
7.5 缓存 Key 设计自检清单
新增 Redis 缓存前必须自检:
| 检查项 | 通过标准 |
|---|---|
| Value 大小 | < 10KB ✅ / 10~100KB ⚠️ 需评估 / >100KB ❌ 必须拆分 |
| Key 粒度 | 有分片维度(如 code/id)✅ / 固定单 Key ❌ |
| 读取频率 | 低频 ✅ / 高频需配合本地缓存 ⚠️ |
| Redis 实例 | 独立实例 ✅ / 与核心链路共用 ❌ |
| TTL | 短 TTL(分钟级)✅ / 长 TTL(天级)需评估 |
| 回源安全性 | 单线程回源 / 并发回源需加锁 |
八、改进措施清单
| 层面 | 改进项 | 优先级 |
|---|---|---|
| 代码 | 新增 Redis 缓存必须评估 Value 大小,超过 10KB 必须拆分 | P0 |
| 代码 | 高频接口的缓存改动必须经过压测验证 | P0 |
| 代码 | 新增缓存必须有 Code Review 检查 Key 粒度和 Value 体积 | P0 |
| 架构 | Token 存储与业务缓存 Redis 实例物理隔离 | P1 |
| 架构 | Redis 大 Key 监控告警(>100KB 报警) | P1 |
| 架构 | Feign 调用和 Redis 操作使用独立线程池 | P2 |
| 流程 | 涉及共享基础设施的变更需 Code Review 强制审查 | P0 |
| 流程 | 部署分批灰度(先 1 Pod 观察 10 分钟再全量) | P1 |
九、经验教训
1. "加缓存"不等于优化
本事故的初衷是"优化性能"------将高频 Feign 调用改为 Redis 缓存。但缓存设计不当(大 Key + 共享 Redis)反而引入了更严重的问题。
缓存优化必须评估:Value 大小、读取频率、Redis 实例的共享情况。三者缺一不可。
2. Redis 大 Key 是隐性炸弹
Redis 单线程架构决定了大 Key 是"全民公敌"------一个 Key 慢,所有 Key 都要等。
600KB 的大 Key 在高并发下相当于对 Redis 做"慢查询 DDoS"。
3. 连锁反应会误导排查方向
prod的大 Key 拖垮 Redis → 认证服务 Token 查询超时 → 看起来像认证服务故障。排查初期花了大量时间在认证服务的 Shiro 配置上,实际上认证服务是受害者。
共享基础设施故障时,应优先排查谁在"打"共享资源,而不是谁"挂了"。
4. 重启不是万能药
尝试重启认证服务后故障立即复现------说明根因不在应用本身,而在其依赖的共享资源(Redis)。
重启无效时应立即转向外部依赖排查。
5. 批量新增缓存需统一评估
事故版本一次性新增了 5 个缓存服务,其中 3 个有不同程度的大 Key 风险。如果逐个评估,可能只上线安全的 2 个(按 code 分片的),避免事故。
批量优化时每个缓存服务都应独立评估 Key 设计,不能"一刀切"。
6. 删除缓存可能让问题更严重
在大 Key 场景下,删除缓存会导致缓存击穿,不仅不能解决问题,还可能打挂下游。
正确做法是回滚代码 + 等待大 Key 自然过期。
十、总结
一个"善意"的缓存批量优化
→ 一次性新增 5 个 Redis 缓存服务
→ 其中一个写入 600KB 大 Key(固定单 Key + 全量商品)
→ 高频读取拖垮 Redis 主线程
→ 共享 Redis 的认证服务连锁故障
→ prod全线不可用 约半小时
→ 回滚代码恢复
| 项目 | 内容 |
|---|---|
| 根因 | App 新版本新增 Redis 缓存,其中 onsale:detail 为固定单 Key,Value 约 600KB |
| 触发 | 用户进入首页时高频读取大 Key,阻塞共享 Redis |
| 影响 | App 全量用户不可用约 40 分钟 |
| 修复 | 回滚缓存逻辑,恢复直接 Feign 调用,等待大 Key 自然过期 |
| 核心教训 | 缓存设计必须评估 Value 大小和 Key 粒度;核心链路与业务缓存 Redis 实例隔离;批量新增缓存需逐个评估;共享基础设施故障时优先排查"谁在打共享资源" |
如果这篇文章对你有帮助,欢迎点赞收藏。关于 Redis 大 Key 的排查和优化,你有什么经验?欢迎评论区交流。
免责声明:本文涉及的命名等均已脱敏,代码为精简伪代码,仅供技术交流。