穿透、击穿、雪崩与热点 Key:回源流量如何被放大
缓存故障常被统称为“命中率下降”,但不存在 key 的持续查询、一个热点过期和百万 key 同时失效,是三种完全不同的放大器。处理手段用错,Bloom filter 会挡住正常新数据,互斥锁会把热点变成长队列。
Redis 的 TTL 与 maxmemory eviction 分属业务过期和容量回收。官方 Key eviction 说明写入触发内存检查和驱逐;业务不能假设所有 key 会在同一精确时刻自动离场。
缓存穿透:不存在的数据也要有策略
缓存穿透是指请求的数据缓存没有,数据库也没有,请求每次都打到数据库。
穿透、击穿、雪崩很容易混在一起,先按故障形态分流:
这张图也能指导排障口径:先问数据库里有没有,再问是不是单个热点,再问是不是大面积同时失效。判断错了,解决方案会完全跑偏。
直接做法一:缓存短 TTL 空值
if (order == null) {
redisTemplate.opsForValue().set(key, "__NULL__", Duration.ofMinutes(1));
return null;
}验证:
GET shop:prod:order:detail:not-exists:v1
TTL shop:prod:order:detail:not-exists:v1常见坑:空值 TTL 太长,新数据创建后短时间内仍然查不到。解决方式是数据创建成功后主动删除对应空值 key,或者把空值 TTL 控制得很短。
直接做法二:布隆过滤器
当请求明显包含大量非法 ID 时,可以先用布隆过滤器过滤。布隆过滤器的特点是:判断不存在时一定不存在,判断存在时可能误判。
伪代码:
if (!bloomFilter.mightContain(orderNo)) {
return null;
}
return getOrder(orderNo);生产建议:
布隆过滤器要有初始化、增量更新和重建流程。不能删除元素的布隆过滤器不适合频繁删除的业务。误判率要结合内存成本评估。
布隆过滤器只能减轻穿透,不能替代权限校验和参数校验。
缓存击穿:热点 key 过期时要单飞重建
缓存击穿是指一个热点 key 失效,大量请求同时回源数据库。
直接做法:互斥重建。只有一个线程查数据库并回填缓存,其他线程短暂等待或返回旧值。
public OrderDTO getHotOrder(String orderNo) {
String key = "shop:prod:order:detail:" + orderNo + ":v1";
OrderDTO cached = readOrderCache(key);
if (cached != null) {
return cached;
}
String lockKey = "shop:prod:lock:order:detail:" + orderNo;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, UUID.randomUUID().toString(), Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
try {
OrderDTO order = orderRepository.findByOrderNo(orderNo);
writeOrderCache(key, order, randomTtl());
return order;
} finally {
redisTemplate.delete(lockKey);
}
}
sleepQuietly(50);
return readOrderCache(key);
}这个示例能讲清思路,但生产里不建议手写复杂锁。手写锁必须保证加锁带过期时间、释放时校验持有者、业务执行时间小于锁有效期。更稳的方式是使用成熟客户端,例如 Redisson。
常见坑:
锁没有过期时间,线程异常后锁永远不释放。释放锁不校验持有者,删掉了别人的锁。所有等待线程无限阻塞,热点变成线程池事故。
热点 key 每次都等失效后才重建,没有预热和逻辑过期。
生产建议:核心热点数据优先使用逻辑过期和异步刷新,让用户请求尽量读旧值而不是一起回源。
缓存雪崩:不要让大量 key 同时失效
缓存雪崩可能来自 Redis 不可用,也可能来自大量 key 同时过期。开发侧至少要处理后者。
直接做法:
TTL 加随机扰动。批量预热时分批写入,不要同一秒写完。热点 key 使用逻辑过期或后台刷新。
Redis 异常时走降级,不要所有请求直接打数据库。
降级示例:
try {
return cacheService.getOrder(orderNo);
} catch (RedisConnectionFailureException ex) {
if (isCoreRead(orderNo)) {
return orderRepository.findByOrderNo(orderNo);
}
return OrderDTO.degraded(orderNo);
}生产建议:降级不能只写代码分支,要有开关、限流和观测指标。否则 Redis 一抖,所有请求绕过缓存查库,数据库仍然会被打满。
热点 key 和 BigKey:性能问题常常不是 Redis 不够快
热点 key 是访问过于集中,BigKey 是单个 key 的 value 或集合成员过大。两者都会让 Redis、网络和客户端抖动。
怎么发现
先看慢命令:
SLOWLOG GET 10看 key 的内存:
MEMORY USAGE shop:prod:order:detail:100001:v1扫描疑似大 key:
redis-cli --bigkeys按模式抽样:
SCAN 0 MATCH shop:prod:order:* COUNT 100SCAN 不是一次性全量阻塞命令,但也不是完全没有成本。生产高峰不要用大 COUNT 粗暴扫描。
怎么治理
热点 key:
本地短缓存,降低 Redis 往返。key 拆分或副本 key,分摊读压力。逻辑过期 + 后台刷新。
对热点接口限流和降级。
BigKey:
大对象拆成多个小 key。大集合分页读取,不用 HGETALL、SMEMBERS、大范围 ZRANGE。删除大 key 用 UNLINK 或渐进式删除,避免阻塞。
建立 value 大小和集合元素数量上限。
示例:
UNLINK shop:prod:large:temp:<RUN_ID>生产建议:BigKey 阈值要按业务和机器规格定。不要迷信一个固定数字。你要看的是单次命令耗时、网络流量、序列化耗时和对其他 key 的影响。
用状态模型验证缓存窗口
两个 Java 17 模型不连接真实 Redis,也不复刻 Caffeine 内部结构。它们把本篇必须守住的状态、版本和容量不变量变成确定输出;真实客户端与 Redis 集成测试继续验证协议、超时、拓扑和服务器行为。
javac --release 17 -Xlint:all -Werror examples/backend-development/cache/penetration-breakdown-avalanche/HotKeyCollapseDemo.java examples/backend-development/cache/penetration-breakdown-avalanche/TtlJitterDemo.java
java -cp examples/backend-development/cache/penetration-breakdown-avalanche HotKeyCollapseDemo
java -cp examples/backend-development/cache/penetration-breakdown-avalanche TtlJitterDemomisses=1000 loaders=1 waiters=999 backendAmplification=1
baseTtl=300 jittered=[271, 284, 307, 319, 333] simultaneousExpiry=false缓存正确性不是“命中返回值”,而是命中值仍在允许的陈旧窗口内、加载不会放大到权威存储、旧版本不能覆盖新版本、失败能够在预算内退化。模型输出任一项改变,都应触发对应集成用例,而不是只调整命中率告警。
