一、事故回顾: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:
// 基线版本:直接 feign 调用,独立线程池
private map<long, list<versionvo>> loadonsalemap() {
response<list<xyyyvo>> response =
assemblerreadapi.listdetail(terminal, is_new); // feign 调用
// ... 处理响应
}
事故版本:新增 posenhancecacheservice,改为读写 redis:
// 事故版本:新增缓存服务,改为读写 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() 调用:
// 基线版本:直接返回,无增强
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 调用链
每次用户进入首页都会触发以下调用链:
用户进入首页页(高频)
→ 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 设计问题对比
┌────────────────────────────────────────────────────────────────────┐ │ 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 雪崩链路
用户进首页(高频)
↓
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 认证服务为什么也报错?
这是排查时最容易踩的坑——认证服务的异常是连锁反应,不是根因。
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 读写后恢复正常 |
五、为什么"删除缓存"不能解决问题
这是很多人会想到的第一个"快速止血"方案:
del biz:onsale:detail
删了之后会发生什么?
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 按维度拆分:
// 改前:一个 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 本地缓存兜底
高频读 + 低频变更的数据应优先使用本地缓存:
请求 → 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 实例。
改前(共用): 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导致生产事故排查与解决方法的详细内容,更多关于redis大key导致生产事故的资料请关注代码网其它相关文章!
发表评论