REDIS · CACHE-ASIDE · PENETRATION · BREAKDOWN · AVALANCHE

缓存是数据库前面的一面盾,它会从三个地方漏

平时 99% 的请求被 Redis 挡住,数据库只看到 1%。这面盾一漏,原本被挡住的流量就直接砸到数据库上。漏法有三种,名字容易混,看图记最快。

穿透Penetration

查一个根本不存在的数据。缓存里没有,库里也没有,所以永远缓存不上,每次都打到数据库。

击穿Breakdown

一个热点 key 过期的那一刻,成千上万的请求同时发现缓存没了,一起去查库重建。

雪崩Avalanche

一大批 key 同时过期,或者 Redis 整个挂了。整面盾都没了,流量全部压到数据库。

请求10,000 QPS

用户、爬虫、也可能是攻击者,先到缓存。

数量远超数据库能扛的
盾Redis 缓存

放在内存里,单个实例每秒能扛几万次读。

命中率决定数据库压力
仓库MySQL

数据的正本,每秒只能扛几千次查询。

被打满就整站变慢
沙漏TTL 过期时间

每个 key 写进去时就定好什么时候作废。

击穿和雪崩都跟它有关

先看盾平时怎么挡

最常用的写法叫 Cache-Aside(旁路缓存):读的时候先查缓存,没有再查库并写回缓存;改数据时先改库,再删缓存。

请求查商品 888 查 RedisGET product:888 直接返回99% 的请求到此为止
查 Redis没有 查数据库SELECT … WHERE id = 888 写回 RedisSET … EX 1800(30 分钟后过期) 返回
命中率一掉,数据库压力成百倍地涨

总共 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 个请求,全都发现缓存没有,全都去查库。

不处理
4,000 个请求同时查库
查库 4,000 次数据库一慢,重建更慢,堆的请求更多
互斥锁
1 个线程拿到锁去查库
查库 1 次其余 3,999 个请求等它建好再读缓存
逻辑过期
后台 1 个线程重建,请求照样拿旧值
查库 1 次4,000 个请求马上返回,但拿到的是旧数据
热点不过期
key 一直在,定时任务每隔一会儿刷新
查库 0 次要维护刷新任务,数据有延迟
互斥锁重建SET NX EX:只有一个人能拿到
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);                                     // 没抢到:稍等再读缓存(要限制重试次数)
}
逻辑过期Redis 上不设 TTL,过期时间写在值里
// 缓存里存的是 { 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;                                           // 不管抢没抢到,先返回旧值
Spring 里@Cacheable(cacheNames = "product", key = "#id", sync = true):同一个 JVM 里,同一个 key 只放一个线程去查库,其他线程等它。多台实例各查一次,通常也能接受。本地缓存 Caffeine 的 cache.get(key, loader) 也是同一个 key 只加载一次(MyBatis 第 04 篇)。

雪崩:一大片 key 一起没了

有两种起因。一种是大批 key 在同一时刻过期,比如凌晨预热时一起写进去、TTL 又都是 30 分钟;另一种是 Redis 本身挂了,整面盾消失。

起因一:同一个 TTL,同一时刻过期
都是 30 分钟
30 分钟 + 随机 0~5 分钟

每根柱子是 10 秒内过期的 key 数量(2 万个 key,示意)。上面全挤在一根柱子里,那 10 秒数据库要补查 2 万次;加了随机值,分散到 5 分钟里,每 10 秒只有六七百次。

起因二:Redis 挂了,靠高可用顶上
主节点哨兵发现并投票从节点升为主客户端改连新主

主从 + 哨兵,或者 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 出问题时还有本地这一层− 多份数据,一致性更难;占应用内存
限流降级三种都能兜底+ 数据库一定不会被打垮− 一部分用户拿到兜底数据或报错
合在一起的读法Spring Data Redis + MyBatis,示意
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 虚拟线程下,互斥锁等待的线程不再占平台线程,但数据库照样只能扛那么多
没变的:Cache-Aside 读写流程穿透靠过滤击穿靠只让一个人重建雪崩靠打散和兜底

常踩的坑

很多坑出在解法本身:防护加上了,却引入了新问题。

新上架的商品,一直显示“不存在”

布隆过滤器只在启动时装了一次 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 加随机值,或者分批预热。

Redis 一卡,整个接口卡 60 秒

没配超时,Lettuce 默认命令超时是 60 秒。配 spring.data.redis.timeout(Boot 2 是 spring.redis.timeout),Redis 出错时降级去查库,并配合限流。

用了逻辑过期,第一次请求还是打到了库

逻辑过期的 key 在 Redis 上永不过期,前提是它一开始就在。热点数据要在上线或活动前提前写进去。

缓存里的数据和库里不一样

先删缓存再改库:删完、改完之间有人读,把旧值又写回了缓存。改成先改库再删缓存;要求更高时延迟一会儿再删一次,或者订阅 binlog 删缓存。