【黑马点评 | 第四篇】Redis缓存雪崩

前言

大量缓存集中失效,或者 Redis 服务突然不可用时,请求可能在短时间内集中访问数据库,形成缓存雪崩。解决这个问题不能只改一个过期时间,还要分别处理批量过期、Redis 故障和数据库回源过载。

常用措施包括:

  • 给 TTL 增加随机偏移、
  • 使用逻辑过期保留旧值、
  • 提高 Redis 可用性、
  • 限制数据库回源量、
  • 设计降级结果,
  • 在合适的业务中增加本地缓存。

一、缓存雪崩的两种来源

1. 大量 key 集中过期

一批店铺数据在相近时间写入 Redis,并统一设置 30 分钟 TTL,就可能在相近时间失效。Redis 此时仍然正常,但大量缓存未命中会让请求集中查询数据库。

产生集中失效通常同时满足两个条件:

  • 多个 key 的写入时间接近。
  • 多个 key 使用相同或相近的 TTL。

固定 TTL 不一定造成雪崩。如果 key 的写入时间本身比较分散,到期时间也会自然分散。

2. Redis 服务不可用

节点故障、连接超时或网络异常都可能让应用无法正常读取缓存。应用在缓存异常后的处理方式,决定了故障会不会继续传到数据库。

缓存异常后的处理 可能结果
直接返回失败 当前服务出现错误,但数据库不会因此收到全部流量
所有请求都回源数据库 数据库流量骤增,可能引发级联故障
限制回源并执行降级 保留部分能力,同时控制数据库压力

CacheClient 直接调用 Redis,Redis 连接异常不会自动转换为受限的数据库回源。因此,Redis 连接异常和普通缓存未命中必须分开处理

二、先区分穿透、击穿和雪崩

问题 请求涉及的数据 主要措施
缓存穿透 数据库中不存在的数据 空值缓存、布隆过滤器
缓存击穿 单个高热度数据 互斥锁、逻辑过期
缓存雪崩 大量缓存数据或整个缓存服务 随机 TTL、高可用、限流降级、多级缓存

同一个系统可以同时遇到这三类问题。按店铺 ID 加互斥锁,只能减少同一个店铺的重复重建;如果一万个不同店铺同时失效,仍然可能产生一万个独立的数据库查询。

缓存雪崩的解决方案可以按照触发原因分为四组:

雪崩原因或风险 对应解决方案 主要作用
大量 key 集中过期 随机 TTL、逻辑过期、分批预热 分散缓存失效和重建时间
Redis 节点故障 主从复制、Sentinel、Cluster 降低单个节点故障的影响
请求集中回源数据库 互斥重建、限流、熔断、降级 控制数据库能够接收的请求量
Redis 暂时不可访问 本地缓存、多级缓存 提供独立于 Redis 的临时读取路径

这些措施都属于缓存雪崩治理方案,但处理的位置不同。实际使用时,需要根据雪崩来源选择组合,不能只配置其中一项。

三、方案一:使用随机 TTL 分散过期时间

随机 TTL 的思路是在基础有效期上增加一个随机偏移,让同一批 key 分散到不同时间过期。

1. 缓存写入位置

ShopServiceImpl 中的普通店铺缓存使用固定 TTL:

java 复制代码
// 数据库查询成功后,将店铺写入 Redis
stringRedisTemplate.opsForValue().set(
        key,
        JSONUtil.toJsonStr(shop),
        CACHE_SHOP_TTL,
        TimeUnit.MINUTES
);

CacheClient 对相同操作进行了封装:

java 复制代码
public void set(
        String key, Object value, Long time, TimeUnit unit) {
    // 把业务对象序列化为 JSON,并按调用方传入的时间设置 TTL
    stringRedisTemplate.opsForValue().set(
            key, JSONUtil.toJsonStr(value), time, unit);
}

CACHE_SHOP_TTL 当前为 30,时间单位为分钟。两个写入位置都没有生成随机时间,因此批量写入时可能形成相近的过期时间。

2. 随机 TTL 改造示例

假设店铺缓存允许在 30 到 35 分钟之间过期,可以先统一换算成秒,再增加 0 到 300 秒的随机偏移:

java 复制代码
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;

public void cacheShopWithRandomTtl(Shop shop) {
    String key = CACHE_SHOP_KEY + shop.getId();

    // 1. 将基础有效期统一换算为秒
    long baseSeconds =
            TimeUnit.MINUTES.toSeconds(CACHE_SHOP_TTL);

    // 2. 生成 0 到 300 秒的随机偏移
    long randomSeconds =
            ThreadLocalRandom.current().nextLong(301);

    // 3. 最终 TTL 为 1800 到 2100 秒
    long ttlSeconds = baseSeconds + randomSeconds;

    // 4. 使用秒作为统一单位写入 Redis
    stringRedisTemplate.opsForValue().set(
            key,
            JSONUtil.toJsonStr(shop),
            ttlSeconds,
            TimeUnit.SECONDS
    );
}

ThreadLocalRandom.current().nextLong(301) 会生成 0 到 300 之间的整数。最终 TTL 分布在 1800 到 2100 秒之间,降低同一批数据同时到期的概率。

随机范围要根据业务允许的数据时效确定。如果 30 分钟已经是缓存有效期上限,可以改为 25 到 30 分钟,不能直接把上限延长到 35 分钟。

3. 复用 CacheClient 的写入能力

普通查询使用 CacheClient 时,也可以在调用前计算随机 TTL:

java 复制代码
long baseSeconds =
        TimeUnit.MINUTES.toSeconds(CACHE_SHOP_TTL);
long randomSeconds =
        ThreadLocalRandom.current().nextLong(301);
long ttlSeconds = baseSeconds + randomSeconds;

// 只有缓存未命中并查到数据库记录时,才使用本次 TTL 回填
Shop shop = cacheClient.querywithpassthrough(
        CACHE_SHOP_KEY,
        id,
        Shop.class,
        this::getById,
        ttlSeconds,
        TimeUnit.SECONDS
);

传入的 ttlSeconds 只控制正常业务数据。数据库中不存在的记录仍然使用 CacheClient 内部的空值 TTL,需要单独设置合适的有效期。

4. 随机 TTL 能解决什么

能力 是否解决
分散一批 key 的物理过期时间
保证所有 key 的过期时间都不同
防止单个热点 key 被并发重建
在 Redis 故障时继续提供缓存
限制数据库总回源量

随机 TTL 只改变缓存的过期分布,还需要与热点保护、高可用和回源限流配合。

四、方案二:使用逻辑过期保留可读旧值

项目中的 CacheClient 还提供了逻辑过期写入方法:

java 复制代码
public void setwithLogicalExpire(
        String key, Object value, Long time, TimeUnit unit) {
    RedisData redisData = new RedisData();

    // 1. 计算业务逻辑过期时间
    redisData.setExpireTime(
            LocalDateTime.now()
                    .plusSeconds(unit.toSeconds(time)));

    // 2. 保存实际业务对象
    redisData.setData(value);

    // 3. 写入 Redis,不设置物理 TTL
    stringRedisTemplate.opsForValue().set(
            key, JSONUtil.toJsonStr(redisData));
}

这个方法把业务对象和 expireTime 一起写进 JSON,Redis key 本身没有设置过期时间。

对比项 物理 TTL 逻辑过期时间
存放位置 Redis key 的过期元数据 Redis value 中的 expireTime
判断者 Redis Java 业务代码
到期结果 key 被删除,查询时未命中 key 仍存在,可以返回旧值
后续处理 查询数据库并回填 尝试异步刷新缓存

给逻辑过期时间增加随机偏移,可以分散后台刷新任务的触发时间;给物理 TTL 增加随机偏移,可以分散 Redis key 的删除时间。两者解决的阶段不同。

逻辑过期也是缓存雪崩的解决方案。它让过期数据继续保留在 Redis 中,请求可以先返回旧值,再由后台线程更新缓存,从而避免大量 key 物理删除后直接回源数据库。

如果一批缓存使用相同的逻辑过期时间,大量不同 key 仍可能同时触发后台重建。因此,逻辑过期通常还要配合随机时间、互斥重建和回源限流。

逻辑过期仍然依赖 Redis 可访问。如果 Redis 节点不可用,保存在其中的旧数据也无法读取,所以逻辑过期不能代替高可用和应用降级。

五、方案三:使用 Sentinel 或 Cluster 提高 Redis 可用性

随机 TTL 处理的是批量到期,Redis 高可用处理的是节点故障。常见部署方式关注点如下:

部署方式 主要作用 需要注意的边界
主从复制 保存数据副本、支持读扩展 单独配置复制不等于自动故障切换
Redis Sentinel 监控主从节点并执行自动故障转移 客户端需要能够发现并连接新的主节点
Redis Cluster 数据分片、横向扩展,并结合副本实现故障转移 仍需配置副本、客户端支持和故障恢复策略

Redis 官方文档说明,复制使用主节点和副本节点保存数据;Sentinel 提供监控与自动故障转移;Cluster 提供自动分片,并可通过副本完成故障转移。

高可用不能解决所有雪崩场景:

  • 节点切换期间仍可能出现短暂失败。
  • 客户端超时和重试配置会影响请求恢复速度。
  • 大量 key 集中过期时,即使所有节点正常,也可能集中回源数据库。
  • Redis 恢复后,应用仍需要确认连接池和请求线程是否恢复。

因此,高可用负责降低单点故障影响,随机 TTL 负责分散批量过期,两者需要分别配置和验证。

六、方案四:通过限流与降级保护数据库

1. 限制进入数据库的请求量

缓存失效后,最终需要保护的是数据库。限流应放在缓存未命中或缓存异常后的回源路径上,控制单位时间内能够查询数据库的请求数量。

互斥锁与回源限流的范围不同:

措施 控制范围
单 key 互斥锁 同一条数据只能由一个请求重建
业务回源限流 限制某类业务进入数据库的总请求量
系统级限流 控制整个服务能够承受的请求规模

应用部署多个实例时,每个实例的限流额度会累加。数据库实际接收的是所有实例的回源总量,容量评估不能只看单个实例。

2. 被限制的请求如何降级

被限流后不能继续无条件查询数据库,需要按业务语义返回可接受的结果。

业务要求 可选降级方式
允许短时间旧数据 返回仍可访问的旧缓存,并限制最大旧值时间
非核心内容可以省略 暂时不返回推荐、统计等附加数据
当前无法提供可靠结果 返回明确的暂不可用提示
必须读取准确数据 在数据库容量允许范围内回源,超出后拒绝请求

如果 Redis 已经不可访问,就不能继续假设旧值可以从 Redis 读取。旧值必须来自仍然可用的本地缓存或其他独立存储。

技术故障也不能被包装成业务事实。例如 Redis 连接失败时直接返回"店铺不存在",会让调用方误以为数据库中确实没有该店铺。

限流和降级需要在 Redis 读取异常或缓存未命中的回源入口单独实现。限流阈值应根据应用实例数和数据库容量设置,降级结果则要符合具体业务语义。

七、方案五:使用多级缓存增加独立读取路径

多级缓存通常把本地内存放在 Redis 之前,请求按以下顺序读取:

  1. 先查询当前应用实例的本地缓存。
  2. 本地未命中,再查询 Redis。
  3. Redis 命中后,回填本地缓存并返回。
  4. Redis 未命中时,先判断是否允许回源数据库。
  5. 允许回源则查询数据库并按策略回填;不允许则执行降级。

本地缓存可以减少热点数据的网络访问,并在 Redis 短时不可用时提供有限的旧值,但会增加新的维护成本:

  • 每个应用实例都有独立副本,更新时要处理实例间失效通知。
  • 本地容量有限,只适合保存有明确价值的热点数据。
  • 应用重启后,本地缓存需要重新建立。
  • 必须明确本地数据允许多旧,命中不代表一定符合业务时效。

多级缓存的核心价值是增加独立读取路径。多一层缓存也意味着多一份数据副本,需要同时设计容量、过期和一致性策略。

八、店铺查询链路中的方案组合

店铺查询链路可以按下面的环节组合不同保护措施:

查询环节 当前代码或改造入口 对应措施
Controller 接收请求 ShopController 判断业务是否允许降级
Redis 缓存读取 CacheClient 配置超时、高可用和异常处理
同一热点重建 ShopServiceImpl 的互斥锁方法 减少单个 key 的重复回源
数据库查询 getById 或传入的查询函数 限制数据库回源总量
普通缓存回填 CacheClient.set 传入随机物理 TTL
逻辑缓存回填 setwithLogicalExpire 分散刷新时间,控制旧值时长
店铺类型缓存 ShopTypeServiceImpl 写入 Redis List 后设置 30 分钟 TTL

示例中的 queryById 使用逻辑过期查询,缓存缺失时直接返回 null,不会自动查询数据库。表中的措施需要根据实际查询模式选择,不能不加区分地同时套用。

可以按故障来源选择组合:

  • 批量 key 同时到期:优先分散 TTL,并限制数据库回源量。
  • 单个热点 key 到期:使用互斥锁或逻辑过期控制重建。
  • Redis 节点故障:依赖 Sentinel 或 Cluster 的故障转移,同时配置应用侧限流与降级。
  • 热点读流量很高:在能够维护一致性的前提下评估本地缓存。

九、如何验证保护措施

验证应在隔离的开发或测试环境完成,不能直接通过生产故障验证。

1. 检查 TTL 是否分散

对同一批测试 key 查看剩余 TTL:

redis 复制代码
TTL cache:shop:1
TTL cache:shop:2
TTL cache:shop:3

Redis 的 TTL 命令返回值含义如下:

返回值 含义
正整数 key 剩余的有效秒数
-1 key 存在,但没有设置物理过期时间
-2 key 不存在

如果使用随机 TTL,同批写入的 key 应在允许范围内出现不同的剩余时间。如果使用逻辑过期,TTL 可能返回 -1,此时要查看 JSON 中的 expireTime,不能把它计入物理 TTL 分布。

2. 检查批量失效后的回源量

对比固定 TTL 和随机 TTL 两种场景,至少记录以下指标:

  • Redis 缓存命中率。
  • 数据库每秒查询量。
  • 接口响应时间。
  • 超时数和错误率。
  • 连接池活跃连接数与等待情况。

只看接口是否成功,可能忽略数据库已经接近容量上限;只看 Redis 命中率,也不能证明数据库仍然稳定。

3. 检查 Redis 故障时的行为

在隔离环境模拟 Redis 连接失败,确认应用会执行哪条路径:

  1. 请求是否被 Redis 连接超时长时间阻塞。
  2. 是否直接失败,还是无条件回源数据库。
  3. 回源请求是否受到限流。
  4. 被限制的请求是否返回正确的降级结果。
  5. Redis 故障转移后,客户端能否恢复连接。

使用 Sentinel 或 Cluster 时,还要观察故障发现、节点切换和客户端重连期间的真实失败情况,不能只确认节点最终恢复。

4. 检查本地缓存与旧值

如果使用多级缓存,需要确认:

  • Redis 不可用时,本地缓存能否独立读取。
  • 本地旧值是否超过业务允许的最大时间。
  • 数据更新后,各实例的本地副本能否及时失效。
  • 被限流的请求不会绕过保护继续访问数据库。

十、常见误区

1. 所有缓存都设置永不过期

不设置 TTL 可以避免 key 因到期被删除,但数据仍可能被淘汰,也会受到 Redis 故障影响。业务还需要解决数据更新、内存占用和旧值时效问题。

2. 部署 Redis Cluster 就不会雪崩

Cluster 主要解决分片与节点故障问题。大量 key 使用相近 TTL 时,仍然可能集中失效并回源数据库。

3. 给每个 key 加锁就能保护数据库

单 key 锁只能控制相同数据的并发重建。大量不同 key 同时失效时,需要额外限制整体回源量。

4. Redis 异常时直接查询数据库

这种处理会把缓存层流量全部转给数据库。只有在明确数据库容量、配置限流并准备降级结果后,才能把回源作为故障处理路径。

十一、总结

缓存雪崩要根据触发原因分别处理:随机 TTL 分散批量过期,Redis 高可用 降低节点故障影响,限流降级 保护数据库,多级缓存提供额外读取路径。

设计时先确认数据允许多旧、数据库能够承受多少回源请求,再决定使用哪些措施。最终效果要通过 TTL 分布、数据库查询量、接口耗时和故障恢复过程共同验证。

相关推荐
小尹哥-程序员1 小时前
第4集:让AI学会看文档:Spring AI RAG入门实战
java·人工智能·spring
Sun 32851 小时前
集中式主备中,CM、OM 和 DN 是怎样协作的?
数据库·opengauss·cm·磐维数据库·集中式·om·dn
可乐配鸡翅1 小时前
day seven 猜数字小游戏
java
张小凡vip1 小时前
Java Web 设置 Session 超时时间:如何让 Session 永不失效
java·spring boot
浪里摸鱼1 小时前
Java 泛型快速学习指南(面向接口扫描场景)
java
我是大猴子1 小时前
redis执行get等命令时底层操作
java
其实防守也摸鱼2 小时前
堆叠注入(Stacked Injection)详解
服务器·数据库·windows·https·ssl
叶总没有会2 小时前
微服务--分布式基础
java·spring·spring cloud·微服务
鹿角片ljp2 小时前
Prompt Cache、Token 成本与 Plan Compiler 的工程设计
java·python·算法