主题
界面
主题
《Redis 数据结构与常用命令》讲"Redis 能装什么";本节讲**"缓存失效时会发生什么"**:
三个问题的共同后果都是「数据库被打爆」,但触发条件与解法完全不同 ✓✓★ 一句话区分 ✓✓:
穿透是"数据不存在",击穿是"数据刚过期",雪崩是"很多数据一起过期" ✓
先看本节的实测数据 ✓:
===== 缓存穿透:查一个不存在的 key,1000 次并发 =====
无防护:DB 被查询 1000 次(耗时 182 ms)✗✗
解法① 缓存空值:DB 被查询 0 次 ✓★ 1000 次并发 = 1000 次数据库查询 ✗✗ —— 而攻击者只要用不存在的 ID 刷接口,就能把数据库打满 ✓
★★ 更关键的是:这类问题在真实场景里往往是"恶意"的 ✓✓
| 问题 | 触发条件 | 现象 | 核心解法 |
|---|---|---|---|
| 穿透 ★★★ | 查不存在的数据 | 缓存天然失效,每次都回源 | 布隆过滤器 / 缓存空值 ✓ |
| 击穿 ★★★ | 热点 key 过期 | 并发同时回源 | 互斥锁 / 逻辑过期 ✓ |
| 雪崩 ★★★ | 大量 key 同时过期(或缓存整体挂掉) | DB 被瞬间打满 | TTL 随机 / 高可用 / 预热 ✓ |
★ 三者的共同点 ✓:
缓存没起作用时,压力会全部压到数据库 ✗
所以缓存的健壮性设计 = 想清楚"缓存失效时会发生什么" ✓✓
★ 实测对比 ✓:
| 方案 | DB 被查询次数 |
|---|---|
| 无防护 ✗✗ | 1000 次 |
| 缓存空值 ✓ | 0 次 |
| 布隆过滤器 ✓✓ | 直接拦截,不查库 |
★ 方案一:缓存空值 ✓:
String value = cache.get(key);
if (value == null) {
String fromDb = db.query(key);
cache.put(key, fromDb == null ? "" : fromDb); // ★ 用空串表示"查过但不存在"
}★ 两个注意点 ✓:
| 注意 | 说明 |
|---|---|
| 空值的 TTL 要短 ⚠️ | 否则大量不存在的 key 会占满内存 ✗ |
| 要能区分"空值"与"未缓存" ✓ | 用空串、特殊标记,或用 Optional 思路 |
★ 方案二:布隆过滤器 ✓✓
本节的实测:
过滤器大小:11984 字节(预期 1 万个元素,误判率 1%)
已有数据的判断:PRODUCT-1 → true(存在 ✓)
不存在的判断: PRODUCT-NOT-EXIST → false(不存在 ✓,直接返回,不查库)
误判率实测:0 / 10000 = 0.00%(设计目标 1%)★★ 布隆过滤器的三个特性(必须记准) ✓✓:
| 特性 | 含义 |
|---|---|
| 说不存在,一定不存在 ★★★ | 可以放心拦掉 ✓✓ |
| 说存在,可能误判 ★★ | 小概率放过去,需要回源兜底 ⚠️ |
| 不能删除元素 ✗ | 删了会影响其他元素的判断(因为多个元素共享位) |
★ 正确用法 ✓✓:
放在缓存之前做"拦截层" ✓
而不是当作"数据是否存在的权威判断" ✗✗
所以布隆说"存在"时,仍要查缓存与数据库 ✓
★ 它的空间效率 ✓:
1 万个元素、1% 误判率 → 约 12 KB(每个元素约 1.2 字节)
1 亿个元素、1% 误判率 → 约 120 MB★ 真实场景里的实现 ✓:Redis 的 bitmap、Guava 的 BloomFilter、Redisson 的 RBloomFilter ✓
★ 时序 ✓:
热点 key 到期的那一瞬间,1000 个并发同时发现"缓存没有"
→ 1000 个线程同时去查数据库 ✗✗★ 方案一:互斥锁重建 ✓:
String value = cache.get(key);
if (value == null) {
lock.lock(); // ★ 只让一个线程进入
try {
value = cache.get(key); // ★ 双重检查:别人可能已经加载好了
if (value == null) {
value = db.query(key);
cache.put(key, value);
}
} finally { lock.unlock(); }
}★ 它的代价 ✓:其他线程要等待 ✗(如果重建慢,所有请求都被阻塞)⚠️
★ 方案二:逻辑过期 ✓✓(推荐)
// value 里存"逻辑过期时间",而不是用 Redis 的 TTL
class CachedValue {
Object data;
long logicalExpireAt; // ★ 逻辑过期时间
}
// 读取时
CachedValue cached = cache.get(key);
if (cached != null && cached.logicalExpireAt > now) {
return cached.data; // 未过期,直接返回 ✓
}
// 已逻辑过期:先返回旧值(不阻塞),同时异步重建
asyncRebuild(key);
return cached.data; // ★ 立即返回旧值,用户体验不受影响 ✓✓★★ 两种方案的取舍 ✓✓:
| 方案 | 一致性 | 延迟 | 复杂度 |
|---|---|---|---|
| 互斥锁重建 ★ | 强(拿到的一定是新值)✓ | 会阻塞 ✗ | 简单 ✓ |
| 逻辑过期 ★★ | 弱(返回短暂旧值)⚠️ | 不阻塞 ✓✓ | 复杂 ⚠️ |
★ 选择依据 ✓:
能容忍"短暂旧值"就用逻辑过期(如商品详情、榜单)✓ 不能容忍就用互斥锁(如库存、余额)✓✓
★ 三个成因 ✓:
| 成因 | 说明 |
|---|---|
| ① 大量 key 同一时刻过期 ★★ | 最典型(如"凌晨统一预热") |
| ② 缓存服务整体挂掉 ✗✗ | 单点故障 |
| ③ 缓存预热不足 ⚠️ | 刚上线/重启,缓存是空的 |
★ 实测(TTL 随机的效果) ✓✓:
固定 TTL=2 秒:1000 个 key 全部在同一秒到期 ✗✗
到期瞬间的并发回源请求:1000 次(DB 一次性被打满)
TTL 加 0~4 秒随机:同一秒到期的最大数量 ≈ 218 个 ✓✓
→ 回源压力被摊薄到 5 个时间片上★ 所以第一条解法是"给 TTL 加随机值" ✓✓:
int ttl = baseTtl + random.nextInt(300); // ★ 基础 TTL + 0~300 秒随机
redis.setex(key, ttl, value);★ 三条解法的对应 ✓:
| 成因 | 解法 |
|---|---|
| ① 同时过期 | TTL 加随机 ✓✓ |
| ② 缓存挂掉 | Redis 主从 + 哨兵/集群(《Redis 持久化与高可用》)、本地缓存兜底 ✓ |
| ③ 预热不足 | 启动时预热热点数据、缓存降级 ✓ |
★★ 最后一道防线:限流 + 熔断 ✓✓:
下游已经扛不住时,快速失败比"硬撑着把请求发过去"更好 ✓
因为它能保住数据库不被彻底打垮 ✓✓
(这就是 《Sentinel 限流与熔断》 要讲的 Sentinel 的核心思想)
| 检查项 | 做法 |
|---|---|
| 所有缓存都设了 TTL 吗 ★★★ | 没有 TTL 的缓存 = 永久脏数据 ✗ |
| TTL 是随机化的吗 ★★ | 避免同时过期 ✓ |
| 热点 key 有防击穿措施吗 ★★★ | 互斥锁或逻辑过期 ✓ |
| 查询不存在的数据时会穿透吗 ★★ | 空值缓存或布隆过滤器 ✓ |
| 缓存挂了会怎样 ★★★ | 有降级方案吗(本地缓存 / 直接返回兜底)✓ |
| 有监控吗 ★★ | 命中率、QPS、大 key、慢查询 ✓ |
★ 第一行最容易被忽略 ✓✓:
"忘了设 TTL"是缓存事故的头号原因 ✗
因为那个 key 会永久保留旧值 ——而且没人知道它什么时候会变成错的 ✗✗
redis.setex(key, 300 + ThreadLocalRandom.current().nextInt(120), value);见 5.3 ✓
见 5.4 的实测:1000 个 key 同时到期 ✗✗
代码:labs/redis-lab/src/main/java/cn/kb/learn/redis/CacheBreakdownLesson.java
运行:
cd labs
.\mvnw.cmd -pl redis-lab compile
java "-Dstdout.encoding=UTF-8" -cp redis-lab/target/classes cn.kb.learn.redis.CacheBreakdownLesson实测输出(关键部分):
===== 1. 缓存穿透 =====
无防护:DB 被查询 1000 次(耗时 182 ms)✗✗
解法① 缓存空值:DB 被查询 0 次 ✓
===== 2. 布隆过滤器 =====
过滤器大小:11984 字节(预期 1 万个元素,误判率 1%)
误判率实测:0 / 10000 = 0.00%
===== 4. 缓存雪崩 =====
固定 TTL=2 秒:1000 个 key 全部在同一秒到期 ✗✗
TTL 加 0~4 秒随机:同一秒到期的最大数量 ≈ 218 个 ✓✓★ 三条可落地的结论 ✓:
| # | 结论 | 依据 |
|---|---|---|
| ① | 查不存在的数据必须防穿透(空值缓存或布隆) | 1000 次并发 = 1000 次查库 ✗ |
| ② | TTL 必须加随机 | 固定 TTL 让 1000 个 key 同时到期 ✗ |
| ③ | 布隆过滤器只能"确定不存在",不能"确定存在" | 有误判率(本次实测 0%)✓ |
★ 一个诚实的说明 ✓:本次布隆实测的误判率是 0%,好于设计目标 1%——因为只放了 1000 个元素(远小于预期的 1 万),位数组显得很空 ✓。元素接近设计容量时,误判率才会逼近 1% ✓✓
nextInt(1):退化成固定 TTL ✓;| # | 错误 | 说明 |
|---|---|---|
| 1 | 缓存不设 TTL ✗✗ | 永久脏数据 ✓ |
| 2 | TTL 全部相同 ✗✗ | 雪崩(5.4)✓ |
| 3 | 布隆过滤器当"权威判断" ✗✗ | 有误判率,必须回源兜底 ✓ |
| 4 | 空值缓存不设 TTL ✗ | 内存被垃圾 key 占满 ✓ |
| 5 | 击穿用互斥锁但没做双重检查 ✗✗ | 每个线程拿到锁后都去查库 ✓ |
| 6 | 没有降级方案 ✗✗ | 缓存挂了直接打垮数据库 ✓ |
| 7 | 不监控缓存命中率 ✗ | 缓存失效了都不知道 ✓ |
目标:亲手把三个问题都复现一遍,并验证解法有效。
步骤:
CacheBreakdownLesson,记录穿透的 1000 次与空值缓存的 0 次 ✓;hotKey 改成一个不存在的 key:观察它变成"穿透"(本实验初版正是踩了这个坑)⚠️;Caffeine 或简单 Map),模拟 Redis 挂掉时的降级 ★★。验收方式:能说出"你的系统在这三种场景下会怎样、怎么接住"。
扩展任务:设计一个"热点商品详情页"的缓存方案:
String value = redis.get(key);
if (value == null) {
value = db.query(key);
redis.set(key, value); // 注意:没有 TTL
}
return value;这段代码有几个问题? 5. (设计)给"秒杀商品库存"设计缓存方案:要求不超卖、不能把数据库打垮、缓存挂了也能降级。
DECR 原子扣减 + 结果 >0 才放行 ✓; 扣减成功再异步落库;Redis 挂了直接拒绝(宁可少卖不可超卖) ✓✓《缓存一致性与更新策略》:缓存与数据库如何保持同步。