Redis大Key导致生产事故复盘博客-2026-09-18

一个 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 主线程需要:

  1. 从内存中找到对应 Value(可能跨多页内存)
  2. 序列化为 RESP 协议格式
  3. 通过网络 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 分钟)

看到 UnavailableSecurityManagerExceptionoauthAccessToken.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 行新增代码)。其中 PosEnhanceCacheServicelistDetail() 方法用了一个固定单 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 的排查和优化,你有什么经验?欢迎评论区交流。

免责声明:本文涉及的命名等均已脱敏,代码为精简伪代码,仅供技术交流。

相关推荐
右耳朵猫AI1 小时前
Java周刊2026W38 | Micronaut 修补三漏洞、JDK 27 提速 54%、Jetty 修复抖动测试
java·后端·spring
事已至此先睡覺吧1 小时前
第二篇:Java 基础语法:变量、数据类型、运算符与类型转换
java
Wang's Blog1 小时前
Java 项目实战: 外卖平台-后台退出功能与首页iframe架构
java·服务器·redis
Wang's Blog1 小时前
Java 项目实战: 外卖平台-软件开发流程与项目整体介绍
java·服务器·redis
她说..1 小时前
常见设计模式-模板方法模式
java·spring·设计模式·springboot
步行cgn1 小时前
BeanFactory 与 FactoryBean 的区别:面试深度解析
java·后端·spring
IT 行者2 小时前
JDK 27 正式发布:内存、安全、并发三路齐发
java
伍一514 小时前
hiprint-spring-boot打印组件介绍
java·hiprint
Wang's Blog10 小时前
Java 接入Redis: 服务启动停止与后台运行配置
java·服务器·redis