前言
大量缓存集中失效,或者 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 之前,请求按以下顺序读取:
- 先查询当前应用实例的本地缓存。
- 本地未命中,再查询 Redis。
- Redis 命中后,回填本地缓存并返回。
- Redis 未命中时,先判断是否允许回源数据库。
- 允许回源则查询数据库并按策略回填;不允许则执行降级。
本地缓存可以减少热点数据的网络访问,并在 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 连接失败,确认应用会执行哪条路径:
- 请求是否被 Redis 连接超时长时间阻塞。
- 是否直接失败,还是无条件回源数据库。
- 回源请求是否受到限流。
- 被限制的请求是否返回正确的降级结果。
- Redis 故障转移后,客户端能否恢复连接。
使用 Sentinel 或 Cluster 时,还要观察故障发现、节点切换和客户端重连期间的真实失败情况,不能只确认节点最终恢复。
4. 检查本地缓存与旧值
如果使用多级缓存,需要确认:
- Redis 不可用时,本地缓存能否独立读取。
- 本地旧值是否超过业务允许的最大时间。
- 数据更新后,各实例的本地副本能否及时失效。
- 被限流的请求不会绕过保护继续访问数据库。
十、常见误区
1. 所有缓存都设置永不过期
不设置 TTL 可以避免 key 因到期被删除,但数据仍可能被淘汰,也会受到 Redis 故障影响。业务还需要解决数据更新、内存占用和旧值时效问题。
2. 部署 Redis Cluster 就不会雪崩
Cluster 主要解决分片与节点故障问题。大量 key 使用相近 TTL 时,仍然可能集中失效并回源数据库。
3. 给每个 key 加锁就能保护数据库
单 key 锁只能控制相同数据的并发重建。大量不同 key 同时失效时,需要额外限制整体回源量。
4. Redis 异常时直接查询数据库
这种处理会把缓存层流量全部转给数据库。只有在明确数据库容量、配置限流并准备降级结果后,才能把回源作为故障处理路径。
十一、总结
缓存雪崩要根据触发原因分别处理:随机 TTL 分散批量过期,Redis 高可用 降低节点故障影响,限流降级 保护数据库,多级缓存提供额外读取路径。
设计时先确认数据允许多旧、数据库能够承受多少回源请求,再决定使用哪些措施。最终效果要通过 TTL 分布、数据库查询量、接口耗时和故障恢复过程共同验证。