热点 Key 自动发现与本地缓存:Redis 热键打爆?Caffeine 二级缓存+过期抖动防雪崩!
大促的时候,运营在首页挂了一个爆款商品。瞬间几十万用户涌进来,同一个商品详情接口被疯狂调用。Redis 里这个商品的缓存 Key 被打到单节点 QPS 上限,响应时间从 1ms 飙升到 50ms,Redis 线程池打满,带着其他 Key 的请求也一起慢了。一块热铁掉进水里,整锅水都烫了。
这就是典型的热点 Key 问题。一个 Key 太热,把 Redis 单节点打穿了。因为 Redis 是单线程处理命令的,一个慢不会拖累别的,但如果请求量超过这个单节点的处理能力上限,所有请求都得排队。
今天聊聊怎么用 Caffeine 本地缓存做二级缓存,配合过期时间抖动防止缓存雪崩。
热点 Key 为什么难搞
先搞清楚热点 Key 的问题本质。正常缓存访问是这样的:
请求 → Redis → 命中 → 返回(1ms)
热点 Key 的情况下:
1000 个并发请求 → Redis 同一个 Key
│
└─ 1000 次网络 IO(即使是 Redis,单节点也有处理上限)
问题不在"数据能不能被缓存",而在"所有请求都打到了同一个 Redis 节点上"。如果你的 Redis 是集群模式,这个热点 Key 永远落在同一个分片上,那个分片被打爆了,其他分片闲得发慌。集群没有帮到你,因为热点 Key 天然不能被分散。
所以解法思路也很明确——在离业务更近的地方拦一道,让大部分请求在到达 Redis 之前就被消化掉。
Caffeine 二级缓存:在 JVM 里拦一道
Caffeine 是 Java 生态里性能最好的本地缓存库。把它放在 Redis 前面,形成两级缓存:
请求 → Caffeine(JVM 堆内,纳秒级)
│
├─ 命中 → 直接返回(0.001ms)
│
└─ 未命中 → Redis(网络 IO,毫秒级)
│
├─ 命中 → 返回 + 回填 Caffeine
│
└─ 未命中 → DB → 回填 Redis → 回填 Caffeine
绝大部分热点 Key 的请求在第一关 Caffeine 就被截住了。1000 个并发里,假设 Caffeine 设了 10 秒过期,那每 10 秒才有一个请求穿透到 Redis,剩下 999 个都在 JVM 内部解决。Redis 压力从 1000 QPS 降到不到 1 QPS。
两级缓存配置伪代码
Caffeine 配置:
maximumSize = 10000 // 最多缓存 10000 个 Key
expireAfterWrite = 10s // 写入后 10 秒过期
recordStats() // 开启统计(看命中率)
读取流程:
value = caffeine.get(key):
if value != null:
return value
// Caffeine 未命中
value = redis.get(key)
if value != null:
caffeine.put(key, value)
return value
// Redis 也未命中
value = db.query(key)
redis.set(key, value, 60s)
caffeine.put(key, value)
return value
但问题来了:怎么知道哪个 Key 是热点?
二级缓存确实有效,但你不能把所有 Key 都往 Caffeine 里塞。Caffeine 是堆内存,10 万个 Key 每个 1KB 就是 100MB。要只缓存热的 Key。
怎么自动识别热点 Key?几个思路:
方案一:滑动窗口计数(不依赖外部组件)
在应用内部维护一个计数组件,统计每个 Key 在最近一段时间内的访问次数。超过阈值的自动升级为热点,加载到 Caffeine。
热点发现器(滑动窗口):
窗口大小:10 秒
阈值:窗口内访问超过 100 次 → 标记为热点
每次请求:
keyStats[key].increment()
if keyStats[key].count > 100:
hotKeys.add(key)
自动加载到 Caffeine
这个方案简单、零依赖,但每个实例独立统计,如果用了负载均衡,同一个 Key 分散在多个实例上,单个实例可能达不到阈值。这其实不是问题——因为热点发现的目的就是保护 Redis,如果一个 Key 在单实例上都达不到阈值,说明它对 Redis 的压力也可控。
方案二:Redis 的热点发现能力
Redis 4.0 以上支持 MEMORY USAGE 和 OBJECT FREQ 命令,可以统计每个 Key 的访问频率。但生产环境不建议频繁调用这些命令,它们本身也是 O(n) 操作。
更实用的方式是——如果你的 Redis 是云厂商的托管版(阿里云、腾讯云),一般都有内置的热点 Key 检测功能,控制台直接能看到。
方案三:客户端收集 + 上报
在缓存访问的拦截层埋点,异步上报到集中式的统计服务。这个方案最通用,但实现成本最高。
对于大多数场景,方案一(滑动窗口)足够用,实现成本低,效果也不错。
过期抖动:防的是 Caffeine 和 Redis 同时过期
二级缓存解决了热点问题,但引入了一个新风险——缓存雪崩的窗口变大了。
假设 Caffeine 和 Redis 都给同一个 Key 设了 10 秒过期。时间一到,两级缓存同时失效,所有请求瞬间打到数据库。这就是雪崩。
解法叫过期时间抖动:不给固定的过期时间,而是在基准时间上加一个随机浮动。
// ❌ 固定过期
caffeine.put(key, value, 10s)
redis.set(key, value, 10s)
// ✅ 抖动过期
base = 60s
jitter = random(0, base * 0.3) // 0 到 18 秒的随机抖动
caffeine.put(key, value, base + jitter)
redis.set(key, value, base + jitter * 2) // Redis 比 Caffeine 多活一段时间
随机抖动让同一批 Key 的过期时间分散在不同的时间点上。比如 1000 个热门商品缓存都在差不多时间创建,没有抖动的话它们会在同一秒全部过期。加了抖动以后,它们分布在 60~78 秒之间陆续过期,流量被均匀摊开。
还有一个额外建议:Redis 的过期时间比 Caffeine 长。 如果 Caffeine 设了 60s,Redis 设 90s。这样 Caffeine 过期后,至少 Redis 还有数据,不会直接打到 DB。等 Redis 快过期的时候,Caffeine 早就从 Redis 回填完了。
热点自动升降级
热点不是永久的。大促结束了,那个爆款商品的流量下来了,它就不应该继续占着 Caffeine 的宝贵空间。
热点生命周期:
1. 冷 Key:不缓存到 Caffeine,只走 Redis
2. 滑动窗口内访问 > 100 次 → 升级为热 Key,加载到 Caffeine
3. Caffeine 过期后重新检查:
- 如果最近窗口内仍 > 100 次 → 保持热 Key,续期
- 如果最近窗口内 < 100 次 → 降级为冷 Key,从 Caffeine 移除
升得快、降得也快。Caffeine 的空间始终留给真正的热点。
内存控制:Caffeine 不是无限大的
Caffeine 的 maximumSize 一定要设。它默认是基于大小的 LRU 淘汰,但热点 Key 本来就应该很少——真正的热点可能就那么几十个。
建议设成 5000~10000 之间。如果你的热点 Key 超过了一万个,可能不是"热点"的问题,而是你的业务本身就是全量高频访问——这种场景应该靠 CDN 或者增加 Redis 分片来解决,而不是堆 Caffeine。
另外,Caffeine 的 expireAfterWrite 和 expireAfterAccess 的区别要注意:
expireAfterWrite:从写入开始计时。适合数据有明确的时效窗口。expireAfterAccess:从最后一次访问开始计时。适合"不热了就自动淘汰"的场景。
热点 Key 场景推荐用 expireAfterWrite,配合过期抖动。如果一个 Key 超过过期时间没被更新,说明它可能已经不热了,自然淘汰。
总结
热点 Key 问题核心就一个字——散。但 Redis 分片散不了同一个 Key,所以只能在本地再挡一道。
三个关键点:
二级缓存——Caffeine 放在 Redis 前面,热点请求在 JVM 内部消化,不经过网络。
过期抖动——固定过期时间 → 随机抖动,防止批量 Key 同时失效触发雪崩。Redis 过期比 Caffeine 长,留个回填窗口。
自动升降级——窗口计数自动发现热点、自动加载、过期自动降级。不需要人工介入。
这套方案落地之后,Redis 上那些 1000+ QPS 的热点 Key,对 Redis 的实际压力可以降到个位数。
有用的话转给还在跟 Redis 热 Key 战斗的同事。
标题:热点 Key 自动发现与本地缓存:Redis 热键打爆?Caffeine 二级缓存+过期抖动防雪崩!
作者:jiangyi
地址:http://jiangyi.space/articles/2026/06/06/1780413795974.html
公众号:服务端技术精选
评论