缓存穿透、缓存击穿、缓存雪崩,这三兄弟是Redis缓存系统上线之后最常遇到的故障源。不少团队是在大促流量把服务打挂之后,才回头认真补这三块设计。我这些年帮别人Review缓存方案、也自己动手修过不少线上事故,今天把这三类问题的成因、判断方法和可落地的解决方案一次性讲透,顺便把那些文档里不会明说的坑也一并交代清楚。
先说一个核心观点:穿透、击穿、雪崩虽然名字像,但成因完全不同,解法也完全不同。最忌讳的就是拿一套方案硬套三个问题,比如无脑加空值缓存,结果把布隆过滤器该干的事也揽过来,最后缓存里塞满了毫无意义的key,内存压力反而更大了。下面逐个拆解。
1. 三个经典问题:成因、特征与危害边界
1.1 缓存穿透:查询一个不存在的数据
穿透的本质是查询一个数据库中也不存在的数据。比如一个电商系统,用户通过ID查商品详情,Redis里没有这个key,于是请求穿透到MySQL,MySQL查了也没有,那这个key既不会写回Redis(因为查无此物),下次同样的请求还是会直达数据库。
正常情况下这不算大问题,但如果是恶意攻击或者有人写脚本疯狂遍历不存在的ID,那所有请求全部压到数据库上。MySQL的QPS能力远低于Redis,一旦请求量上来,数据库连接池很快被打满,紧接着就是慢查询堆积、主从延迟变大,最后整个服务雪崩。
穿透的典型特征是:Redis命中率骤降,数据库QPS异常升高,而且查询的key完全没有规律。如果是正常业务流量,key通常是有边界规律的,比如自增ID,穿透时往往是随机数或超大ID。
1.2 缓存击穿:热点key瞬间失效
击穿和穿透最大的区别是:击穿针对的是一个存在的热点key,它在某个瞬间恰好过期了,导致大量并发请求同时落到数据库上。
举个例子:某条爆款商品的详情页做了缓存,缓存时间是30分钟。这个商品平时每天被访问几十万次,Redis命中率很高。某天下午2点,缓存刚好到期,此时正好有1000个用户同时点开这个商品。缓存没命中,1000个请求几乎同时去MySQL查这条数据,MySQL单条查询不慢,但1000个请求同时打进来,再加上其他正常流量,数据库的负载瞬间飙高,可能出现几百毫秒的延迟甚至连接超时。
击穿的关键词是热点、并发集中、过期瞬间。它不要求key不存在,恰恰是因为key存在才危险——所有并发都盯着这一个key。
1.3 缓存雪崩:大量key同时失效
雪崩是击穿的放大版:大量key在同一时间段集中过期,或者Redis实例本身宕机,导致请求全部打到数据库。
最常见的雪崩场景其实很朴素——很多团队给缓存key设置的过期时间一样。比如所有商品详情缓存都是30分钟,那每个整点时刻(比如10:00、10:30、11:00)都会有一大批key同时失效。如果此时流量正处于高峰期,数据库压力瞬间翻几倍,扛不住就挂了。
Redis宕机导致的雪崩更隐蔽。有些团队没有做主从和高可用,Redis一挂,所有流量直接穿透到数据库。更惨的是,服务重启后大量请求又去回填缓存,数据库根本来不及响应,形成“缓存雪崩 – 数据库过载 – 缓存回填失败 – 更多请求打库”的恶性循环。
2. 缓存穿透的实战解法:空值缓存与布隆过滤器
2.1 方案一:空值缓存,成本最低的挡箭牌
空值缓存的思路很简单:数据库查不到数据时,也在Redis里写一个key,只不过value是null,并且设置一个较短的过期时间,比如3到5分钟。这样后续相同key的请求就会命中这个空值缓存,不会继续穿透到数据库。
public Product getProduct(String productId) { String cacheKey = "product:" + productId; String cachedValue = redisTemplate.opsForValue().get(cacheKey); // 命中缓存,直接返回(需要区分空值标记) if (cachedValue != null) { if ("NULL_VALUE".equals(cachedValue)) { return null; } return JSON.parseObject(cachedValue, Product.class); } // 未命中,查数据库 Product product = productMapper.selectById(productId); if (product == null) { // 空值也缓存,设置较短过期时间 redisTemplate.opsForValue().set(cacheKey, "NULL_VALUE", 3, TimeUnit.MINUTES); return null; } // 正常写入缓存,设置业务过期时间 redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES); return product; }这里有个细节很多人会忽略:空值缓存一定要设置独立的、较短的过期时间。如果和正常key一样设置30分钟,那么一个被反复查询10分钟的恶意ID,其空值标记会在Redis里占30分钟的内存,长时间累积下来,Redis的内存会被大量无意义的key撑爆。更合理的做法是给空值key设置5分钟左右的TTL,并且可以考虑在业务层再做一个本地短时缓存(比如Caffeine,几十秒),进一步拦截同一秒内的重复穿透请求。
我在实际项目里还见过一个进阶玩法:空值缓存不直接存null,而是存一个带时间戳的标记对象。比如{"empty":true, "createTime": 1702800000000},这样后台可以定期扫描空值标记,统计哪些key被高频穿透访问,用于反作弊或者发现系统漏洞——比如某个产品ID不存在但被疯狂访问,很可能有人在扫描你的商品接口。
2.2 方案二:布隆过滤器,从源头拦掉不存在的key
布隆过滤器(Bloom Filter)是一个很优雅的数据结构:用一个很小的内存空间,精确判断一个元素是否绝对不存在。它的原理是多个哈希函数 + 一个位数组,查询时如果任何一个哈希位为0,那这个key一定不存在;如果所有哈希位都是1,那只能说“可能存在”。
在缓存穿透场景里,我们可以在服务启动时把数据库里所有商品ID加载到布隆过滤器中。请求进来时先查布隆过滤器,如果判定不存在,直接返回结果,Redis和数据库都不用碰。
@Component public class BloomFilterService { private static final int EXPECTED_INSERTIONS = 100_0000; // 预期数据量 private static final double FPP = 0.001; // 期望误判率 private final BloomFilter<String> bloomFilter; public BloomFilterService() { this.bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), EXPECTED_INSERTIONS, FPP); } @PostConstruct public void init() { // 启动时加载所有存在的商品ID List<String> allProductIds = productMapper.selectAllIds(); allProductIds.forEach(bloomFilter::put); } public boolean mightContain(String productId) { return bloomFilter.mightContain(productId); } }需要说明的是,布隆过滤器存在误判率,也就是说“存在”的判断不可全信,但“不存在”的判断是绝对可信的。这意味着它会放少数漏网之鱼到数据库,但绝不会把真实存在的key挡在门外。内存占用也很可观:100万个key、误判率0.1%,大约只需要1.2MB左右的内存(具体占用跟实现库有关系,Guava的实现大概在1.3MB),这点内存换数据库的安全,非常划算。
2.3 方案对比:到底选哪个
| 维度 | 空值缓存 | 布隆过滤器 |
|---|---|---|
| 实现成本 | 极低,几行代码 | 中等,需要引入库、维护数据同步 |
| 内存消耗 | 取决于穿透key数量,可能很大 | 固定内存,且很小 |
| 拦截能力 | 只能拦截查过的key | 能拦截所有不存在的key |
| 数据一致性 | 无,天然一致 | 需要处理新增数据同步 |
| 误伤可能 | 无 | 有极小概率误判存在的key(影响很小) |
| 适用场景 | 穿透量不大、没有恶意攻击 | 对安全性要求高、数据量可控 |
我的建议是:日常业务先用空值缓存,成本最低,能挡住80%的穿透问题。如果发现穿透量很大,或者有被恶意刷接口的迹象,再引入布隆过滤器。还有一种折中方案:空值缓存只拦截高频重复的穿透key,布隆过滤器负责一次性拦截所有不存在的key,两者可以共存,不冲突。
3. 缓存击穿的实战解法:互斥锁与逻辑过期
3.1 方案一:互斥锁,只让一个请求去重建缓存
互斥锁的核心逻辑是:当缓存未命中时,不是每个请求都去数据库查,而是先尝试获取锁。拿到锁的线程去查数据库并重建缓存,其他线程等锁释放后再去读缓存。
实现方式最常用的是Redis的SET NX EX指令,这是原生的原子操作,安全可靠:
public Product queryProduct(String productId) throws InterruptedException { String cacheKey = "product:" + productId; String cachedValue = redisTemplate.opsForValue().get(cacheKey); if (cachedValue != null) { return JSON.parseObject(cachedValue, Product.class); } // 缓存未命中,尝试获取互斥锁 String lockKey = "lock:product:" + productId; String requestId = UUID.randomUUID().toString(); boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS); if (locked) { try { // 拿到锁,二次检查缓存(double check) cachedValue = redisTemplate.opsForValue().get(cacheKey); if (cachedValue != null) { return JSON.parseObject(cachedValue, Product.class); } // 查数据库并回填缓存 Product product = productMapper.selectById(productId); if (product == null) { redisTemplate.opsForValue().set(cacheKey, "NULL_VALUE", 3, TimeUnit.MINUTES); } else { redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES); } return product; } finally { // 释放锁:要校验value是否等于自己的requestId String lockValue = redisTemplate.opsForValue().get(lockKey); if (requestId.equals(lockValue)) { redisTemplate.delete(lockKey); } } } else { // 没拿到锁,短暂休眠后重试 Thread.sleep(50); return queryProduct(productId); // 递归重试,注意设置重试上限 } }这段代码里有两个关键细节,绝大多数初学的人会踩坑:
第一是double check。拿到锁之后不能立刻查库,要先再查一次缓存。因为可能有这种情况:A线程刚重建完缓存并释放锁,B线程拿到锁,如果B不重新查缓存,就会重复查库,锁的意义就打了折扣。
第二是释放锁时的value校验。SET NX EX如果没有设置唯一的requestId,那可能A线程的锁超时自动释放了,B线程拿到新锁,A线程执行完finally直接delete(lockKey),把B的锁给误删了。加了requestId校验,只删自己持有的锁,这个误删问题才能根治。
锁的过期时间也需要留心。如果重建缓存需要500ms,锁设置1秒有点短,极端情况下锁提前失效,多个请求同时进库。如果重建需要2秒,但锁设了10秒,一旦持有锁的线程挂了(虽然没有挂,但GC停顿或者网络抖动),其他线程要白白等很久。实践经验是:锁过期时间设为重建缓存预估耗时的3到5倍,如果不知道耗时,先压测一下再定。
3.2 方案二:逻辑过期,让缓存永不物理过期
逻辑过期的思路比较巧妙:缓存value里不仅存业务数据,还存一个过期时间戳。Redis层面不给key设置TTL,key永远不会被物理删除。读取缓存时判断时间戳,如果没过期直接返回;如果过期了,就返回旧数据(反正还有),同时异步去更新缓存。
// 缓存value结构 public class RedisData { private Object data; // 业务数据 private long expireTime; // 逻辑过期时间戳(毫秒) } public Product queryProduct(String productId) { String cacheKey = "product:" + productId; // 1. 从缓存中读取数据 String json = redisTemplate.opsForValue().get(cacheKey); if (json == null) { // 缓存未命中,先查数据库并构建缓存(兜底逻辑,正常情况不会走到这里) return queryDbAndSetCache(productId, cacheKey); } RedisData redisData = JSON.parseObject(json, RedisData.class); long currentTime = System.currentTimeMillis(); // 2. 逻辑过期没有过期,直接返回 if (redisData.getExpireTime() > currentTime) { return (Product) redisData.getData(); } // 3. 逻辑过期了,尝试获取锁并异步重建缓存 String lockKey = "lock:product:" + productId; String requestId = UUID.randomUUID().toString(); boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 5, TimeUnit.SECONDS); if (locked) { try { // 异步重建缓存 ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100)); executor.execute(() -> queryDbAndSetCache(productId, cacheKey)); } finally { // 释放锁(同样需要value校验) String lockValue = redisTemplate.opsForValue().get(lockKey); if (requestId.equals(lockValue)) { redisTemplate.delete(lockKey); } } } // 4. 返回旧数据,由异步线程更新缓存 return (Product) redisData.getData(); }逻辑过期的优势非常明显:请求永远不会阻塞。哪怕缓存过期了,用户拿到的还是旧数据(可能差几秒),体验上几乎感知不到,后台异步线程把新数据刷进去之后,下一次请求就是新数据了。
不过代价也很明确:数据一致性变弱了。在逻辑过期到异步重建完成的这个时间窗口内,用户看到的是旧数据。如果业务对数据一致性要求很高(比如库存、金额),逻辑过期要慎用。另外,代码里需要自己管理线程池,做并发控制,实现比互斥锁复杂一些。
3.3 两种方案怎么选
| 维度 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 数据一致性 | 强一致,缓存重建完才返回 | 弱一致,可能读到旧数据 |
| 用户体验 | 可能轻微卡顿(等待锁) | 无卡顿,永远有数据返回 |
| 实现复杂度 | 较低 | 较高,需要线程池、时间戳 |
| 适用场景 | 一致性敏感、查询频率极高 | 性能敏感、允许短时旧数据 |
如果是金融类、库存类业务,用互斥锁更稳。如果是内容资讯类、商品详情类,逻辑过期体验更好。我自己做电商详情页缓存时偏好逻辑过期,因为商品信息允许秒级延迟,没必要让用户为几百毫秒的锁等待买单。
4. 缓存雪崩的实战解法:过期时间随机化与高可用设计
4.1 基础防线:过期时间加随机因子
雪崩最简单的触发原因是大量key同一时刻过期,解法也是最朴素的:不要让过期时间一样。
具体来说,在设置缓存过期时间时,加一个随机偏差:
// 基础过期时间 + 随机数,让key在不同时间点过期 int baseExpireSeconds = 30 * 60; int randomExpireSeconds = baseExpireSeconds + new Random().nextInt(60 * 10); redisTemplate.opsForValue().set(cacheKey, json, randomExpireSeconds, TimeUnit.SECONDS);有的团队喜欢固定加random.nextInt(600)(0到600秒的随机偏移),有的直接用baseExpire * random.nextDouble()让TTL在base的0.8到1.2倍之间浮动。效果是一样的:把原来“同时过期”变成“前后几分钟内陆续过期”,数据库压力曲线从尖峰变成平缓的波浪。
注意,这里有一个容易被忽略的细节:随机因子不能分布太窄。比如基础TTL是30分钟,随机偏移只有5秒,那所有key还是集中在同一分钟内过期,效果微乎其微。我的建议是随机偏移至少覆盖基础TTL的10%到20%,才能明显打散过期时间。
4.2 缓冲层:多级缓存与限流降级
单靠Redis扛流量是不现实的,更稳妥的做法是在Redis前面加一层本地缓存(JVM堆内缓存),形成两级缓存架构:
请求进来先查本地缓存(Caffeine/Guava Cache,TTL设置很短,比如30秒),再查Redis,最后查MySQL。这等于又加了一道缓冲,即使Redis里大量key过期,本地缓存还能挡住相当比例的重复请求。
再配合限流降级:当检测到数据库负载过高时,直接熔断对数据库的查询,返回降级数据(比如热门商品缓存副本、静态默认值)。这属于最后一道兜底,保证系统不挂,而不是保证数据最新。
我见过一个做得比较好的团队,他们把全量热门商品的JSON快照定期备份到本地磁盘文件,数据库不可用时直接从文件加载兜底。这个方案虽然听起来“土”,但非常实用,尤其是在极端故障场景下,比临时拼接口靠谱得多。
4.3 架构层面:Redis高可用与持久化
避免雪崩的另一半工作是保证Redis本身不能挂。这要求:
第一,主从复制。至少配置一主一从,从节点实时同步主节点数据。主节点故障时,哨兵(Sentinel)自动完成故障切换,从节点晋升为主节点,整个过程对应用层基本透明。
第二,持久化开启。极端情况下主节点宕机且从节点还没来得及同步数据,如果开启了AOF持久化,重启后可以恢复数据,避免缓存冷启动后大量请求穿透到数据库。
第三,预热机制。大促前提前把热点数据刷入Redis,别等用户来触发缓存回填。这个操作成本不高,效果立竿见影。
第四,多Redis集群分片。如果业务量很大,单实例内存到了几十GB,建议做Cluster集群,把key分散到多个节点。某个节点宕机时,只有部分key受影响,不会全站雪崩。
5. 线上问题排查思路与避坑手记
5.1 快速判断是穿透、击穿还是雪崩
很多人在做根因分析时,花了很多时间看代码,其实通过几个监控指标就能快速定位:
| 现象 | 大概率是 |
|---|---|
| Redis QPS无明显变化,MySQL QPS暴涨 | 穿透(key大量不存在) |
| Redis命中率突然掉到很低,MySQL出现大量同key查询 | 击穿(热点key过期) |
| Redis命中率正常,但某个时间点MySQL QPS瞬间翻数倍 | 雪崩(大量key集中过期) |
| Redis连接异常/超时,MySQL QPS同步暴涨 | Redis宕机导致雪崩 |
我自己的排查习惯是:先看Redis监控面板(命中率、QPS、内存、慢查询),再看数据库慢查询日志,最后结合业务日志定位是否集中在某个特定key上。这套流程十次有八次能直接锁定问题类型。
5.2 缓存与数据库的一致性,别忽略
前面聊了三大高并发问题,但还有一个高频踩坑点:缓存更新后,数据库和缓存数据不一致。
最常见的不一致场景是:先更新数据库,再删除缓存(Cache Aside模式),删除缓存失败了,导致旧缓存长期存在。更隐蔽的是并发场景——线程A更新数据库,线程B读旧数据并回写缓存,线程A删除缓存时把B刚写入的缓存删掉了,或者反过来B的旧缓存覆盖了A的新数据。
相对稳妥的做法:更新数据库后,延迟双删——先删除缓存,再更新数据库,过几百毫秒再删一次缓存。如果还不行,就上消息队列异步重试删除,保证缓存最终被清掉。这个方案的细节很多,这里不展开,但提醒一点:任何时候不要先更新缓存再更新数据库,那会把一致性难题放大十倍。
5.3 Redis使用中的常见坑清单
这几年帮别人Review Redis代码,反复见到下面这些坑,写下来给大家避雷:
- 大key问题:单个key的value超过几MB,读写耗时陡增,还会拖垮整个Redis实例。商品详情、文章内容这类大文本一定要压缩或拆分存储。
- 热key问题:某个key的访问量占到了全量请求的80%以上,单分片节点CPU打满。可以把热key加随机后缀,分散到多个分片上。
- 序列化混乱:不同服务用不同的序列化方式读写同一个key,导致数据解析失败。统一用JSON或Protobuf,别混用。
- key命名不规范:没有前缀规范,多个业务共用一套Redis,定位问题难、清理数据难。强制要求
业务名:模块名:key名的格式。 - 连接池配置不当:连接池太小,高并发下线程全在等连接,Redis很闲但应用很慢。结合压测调大
maxTotal和maxWaitMillis。 - 使用KEYS命令:数据量一大,
KEYS *直接阻塞Redis主线程。用SCAN代替。
5.4 缓存预热:上线前的最后一公里
缓存预热是很多人容易忽略的步骤。新功能上线或大促前,如果不预热,第一批用户的请求会全部打到数据库,即便有互斥锁和逻辑过期,数据库在那一瞬间也是满负荷。
我的做法是写一个预热脚本,扫描目标key列表,先查一次Redis,如果缓存不存在就从数据库拉取并写入。大促前跑一遍,把Top N的热点key全部刷进缓存。预热脚本还可以设置并发度,比如10个线程并发预热,避免预热本身就把数据库打挂了。
写在最后:方案不是堆砌,而是匹配场景
我见过不少团队,把空值缓存、布隆过滤器、互斥锁、逻辑过期、多级缓存全都堆上去,结果系统复杂度上来了,维护成本很高,但问题并没有减少。每种方案都有适用范围和代价,关键是想清楚你的业务最在意什么:
- 如果读多写少、数据一致性要求高,优先保证缓存更新链路正确,用互斥锁防击穿。
- 如果性能优先、能容忍短暂旧数据,逻辑过期是好选择。
- 如果安全要求高、存在恶意刷接口风险,布隆过滤器值得做。
- 如果Redis本身稳定性存疑,先把主从、持久化、哨兵配好,再谈后面那些优化。
最后分享一个我的排查小技巧:线上出了缓存事故,先别急着改代码,先看一眼监控时间轴。把Redis QPS、MySQL QPS、CPU、响应时间拉倒同一张图里,往往一眼就能看出谁是源头、谁是受害者。定位清楚了再动手,很多时候只需要改一行配置或者加几行逻辑就能解决,不需要大动干戈重写缓存层。这也是我这些年做缓存治理最大的体会——先分类,再对症下药,缓存系统才能又稳又省心。