分布式系统中缓存雪崩的解决方案

前言

在中大型项目中经常会使用到 Redis,当 Redis 缓存服务(一主多从 + 哨兵架构或者集群架构)不可用时,所有请求会直接打到 MySQL,瞬间把数据库压垮(即缓存雪崩),导致整个系统不可用。那么如何设计系统架构,即使 Redis 缓存服务完全不可用,整个系统也可以正常运行(不至于完全挂掉)呢?

缓存雪崩的概念

  • 缓存雪崩是指在设置缓存时采用了相同的过期时间,甚至缓存中间件挂掉,导致大量缓存在某一时刻同时失效,外部请求全部转发到数据库,而数据库由于瞬时压力过重导致雪崩。
  • 简单的解决方案有以下几种:
    • (1) 可以在原有的缓存过期时间基础上增加一个随机值,比如 1 ~ 5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,进而很难引发缓存集体失效的事件。
    • (2) 使用本地缓存(Caffeine)+ Redis 哨兵 / 集群 + Hystrix 熔断 / 降级 / 限流
      • 其中本地缓存(Caffeine)作为一级缓存兜底;
      • Redis 哨兵 / 集群保证高可用,并设置 Key 错峰过期;
      • Hystrix 在 Redis 服务异常时触发熔断,并降级到返回本地缓存(Caffeine)数据或默认数据,从而防止缓存雪崩导致大量请求涌入冲跨数据库。

缓存雪崩的解决

不依赖单一防线,而是用「网关限流 → 应用熔断 / 熔断 / 本地缓存 → 请求合并 → 后端拒绝」多层保护,同时提升 Redis 的 HA(高可用)能力,才能有效避免 Redis 故障时的缓存雪崩把 MySQL 打垮。

整体的防护思路

  • 事前降级:
    • 在缓存不可用时允许返回 “可接受的弱一致性或降级结果”(例如:部分功能只返回缓存数据、或返回静态提示页)。
  • 保护后端(削峰限流):
    • 对外限流、熔断、队列化,防止大量并发请求落到数据库。
  • 多级缓存 + 回退策略:
    • 本地缓存(LRU) + 远端 Redis + 数据库回源。
  • 请求合并:
    • 避免大量相同 Key 的并发请求回源到数据库。
  • 提高 Redis 可用性:
    • 部署多副本、Sentinel/Cluster、跨可用区和异地多活(长期方案)。
  • 可观测性 + 自动化恢复:
    • 指标告警(QPS、错误率、数据库连接数、Redis 失败率)、并配合自动限流 / 熔断规则。

具体方案与可选框架

  • (1) 边缘 / 网关限流(第一道防线)

    • 目的:
      • 在流量激增或缓存不可用时,尽早拒绝 / 排队请求,保护后端。
    • 常用方案:
      • API 网关:Kong、Nginx + Lua(OpenResty)、Envoy、Traefik。
      • CDN / 边缘:使用 CDN 缓存静态或部分接口响应结果(可作为快速降级)。
    • 实现:
      • Nginx + Lua(OpenResty)实现令牌桶 / 漏桶限流算法,非常高效,放在 LB(负载均衡)层。
      • Envoy 支持熔断机制(Circuit-Breaking) + 全局限流器(Global Rate-Limiter),适合微服务网格。
  • (2) 服务端限流与令牌桶(应用层)

    • 目的:
      • 对热点接口进行精细限流,允许重要请求先行。
    • 常用库:
      • Java:Bucket4j、Guava RateLimiter、Resilience4j Rate Limiter、Sentinel Rate Limiter、Spring Cloud Gateway Rate Limiter。
      • Go:Ratelimit, Uber/Ratelimit。
    • 要点:
      • 支持按用户 / 按 key / 按接口限流。
      • 与下游熔断器联动:当 Redis 故障探测到上升,网关自动降低阈值。
  • (3) 熔断 / 降级(保护数据库)

    • 目的:
      • 当 Redis / 数据库或下游服务错误率(异常、超时等)上升时,自动短路请求并返回降级结果或缓存的旧数据。
    • 常用库:
      • Sentinel(阿里开源):支持熔断、降级、限流、系统保护,尤其适合高并发微服务场景,可与 Spring Cloud Alibaba 集成。
      • Resilience4j(推荐,替代 Hystrix),支持断路器、舱壁隔离、重试、限流等。
      • Spring Cloud Circuit Breaker(抽象层,可对接 Resilience4j)。
      • Hystrix(已逐步退役 / 不推荐新项目使用)。
    • 策略:
      • 熔断器触发条件:比如,10 秒内,请求数达到阈值 20,且错误率 >= 50%。
      • 熔断后:返回降级结果(例如默认值 / 本地缓存数据 / 静态页面),同时异步收集重试 / 探测请求。
  • (4) 多级缓存(本地 + 远端)

    • 目的:
      • Redis 故障时,使用本地近端缓存(Caffeine)降低数据库回源率;
      • 利用 TTL + stale-while-revalidate 策略保证可用。
        • stale-while-revalidate 策略允许在缓存过期后,先从本地缓存(Caffeine)返回旧数据保证系统可用性,同时后台异步刷新最新数据写回本地缓存和 Redis,从而避免请求阻塞和缓存雪崩。
    • 实现:
      • 应用内 LRU(本地缓存):Caffeine、Guava Cache。
      • 远端缓存:Redis Cluster(Lettuce / Redisson 客户端)。
      • 读写策略:
        • 读取流程:本地缓存 -> Redis -> DB;
        • 写入流程:DB -> Redis -> 本地缓存。
    • 失效策略:
      • 当 Redis 不可用时,从本地缓存(Caffeine)返回 “过期但可接受的旧数据”,并异步刷新最新数据写回本地缓存和 Redis。
      • 对于强一致性场景需要谨慎考虑,必须有业务允许范围。
  • (5) 请求合并(防止大量请求回源数据库)

    • 目的:
      • 对同一 key 的高并发请求只允许一个请求回源到数据库,其余请求等待或返回本地缓存数据。
    • 实现:
      • Java 自行实现,或者借助 Guava 的 LoadingCache、Caffeine 的 AsyncLoadingCache + 互斥锁。
  • (6) 后端拒绝(数据库层保护)

    • 目的:
      • 限制数据库的并发连接数 / QPS,保证数据库不会被压垮,必要时拒绝请求或请求排队。
    • 实现:
      • 数据库代理中间件:ProxySQL、MySQL Router,用于数据库连接限流、优先级控制。
      • 应用层的连接池设置:合理设置数据库的最大连接数、超时、最大等待队列。
      • 在应用层使用熔断 / 限流机制,且当 DB 连接池耗尽时直接快速失败(不阻塞调用线程)。
  • (7) 健康检查 + 自动切换(提高 Redis 可用性)

    • 目的:
      • 及时检测 Redis 故障并自动降级到备用逻辑(或使用备用缓存)。
    • 实现:
      • 短期方案:Redis Sentinel(一主多从)或者 Redis Cluster(多主多从、分片存储),支持自动故障转移。
      • 长期方案:跨可用区 / 跨区域多活。
      • 使用客户端(Lettuce / Redisson)配置连接超时、重试与负载均衡策略。

具体可落地的架构与实现

提示

本节将给出一个典型的 Java 微服务防护栈(分层)与核心代码示例。

整体业务流程(简化)

  • Client -> API Gateway(Nginx + Lua、Envoy 限流) -> Service A
  • Service A 读取缓存:本地缓存(Caffeine)-> Redis -> DB。
  • 系统保护组件:Resilience4j(熔断 + 舱壁隔离)、Bucket4j(限流)、Singleflight(请求合并)、异步队列(重建缓存)

边缘 / 网关限流(基于 Nginx + Lua)

  • 限流实现方式

    • 在 Nginx 层使用 lua-resty-limit-traffic 模块
    • 或者自定义实现令牌桶(Token-Bucket)算法
  • 限流粒度

    • 按 IP 地址
    • 按用户 ID
    • 按 API Key(通常代表不同的调用者或应用)
  • 动态调整

    • 通过健康探测检测后端 Redis 状态
    • 当 Redis 健康下降时,动态降低令牌桶的速率
  • 实现目的

    • 控制请求流量,防止后端过载
    • 提高系统可用性和稳定性

SpringBoot + Resilience4j + Caffeine + Lettuce(伪代码)

  • 实现要点
    • readFromRedisOrDB 的 Redis 读需要设置短超时(比如 50 ~ 200ms),避免阻塞线程池。
    • Resilience4j 的 CB 配置要与业务 QPS 级别匹配:最小请求数、错误率阈值、滚动窗口大小等。
    • Singleflight(请求合并)可以避免短时间内 N 个相同 Key 的请求并发回源到数据库。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 1. 本地缓存(Caffeine)
Cache<String, Value> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build();

// 2. 请求合并(简化实现)
private final ConcurrentHashMap<String, CompletableFuture<Value>> inFlight = new ConcurrentHashMap<>();

public Value getValue(String key) {
// 1) 先查本地缓存(Caffeine)
Value v = localCache.getIfPresent(key);
if (v != null) return v;

// 2) 尝试合并请求
CompletableFuture<Value> future = inFlight.computeIfAbsent(key, k -> {
CompletableFuture<Value> f = new CompletableFuture<>();
// 异步去 Redis / 数据库获取数据
CompletableFuture.runAsync(() -> {
try {
Value val = readFromRedisOrDB(k); // 包含 Redis 超时 / 异常处理
if (val != null) localCache.put(k, val);
f.complete(val);
} catch (Exception ex) {
f.completeExceptionally(ex);
} finally {
inFlight.remove(k);
}
});
return f;
});

try {
return future.get(2, TimeUnit.SECONDS); // 设置等待超时时间
} catch (Exception e) {
// 超时或失败:返回降级数据或 Error
return getFallbackValue(key);
}
}

// 3. Resilience4j 的断路器 + 降级(示例)
@CircuitBreaker(name = "redisCB", fallbackMethod = "redisFallback")
public Value readFromRedisOrDB(String key) {
// 先读 Redis(配置了短超时),若 Redis 抛出异常,则会触发熔断规则
Value v = redisClient.get(key);
if (v != null) return v;
// Redis 缓存未命中 -> 读取数据库
Value dbv = dbRepository.findByKey(key);
// 回写 Redis 和本地缓存(Caffeine)
try { redisClient.set(key, dbv, ttl); } catch(Exception e) { /*记录日志*/ }
return dbv;
}

// 4. Redis 不可用或熔断时的降级逻辑
public Value redisFallback(String key, Throwable t) {
// 优先返回本地缓存的 "过期但可用" 数据
Value v = localCache.getIfPresent(key);
if (v != null) return v;
// 或返回静态默认/错误提示/降级响应
return defaultFallbackValue();
}

最佳实践(落地顺序建议)

  • 短期(1 ~ 2 周)

    • 在网关层加全局限流(简单的令牌桶),保护数据库。
    • 在应用内增加本地缓存(Caffeine),并使用 stale-while-revalidate 策略。
    • 给 Redis 客户端设置短超时 + 快速失败策略。
    • 在关键读取接口实现 SingleFlight(请求合并)。
    • 配置 Resilience4j 的熔断与降级(Fallback)机制。
  • 中期(1 ~ 3 个月)

    • 精细化限流(按接口 / 用户 / 业务),使用 Bucket4j 或 Envoy Rate-Limit。
    • 部署数据库代理中间件(比如 ProxySQL),实现数据库连接限流与优先级控制。
    • 增加监控 / 告警(Redis 执行命令的错误率、数据库连接数、服务断路器的开启和关闭状态)。
  • 长期(3 个月以上)

    • 优化 Redis 高可用架构(Sentinel 、Cluster + 跨可用区副本、多活 / 读写分离)。
    • 建立灰度降级策略与用户体验方案(部分用户可以看到降级内容)。
    • 容灾演练:模拟 Redis 故障,并验证熔断 / 降级 / 降级链路是否按预期工作。

推荐使用技术的组合

  • 边缘 / 网关限流:

    • Nginx + Lua(OpenResty)或 Envoy(支持熔断和限流)
  • Spring Cloud 微服务:

    • 本地缓存:Caffeine
    • 熔断 / 降级 / 限流:Resilience4j + Spring Cloud Circuit Breaker
    • 请求合并:Caffeine AsyncLoading / 自行实现 SingleFlight
    • Redis 客户端:Lettuce / Redisson(配置短超时)
    • API 网关:Spring Cloud Gateway + Redis 限流,或者 Envoy(支持熔断和限流)
    • 精细限流:Bucket4j,或者 Hazelcast / Redis 限流)
  • 数据库保护:

    • ProxySQL / MySQL Router 做数据库连接管理与慢查询保护

常见误区与注意事项

  • 不要把分布式锁依赖在 Redis 的可用性上:

    • 当 Redis 不可用时分布式锁会失效,不能用分布式锁作为保护数据库的唯一手段。
    • 在应用内使用 SingleFlight(请求合并)更可靠,用于热点请求合并。
  • 熔断参数太松或太紧都会有问题:

    • 需结合真实流量做压测与迭代调整。
  • 本地缓存生存期太久会造成一致性问题:

    • 对于强一致性场景,务必权衡本地缓存的有效时间(或在写路径强制失效 / 异步同步)。
  • 确保快速失败:

    • 使用快速失败机制,不等待、不重试、不排队。
    • 一旦线程池 / 连接池资源耗尽,会导致系统级雪崩,应避免使用无限等待。

多级缓存的读写流程

在多级缓存(本地缓存 → Redis → 数据库)架构中,读取和更新操作的流程有所不同。下面将简单梳理一下常见模式,包含缓存命中、缓存未命中、缓存更新策略以及缓存异步刷新机制。

读取流程

  • 读取流程(Read)

    • (1) 访问本地缓存(Caffeine)
      • 如果缓存命中 → 直接返回数据(延迟最低)。
      • 如果缓存未命中 → 继续访问 Redis。
    • (2) 访问 Redis(远程缓存)
      • 如果缓存命中 → 写回本地缓存(Caffeine) → 返回数据。
      • 如果缓存命中 → 继续访问数据库。
    • (3) 访问数据库
      • 获取数据库最新的数据 → 写回 Redis → 写回本地缓存(Caffeine) → 返回数据。
      • 注意,这里先写 Redis,后写本地缓存(Caffeine);优先保证全局缓存一致性,最后加速本节点访问。
  • 可选优化(stale-while-revalidate 策略)

    • 当本地缓存(Caffeine)或 Redis 缓存的数据已过期:
      • 先返回过期数据保证系统可用;
      • 在后台异步刷新缓存数据,即从数据库加载最新数据,然后写回本地缓存(Caffeine)和 Redis。
  • 工作流程图

更新流程

多级缓存的更新通常有三种策略:写穿(Write-through)、写回(Write-behind)、缓存失效(Cache-Aside)。

  • 写穿策略(Write-through)

    • 概述:
      • 更新数据库的同时立即更新缓存。
    • 流程:
    • 优点:
      • 缓存与数据库保持一致。
    • 缺点:
      • 写操作延迟增加。
  • 写回策略(Write-behind)

    • 概述:
      • 先写缓存,数据库异步刷新。
    • 流程:
    • 优点:
      • 写入延迟低,适合高吞吐写场景。
    • 缺点:
      • 短时间内缓存与数据库可能不一致,需要保证异步刷新数据库的可靠性。
  • 失效策略(Cache-Aside)

    • 概述:
      • 更新数据库后,主动删除缓存,下一次读取数据时重建缓存。
    • 流程:
    • 优点:简单、保证最终一致性。
    • 缺点:第一次访问会有缓存未命中,可能带来突发的数据库压力,建议使用分布式锁进行控制。

最佳实践组合

  • 读取操作:

    • 本地缓存 + Redis + 数据库回源
    • 可选结合 stale-while-revalidate 策略提升性能和可用性。
  • 更新操作:

    • 读多写少的场景 → 缓存失效策略(Cache-Aside)
    • 高并发写的场景 → 写回策略(Write-behind)
    • 对一致性要求高的场景 → 写穿策略(Write-through)

方案总结对比

操作流程概述优点缺点
读取读本地缓存 → 读 Redis → 读数据库延迟低,高可用复杂性高,需要多级缓存同步
写穿策略写数据库 → 写 Redis → 写本地缓存数据一致性高写延迟高
写回策略写本地缓存 → 写 Redis → 异步写数据库写性能高数据短期不一致,需要保证异步写数据库的可靠性
失效策略写数据库 → 删除 Redis → 删除本地缓存简单,最终一致性大量缓存突发未命中时,数据库压力大