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 缓存层后,一次典型的请求流程是这样的:
#mermaid-svg-pNLpxmSFyIiemiiN{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pNLpxmSFyIiemiiN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pNLpxmSFyIiemiiN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pNLpxmSFyIiemiiN .error-icon{fill:#552222;}#mermaid-svg-pNLpxmSFyIiemiiN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pNLpxmSFyIiemiiN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pNLpxmSFyIiemiiN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pNLpxmSFyIiemiiN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pNLpxmSFyIiemiiN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pNLpxmSFyIiemiiN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pNLpxmSFyIiemiiN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pNLpxmSFyIiemiiN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pNLpxmSFyIiemiiN .marker.cross{stroke:#333333;}#mermaid-svg-pNLpxmSFyIiemiiN svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pNLpxmSFyIiemiiN p{margin:0;}#mermaid-svg-pNLpxmSFyIiemiiN .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-pNLpxmSFyIiemiiN .cluster-label text{fill:#333;}#mermaid-svg-pNLpxmSFyIiemiiN .cluster-label span{color:#333;}#mermaid-svg-pNLpxmSFyIiemiiN .cluster-label span p{background-color:transparent;}#mermaid-svg-pNLpxmSFyIiemiiN .label text,#mermaid-svg-pNLpxmSFyIiemiiN span{fill:#333;color:#333;}#mermaid-svg-pNLpxmSFyIiemiiN .node rect,#mermaid-svg-pNLpxmSFyIiemiiN .node circle,#mermaid-svg-pNLpxmSFyIiemiiN .node ellipse,#mermaid-svg-pNLpxmSFyIiemiiN .node polygon,#mermaid-svg-pNLpxmSFyIiemiiN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pNLpxmSFyIiemiiN .rough-node .label text,#mermaid-svg-pNLpxmSFyIiemiiN .node .label text,#mermaid-svg-pNLpxmSFyIiemiiN .image-shape .label,#mermaid-svg-pNLpxmSFyIiemiiN .icon-shape .label{text-anchor:middle;}#mermaid-svg-pNLpxmSFyIiemiiN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pNLpxmSFyIiemiiN .rough-node .label,#mermaid-svg-pNLpxmSFyIiemiiN .node .label,#mermaid-svg-pNLpxmSFyIiemiiN .image-shape .label,#mermaid-svg-pNLpxmSFyIiemiiN .icon-shape .label{text-align:center;}#mermaid-svg-pNLpxmSFyIiemiiN .node.clickable{cursor:pointer;}#mermaid-svg-pNLpxmSFyIiemiiN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pNLpxmSFyIiemiiN .arrowheadPath{fill:#333333;}#mermaid-svg-pNLpxmSFyIiemiiN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pNLpxmSFyIiemiiN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pNLpxmSFyIiemiiN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pNLpxmSFyIiemiiN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pNLpxmSFyIiemiiN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pNLpxmSFyIiemiiN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pNLpxmSFyIiemiiN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pNLpxmSFyIiemiiN .cluster text{fill:#333;}#mermaid-svg-pNLpxmSFyIiemiiN .cluster span{color:#333;}#mermaid-svg-pNLpxmSFyIiemiiN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pNLpxmSFyIiemiiN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pNLpxmSFyIiemiiN rect.text{fill:none;stroke-width:0;}#mermaid-svg-pNLpxmSFyIiemiiN .icon-shape,#mermaid-svg-pNLpxmSFyIiemiiN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pNLpxmSFyIiemiiN .icon-shape p,#mermaid-svg-pNLpxmSFyIiemiiN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pNLpxmSFyIiemiiN .icon-shape .label rect,#mermaid-svg-pNLpxmSFyIiemiiN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pNLpxmSFyIiemiiN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pNLpxmSFyIiemiiN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pNLpxmSFyIiemiiN :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
命中
未命中
用户请求
业务服务
查询 Redis 缓存
直接返回缓存数据
查询 MySQL 数据库
回写到 Redis 缓存
返回数据给用户
结束
把这个流程拆开看:
- T1:用户请求到达业务服务。
- T2:业务服务先去 Redis 查缓存。
- T3:如果缓存命中,直接返回缓存数据,请求在内存层就被消化掉了,根本不会到数据库。
- T4:如果缓存未命中,再去查 MySQL 数据库,拿到数据后回写到 Redis(方便下次直接命中),最后返回给用户。
正常情况下,绝大多数请求都能在 T3 这一步命中缓存返回,数据库的压力会被 Redis 这一层极大地挡住。这就是 Redis 缓存层的核心价值:用内存的速度挡住磁盘的速度,用 Redis 的高并发挡住数据库的低并发。
1.3 缓存层失效会发生什么
但缓存层并不是万能的,它本身也会出问题。一旦 Redis 里的缓存大面积失效——不管是 TTL 到期、Redis 宕机,还是查询的根本是不存在的数据——请求就会绕过 Redis 直接打到数据库。这时候数据库承受的并发量会瞬间飙升,远超它能承受的上限,数据库崩溃,依赖它的所有业务也跟着崩溃,整个系统瘫痪。
这就是下面要讲的三类经典问题的共同根源。它们之间的区别在于触发条件不同:
- 缓存雪崩:大量缓存(不分热点非热点)同时失效,请求大面积穿透。
- 缓存击穿:单个热点数据的缓存失效,但请求量极大,集中打到数据库。
- 缓存穿透:查询的是根本不存在的数据,缓存和数据库都没有,每次都穿透。
下面我们逐一展开。
二、缓存雪崩:缓存大面积失效引发的连锁崩溃
2.1 什么是缓存雪崩
缓存雪崩这个名字其实挺形象的——缓存突然消失带来的雪崩效应。从某一个接口的宕机,到整个系统的全面崩溃,就像雪崩一样从局部扩散到整体。
具体来说,就是 Redis 缓存层在某一个时刻大面积失效,相当于缓存层"没有作用了",原本被 Redis 挡住的请求全部直接打到数据库。请求数量远超数据库可承受的最大并发量,数据库直接被冲垮。数据库一崩,依赖它的其他业务也没法用了,整个系统崩溃。
这个连锁反应可以用下图表示:
#mermaid-svg-gBVchcxAjcScsSWp{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gBVchcxAjcScsSWp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gBVchcxAjcScsSWp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gBVchcxAjcScsSWp .error-icon{fill:#552222;}#mermaid-svg-gBVchcxAjcScsSWp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gBVchcxAjcScsSWp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gBVchcxAjcScsSWp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gBVchcxAjcScsSWp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gBVchcxAjcScsSWp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gBVchcxAjcScsSWp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gBVchcxAjcScsSWp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gBVchcxAjcScsSWp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gBVchcxAjcScsSWp .marker.cross{stroke:#333333;}#mermaid-svg-gBVchcxAjcScsSWp svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gBVchcxAjcScsSWp p{margin:0;}#mermaid-svg-gBVchcxAjcScsSWp .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-gBVchcxAjcScsSWp .cluster-label text{fill:#333;}#mermaid-svg-gBVchcxAjcScsSWp .cluster-label span{color:#333;}#mermaid-svg-gBVchcxAjcScsSWp .cluster-label span p{background-color:transparent;}#mermaid-svg-gBVchcxAjcScsSWp .label text,#mermaid-svg-gBVchcxAjcScsSWp span{fill:#333;color:#333;}#mermaid-svg-gBVchcxAjcScsSWp .node rect,#mermaid-svg-gBVchcxAjcScsSWp .node circle,#mermaid-svg-gBVchcxAjcScsSWp .node ellipse,#mermaid-svg-gBVchcxAjcScsSWp .node polygon,#mermaid-svg-gBVchcxAjcScsSWp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-gBVchcxAjcScsSWp .rough-node .label text,#mermaid-svg-gBVchcxAjcScsSWp .node .label text,#mermaid-svg-gBVchcxAjcScsSWp .image-shape .label,#mermaid-svg-gBVchcxAjcScsSWp .icon-shape .label{text-anchor:middle;}#mermaid-svg-gBVchcxAjcScsSWp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-gBVchcxAjcScsSWp .rough-node .label,#mermaid-svg-gBVchcxAjcScsSWp .node .label,#mermaid-svg-gBVchcxAjcScsSWp .image-shape .label,#mermaid-svg-gBVchcxAjcScsSWp .icon-shape .label{text-align:center;}#mermaid-svg-gBVchcxAjcScsSWp .node.clickable{cursor:pointer;}#mermaid-svg-gBVchcxAjcScsSWp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-gBVchcxAjcScsSWp .arrowheadPath{fill:#333333;}#mermaid-svg-gBVchcxAjcScsSWp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-gBVchcxAjcScsSWp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-gBVchcxAjcScsSWp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gBVchcxAjcScsSWp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-gBVchcxAjcScsSWp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gBVchcxAjcScsSWp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-gBVchcxAjcScsSWp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-gBVchcxAjcScsSWp .cluster text{fill:#333;}#mermaid-svg-gBVchcxAjcScsSWp .cluster span{color:#333;}#mermaid-svg-gBVchcxAjcScsSWp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-gBVchcxAjcScsSWp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-gBVchcxAjcScsSWp rect.text{fill:none;stroke-width:0;}#mermaid-svg-gBVchcxAjcScsSWp .icon-shape,#mermaid-svg-gBVchcxAjcScsSWp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gBVchcxAjcScsSWp .icon-shape p,#mermaid-svg-gBVchcxAjcScsSWp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-gBVchcxAjcScsSWp .icon-shape .label rect,#mermaid-svg-gBVchcxAjcScsSWp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gBVchcxAjcScsSWp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-gBVchcxAjcScsSWp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-gBVchcxAjcScsSWp :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
大量缓存同时失效
请求全部绕过 Redis
请求集中打到 MySQL
MySQL 并发量远超上限
MySQL 崩溃
依赖 MySQL 的业务全部不可用
整个系统崩溃
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 分钟之间随机分布。这样短时间内大量缓存数据同时过期的概率就低很多了,从源头上实现了预防。
伪代码示意:
// 原本所有 key 都设置 30 分钟过期,会导致同时过期
redis.set(key, value, 30, TimeUnit.MINUTES);
// 加随机偏移量:30 分钟 ± 5 分钟
int baseTTL = 30;
int randomOffset = ThreadLocalRandom.current().nextInt(–5, 6); // -5 到 +5
long finalTTL = baseTTL + randomOffset; // 25 ~ 35 分钟
redis.set(key, value, finalTTL, TimeUnit.MINUTES);
策略二:逻辑过期
由于缓存有 TTL,所以可能会出现大量缓存同时过期的情况。逻辑过期的思路是反过来——直接不设置 TTL(永不过期),然后在 value 里多存储一个 expireTime 字段,这个字段存储的是逻辑上的过期时间戳。每次访问到该缓存的时候,业务线程主动检查:如果当前时间戳大于缓存里存的 expireTime,就说明这条数据逻辑上已经过期了,需要重建缓存。
数据结构大致是这样:
public class CacheData<T> {
private T data; // 真实的业务数据
private long expireTime; // 逻辑过期时间戳(毫秒)
}
访问时的判断逻辑:
public T get(String key) {
CacheData<T> cache = redis.get(key);
if (cache == null) {
// 物理上不存在(缓存被淘汰或未预热),按缓存未命中处理
return null;
}
// 物理存在,但逻辑上可能过期
if (System.currentTimeMillis() > cache.getExpireTime()) {
// 逻辑过期,需要重建缓存
return rebuildCache(key);
}
return cache.getData();
}
这里有几个坑需要特别注意:
策略三:缓存预热
在某些大活动开始之前(比如双 11、大促、新品发布),把所有涉及到的数据使用脚本提前批量写入 Redis,确保活动开始时缓存是满的,不会出现"活动一开始缓存还是空的、请求全部打到数据库"这种情况。
// 启动脚本:批量预热热点数据
public void warmupCache() {
List<Product> hotProducts = productMapper.findHotProducts();
for (Product p : hotProducts) {
String key = "product:" + p.getId();
// 加随机偏移量,避免同时过期
long ttl = 30 + ThreadLocalRandom.current().nextInt(–5, 6);
redis.set(key, JSON.toJSONString(p), ttl, TimeUnit.MINUTES);
}
}
2.3.2 三种预防策略的选型
这三种策略不是互斥的,而是应该根据数据特性合理选择:
| TTL 加随机偏移量 | 通用,几乎所有有 TTL 的缓存都可以加 | 几乎没有不适用,是最基础的兜底手段 |
| 逻辑过期 | 长期不会更改的数据,或者重建开销大的数据,比如商品详情页(不经常更新,重建缓存可能要查询多张表联表) | 经常更新的数据,比如排行榜、库存(数据频繁变化,逻辑过期会导致大量旧数据残留) |
| 缓存预热 | 大促活动前、新系统上线、缓存集群迁移后 | 日常增量数据(数据量太大无法全量预热) |
举个具体例子:商品详情页就很适合用逻辑过期——商品的基本信息(标题、主图、详情描述)不会频繁变化,重建一次要联表查商品基本信息表、SKU 表、属性表等多张表,开销大。如果给它设置物理 TTL,一旦过期重建的瞬间请求会打到数据库;而用逻辑过期,即使逻辑过期了也能返回旧数据,重建可以异步进行,用户基本无感。
但如果是排行榜这种数据,每分钟都在变,用逻辑过期就会导致用户看到的永远是旧数据,体验很差,这种情况就不能用逻辑过期,应该用 TTL 加随机偏移量 + 短 TTL 的方案。
2.4 响应层:缓存失效瞬间的请求处理
即使采取了上面的三种预防策略,我们的缓存还是可能会过期的。如果数据量非常庞大,即使使用了上面这三种策略,还是有可能会有大量缓存重建的请求冲向数据库,使得数据库宕机。所以在响应层我们还需要进行对应的处理。
2.4.1 互斥锁:避免重复重建
此时我们可以想象一下这个场景:如果两条线程同时访问同一个缓存,此时发现缓存已经逻辑过期了或者不存在了,两条线程都需要去重建缓存。如果这两条线程都去重建,显然是没必要的——因为最终更新的只是同一条缓存,重复重建就是浪费资源,而且会让多个请求同时打到数据库上。此时让一条线程去重建就好了,其他线程等待或者降级返回。
这就需要加互斥锁:重建之前先去抢锁,抢到锁的线程才能重建,没抢到的就等待或降级。这样就避免了重复的重建,直接打向数据库的请求数量也就少了。
互斥锁可以用 Redis 的 SET NX EX 实现:
public T getWithMutex(String key) {
T data = redis.get(key);
if (data != null) {
return data;
}
// 缓存未命中,尝试抢锁
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
boolean locked = redis.set(lockKey, requestId, 10, TimeUnit.SECONDS, SetParams.nx());
if (locked) {
try {
// 双重确认:拿到锁之后再查一次缓存
data = redis.get(key);
if (data != null) {
return data; // 别的线程已经重建好了,无需重复重建
}
// 查询数据库并回写缓存
data = db.query(key);
redis.set(key, data, 30, TimeUnit.MINUTES);
return data;
} finally {
// 释放锁(用 Lua 脚本保证原子性)
releaseLock(lockKey, requestId);
}
} else {
// 没抢到锁,降级处理
return degrade(key);
}
}
2.4.2 双重确认:避免拿到锁后重复重建
这里有一个关键的细节:拿到锁之后应该再确认一次缓存是否已经重建成功。如果缓存是新的或者已经存在了,那就不用重建了。
为什么要这么做?考虑下面这个时序:
MySQLRedis线程 B线程 AMySQLRedis线程 B线程 A#mermaid-svg-R8n1FnBGAHCurOKK{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-R8n1FnBGAHCurOKK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-R8n1FnBGAHCurOKK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-R8n1FnBGAHCurOKK .error-icon{fill:#552222;}#mermaid-svg-R8n1FnBGAHCurOKK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-R8n1FnBGAHCurOKK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-R8n1FnBGAHCurOKK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-R8n1FnBGAHCurOKK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-R8n1FnBGAHCurOKK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-R8n1FnBGAHCurOKK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-R8n1FnBGAHCurOKK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-R8n1FnBGAHCurOKK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-R8n1FnBGAHCurOKK .marker.cross{stroke:#333333;}#mermaid-svg-R8n1FnBGAHCurOKK svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-R8n1FnBGAHCurOKK p{margin:0;}#mermaid-svg-R8n1FnBGAHCurOKK .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-R8n1FnBGAHCurOKK text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-R8n1FnBGAHCurOKK .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-R8n1FnBGAHCurOKK .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-R8n1FnBGAHCurOKK .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-R8n1FnBGAHCurOKK .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-R8n1FnBGAHCurOKK #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-R8n1FnBGAHCurOKK .sequenceNumber{fill:white;}#mermaid-svg-R8n1FnBGAHCurOKK #sequencenumber{fill:#333;}#mermaid-svg-R8n1FnBGAHCurOKK #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-R8n1FnBGAHCurOKK .messageText{fill:#333;stroke:none;}#mermaid-svg-R8n1FnBGAHCurOKK .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-R8n1FnBGAHCurOKK .labelText,#mermaid-svg-R8n1FnBGAHCurOKK .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-R8n1FnBGAHCurOKK .loopText,#mermaid-svg-R8n1FnBGAHCurOKK .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-R8n1FnBGAHCurOKK .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-R8n1FnBGAHCurOKK .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-R8n1FnBGAHCurOKK .noteText,#mermaid-svg-R8n1FnBGAHCurOKK .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-R8n1FnBGAHCurOKK .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-R8n1FnBGAHCurOKK .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-R8n1FnBGAHCurOKK .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-R8n1FnBGAHCurOKK .actorPopupMenu{position:absolute;}#mermaid-svg-R8n1FnBGAHCurOKK .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-R8n1FnBGAHCurOKK .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-R8n1FnBGAHCurOKK .actor-man circle,#mermaid-svg-R8n1FnBGAHCurOKK line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-R8n1FnBGAHCurOKK :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}T0 A、B 同时发现缓存未命中T1 A 抢到锁T1 B 抢锁失败,进入等待/降级T2 A 查 DB、回写缓存T3 A 释放锁T4 B 等待一段时间后重试,再次抢锁T4 此时 A 已释放锁,B 抢到锁⚠ 如果不做双重确认,B 会再次查 DB 重建缓存✅ 双重确认:B 再查一次缓存T5 B 发现缓存已存在,直接返回,无需重建SET lock NX EX(抢锁)SET lock NX EX(抢锁失败)SELECT * FROM table返回数据SET key valueDEL lock(释放锁)SET lock NX EX(抢锁)GET key返回数据(A 已写入)DEL lock(释放锁)
把这个时序图按时间轴拆开看就清楚了:
- T0:线程 A 和线程 B 几乎同时发现缓存未命中。
- T1:A 抢到锁,B 抢锁失败,进入等待或降级流程。
- T2~T3:A 查数据库、回写缓存、释放锁。
- T4:B 等待一段时间后重试,此时 A 已经释放锁,B 抢到了锁。
- T4~T5:如果不做双重确认,B 会再次查 DB 重建缓存,这是纯粹的资源浪费——A 刚刚已经重建好了。双重确认就是在拿到锁后再查一次缓存,如果缓存已经存在就直接返回,避免重复重建。
完整的互斥锁 + 双重确认流程图如下:
#mermaid-svg-mkBXwHd4YxNfRrfz{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-mkBXwHd4YxNfRrfz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mkBXwHd4YxNfRrfz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mkBXwHd4YxNfRrfz .error-icon{fill:#552222;}#mermaid-svg-mkBXwHd4YxNfRrfz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mkBXwHd4YxNfRrfz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mkBXwHd4YxNfRrfz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mkBXwHd4YxNfRrfz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mkBXwHd4YxNfRrfz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mkBXwHd4YxNfRrfz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mkBXwHd4YxNfRrfz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mkBXwHd4YxNfRrfz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mkBXwHd4YxNfRrfz .marker.cross{stroke:#333333;}#mermaid-svg-mkBXwHd4YxNfRrfz svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mkBXwHd4YxNfRrfz p{margin:0;}#mermaid-svg-mkBXwHd4YxNfRrfz .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-mkBXwHd4YxNfRrfz .cluster-label text{fill:#333;}#mermaid-svg-mkBXwHd4YxNfRrfz .cluster-label span{color:#333;}#mermaid-svg-mkBXwHd4YxNfRrfz .cluster-label span p{background-color:transparent;}#mermaid-svg-mkBXwHd4YxNfRrfz .label text,#mermaid-svg-mkBXwHd4YxNfRrfz span{fill:#333;color:#333;}#mermaid-svg-mkBXwHd4YxNfRrfz .node rect,#mermaid-svg-mkBXwHd4YxNfRrfz .node circle,#mermaid-svg-mkBXwHd4YxNfRrfz .node ellipse,#mermaid-svg-mkBXwHd4YxNfRrfz .node polygon,#mermaid-svg-mkBXwHd4YxNfRrfz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-mkBXwHd4YxNfRrfz .rough-node .label text,#mermaid-svg-mkBXwHd4YxNfRrfz .node .label text,#mermaid-svg-mkBXwHd4YxNfRrfz .image-shape .label,#mermaid-svg-mkBXwHd4YxNfRrfz .icon-shape .label{text-anchor:middle;}#mermaid-svg-mkBXwHd4YxNfRrfz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-mkBXwHd4YxNfRrfz .rough-node .label,#mermaid-svg-mkBXwHd4YxNfRrfz .node .label,#mermaid-svg-mkBXwHd4YxNfRrfz .image-shape .label,#mermaid-svg-mkBXwHd4YxNfRrfz .icon-shape .label{text-align:center;}#mermaid-svg-mkBXwHd4YxNfRrfz .node.clickable{cursor:pointer;}#mermaid-svg-mkBXwHd4YxNfRrfz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-mkBXwHd4YxNfRrfz .arrowheadPath{fill:#333333;}#mermaid-svg-mkBXwHd4YxNfRrfz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-mkBXwHd4YxNfRrfz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-mkBXwHd4YxNfRrfz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mkBXwHd4YxNfRrfz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-mkBXwHd4YxNfRrfz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mkBXwHd4YxNfRrfz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-mkBXwHd4YxNfRrfz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-mkBXwHd4YxNfRrfz .cluster text{fill:#333;}#mermaid-svg-mkBXwHd4YxNfRrfz .cluster span{color:#333;}#mermaid-svg-mkBXwHd4YxNfRrfz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-mkBXwHd4YxNfRrfz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-mkBXwHd4YxNfRrfz rect.text{fill:none;stroke-width:0;}#mermaid-svg-mkBXwHd4YxNfRrfz .icon-shape,#mermaid-svg-mkBXwHd4YxNfRrfz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mkBXwHd4YxNfRrfz .icon-shape p,#mermaid-svg-mkBXwHd4YxNfRrfz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-mkBXwHd4YxNfRrfz .icon-shape .label rect,#mermaid-svg-mkBXwHd4YxNfRrfz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mkBXwHd4YxNfRrfz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-mkBXwHd4YxNfRrfz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-mkBXwHd4YxNfRrfz :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
是
否
否
是
是
否
业务线程访问缓存
查询 Redis 缓存
缓存命中?
返回缓存数据
尝试获取互斥锁SET key value NX EX
获取锁成功?
降级处理:等待重试 或 返回旧数据/友好提示
双重确认:再次查询缓存
缓存已存在?
释放锁并返回数据
查询数据库
回写 Redis 缓存
释放锁
返回数据
结束
2.4.3 线程池异步重建:减少线程开销
上面是基础的互斥锁方案,还可以进一步优化:重建的时候开启线程池进行异步重建,把重建任务交给一个专门的线程池来执行,而不是让业务线程自己同步重建。
这样做有两个好处:
// 专门的缓存重建线程池
private final ExecutorService rebuildExecutor = Executors.newFixedThreadPool(8);
public T getWithAsyncRebuild(String key) {
CacheData<T> cache = redis.get(key);
if (cache != null) {
if (System.currentTimeMillis() <= cache.getExpireTime()) {
// 未逻辑过期,直接返回
return cache.getData();
}
// 逻辑过期,尝试抢锁异步重建
String lockKey = "lock:" + key;
boolean locked = redis.set(lockKey, "1", 10, TimeUnit.SECONDS, SetParams.nx());
if (locked) {
// 双重确认
cache = redis.get(key);
if (cache != null && System.currentTimeMillis() <= cache.getExpireTime()) {
releaseLock(lockKey);
return cache.getData();
}
// 异步重建
rebuildExecutor.submit(() -> {
try {
T newData = db.query(key);
CacheData<T> newCache = new CacheData<>(newData,
System.currentTimeMillis() + 30 * 60 * 1000);
redis.set(key, newCache);
} catch (Exception e) {
// 重试逻辑(见 2.4.5)
handleRebuildFailure(key);
} finally {
releaseLock(lockKey);
}
});
}
// 无论是否抢到锁,都先返回旧数据(降级处理)
return cache != null ? cache.getData() : null;
}
// 物理不存在的情况,按缓存未命中 + 互斥锁处理
return getWithMutex(key);
}
2.4.4 降级 vs 等待:两种业务策略
异步重建之后,业务线程在重建期间要返回什么?根据业务的选择有两种策略:
策略一:降级处理
给前端返回旧数据(逻辑过期的情况)或者友好提示(缓存物理不存在的情况)。同时,没抢到锁的线程也是一样的处理。
- 适用场景:对数据实时性要求不高的业务,比如商品详情页、推荐列表、文章内容。延迟几秒看到新数据用户基本无感,但接口能快速响应,体验比卡住等待好得多。
- 代价:会有一段时间的数据不一致窗口(用户看到的是旧数据)。
策略二:等待缓存重建完毕
业务线程阻塞等待,直到缓存重建完成,把新缓存的数据返回去。
- 适用场景:对数据实时性要求高的业务,比如订单状态、支付结果、库存余额。这种场景下返回旧数据可能引发资损或客诉,宁愿让用户多等一会儿也要返回最新数据。
- 代价:用户等待时间变长,如果数据库压力大可能超时。
| 降级处理 | 立即返回旧数据或友好提示 | 商品详情页、推荐、文章等容忍旧数据的业务 | 短暂数据不一致 |
| 等待重建 | 阻塞等待新缓存返回 | 订单、支付、库存等强一致性业务 | 用户等待时间变长 |
需要注意的是,对于选择"等待"策略的业务,如果重建失败,应该快速告知处理失败,然后返回友好提示,而不是让用户一直等下去。下面就要讲重建失败时的兜底机制。
2.4.5 重试 + MQ:重建失败的兜底
重建过程中可能出现异常(数据库连接超时、网络抖动等),需要兜底机制。最简单的是重试次数:如果重建过程中出现异常就重试,一般是 3 次。这个通过代码就能实现:
public T rebuildWithRetry(String key) {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
try {
T data = db.query(key);
redis.set(key, data, 30, TimeUnit.MINUTES);
return data;
} catch (Exception e) {
log.warn("缓存重建失败,第 {} 次重试, key={}", i + 1, key, e);
if (i == maxRetry – 1) {
throw new RuntimeException("缓存重建失败", e);
}
// 指数退避:1s, 2s, 4s…
try {
Thread.sleep(1000L * (1 << i));
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}
throw new RuntimeException("不应到达此处");
}
如果重试 3 次都失败,就交给 MQ 处理,MQ 再进行重试。而且 MQ 可以进行指数退避的重试,避免短时间内大量重试压垮数据库:
// 重试 3 次都失败,投递到 MQ
public void sendToMQ(String key) {
CacheRebuildMessage msg = new CacheRebuildMessage(key);
mqTemplate.send("cache.rebuild.retry", msg);
}
// MQ 消费者
@MQListener(topic = "cache.rebuild.retry")
public void onRebuildMessage(CacheRebuildMessage msg) {
String key = msg.getKey();
try {
T data = db.query(key);
redis.set(key, data, 30, TimeUnit.MINUTES);
} catch (Exception e) {
// MQ 中间件会自动重试,配合指数退避策略
throw e; // 抛出异常触发 MQ 重试
}
}
对于选择"等待"策略的业务,如果重试 3 次失败,应该快速告知处理失败,然后返回友好提示,避免用户长时间等待。
这里需要特别强调逻辑过期场景下重试的重要性。前面讲过,逻辑过期的缓存物理上永不过期,靠 expireTime 字段判断是否需要重建。这意味着如果重建失败,缓存会一直停留在"逻辑过期但物理存在"的状态,带来两个严重后果:
所以对逻辑过期的业务来说,重试不是"可选项"而是"必选项"——它是消除数据不一致窗口、让缓存恢复健康的唯一手段。重试 3 次还失败就交给 MQ 兜底,是为了保证最终一定能重建成功,避免旧数据永久残留。相比之下,物理 TTL 的场景下即使重建失败,缓存 key 也会在 TTL 到期后被删除,下一个请求会重新走未命中流程,不会一直返回旧数据,所以重试的重要性没那么高。这也是为什么前面强调"逻辑过期适用于不经常更新的数据"——更新越频繁,重建失败的代价就越大。
2.4.6 进阶:定时任务巡检 + MQ 兜底
对于一些核心业务(比如电商的商品详情页、首页推荐),还能有进一步的处理:设置定时任务定期检测对应的热点数据是否过期,发现不存在就发消息给 MQ,快速进行缓存重建。
这套机制的核心思路是把缓存重建从"被动触发"变成"主动巡检",在用户感知到缓存失效之前就主动重建好。
#mermaid-svg-fTOfxrxQYx8C9VJD{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fTOfxrxQYx8C9VJD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fTOfxrxQYx8C9VJD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fTOfxrxQYx8C9VJD .error-icon{fill:#552222;}#mermaid-svg-fTOfxrxQYx8C9VJD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fTOfxrxQYx8C9VJD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fTOfxrxQYx8C9VJD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fTOfxrxQYx8C9VJD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fTOfxrxQYx8C9VJD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fTOfxrxQYx8C9VJD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fTOfxrxQYx8C9VJD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fTOfxrxQYx8C9VJD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fTOfxrxQYx8C9VJD .marker.cross{stroke:#333333;}#mermaid-svg-fTOfxrxQYx8C9VJD svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fTOfxrxQYx8C9VJD p{margin:0;}#mermaid-svg-fTOfxrxQYx8C9VJD .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-fTOfxrxQYx8C9VJD .cluster-label text{fill:#333;}#mermaid-svg-fTOfxrxQYx8C9VJD .cluster-label span{color:#333;}#mermaid-svg-fTOfxrxQYx8C9VJD .cluster-label span p{background-color:transparent;}#mermaid-svg-fTOfxrxQYx8C9VJD .label text,#mermaid-svg-fTOfxrxQYx8C9VJD span{fill:#333;color:#333;}#mermaid-svg-fTOfxrxQYx8C9VJD .node rect,#mermaid-svg-fTOfxrxQYx8C9VJD .node circle,#mermaid-svg-fTOfxrxQYx8C9VJD .node ellipse,#mermaid-svg-fTOfxrxQYx8C9VJD .node polygon,#mermaid-svg-fTOfxrxQYx8C9VJD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fTOfxrxQYx8C9VJD .rough-node .label text,#mermaid-svg-fTOfxrxQYx8C9VJD .node .label text,#mermaid-svg-fTOfxrxQYx8C9VJD .image-shape .label,#mermaid-svg-fTOfxrxQYx8C9VJD .icon-shape .label{text-anchor:middle;}#mermaid-svg-fTOfxrxQYx8C9VJD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fTOfxrxQYx8C9VJD .rough-node .label,#mermaid-svg-fTOfxrxQYx8C9VJD .node .label,#mermaid-svg-fTOfxrxQYx8C9VJD .image-shape .label,#mermaid-svg-fTOfxrxQYx8C9VJD .icon-shape .label{text-align:center;}#mermaid-svg-fTOfxrxQYx8C9VJD .node.clickable{cursor:pointer;}#mermaid-svg-fTOfxrxQYx8C9VJD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fTOfxrxQYx8C9VJD .arrowheadPath{fill:#333333;}#mermaid-svg-fTOfxrxQYx8C9VJD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fTOfxrxQYx8C9VJD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fTOfxrxQYx8C9VJD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fTOfxrxQYx8C9VJD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fTOfxrxQYx8C9VJD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fTOfxrxQYx8C9VJD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fTOfxrxQYx8C9VJD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fTOfxrxQYx8C9VJD .cluster text{fill:#333;}#mermaid-svg-fTOfxrxQYx8C9VJD .cluster span{color:#333;}#mermaid-svg-fTOfxrxQYx8C9VJD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fTOfxrxQYx8C9VJD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fTOfxrxQYx8C9VJD rect.text{fill:none;stroke-width:0;}#mermaid-svg-fTOfxrxQYx8C9VJD .icon-shape,#mermaid-svg-fTOfxrxQYx8C9VJD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fTOfxrxQYx8C9VJD .icon-shape p,#mermaid-svg-fTOfxrxQYx8C9VJD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fTOfxrxQYx8C9VJD .icon-shape .label rect,#mermaid-svg-fTOfxrxQYx8C9VJD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fTOfxrxQYx8C9VJD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fTOfxrxQYx8C9VJD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fTOfxrxQYx8C9VJD :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
是
否
定时任务: 每隔 N 秒巡检热点 key
检查 Redis 中是否存在/逻辑过期
缓存缺失或过期?
检查下一个 key
Redis 去重 key 存在?
设置去重 keySET rebuild:key 1 EX 60
发送重建消息到 MQ
MQ 消费者异步重建
重建完成, 删除去重 key
继续巡检
这里为了避免重复发送消息也可以借助 Redis 做去重:发送 MQ 消息之前先设置对应的 key,如果 key 存在就代表对应的消息已经传递过了,没必要再次重建;重建完成之后再删除这个 key 就好了。
@Scheduled(fixedRate = 30000) // 每 30 秒巡检一次
public void checkHotKeys() {
List<String> hotKeys = getHotKeys();
for (String key : hotKeys) {
CacheData<?> cache = redis.get(key);
boolean needRebuild = false;
if (cache == null) {
needRebuild = true;
} else if (System.currentTimeMillis() > cache.getExpireTime()) {
needRebuild = true;
}
if (needRebuild) {
// 去重:检查是否已有重建任务在排队
String dedupKey = "rebuild:" + key;
boolean set = redis.set(dedupKey, "1", SetParams.nx().ex(60));
if (set) {
// 首次发送重建消息
mqTemplate.send("cache.rebuild", new CacheRebuildMessage(key));
}
}
}
}
@MQListener(topic = "cache.rebuild")
public void onRebuild(CacheRebuildMessage msg) {
String key = msg.getKey();
try {
Object data = db.query(key);
CacheData<Object> newCache = new CacheData<>(data,
System.currentTimeMillis() + 30 * 60 * 1000);
redis.set(key, newCache);
} finally {
// 重建完成后删除去重 key
redis.del("rebuild:" + key);
}
}
注意:这个去重 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 相关的所有接口,全部熔断,返回友好提示或进行其他的降级处理。这能保护数据库不被冲垮,但代价是整体业务不可用——对于某些要求高可用的业务来说是不可容忍的。
请求限流:相比熔断更温和。允许一定量的请求到达数据库(在数据库能承受的范围内),对于超出阈值的请求直接拒绝。这样既能保护数据库不被冲垮,又能保证一定比例的用户能正常使用,是更常用的方案。
// 限流器示例:每秒只允许 100 个请求查数据库
private final RateLimiter dbLimiter = RateLimiter.create(100);
public T getWithRateLimit(String key) {
T data = redis.get(key);
if (data != null) {
return data;
}
// 缓存未命中,走限流
if (dbLimiter.tryAcquire()) {
try {
data = db.query(key);
redis.set(key, data, 30, TimeUnit.MINUTES);
return data;
} catch (Exception e) {
return degrade(key);
}
} else {
// 超过限流阈值,直接降级
return degrade(key);
}
}
| 服务熔断 | 拒绝所有相关接口请求 | 数据库零压力 | 业务完全不可用 | 非核心业务,宁可不可用也不能拖垮数据库 |
| 请求限流 | 允许限量请求通过,其余拒绝 | 数据库可控压力,部分用户可用 | 实现稍复杂,部分用户被拒 | 核心业务,保证部分用户可用 |
应当根据业务特性选择合适的方法:非核心业务可以熔断,核心业务用限流保证部分可用。
三、缓存击穿:热点数据缓存失效
3.1 什么是缓存击穿
缓存击穿其实和缓存雪崩差不多,都是缓存没了导致请求打到数据库。区别在于:
- 缓存雪崩:大量缓存(不分热点非热点)同时失效。
- 缓存击穿:单个热点数据的缓存失效,但由于这个数据是热点,会有大量的请求集中打过来,导致数据库瞬时压力过大而宕机。
可以理解为:缓存雪崩是"大面积失效",缓存击穿是"单点失效但请求量极大"。
3.2 解决方案
缓存击穿的解决方案和缓存雪崩成因一的响应层方案高度重合,本质都是"缓存失效瞬间避免请求集中打到数据库"。
3.2.1 互斥锁方案
和前面讲的一样:保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待释放后重新读取缓存,要么就返回空值或者默认值。
public T getHotData(String key) {
T data = redis.get(key);
if (data != null) {
return data;
}
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
boolean locked = redis.set(lockKey, requestId, 10, TimeUnit.SECONDS, SetParams.nx());
if (locked) {
try {
// 双重确认
data = redis.get(key);
if (data != null) {
return data;
}
data = db.query(key);
redis.set(key, data, 30, TimeUnit.MINUTES);
return data;
} finally {
releaseLock(lockKey, requestId);
}
} else {
// 没抢到锁,短暂休眠后重试读取缓存
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
data = redis.get(key);
return data != null ? data : degrade(key);
}
}
3.2.2 永不过期 + 后台异步更新
针对热点数据,可以不给它设置过期时间(物理不过期),由后台异步线程负责更新缓存。或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间。
这其实就是前面讲的"逻辑过期"方案的延伸——热点数据用逻辑过期 + 异步重建,业务线程永远能读到数据(哪怕是旧的),重建在后台异步进行,用户无感知。
两种方案的取舍:
| 互斥锁 | 保证数据一致性 | 等待锁期间响应变慢 | 强一致性要求的热点数据 |
| 永不过期 + 异步更新 | 响应永远快 | 短暂数据不一致 | 容忍旧数据的热点数据 |
需要说明的是,缓存击穿这两种方案的本质都是正常的访问——数据库里面还是有正常的数据的,只要缓存层重建成功,就可以解决了。但下面要讲的缓存穿透就不一样了,它涉及的是不存在的数据,重建缓存也救不了。
四、缓存穿透:查询根本不存在的数据
4.1 什么是缓存穿透
缓存穿透是指访问了不存在的数据——即不在缓存中,也不存在数据库中。这种访问是一定会打到数据库的:缓存未命中 → 查数据库 → 数据库也没有 → 返回空。每一次这样的请求都会完整走一遍"缓存 → 数据库"的链路,一旦多了,也会导致数据库崩溃。
和缓存击穿、缓存雪崩的根本区别在于:
- 缓存雪崩 / 击穿:数据库里有数据,只是缓存没了,重建缓存就能解决。
- 缓存穿透:数据库里没有数据,重建缓存也没用,每次查询都会穿透。
导致缓存穿透的情形一般有两种:
4.2 解决方案
缓存穿透一般有三种解决策略:
4.2.1 策略一:非法请求的限制
这种策略的思路是在请求进入系统前就拦掉非法请求,分为两层:
网关层:限流 / 熔断
在 API 网关层对异常高频的请求 IP 或用户进行限流,防止恶意刷接口。比如同一个 IP 每秒查询同一个接口超过 100 次,直接限流。
服务层:参数合法性校验
在业务服务层对请求参数做合法性校验,比如:
- ID 长度是否符合数据库存储规则(比如商品 ID 应该是 18 位数字,传 100 位的明显是非法)。
- ID 范围是否合理(比如自增 ID 不应该是负数)。
- 必填字段是否齐全。
- 数据格式是否正确(邮箱、手机号格式等)。
public Product getProduct(String id) {
// 参数合法性校验
if (StringUtils.isBlank(id) || id.length() > 18 || !id.matches("\\\\d+")) {
throw new IllegalArgumentException("非法的商品 ID");
}
long idValue = Long.parseLong(id);
if (idValue <= 0) {
throw new IllegalArgumentException("商品 ID 必须为正数");
}
// 后续走缓存查询…
return doGetProduct(id);
}
这种策略能拦掉一部分明显的非法请求,但对于"格式合法但实际不存在"的请求(比如一个合法格式但不存在的商品 ID)就无能为力了,需要配合下面的策略。
4.2.2 策略二:缓存空值或默认值
这个策略的思路很简单:查询数据库发现为空后,在 Redis 里缓存对应的空值或者默认值进去,设置一个合理的 TTL,下次还访问同样的 key 时直接返回空值就好了,打不到数据库。
public Product getProduct(String id) {
// 1. 查缓存
String cached = redis.get("product:" + id);
if (cached != null) {
if ("NULL".equals(cached)) {
return null; // 命中空值缓存,直接返回 null
}
return JSON.parseObject(cached, Product.class);
}
// 2. 查数据库
Product product = productMapper.findById(id);
if (product == null) {
// 数据库也没有,缓存空值,TTL 设短一些(比如 5 分钟)
redis.set("product:" + id, "NULL", 5, TimeUnit.MINUTES);
return null;
}
// 3. 回写缓存
redis.set("product:" + id, JSON.toJSONString(product), 30, TimeUnit.MINUTES);
return product;
}
优点:实现简单,能解决"误删导致的穿透"问题。
缺点:
为了缓解第一个问题,可以给空值缓存设置较短的 TTL(比如 5 分钟),让空值缓存尽快过期。为了缓解第二个问题,可以在数据新增时主动删除对应的空值缓存:
public void addProduct(Product product) {
productMapper.insert(product);
// 主动删除可能存在的空值缓存
redis.del("product:" + product.getId());
}
4.2.3 策略三:布隆过滤器
布隆过滤器是解决缓存穿透最优雅的方案。它的思路是:在写入数据库数据时,使用布隆过滤器做个标记;然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。
请求流程变成这样:
#mermaid-svg-J02PJ50bOokaIYhq{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-J02PJ50bOokaIYhq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-J02PJ50bOokaIYhq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-J02PJ50bOokaIYhq .error-icon{fill:#552222;}#mermaid-svg-J02PJ50bOokaIYhq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-J02PJ50bOokaIYhq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-J02PJ50bOokaIYhq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-J02PJ50bOokaIYhq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-J02PJ50bOokaIYhq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-J02PJ50bOokaIYhq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-J02PJ50bOokaIYhq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-J02PJ50bOokaIYhq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-J02PJ50bOokaIYhq .marker.cross{stroke:#333333;}#mermaid-svg-J02PJ50bOokaIYhq svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-J02PJ50bOokaIYhq p{margin:0;}#mermaid-svg-J02PJ50bOokaIYhq .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-J02PJ50bOokaIYhq .cluster-label text{fill:#333;}#mermaid-svg-J02PJ50bOokaIYhq .cluster-label span{color:#333;}#mermaid-svg-J02PJ50bOokaIYhq .cluster-label span p{background-color:transparent;}#mermaid-svg-J02PJ50bOokaIYhq .label text,#mermaid-svg-J02PJ50bOokaIYhq span{fill:#333;color:#333;}#mermaid-svg-J02PJ50bOokaIYhq .node rect,#mermaid-svg-J02PJ50bOokaIYhq .node circle,#mermaid-svg-J02PJ50bOokaIYhq .node ellipse,#mermaid-svg-J02PJ50bOokaIYhq .node polygon,#mermaid-svg-J02PJ50bOokaIYhq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-J02PJ50bOokaIYhq .rough-node .label text,#mermaid-svg-J02PJ50bOokaIYhq .node .label text,#mermaid-svg-J02PJ50bOokaIYhq .image-shape .label,#mermaid-svg-J02PJ50bOokaIYhq .icon-shape .label{text-anchor:middle;}#mermaid-svg-J02PJ50bOokaIYhq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-J02PJ50bOokaIYhq .rough-node .label,#mermaid-svg-J02PJ50bOokaIYhq .node .label,#mermaid-svg-J02PJ50bOokaIYhq .image-shape .label,#mermaid-svg-J02PJ50bOokaIYhq .icon-shape .label{text-align:center;}#mermaid-svg-J02PJ50bOokaIYhq .node.clickable{cursor:pointer;}#mermaid-svg-J02PJ50bOokaIYhq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-J02PJ50bOokaIYhq .arrowheadPath{fill:#333333;}#mermaid-svg-J02PJ50bOokaIYhq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-J02PJ50bOokaIYhq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-J02PJ50bOokaIYhq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-J02PJ50bOokaIYhq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-J02PJ50bOokaIYhq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-J02PJ50bOokaIYhq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-J02PJ50bOokaIYhq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-J02PJ50bOokaIYhq .cluster text{fill:#333;}#mermaid-svg-J02PJ50bOokaIYhq .cluster span{color:#333;}#mermaid-svg-J02PJ50bOokaIYhq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-J02PJ50bOokaIYhq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-J02PJ50bOokaIYhq rect.text{fill:none;stroke-width:0;}#mermaid-svg-J02PJ50bOokaIYhq .icon-shape,#mermaid-svg-J02PJ50bOokaIYhq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-J02PJ50bOokaIYhq .icon-shape p,#mermaid-svg-J02PJ50bOokaIYhq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-J02PJ50bOokaIYhq .icon-shape .label rect,#mermaid-svg-J02PJ50bOokaIYhq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-J02PJ50bOokaIYhq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-J02PJ50bOokaIYhq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-J02PJ50bOokaIYhq :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
是
否
不存在
可能存在
有
无
用户请求
查询 Redis 缓存
缓存命中?
返回数据
查询布隆过滤器
布隆过滤器判断存在?
直接返回 null无需查数据库
查询 MySQL
数据库有数据?
回写 Redis 缓存
返回数据
结束
注意布隆过滤器的位置:在缓存未命中之后、查数据库之前。它是一层"前置过滤",把"肯定不存在的请求"直接拦掉,只有"可能存在的请求"才放行到数据库。
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 | 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 | 1 | 1 | 1 | 1 | 1 | 0 |
| 标记来源 | – | X | Y | Y | X | Y | X | – |
现在查询数据 Z,假设 Z 经过 3 个哈希函数计算得到位置 1, 3, 5。把 Z 的查询位置和位图状态对应起来看:
| 位图状态 | 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 大小、一致性要求、是否核心链路)选择合适的组合方案,并在"实现复杂度"和"系统可用性"之间取得平衡。



