REDIS · CACHE-ASIDE · PENETRATION · BREAKDOWN · AVALANCHE
缓存是数据库前面的一面盾,它会从三个地方漏
平时 99% 的请求被 Redis 挡住,数据库只看到 1%。这面盾一漏,原本被挡住的流量就直接砸到数据库上。漏法有三种,名字容易混,看图记最快。
穿透Penetration
查一个根本不存在的数据。缓存里没有,库里也没有,所以永远缓存不上,每次都打到数据库。
击穿Breakdown
一个热点 key 过期的那一刻,成千上万的请求同时发现缓存没了,一起去查库重建。
雪崩Avalanche
一大批 key 同时过期,或者 Redis 整个挂了。整面盾都没了,流量全部压到数据库。
10,000 QPS用户、爬虫、也可能是攻击者,先到缓存。
数量远超数据库能扛的Redis 缓存放在内存里,单个实例每秒能扛几万次读。
命中率决定数据库压力MySQL数据的正本,每秒只能扛几千次查询。
被打满就整站变慢TTL 过期时间每个 key 写进去时就定好什么时候作废。
击穿和雪崩都跟它有关先看盾平时怎么挡
最常用的写法叫 Cache-Aside(旁路缓存):读的时候先查缓存,没有再查库并写回缓存;改数据时先改库,再删缓存。
总共 10,000 QPS,虚线是数据库能扛的 2,000 QPS。命中率从 99% 掉到 90%,数据库压力就翻了 10 倍。三个问题,本质都是某一部分请求的命中率突然变成了 0。
| 穿透 | 击穿 | 雪崩 | |
|---|---|---|---|
| 数据存在吗 | 不存在,库里也没有 | 存在 | 存在 |
| 涉及多少 key | 大量随机的、不存在的 key | 一个热点 key | 一大批 key,或者全部 |
| 怎么发生的 | 恶意攻击、爬虫、前端传错参数 | 热点 key 到期 | 同一时刻批量过期;Redis 宕机 |
| 持续多久 | 攻击多久就多久 | 重建完成前那一小会儿 | 过期:几秒到几分钟;宕机:直到恢复 |
| 主要解法 | 布隆过滤器 缓存空值 参数校验 | 互斥锁 逻辑过期 热点不过期 | 过期时间加随机值 高可用 多级缓存 限流降级 |
穿透:查的东西根本不存在
有人拿 id = -1、id = 99999999 这种不存在的值来查。数据库查不到,就没东西可以写回缓存,下一次照样穿过去。攻击者换着 id 一直打,数据库就被拖垮。
id 必须是正数、格式要对,明显不合法的直接拒绝。
优 最便宜 · 代 只挡得住明显非法的,挡不住 id = 99999999库里查不到,也往缓存写一个空值,TTL 设短一点,比如 60 秒。
优 简单 · 代 攻击者每次换 id 就没用,还会塞满一堆垃圾 key;新增数据时要删掉对应的空值把所有存在的 id 先放进一个很小的位数组。它说“没有”,就一定没有,直接返回。
优 内存极小,随机 id 也挡得住 · 代 会误判“有”;不好删除;新增数据要同步加进去布隆过滤器怎么判断
一个 24 位的位数组,3 个哈希函数。已经放进去 5 个商品 id,每个 id 把 3 个位置设成 1。查一个 id 时,只要它的 3 个位置里有一个是 0,就一定没放进去过。
真实场景:1 亿个 id、误判率 1%,大约需要 9.6 亿位(约 120 MB)和 7 个哈希函数。存成一个 Set 要好几个 GB。位数组越大、误判越少;这里只有 24 位,所以误判很容易碰到。
String json = redis.opsForValue().get(key);
if (json != null) return json.isEmpty() ? null : parse(json); // "" 就是缓存的空值
Product p = productMapper.selectById(id);
if (p == null) {
redis.opsForValue().set(key, "", Duration.ofSeconds(60)); // 空值只存 60 秒
return null;
}
击穿:一个热点 key 刚好过期
爆款商品的 key 每秒被读 8,000 次。它在 20:00:00 过期,查库重建要 500 毫秒。这 500 毫秒里来的 4,000 个请求,全都发现缓存没有,全都去查库。
String token = UUID.randomUUID().toString();
Boolean ok = redis.opsForValue()
.setIfAbsent("lock:" + key, token, Duration.ofSeconds(10)); // 锁一定要有过期时间
if (Boolean.TRUE.equals(ok)) {
try {
json = redis.opsForValue().get(key); // 再查一次:可能别人刚建好
if (json == null) json = rebuild(id); // 查库并写回缓存
} finally {
unlock("lock:" + key, token); // 用 Lua 比较 token 后再删,只删自己的锁
}
} else {
Thread.sleep(50); // 没抢到:稍等再读缓存(要限制重试次数)
}
// 缓存里存的是 { data: …, expireAt: 1767225600 }
CacheBox box = read(key);
if (box.expireAt > now()) return box.data; // 没过期,直接用
if (tryLock("lock:" + key)) { // 过期了:抢到锁的人去后台重建
rebuildPool.submit(() -> {
try { write(key, new CacheBox(load(id), now() + 1800)); }
finally { unlock("lock:" + key); }
});
}
return box.data; // 不管抢没抢到,先返回旧值
@Cacheable(cacheNames = "product", key = "#id", sync = true):同一个 JVM 里,同一个 key 只放一个线程去查库,其他线程等它。多台实例各查一次,通常也能接受。本地缓存 Caffeine 的 cache.get(key, loader) 也是同一个 key 只加载一次(MyBatis 第 04 篇)。雪崩:一大片 key 一起没了
有两种起因。一种是大批 key 在同一时刻过期,比如凌晨预热时一起写进去、TTL 又都是 30 分钟;另一种是 Redis 本身挂了,整面盾消失。
每根柱子是 10 秒内过期的 key 数量(2 万个 key,示意)。上面全挤在一根柱子里,那 10 秒数据库要补查 2 万次;加了随机值,分散到 5 分钟里,每 10 秒只有六七百次。
主从 + 哨兵,或者 Redis Cluster:主节点挂了自动切到从节点,从节点里本来就有数据,不用重新预热。切换期间仍有十几秒左右不可用(看配置),这段要靠下面两招兜住。
多级缓存:应用里再放一层本地缓存(Caffeine),热点数据在本地就命中,Redis 挂了也还能挡住大部分。
限流降级:给查库加一道闸,每秒最多放过数据库扛得住的量,多出来的请求返回兜底数据或“稍后再试”。数据库保住了,代价是一部分用户拿不到真数据。
数据库压力实验台
选一个出事的场景,再打开几种防护,看 60 秒内数据库每秒要扛多少查询。数据库最多扛 2,000 QPS(虚线),超过就是红色。每种防护下面会告诉你它在这个场景里有没有用。
模型是示意:总流量 10,000 QPS,平时命中率 99%(数据库 100 QPS);攻击流量 3,000 QPS;爆款 key 每秒 8,000 次读、重建 500 ms;限流把查库控制在 1,800 QPS。鼠标移到柱子上看每一秒的数值。
十种防护,各自的代价
实际项目里通常组合使用:参数校验 + 布隆过滤器挡穿透,热点用互斥锁或逻辑过期挡击穿,TTL 加随机值 + 高可用 + 限流降级挡雪崩。
| 方案 | 防什么 | 好处 | 代价 |
|---|---|---|---|
| 参数校验 | 穿透 | + 最便宜 | − 只能挡明显非法的请求 |
| 缓存空值 | 穿透 | + 简单,重复查同一批 id 时很有效 | − 随机 id 时没用,还占内存;新增数据后要删空值 |
| 布隆过滤器 | 穿透 | + 内存极小,随机 id 也挡得住 | − 有误判;普通布隆过滤器不能删除;要先装入全部 id,新增要同步 |
| 互斥锁重建 | 击穿 | + 数据库只查一次,数据是新的 | − 其他请求要等;锁要设过期,释放时要防删错 |
| 逻辑过期 | 击穿 | + 没有人需要等,响应最快 | − 重建期间返回旧数据;写法复杂;要提前预热 |
| 热点不过期 + 定时刷新 | 击穿 | + 不过期就不会击穿 | − 要维护刷新任务;数据更新有延迟 |
| 过期时间加随机值 | 雪崩 | + 一行代码 | − 只解决“同时过期”,Redis 挂了不管用 |
| Redis 高可用 | 雪崩 | + 主节点挂了自动切换,数据还在 | − 多花机器;切换期间仍有短暂不可用 |
| 多级缓存 | 击穿 雪崩 | + Redis 出问题时还有本地这一层 | − 多份数据,一致性更难;占应用内存 |
| 限流降级 | 三种都能兜底 | + 数据库一定不会被打垮 | − 一部分用户拿到兜底数据或报错 |
public Product get(long id) {
if (id <= 0) return null; // ① 参数校验
if (!bloom.mightContain(id)) return null; // ② 布隆过滤器说没有,就一定没有
String key = "product:" + id;
String json = redis.opsForValue().get(key);
if (json != null) return json.isEmpty() ? null : parse(json);
return rebuildWithLock(id, key); // ③ 没命中:互斥锁重建(见上)
}
void writeCache(String key, Product p) {
if (p == null) { redis.opsForValue().set(key, "", Duration.ofSeconds(60)); return; } // ④ 空值短 TTL
long ttl = 1800 + ThreadLocalRandom.current().nextInt(300); // ⑤ 30 分钟 + 随机 0~5 分钟
redis.opsForValue().set(key, toJson(p), Duration.ofSeconds(ttl));
}
JDK 8 和 JDK 17:Boot 2 和 Boot 3
三个问题和解法跟 Java 版本无关。变化在客户端库和 Spring 的配置写法。
Spring Boot 2
JDK 8+- 配置前缀
spring.redis.*,默认客户端 Lettuce - 本地缓存用 Caffeine 2.x(3.x 要 JDK 11)
RedisCacheConfiguration.entryTtl只能给一个固定 TTL,想加随机值要自己扩展 CacheManager- 布隆过滤器常用 Guava(单机)或 Redisson 的
RBloomFilter(放在 Redis 里,多实例共享)
Spring Boot 3
JDK 17+- 配置前缀改成
spring.data.redis.*,老前缀不再生效 - Caffeine 3.x
- Spring Data Redis 3.2 起,
entryTtl可以传一个函数按条目算 TTL,加随机值方便了 - Redis 8 起布隆过滤器(
BF.ADD/BF.EXISTS)直接内置,之前要装 RedisBloom 模块 - JDK 21 虚拟线程下,互斥锁等待的线程不再占平台线程,但数据库照样只能扛那么多
常踩的坑
很多坑出在解法本身:防护加上了,却引入了新问题。
布隆过滤器只在启动时装了一次 id,新增数据没加进去,它就一定说“没有”。新增时同步写入过滤器,并定期全量重建。
普通布隆过滤器删不掉,只会误判多一点,请求会走到缓存和数据库那一步,不算错。删得多了就定期重建,或者换支持删除的布谷鸟过滤器(Cuckoo Filter)。
空值缓存的 TTL 设得太长。空值 TTL 要短(几十秒),新增数据时顺手删掉对应的 key。Spring 的 RedisCache 默认也会缓存 null,而且用的是和正常数据一样长的 TTL。
用 SETNX 加锁后再单独 EXPIRE,中间一挂就成了死锁。要用一条命令 SET key token NX EX 10。
自己的锁已经过期、被别人拿到了,自己执行完又 DEL 掉。释放前要比较 token,用 Lua 脚本保证“比较 + 删除”一起完成;或者直接用 Redisson 的锁。
拿到锁后没有再查一次缓存。前一个线程刚建好缓存、放了锁,后一个线程拿到锁又去查库。拿锁后先再读一次缓存。
定时任务在同一时刻预热所有 key,TTL 又一样,于是每天同一时间一起过期。TTL 加随机值,或者分批预热。
没配超时,Lettuce 默认命令超时是 60 秒。配 spring.data.redis.timeout(Boot 2 是 spring.redis.timeout),Redis 出错时降级去查库,并配合限流。
逻辑过期的 key 在 Redis 上永不过期,前提是它一开始就在。热点数据要在上线或活动前提前写进去。
先删缓存再改库:删完、改完之间有人读,把旧值又写回了缓存。改成先改库再删缓存;要求更高时延迟一会儿再删一次,或者订阅 binlog 删缓存。