Redis 缓存三大经典问题:雪崩、击穿、穿透从原理到方案
Redis 缓存层一旦失效,请求就会瞬间穿透到数据库,把数据库冲垮,进而拖垮整个系统。本文按"问题 → 成因 → 预防层 → 响应层"的结构,系统讲透缓存雪崩(大面积失效)、缓存击穿(热点单点失效)、缓存穿透(查询不存在数据)三大经典问题,涵盖 TTL 随机偏移、逻辑过期、缓存预热、互斥锁双重确认、线程池异步重建、降级/等待策略、重试+MQ 兜底、定时任务巡检、布隆过滤器等核心机制。
一、为什么需要 Redis 缓存层:先看清它在系统里的位置
在讲三大缓存问题之前,有必要先把 Redis 在系统里的位置和缓存的作用说清楚,因为后面所有的问题本质都源自"缓存层失效 → 请求穿透到数据库"这个核心矛盾。
1.1 直接访问数据库为什么扛不住
业务数据通常存储在 MySQL 这类关系型数据库里,而 MySQL 的数据最终是落在磁盘上的。磁盘的读写速度几乎是计算机硬件里最慢的一环——即便是 SSD,随机读写延迟也是在百微秒到毫秒级,而内存的读写延迟在纳秒级,两者差了好几个数量级。
更关键的是,MySQL 处理一次查询不仅要走磁盘 IO,还要经过解析、优化、执行器、存储引擎等一整套流程,单机能扛的并发连接数也是有限的(一般几百到几千 QPS 就要开始警惕了)。当用户的请求全部直接打到数据库上,请求数量一上来,数据库的连接池被打满、CPU 飙高、IO 阻塞,很快就会崩溃。数据库一崩,依赖它的所有业务都没法用了。
1.2 Redis 缓存层的位置和作用
为了避免用户请求直接访问数据库,会在业务服务和数据库之间加一层 Redis 作为缓存。因为 Redis 是内存数据库,我们把数据库里的常用数据同步一份到 Redis 里,相当于数据缓存在内存中。内存的读写速度比磁盘快好几个数量级,单机 Redis 能轻松扛住几万到十几万 QPS,远高于 MySQL。
加上 Redis 缓存层后,一次典型的请求流程是这样的:
flowchart LR
User[用户请求] --> Server[业务服务]
Server --> Redis{查询 Redis 缓存}
Redis -->|命中| ReturnCache[直接返回缓存数据]
Redis -->|未命中| DB[查询 MySQL 数据库]
DB --> WriteBack[回写到 Redis 缓存]
WriteBack --> ReturnDB[返回数据给用户]
ReturnCache --> End[结束]
ReturnDB --> End把这个流程拆开看:
- T1:用户请求到达业务服务。
- T2:业务服务先去 Redis 查缓存。
- T3:如果缓存命中,直接返回缓存数据,请求在内存层就被消化掉了,根本不会到数据库。
- T4:如果缓存未命中,再去查 MySQL 数据库,拿到数据后回写到 Redis(方便下次直接命中),最后返回给用户。
正常情况下,绝大多数请求都能在 T3 这一步命中缓存返回,数据库的压力会被 Redis 这一层极大地挡住。这就是 Redis 缓存层的核心价值:用内存的速度挡住磁盘的速度,用 Redis 的高并发挡住数据库的低并发。
1.3 缓存层失效会发生什么
但缓存层并不是万能的,它本身也会出问题。一旦 Redis 里的缓存大面积失效——不管是 TTL 到期、Redis 宕机,还是查询的根本是不存在的数据——请求就会绕过 Redis 直接打到数据库。这时候数据库承受的并发量会瞬间飙升,远超它能承受的上限,数据库崩溃,依赖它的所有业务也跟着崩溃,整个系统瘫痪。
这就是下面要讲的三类经典问题的共同根源。它们之间的区别在于触发条件不同:
- 缓存雪崩:大量缓存(不分热点非热点)同时失效,请求大面积穿透。
- 缓存击穿:单个热点数据的缓存失效,但请求量极大,集中打到数据库。
- 缓存穿透:查询的是根本不存在的数据,缓存和数据库都没有,每次都穿透。
下面我们逐一展开。
二、缓存雪崩:缓存大面积失效引发的连锁崩溃
2.1 什么是缓存雪崩
缓存雪崩这个名字其实挺形象的——缓存突然消失带来的雪崩效应。从某一个接口的宕机,到整个系统的全面崩溃,就像雪崩一样从局部扩散到整体。
具体来说,就是 Redis 缓存层在某一个时刻大面积失效,相当于缓存层"没有作用了",原本被 Redis 挡住的请求全部直接打到数据库。请求数量远超数据库可承受的最大并发量,数据库直接被冲垮。数据库一崩,依赖它的其他业务也没法用了,整个系统崩溃。
这个连锁反应可以用下图表示:
flowchart TD
A[大量缓存同时失效] --> B[请求全部绕过 Redis]
B --> C[请求集中打到 MySQL]
C --> D[MySQL 并发量远超上限]
D --> E[MySQL 崩溃]
E --> F[依赖 MySQL 的业务全部不可用]
F --> G[整个系统崩溃]2.2 缓存雪崩的两种成因
缓存雪崩的触发原因主要有两种,对应的解决方案也不同:
- 成因一:缓存同时过期。比如某次大批量缓存预热时给所有 key 设置了相同的 TTL,到了某个时刻这些 key 集体过期,缓存层瞬间空了一大块。
- 成因二:Redis 宕机。Redis 节点本身挂了(比如机器故障、内存打满被 OS kill、网络分区),整个缓存层在一段时间内完全不可用。
下面分别讲对应的解决方案。
2.3 成因一的解决方案:缓存同时过期
对于"缓存同时过期"这种成因,我们可以从两层去防护:预防层和响应层。
- 预防层:让缓存尽量不要在同一时刻大面积过期,从源头降低雪崩发生的概率。
- 响应层:即便预防层做得再好,缓存还是可能失效(数据量太大、TTL 设置不合理等),响应层负责在缓存失效的瞬间挡住请求,避免它们直接冲垮数据库。
2.3.1 预防层:三种策略
预防层的核心目标是避免大量缓存在同一时刻集体过期。一共有三种主流策略。
策略一:TTL 加随机偏移量
这是最简单也最常用的一种。问题根源是"大量 key 的 TTL 相同",那就在设置 TTL 的时候主动加上一个随机偏移量,把过期时间打散。
举个例子,原本 30 分钟过期的 key,我们给它添加一个 -5 到 +5 分钟的随机数,TTL 就变成了 25~35 分钟之间随机分布。这样短时间内大量缓存数据同时过期的概率就低很多了,从源头上实现了预防。
伪代码示意:
1 | // 原本所有 key 都设置 30 分钟过期,会导致同时过期 |
策略二:逻辑过期
由于缓存有 TTL,所以可能会出现大量缓存同时过期的情况。逻辑过期的思路是反过来——直接不设置 TTL(永不过期),然后在 value 里多存储一个 expireTime 字段,这个字段存储的是逻辑上的过期时间戳。每次访问到该缓存的时候,业务线程主动检查:如果当前时间戳大于缓存里存的 expireTime,就说明这条数据逻辑上已经过期了,需要重建缓存。
数据结构大致是这样:
1 | public class CacheData<T> { |
访问时的判断逻辑:
1 | public T get(String key) { |
这里有几个坑需要特别注意:
- **数据库更新时必须同步更新缓存的
expireTime和data**,并且一定要更新成功。因为逻辑过期没有物理 TTL 兜底,如果数据库更新了但缓存没更新,就会一直留存旧数据,永远不过期。这一点非常关键,后面响应层里会进一步展开讲。 - 逻辑过期不等于物理不过期,物理上 key 永远存在(除非显式删除),但业务层根据
expireTime字段判断是否需要重建。这意味着即使重建失败,旧数据依然能被读到,是一种降级可用的设计。
策略三:缓存预热
在某些大活动开始之前(比如双 11、大促、新品发布),把所有涉及到的数据使用脚本提前批量写入 Redis,确保活动开始时缓存是满的,不会出现"活动一开始缓存还是空的、请求全部打到数据库"这种情况。
1 | // 启动脚本:批量预热热点数据 |
2.3.2 三种预防策略的选型
这三种策略不是互斥的,而是应该根据数据特性合理选择:
| 策略 | 适用场景 | 不适用场景 |
|---|---|---|
| TTL 加随机偏移量 | 通用,几乎所有有 TTL 的缓存都可以加 | 几乎没有不适用,是最基础的兜底手段 |
| 逻辑过期 | 长期不会更改的数据,或者重建开销大的数据,比如商品详情页(不经常更新,重建缓存可能要查询多张表联表) | 经常更新的数据,比如排行榜、库存(数据频繁变化,逻辑过期会导致大量旧数据残留) |
| 缓存预热 | 大促活动前、新系统上线、缓存集群迁移后 | 日常增量数据(数据量太大无法全量预热) |
举个具体例子:商品详情页就很适合用逻辑过期——商品的基本信息(标题、主图、详情描述)不会频繁变化,重建一次要联表查商品基本信息表、SKU 表、属性表等多张表,开销大。如果给它设置物理 TTL,一旦过期重建的瞬间请求会打到数据库;而用逻辑过期,即使逻辑过期了也能返回旧数据,重建可以异步进行,用户基本无感。
但如果是排行榜这种数据,每分钟都在变,用逻辑过期就会导致用户看到的永远是旧数据,体验很差,这种情况就不能用逻辑过期,应该用 TTL 加随机偏移量 + 短 TTL 的方案。
2.4 响应层:缓存失效瞬间的请求处理
即使采取了上面的三种预防策略,我们的缓存还是可能会过期的。如果数据量非常庞大,即使使用了上面这三种策略,还是有可能会有大量缓存重建的请求冲向数据库,使得数据库宕机。所以在响应层我们还需要进行对应的处理。
2.4.1 互斥锁:避免重复重建
此时我们可以想象一下这个场景:如果两条线程同时访问同一个缓存,此时发现缓存已经逻辑过期了或者不存在了,两条线程都需要去重建缓存。如果这两条线程都去重建,显然是没必要的——因为最终更新的只是同一条缓存,重复重建就是浪费资源,而且会让多个请求同时打到数据库上。此时让一条线程去重建就好了,其他线程等待或者降级返回。
这就需要加互斥锁:重建之前先去抢锁,抢到锁的线程才能重建,没抢到的就等待或降级。这样就避免了重复的重建,直接打向数据库的请求数量也就少了。
互斥锁可以用 Redis 的 SET NX EX 实现:
1 | public T getWithMutex(String key) { |
2.4.2 双重确认:避免拿到锁后重复重建
这里有一个关键的细节:拿到锁之后应该再确认一次缓存是否已经重建成功。如果缓存是新的或者已经存在了,那就不用重建了。
为什么要这么做?考虑下面这个时序:
sequenceDiagram
participant TA as 线程 A
participant TB as 线程 B
participant Redis as Redis
participant DB as MySQL
Note over TA,TB: T0 A、B 同时发现缓存未命中
TA->>Redis: SET lock NX EX(抢锁)
Note over TA: T1 A 抢到锁
TB->>Redis: SET lock NX EX(抢锁失败)
Note over TB: T1 B 抢锁失败,进入等待/降级
Note over TA: T2 A 查 DB、回写缓存
TA->>DB: SELECT * FROM table
DB-->>TA: 返回数据
TA->>Redis: SET key value
TA->>Redis: DEL lock(释放锁)
Note over TA: T3 A 释放锁
Note over TB: T4 B 等待一段时间后重试,再次抢锁
TB->>Redis: SET lock NX EX(抢锁)
Note over TB: T4 此时 A 已释放锁,B 抢到锁
Note over TB: ⚠ 如果不做双重确认,B 会再次查 DB 重建缓存
Note over TB: ✅ 双重确认:B 再查一次缓存
TB->>Redis: GET key
Redis-->>TB: 返回数据(A 已写入)
Note over TB: T5 B 发现缓存已存在,直接返回,无需重建
TB->>Redis: DEL lock(释放锁)把这个时序图按时间轴拆开看就清楚了:
- T0:线程 A 和线程 B 几乎同时发现缓存未命中。
- T1:A 抢到锁,B 抢锁失败,进入等待或降级流程。
- T2~T3:A 查数据库、回写缓存、释放锁。
- T4:B 等待一段时间后重试,此时 A 已经释放锁,B 抢到了锁。
- T4~T5:如果不做双重确认,B 会再次查 DB 重建缓存,这是纯粹的资源浪费——A 刚刚已经重建好了。双重确认就是在拿到锁后再查一次缓存,如果缓存已经存在就直接返回,避免重复重建。
完整的互斥锁 + 双重确认流程图如下:
flowchart TD
Start[业务线程访问缓存] --> Get1[查询 Redis 缓存]
Get1 --> Hit{缓存命中?}
Hit -->|是| Return[返回缓存数据]
Hit -->|否| TryLock[尝试获取互斥锁SET key value NX EX]
TryLock --> LockResult{获取锁成功?}
LockResult -->|否| Degrade[降级处理:等待重试 或 返回旧数据/友好提示]
LockResult -->|是| DoubleCheck[双重确认:再次查询缓存]
DoubleCheck --> Check2{缓存已存在?}
Check2 -->|是| Release1[释放锁并返回数据]
Check2 -->|否| QueryDB[查询数据库]
QueryDB --> WriteBack[回写 Redis 缓存]
WriteBack --> Release2[释放锁]
Release2 --> ReturnDB[返回数据]
Degrade --> End[结束]
Return --> End
ReturnDB --> End
Release1 --> End2.4.3 线程池异步重建:减少线程开销
上面是基础的互斥锁方案,还可以进一步优化:重建的时候开启线程池进行异步重建,把重建任务交给一个专门的线程池来执行,而不是让业务线程自己同步重建。
这样做有两个好处:
- 减少线程开销:重建任务交给专门的线程池,线程池可以复用线程,避免为每次重建都创建新线程。
- 业务线程快速返回:原本的业务线程把重建任务丢给线程池后就可以立即返回,无需等待重建完成,能快速给前端返回信息(返回旧数据或友好提示)。
1 | // 专门的缓存重建线程池 |
2.4.4 降级 vs 等待:两种业务策略
异步重建之后,业务线程在重建期间要返回什么?根据业务的选择有两种策略:
策略一:降级处理
给前端返回旧数据(逻辑过期的情况)或者友好提示(缓存物理不存在的情况)。同时,没抢到锁的线程也是一样的处理。
- 适用场景:对数据实时性要求不高的业务,比如商品详情页、推荐列表、文章内容。延迟几秒看到新数据用户基本无感,但接口能快速响应,体验比卡住等待好得多。
- 代价:会有一段时间的数据不一致窗口(用户看到的是旧数据)。
策略二:等待缓存重建完毕
业务线程阻塞等待,直到缓存重建完成,把新缓存的数据返回去。
- 适用场景:对数据实时性要求高的业务,比如订单状态、支付结果、库存余额。这种场景下返回旧数据可能引发资损或客诉,宁愿让用户多等一会儿也要返回最新数据。
- 代价:用户等待时间变长,如果数据库压力大可能超时。
| 策略 | 业务线程行为 | 适用业务 | 代价 |
|---|---|---|---|
| 降级处理 | 立即返回旧数据或友好提示 | 商品详情页、推荐、文章等容忍旧数据的业务 | 短暂数据不一致 |
| 等待重建 | 阻塞等待新缓存返回 | 订单、支付、库存等强一致性业务 | 用户等待时间变长 |
需要注意的是,对于选择"等待"策略的业务,如果重建失败,应该快速告知处理失败,然后返回友好提示,而不是让用户一直等下去。下面就要讲重建失败时的兜底机制。
2.4.5 重试 + MQ:重建失败的兜底
重建过程中可能出现异常(数据库连接超时、网络抖动等),需要兜底机制。最简单的是重试次数:如果重建过程中出现异常就重试,一般是 3 次。这个通过代码就能实现:
1 | public T rebuildWithRetry(String key) { |
如果重试 3 次都失败,就交给 MQ 处理,MQ 再进行重试。而且 MQ 可以进行指数退避的重试,避免短时间内大量重试压垮数据库:
1 | // 重试 3 次都失败,投递到 MQ |
对于选择"等待"策略的业务,如果重试 3 次失败,应该快速告知处理失败,然后返回友好提示,避免用户长时间等待。
这里需要特别强调逻辑过期场景下重试的重要性。前面讲过,逻辑过期的缓存物理上永不过期,靠 expireTime 字段判断是否需要重建。这意味着如果重建失败,缓存会一直停留在"逻辑过期但物理存在"的状态,带来两个严重后果:
- 旧数据永久残留:逻辑过期没有物理 TTL 兜底,如果重建一直失败,缓存里的旧数据会永远被返回。如果数据库的数据已经更新(比如商品价格调整),用户看到的就一直是旧价格,数据不一致窗口被无限拉长,这对业务来说往往是不可接受的。
- 每个请求都触发重建流程:缓存处于逻辑过期状态后,后续每一个请求都会发现"逻辑过期",尝试抢锁、查 DB、重建。如果重建一直失败,每个请求都要走一遍这个流程,抢锁的开销、查 DB 的开销都会被放大,反而加重了系统负担,甚至可能把数据库拖垮。
所以对逻辑过期的业务来说,重试不是"可选项"而是"必选项"——它是消除数据不一致窗口、让缓存恢复健康的唯一手段。重试 3 次还失败就交给 MQ 兜底,是为了保证最终一定能重建成功,避免旧数据永久残留。相比之下,物理 TTL 的场景下即使重建失败,缓存 key 也会在 TTL 到期后被删除,下一个请求会重新走未命中流程,不会一直返回旧数据,所以重试的重要性没那么高。这也是为什么前面强调"逻辑过期适用于不经常更新的数据"——更新越频繁,重建失败的代价就越大。
2.4.6 进阶:定时任务巡检 + MQ 兜底
对于一些核心业务(比如电商的商品详情页、首页推荐),还能有进一步的处理:设置定时任务定期检测对应的热点数据是否过期,发现不存在就发消息给 MQ,快速进行缓存重建。
这套机制的核心思路是把缓存重建从"被动触发"变成"主动巡检",在用户感知到缓存失效之前就主动重建好。
flowchart LR
Schedule[定时任务: 每隔 N 秒巡检热点 key] --> Check[检查 Redis 中是否存在/逻辑过期]
Check --> Missing{缓存缺失或过期?}
Missing -->|否| Next[检查下一个 key]
Missing -->|是| Dedup{Redis 去重 key 存在?}
Dedup -->|是| Next
Dedup -->|否| SetKey[设置去重 keySET rebuild:key 1 EX 60]
SetKey --> SendMQ[发送重建消息到 MQ]
SendMQ --> Consume[MQ 消费者异步重建]
Consume --> RebuildOK[重建完成, 删除去重 key]
Next --> Loop[继续巡检]
RebuildOK --> Loop这里为了避免重复发送消息也可以借助 Redis 做去重:发送 MQ 消息之前先设置对应的 key,如果 key 存在就代表对应的消息已经传递过了,没必要再次重建;重建完成之后再删除这个 key 就好了。
1 | // 每 30 秒巡检一次 |
注意:这个去重 key 也要设置对应的过期时间作为兜底,避免删除操作出现问题导致 key 永远残留,后续重建消息永远发不出去。上面代码里 ex(60) 就是 60 秒后自动过期。
这套机制其实就很复杂了,一般来说没必要上。对于普通业务,第一种"重试 3 次"就够了,不用引入 MQ。但是对于某些重点业务——比如核心链路、保证不能塌的业务——MQ 的引入和定时任务巡检就很有必要了。
2.4.7 响应层策略选型:不同业务怎么选
响应层方案这么多,实际业务里应该怎么选?下面举几个具体的例子。
场景一:商品详情页
- 数据特性:更新不频繁,重建开销大(联表查询),对实时性要求中等。
- 推荐组合:逻辑过期 + 互斥锁 + 线程池异步重建 + 降级返回旧数据。
- 理由:用户看商品详情时,看到 1 分钟前的旧数据基本无感,但接口必须快。逻辑过期保证缓存物理上不过期永远有数据返回,异步重建让业务线程秒回,降级返回旧数据让用户无感知。
场景二:库存扣减
- 数据特性:更新极频繁,强一致性要求高。
- 推荐组合:短 TTL + 随机偏移量 + 互斥锁 + 等待重建。
- 理由:库存不能返回旧数据(会引发超卖),宁可让用户等几百毫秒也要拿到最新数据。不能用逻辑过期,否则会一直返回旧库存。
场景三:首页推荐位
- 数据特性:核心入口,QPS 极高,绝不能塌。
- 推荐组合:缓存预热 + 逻辑过期 + 互斥锁 + 异步重建 + 定时任务巡检 + MQ 兜底。
- 理由:首页是核心业务,一旦缓存击穿整个站都受影响,必须用最重的方案保证万无一失。定时任务主动巡检 + MQ 异步重建,能在用户感知到之前就把缓存重建好。
场景四:用户中心个人资料
- 数据特性:QPS 不高,更新频率低,对实时性要求一般。
- 推荐组合:TTL 加随机偏移量 + 互斥锁 + 重试 3 次。
- 理由:QPS 不高,即便缓存失效也不会瞬间冲垮数据库,用最简单的方案就够了,没必要上 MQ 和定时任务。
2.5 成因二的解决方案:Redis 宕机
前面讲的是"缓存同时过期"的解决方案,下面讲第二种成因——Redis 宕机。这种情况下不是单个 key 失效,而是整个 Redis 节点挂了,所有缓存都不可用。
2.5.1 高可用:主从 + 哨兵 / 集群
最根本的解决方案是保证 Redis 本身的高可用,避免 Redis 宕机。这就是前面几篇文章讲过的主从架构 + 哨兵机制,或者Cluster 集群。
- 主从 + 哨兵:主节点挂了,哨兵会自动把从节点提升为新的主节点,整个切换过程对业务透明。
- Cluster 集群:数据分片存储在多个节点上,每个分片有自己的主从,单个节点挂了只影响该分片,且从节点会顶上。
这套机制保证了 Redis 不会因为单点故障而整体不可用,是 Redis 高可用的最后一道防线。
2.5.2 服务熔断 / 请求限流
即使有高可用方案,主从切换的瞬间依然会有几秒到十几秒的不可用窗口,这段时间请求依然会打到数据库。而且如果是整个机房的网络问题,高可用也无能为力。这时候就需要服务熔断或请求限流。
服务熔断:Redis 宕机之后,直接拒绝访问跟 Redis 相关的所有接口,全部熔断,返回友好提示或进行其他的降级处理。这能保护数据库不被冲垮,但代价是整体业务不可用——对于某些要求高可用的业务来说是不可容忍的。
请求限流:相比熔断更温和。允许一定量的请求到达数据库(在数据库能承受的范围内),对于超出阈值的请求直接拒绝。这样既能保护数据库不被冲垮,又能保证一定比例的用户能正常使用,是更常用的方案。
1 | // 限流器示例:每秒只允许 100 个请求查数据库 |
| 策略 | 行为 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 服务熔断 | 拒绝所有相关接口请求 | 数据库零压力 | 业务完全不可用 | 非核心业务,宁可不可用也不能拖垮数据库 |
| 请求限流 | 允许限量请求通过,其余拒绝 | 数据库可控压力,部分用户可用 | 实现稍复杂,部分用户被拒 | 核心业务,保证部分用户可用 |
应当根据业务特性选择合适的方法:非核心业务可以熔断,核心业务用限流保证部分可用。
三、缓存击穿:热点数据缓存失效
3.1 什么是缓存击穿
缓存击穿其实和缓存雪崩差不多,都是缓存没了导致请求打到数据库。区别在于:
- 缓存雪崩:大量缓存(不分热点非热点)同时失效。
- 缓存击穿:单个热点数据的缓存失效,但由于这个数据是热点,会有大量的请求集中打过来,导致数据库瞬时压力过大而宕机。
可以理解为:缓存雪崩是"大面积失效",缓存击穿是"单点失效但请求量极大"。
3.2 解决方案
缓存击穿的解决方案和缓存雪崩成因一的响应层方案高度重合,本质都是"缓存失效瞬间避免请求集中打到数据库"。
3.2.1 互斥锁方案
和前面讲的一样:保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待释放后重新读取缓存,要么就返回空值或者默认值。
1 | public T getHotData(String key) { |
3.2.2 永不过期 + 后台异步更新
针对热点数据,可以不给它设置过期时间(物理不过期),由后台异步线程负责更新缓存。或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间。
这其实就是前面讲的"逻辑过期"方案的延伸——热点数据用逻辑过期 + 异步重建,业务线程永远能读到数据(哪怕是旧的),重建在后台异步进行,用户无感知。
两种方案的取舍:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 互斥锁 | 保证数据一致性 | 等待锁期间响应变慢 | 强一致性要求的热点数据 |
| 永不过期 + 异步更新 | 响应永远快 | 短暂数据不一致 | 容忍旧数据的热点数据 |
需要说明的是,缓存击穿这两种方案的本质都是正常的访问——数据库里面还是有正常的数据的,只要缓存层重建成功,就可以解决了。但下面要讲的缓存穿透就不一样了,它涉及的是不存在的数据,重建缓存也救不了。
四、缓存穿透:查询根本不存在的数据
4.1 什么是缓存穿透
缓存穿透是指访问了不存在的数据——即不在缓存中,也不存在数据库中。这种访问是一定会打到数据库的:缓存未命中 → 查数据库 → 数据库也没有 → 返回空。每一次这样的请求都会完整走一遍"缓存 → 数据库"的链路,一旦多了,也会导致数据库崩溃。
和缓存击穿、缓存雪崩的根本区别在于:
- 缓存雪崩 / 击穿:数据库里有数据,只是缓存没了,重建缓存就能解决。
- 缓存穿透:数据库里没有数据,重建缓存也没用,每次查询都会穿透。
导致缓存穿透的情形一般有两种:
- 业务操作错误:把数据库里面的数据删了,但前端入口还在,导致一直查不到数据。比如商品下架了但详情页链接还在传播,用户点进来查询不到。
- 黑客恶意攻击:攻击者构造大量不存在的 key(比如随机 ID),疯狂打接口,每个请求都穿透到数据库,意图拖垮数据库。
4.2 解决方案
缓存穿透一般有三种解决策略:
- 非法请求的限制:在请求进入系统前就拦截掉明显的非法请求。
- 缓存空值或默认值:把"查不到"这个结果也缓存起来。
- 布隆过滤器:在缓存未命中后、查数据库前,先用布隆过滤器快速判断数据是否存在。
4.2.1 策略一:非法请求的限制
这种策略的思路是在请求进入系统前就拦掉非法请求,分为两层:
网关层:限流 / 熔断
在 API 网关层对异常高频的请求 IP 或用户进行限流,防止恶意刷接口。比如同一个 IP 每秒查询同一个接口超过 100 次,直接限流。
服务层:参数合法性校验
在业务服务层对请求参数做合法性校验,比如:
- ID 长度是否符合数据库存储规则(比如商品 ID 应该是 18 位数字,传 100 位的明显是非法)。
- ID 范围是否合理(比如自增 ID 不应该是负数)。
- 必填字段是否齐全。
- 数据格式是否正确(邮箱、手机号格式等)。
1 | public Product getProduct(String id) { |
这种策略能拦掉一部分明显的非法请求,但对于"格式合法但实际不存在"的请求(比如一个合法格式但不存在的商品 ID)就无能为力了,需要配合下面的策略。
4.2.2 策略二:缓存空值或默认值
这个策略的思路很简单:查询数据库发现为空后,在 Redis 里缓存对应的空值或者默认值进去,设置一个合理的 TTL,下次还访问同样的 key 时直接返回空值就好了,打不到数据库。
1 | public Product getProduct(String id) { |
优点:实现简单,能解决"误删导致的穿透"问题。
缺点:
- 对恶意攻击效果有限:如果攻击者使用大量随机不存在的 key 进行攻击,每个 key 都会在 Redis 里缓存一个空值,Redis 内存会被大量空值占满,造成缓存资源浪费。
- 存在短暂的数据不一致窗口:数据新增后,Redis 里的空值缓存还没过期,这段时间内查询依然会返回 null,需要等 TTL 到期或主动删除空值缓存才能查到新数据。
为了缓解第一个问题,可以给空值缓存设置较短的 TTL(比如 5 分钟),让空值缓存尽快过期。为了缓解第二个问题,可以在数据新增时主动删除对应的空值缓存:
1 | public void addProduct(Product product) { |
4.2.3 策略三:布隆过滤器
布隆过滤器是解决缓存穿透最优雅的方案。它的思路是:在写入数据库数据时,使用布隆过滤器做个标记;然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。
请求流程变成这样:
flowchart TD
Req[用户请求] --> Cache[查询 Redis 缓存]
Cache --> Hit{缓存命中?}
Hit -->|是| Return[返回数据]
Hit -->|否| Bloom[查询布隆过滤器]
Bloom --> Exist{布隆过滤器判断存在?}
Exist -->|不存在| ReturnNull[直接返回 null无需查数据库]
Exist -->|可能存在| DB[查询 MySQL]
DB --> Found{数据库有数据?}
Found -->|有| WriteBack[回写 Redis 缓存]
WriteBack --> ReturnData[返回数据]
Found -->|无| ReturnNull
Return --> End[结束]
ReturnNull --> End
ReturnData --> End注意布隆过滤器的位置:在缓存未命中之后、查数据库之前。它是一层"前置过滤",把"肯定不存在的请求"直接拦掉,只有"可能存在的请求"才放行到数据库。
4.3 布隆过滤器的工作机制
布隆过滤器是怎么做到"快速判断数据是否存在"的?它由两部分组成:
- 一个全为 0 的位图(bitmap):一段连续的二进制位,初始全为 0。
- N 个哈希函数:每个哈希函数都能把输入数据映射到位图的某个位置。
4.3.1 写入流程
写入一个数据时,这个数据会与每一个哈希函数做运算,然后对位图长度求余,得到对应的在位图中的位置,然后把对应位置标记成 1。
举个例子:假设位图长度为 8,有 3 个哈希函数。写入数据 id=100001:
- 哈希函数 1 计算
100001,对 8 求余,得到位置 1,把位图位置 1 标记成 1。 - 哈希函数 2 计算
100001,对 8 求余,得到位置 4,把位图位置 4 标记成 1。 - 哈希函数 3 计算
100001,对 8 求余,得到位置 6,把位图位置 6 标记成 1。
写入后的位图:
| 位图位置 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 位图状态 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 |
| 标记来源 | - | h1(100001)%8 | - | - | h2(100001)%8 | - | h3(100001)%8 | - |
可以看到位置 1、4、6 被标记成了 1,其余位置仍然是 0。
4.3.2 查询流程
下次数据进来,在缓存中查找发现不存在之后,再让这个数据与布隆过滤器的 N 个哈希函数做运算,得到对应的位图位置,判断这些位置是不是都是 1:
- 如果都是 1:说明数据可能存在,需要进行数据库查询(因为可能是哈希冲突导致的误判)。
- 如果至少有一个位置为 0:说明数据一定不存在,直接返回 null,无需查数据库。
4.3.3 关键特性:不存在一定不存在,存在不一定存在
布隆过滤器有一个非常重要的特性:**"不存在"的判断一定是准确的,"存在"的判断可能不准确**。
为什么?看下面这个例子。假设有两条数据 X 和 Y:
- 数据 X 标记位置:1, 4, 6
- 数据 Y 标记位置:2, 3, 5
写入 X 和 Y 之后的位图(同一位置可能被多条数据共同标记,但状态只能是 0 或 1):
| 位图位置 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 位图状态 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 0 |
| 标记来源 | - | X | Y | Y | X | Y | X | - |
现在查询数据 Z,假设 Z 经过 3 个哈希函数计算得到位置 1, 3, 5。把 Z 的查询位置和位图状态对应起来看:
| Z 的查询位置 | 1 | 3 | 5 |
|---|---|---|---|
| 位图状态 | 1 | 1 | 1 |
| 实际标记者 | X | Y | Y |
三处全是 1,布隆过滤器判断"Z 可能存在"。但实际上 Z 根本没有被写入过——位置 1 是 X 标记的,位置 3 是 Y 标记的,位置 5 也是 Y 标记的,是 X 和 Y 的标记"恰好凑出"了 Z 的查询位置。这就是哈希冲突导致的误判。
反过来,如果布隆过滤器判断"Z 不存在",那一定不存在。因为只要有一个位置为 0,就说明没有任何一个写入过的数据能把这个位置标记成 1,所以 Z 一定没被写入过。
用一句话总结:
布隆过滤器说"不存在"一定不存在;说"存在"可能不存在(哈希冲突)。
这个特性对缓存穿透来说刚好够用:误判(把不存在的判断成存在)最多让请求多查一次数据库,不会造成灾难;而正确判断(把不存在的判断成不存在)则直接挡住了请求,保护了数据库。
4.4 三种策略对比与选型
| 策略 | 实现复杂度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 非法请求限制 | 低 | 拦掉明显非法请求,从源头减负 | 拦不住格式合法的不存在请求 | 所有业务都应做的基础防护 |
| 缓存空值/默认值 | 低 | 实现简单,对误删场景效果好 | 内存浪费,攻击下失效,存在不一致窗口 | 误删导致的穿透,数据量小的场景 |
| 布隆过滤器 | 中 | 内存占用极小,能挡住绝大多数不存在请求 | 有误判率,删除困难(一般不支持删除) | 数据量大、攻击风险高的核心场景 |
实际生产中通常是组合使用:网关层限流 + 服务层参数校验作为基础防护,核心接口叠加布隆过滤器,针对偶尔的误删用空值缓存兜底。
五、三大问题对比总结
最后用一张表把三大缓存问题的核心区别总结清楚:
| 维度 | 缓存雪崩 | 缓存击穿 | 缓存穿透 |
|---|---|---|---|
| 本质 | 大量缓存同时失效 | 单个热点缓存失效 | 查询不存在的数据 |
| 触发条件 | TTL 同时到期 / Redis 宕机 | 热点 key 过期 | 数据被删 / 恶意攻击 |
| 数据库是否有数据 | 有 | 有 | 没有 |
| 影响范围 | 大面积接口 | 单个热点接口 | 单个接口(但可能拖垮 DB) |
| 核心解决思路 | 预防(打散 TTL)+ 响应(互斥锁) | 互斥锁 / 永不过期 + 异步更新 | 拦非法请求 + 缓存空值 + 布隆过滤器 |
| 重建缓存能否解决 | 能 | 能 | 不能(数据不存在) |
理解这三大问题的核心在于抓住一个主线:缓存层的作用是挡住数据库,任何让缓存层失效、请求穿透到数据库的场景都是潜在风险。雪崩是大面积穿透,击穿是单点高并发穿透,穿透是永远穿透。解决方案的设计思路也是统一的:要么不让缓存失效(预防),要么失效了别让请求集中打到数据库(互斥锁、限流),要么提前判断请求是否合理(参数校验、布隆过滤器)。
实际生产中没有银弹,应当根据业务特性(数据更新频率、QPS 大小、一致性要求、是否核心链路)选择合适的组合方案,并在"实现复杂度"和"系统可用性"之间取得平衡。





