欢迎光临
我们一直在努力

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 缓存层后,一次典型的请求流程是这样的:

#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();
}

这里有几个坑需要特别注意:

  • 数据库更新时必须同步更新缓存的 expireTime 和 data,并且一定要更新成功。因为逻辑过期没有物理 TTL 兜底,如果数据库更新了但缓存没更新,就会一直留存旧数据,永远不过期。这一点非常关键,后面响应层里会进一步展开讲。
  • 逻辑过期不等于物理不过期,物理上 key 永远存在(除非显式删除),但业务层根据 expireTime 字段判断是否需要重建。这意味着即使重建失败,旧数据依然能被读到,是一种降级可用的设计。
  • 策略三:缓存预热

    在某些大活动开始之前(比如双 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 字段判断是否需要重建。这意味着如果重建失败,缓存会一直停留在"逻辑过期但物理存在"的状态,带来两个严重后果:

  • 旧数据永久残留:逻辑过期没有物理 TTL 兜底,如果重建一直失败,缓存里的旧数据会永远被返回。如果数据库的数据已经更新(比如商品价格调整),用户看到的就一直是旧价格,数据不一致窗口被无限拉长,这对业务来说往往是不可接受的。
  • 每个请求都触发重建流程:缓存处于逻辑过期状态后,后续每一个请求都会发现"逻辑过期",尝试抢锁、查 DB、重建。如果重建一直失败,每个请求都要走一遍这个流程,抢锁的开销、查 DB 的开销都会被放大,反而加重了系统负担,甚至可能把数据库拖垮。
  • 所以对逻辑过期的业务来说,重试不是"可选项"而是"必选项"——它是消除数据不一致窗口、让缓存恢复健康的唯一手段。重试 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 什么是缓存穿透

    缓存穿透是指访问了不存在的数据——即不在缓存中,也不存在数据库中。这种访问是一定会打到数据库的:缓存未命中 → 查数据库 → 数据库也没有 → 返回空。每一次这样的请求都会完整走一遍"缓存 → 数据库"的链路,一旦多了,也会导致数据库崩溃。

    和缓存击穿、缓存雪崩的根本区别在于:

    • 缓存雪崩 / 击穿:数据库里有数据,只是缓存没了,重建缓存就能解决。
    • 缓存穿透:数据库里没有数据,重建缓存也没用,每次查询都会穿透。

    导致缓存穿透的情形一般有两种:

  • 业务操作错误:把数据库里面的数据删了,但前端入口还在,导致一直查不到数据。比如商品下架了但详情页链接还在传播,用户点进来查询不到。
  • 黑客恶意攻击:攻击者构造大量不存在的 key(比如随机 ID),疯狂打接口,每个请求都穿透到数据库,意图拖垮数据库。
  • 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;
    }

    优点:实现简单,能解决"误删导致的穿透"问题。

    缺点:

  • 对恶意攻击效果有限:如果攻击者使用大量随机不存在的 key 进行攻击,每个 key 都会在 Redis 里缓存一个空值,Redis 内存会被大量空值占满,造成缓存资源浪费。
  • 存在短暂的数据不一致窗口:数据新增后,Redis 里的空值缓存还没过期,这段时间内查询依然会返回 null,需要等 TTL 到期或主动删除空值缓存才能查到新数据。
  • 为了缓解第一个问题,可以给空值缓存设置较短的 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。

    写入后的位图:

    位图位置01234567
    位图状态 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):

    位图位置01234567
    位图状态 0 1 1 1 1 1 1 0
    标记来源 X Y Y X Y X

    现在查询数据 Z,假设 Z 经过 3 个哈希函数计算得到位置 1, 3, 5。把 Z 的查询位置和位图状态对应起来看:

    Z 的查询位置135
    位图状态 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 大小、一致性要求、是否核心链路)选择合适的组合方案,并在"实现复杂度"和"系统可用性"之间取得平衡。

    赞(0)
    未经允许不得转载:171主机测评 » Redis 缓存三大经典问题:雪崩、击穿、穿透从原理到方案
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址