概述
系列定位说明
本文是 “分布式数据架构与存储选型” 系列的第六篇。在分库分表、读写分离、多租户数据隔离、全文搜索与数据分析引擎选型、冷热分离存储架构之后,我们将视角从“数据如何组织与存储”转移到“数据如何被高效访问”。缓存是数据架构中最接近业务响应时间的一层,是多级存储体系中“极热数据”的承载者。理解多级缓存架构的设计与一致性保障,是构建高并发、低延迟系统的核心能力。本文将以“多级缓存如何根据数据温度分层,以及如何在一致性、性能、成本之间找到平衡”为主线,贯穿全文。
总结性引言
在秒杀场景中,商品库存的查询 QPS 可能在 1 秒内从 100 飙升至 100000。如果每次查询都穿透到 MySQL,DB 的连接池会被瞬间打满,响应时间从 10ms 飙升到 10s,最终导致整个系统雪崩。多级缓存架构(Caffeine L1 本地缓存 + Redis L2 分布式缓存 + MySQL L3 持久化存储)能够将 95% 以上的读请求拦截在 DB 之前 —— L1 命中延迟 <1μs,L2 命中延迟 <1ms。但缓存引入了一个核心挑战:当数据在 DB 中被更新后,如何保证缓存中的副本被及时刷新? Cache-Aside 的“写 DB 后删缓存”是最简单但非强一致的方案;延迟双删通过删除缓存→写 DB→延迟→再删除,尽力缩短不一致窗口;基于 Debezium 的 CDC 方案则通过订阅 Binlog 实现缓存的准实时刷新(延迟 50‑200ms),将缓存更新与业务代码完全解耦。当 Caffeine 的 W‑TinyLFU 将极热数据锁定在 JVM 堆内,当 Redis 的 allkeys‑lru 将温数据逐步淘汰出 L2,当布隆过滤器在查询前就拦截不存在的 Key,当 Redisson 的互斥锁在热点 Key 过期的瞬间阻止数千并发请求同时涌入 DB——这些机制背后,是对多级缓存分层、一致性权衡与故障防御的深度设计。本文将从三级缓存架构的职责划分出发,到三种一致性方案的对比选型,再到穿透、击穿、雪崩的完整防御体系,完整拆解缓存策略的全局架构决策。
核心要点
- 多级缓存架构:L1 Caffeine(极热,<1μs)→ L2 Redis(热,<1ms)→ L3 MySQL(温冷,10‑100ms),L1 命中率 >80%,L2 命中率 >95%。
- 缓存一致性三大方案:Cache‑Aside(写 DB 后删缓存)+ 延迟双删(删→写 DB→等待 Nms→再删)+ CDC 异步刷新(Binlog → Kafka → 更新缓存),延迟从秒级到 50‑200ms。
- 三大经典问题防御:穿透(布隆过滤器 + 空值缓存)、击穿(互斥锁 + 逻辑过期)、雪崩(过期随机化 + 多级降级 + 熔断)。
- Redis 客户端选型:Redisson(高级分布式对象 + 看门狗锁) vs Lettuce(异步非阻塞 + Cluster 拓扑感知) vs Jedis(同步阻塞,不推荐新项目)。
- 热点探测与保护:滑动窗口计数器探测 → 自动加载 L1 本地缓存兜底 → 异步定时刷新 → 独立线程池隔离。
- 跨系列关联:与冷热分离(第 5 篇)、多租户 ACL(第 3 篇)、CDC 发件箱(分布式事务系列第 6 篇)、分布式锁(分布式理论基石第 6 篇)的深度联动。
文章组织架构图
flowchart TD
subgraph s1 ["1. 多级缓存架构与数据温度映射"]
direction TB
A1["L1 Caffeine 极热数据"]
A2["L2 Redis 热数据"]
A3["L3 MySQL 温冷数据"]
A1 --> A2 --> A3
end
subgraph s2 ["2. 缓存读写路径与故障降级"]
B1["读路径 L1→L2→L3 回填"]
B2["写路径 Cache-Aside / CDC"]
B3["L2 故障 L1 兜底 → DB 限流"]
end
subgraph s3 ["3. 缓存一致性三大方案"]
C1["Cache-Aside 写DB后删缓存"]
C2["延迟双删 删→写→延迟删"]
C3["CDC异步刷新 Binlog→Kafka→缓存"]
end
subgraph s4 ["4. 缓存穿透防御"]
D1["布隆过滤器"]
D2["空值缓存"]
end
subgraph s5 ["5. 缓存击穿防御"]
E1["互斥锁+双重检查"]
E2["逻辑过期+异步刷新"]
end
subgraph s6 ["6. 缓存雪崩防御"]
F1["过期时间随机化"]
F2["多级降级"]
F3["熔断+Redis高可用"]
end
subgraph s7 ["7. Redis 客户端选型"]
G1["Redisson 分布式对象+看门狗"]
G2["Lettuce 异步+拓扑感知"]
G3["Jedis 同步 不推荐"]
end
subgraph s8 ["8. 热点数据探测与保护"]
H1["滑动窗口统计"]
H2["L1 本地兜底"]
H3["线程池隔离"]
end
subgraph s9 ["9. 面试高频专题"]
I1["多级缓存职责/一致性/穿透击穿雪崩/客户端选型/系统设计"]
end
s1 --> s2 --> s3 --> s4 --> s5 --> s6 --> s7 --> s8 --> s9
classDef c1 fill:#f1f5f9,stroke:#334155,stroke-width:2px,color:#0f172a
classDef c2 fill:#f8fafc,stroke:#475569,stroke-width:2px,color:#1e293b
classDef c3 fill:#e2e8f0,stroke:#64748b,stroke-width:2px,color:#1e293b
class s1,s2,s3 c1
class s4,s5,s6 c2
class s7,s8,s9 c3
架构图说明
总览说明
全文 9 个模块从多级缓存架构的基础分层出发,逐步深入到一致性方案、三大问题防御、客户端选型、热点保护,最后以 14 道面试题(含综合系统设计)收尾。模块间的依赖关系体现了从“架构认知 → 一致性权衡 → 问题防御 → 基础设施选型 → 进阶优化”的认知路径。
逐模块说明
- 模块 1‑2 建立多级缓存的全局架构:L1/L2/L3 的延迟量级、数据温度映射、预热与回填策略,以及读/写路径和故障降级策略。
- 模块 3 是全文核心——三种一致性方案的实现原理、延迟量化与选型决策树,并给出详细的对比表格和时序图。
- 模块 4‑6 构建完整的穿透、击穿、雪崩防御体系,包含布隆过滤器误判率计算、互斥锁看门狗续期、逻辑过期算法、熔断降级配置等。
- 模块 7 从基础设施视角对比 Redisson、Lettuce、Jedis 的适用场景与性能模型。
- 模块 8 将热点保护方案落地为滑动窗口探测 + 本地兜底 + 线程池隔离,并提供 Lua 脚本和 Java 实现。
- 模块 9 通过 14 道面试专题(含完整系统设计)巩固并检验读者对缓存全局决策的掌握,系统设计题配有架构图和时序图。
关键结论
多级缓存是分布式系统中应对高并发的“最后一道防线”——L1 Caffeine 解决极热数据的极致延迟(<1μs),L2 Redis 解决跨实例共享与故障降级,L3 MySQL 解决全量数据持久化。缓存一致性的选型取决于业务对不一致窗口的容忍度:Cache‑Aside 适合简单 CRUD(允许秒级不一致),CDC 异步刷新适合复杂更新场景(准实时,50‑200ms)。穿透、击穿、雪崩三种问题的防御体系是缓存架构能否扛住生产压力的关键——布隆过滤器防穿透、互斥锁防击穿、过期随机化防雪崩,三者缺一不可。
1. 多级缓存架构:L1 Caffeine + L2 Redis + L3 MySQL 的职责划分与数据温度映射
多级缓存的核心思想是将数据按照“温度”放置在不同延迟和成本代价的存储层中。根据第 5 篇冷热分离架构所阐述的数据生命周期管理,缓存本质上是极热数据与热数据的专用存储层。三级架构的划分如下:
-
L1 本地缓存 — Caffeine
数据存储于 JVM 堆内,访问延迟 <1μs。容量受限于堆内存大小,通常设置在 1GB~4GB。主要承载极热数据,例如秒杀商品库存、首页推荐位、配置类元数据等,这类数据要求微秒级访问,且允许短时间内存在一定程度的不一致。Caffeine 使用W‑TinyLFU淘汰算法,在有限内存中保持极高的命中率(通常 >80%)。L1 缓存的值通常设置极短的 TTL(1‑5s),以保证数据的新鲜度。 -
L2 分布式缓存 — Redis
独立进程运行,访问延迟 <1ms(局域网)。容量可水平扩展,通过 Redis Cluster 或 Codis 管理数百 GB 甚至 TB 级数据。承载热数据,如用户 Session、商品详情、热门文章等。这些数据访问频繁且需跨实例共享。L2 缓存的 TTL 通常设置为 5‑30min,采用allkeys‑lru淘汰策略,确保热数据驻留。Redis 的高可用通过 Sentinel 或 Cluster 提供自动故障转移。 -
L3 持久化存储 — MySQL
磁盘 IO 延迟 10‑100ms,容量近乎无限。承载温冷数据,如历史订单、用户资料、日志等全量业务数据。InnoDB 存储引擎通过 B+ 树索引和 Buffer Pool 提供一定的查询加速,但大量请求仍会受限于磁盘随机读。L3 是数据的最终权威来源,缓存层仅作为其读取副本。
数据温度与缓存层级的映射关系
- 极热数据(访问频率 >1000 QPS,要求微秒级响应)→ L1 Caffeine(TTL 1‑5s,高频刷新)
- 热数据(访问频率 100‑1000 QPS,毫秒级响应可接受)→ L2 Redis(TTL 5‑30min)
- 温冷数据(访问频率低,秒级响应可接受)→ L3 MySQL + 索引优化
这一温度映射与第 5 篇冷热分离的热/温/冷划分一脉相承:热数据留在缓存,温冷数据落在 DB 或对象存储。在多租户场景下,通过 Redis ACL 的 Key 前缀隔离 ~tenant:{tenantId}:*(详见第 3 篇)确保租户间缓存的严格隔离。
1.1 多级缓存的读写路径
读路径(如图 1)
- 应用查询 Key 时先访问 L1 Caffeine。若命中,直接返回,并统计命中率。
- L1 未命中则查询 L2 Redis。若命中,返回值并回填到 L1,设置较短的 TTL(如 2s),以应对后续的极热访问。
- L2 也未命中则查询 L3 MySQL。查到数据后,顺序回填 L2(设置业务 TTL)和 L1(设置短 TTL),然后返回。
- 若查询的数据在 DB 中不存在(例如非法用户ID),则为防止穿透,在 L2 中缓存空值(
"")并设置较短 TTL,L1 不缓存空值(也可视情况缓存极短的空值标记)。
写路径
为保证数据一致性,通常采用 Cache‑Aside 模式:
- 先更新 L3 MySQL(保证持久化)。
- 删除 L2 Redis 中的对应缓存(而非更新缓存,避免并发写造成脏数据)。
- 删除 L1 Caffeine 的对应缓存(如果 L1 是以集中方式管理,可通过消息或事件通知所有应用实例驱逐本地缓存)。
- 或者采用更先进的 CDC 异步刷新方案,由 Binlog 事件触发缓存更新,业务代码只负责写 DB。
1.2 故障降级策略
当 Redis 集群不可用或网络分区时,必须确保系统不崩溃:
- L2 故障 → 切断对 Redis 的依赖,降级为“L1 + L3”模式。此时需确保极热数据已通过预热机制加载到 L1 Caffeine 中(例如秒杀开始前全量预热商品库存到本地)。对于 L1 未命中的请求,直接穿透到 DB,但必须启用限流组件(如 Sentinel 或
Resilience4jRateLimiter)控制并发,防止 DB 过载。 - L1 + L2 同时不可用 → 这是极端灾难场景,应用直接降级到 L3 DB,并触发全局限流 + 熔断。此时可返回兜底数据(如静态页面或默认值),同时报警。
- 恢复策略:当 Redis 重新可用时,缓存的回填应渐进式进行,避免冷启动时大量 DB 查询。可通过预加载脚本或先开启热点数据回填。
图 1:多级缓存架构与读写路径图
flowchart TB
subgraph Client ["客户端"]
direction TB
App["应用服务"]
end
subgraph L1 ["L1 本地缓存 JVM 堆"]
Caffeine[("Caffeine<br/>W-TinyLFU")]
end
subgraph L2 ["L2 分布式缓存"]
Redis[("Redis Cluster<br/>allkeys-lru")]
end
subgraph L3 ["L3 持久化存储"]
MySQL[("MySQL InnoDB")]
end
App -->|"1. 读"| Caffeine
Caffeine -->|"命中 <1μs"| App
Caffeine -->|"未命中"| Redis
Redis -->|"命中 <1ms"| App
Redis -->|"回填 L1"| Caffeine
Redis -->|"未命中"| MySQL
MySQL -->|"命中 10-100ms"| App
MySQL -->|"回填 L2"| Redis
MySQL -->|"回填 L1"| Caffeine
App -->|"2. 写"| MySQL
MySQL -->|"3a. 删除 L2"| Redis
MySQL -->|"3b. 删除 L1<br/>(广播/CDC)"| Caffeine
Redis -.->|"故障降级"| Caffeine
Caffeine -.->|"故障降级"| MySQL
classDef client fill:#f1f5f9,stroke:#334155,stroke-width:2px,color:#0f172a
classDef cache1 fill:#ede9fe,stroke:#8b5cf6,stroke-width:2px,color:#3b2f4b
classDef cache2 fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a8a
classDef db fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#78350f
class App client
class Caffeine cache1
class Redis cache2
class MySQL db
架构分层与组件说明
架构分为三层:JVM 堆内的 Caffeine 本地缓存(L1),网络可达的 Redis 集群(L2)以及 MySQL 数据库(L3)。每个组件独立部署,Caffeine 随应用进程存活,Redis 是独立服务,MySQL 为持久化存储。L1 和 L2 属于缓存抽象,L3 是权威数据源。
数据流与关键路径
读取路径依次穿透 L1、L2、L3,并在命中后将数据向上回填,以提高后续命中率。写路径先持久化到 L3,再根据缓存一致性策略删除 L2 和 L1。当 L2 不可用时,应用可直接读取 L1 或 L3,保证服务的基本可用。图中的虚线表示故障降级路径。
设计意图与权衡
三级架构利用延迟和成本的梯度差异,将 95% 以上的读请求消化在 L1 和 L2 中,减轻 DB 压力。L1 到 L2 的回填与 L2 到 L1 的回填使用了不同的 TTL,避免本地缓存长期持有过期数据。写操作采用删除缓存而非更新缓存,避免了并发写带来的数据不一致风险,代价是增加了后续首次读取的延迟。
故障场景与应对
若 Redis 宕机,App 感知到连接异常后,自动开启“L1‑only”模式,所有请求先查 Caffeine;本地未命中时,以限流方式查询 MySQL。若 MySQL 压力过大触发熔断,可返回默认值。整个过程依赖 CircuitBreaker 的快速失败和预热的本地缓存,保障核心链路可用。
2. 缓存读写路径与故障降级策略
2.1 读路径详解
读路径的核心是“逐级穿透,向上回填,防止穿透”。以 Spring Boot + Caffeine + Redis 整合为例,典型的读方法如下:
@Cacheable(value = "product", key = "#productId",
cacheManager = "caffeineCacheManager", unless = "#result == null")
public Product getProduct(Long productId) {
// 尝试 L1 Caffeine(通过 @Cacheable 自动处理)
// 若 L1 未命中,进入方法体
Product product = redisTemplate.opsForValue()
.get("product:" + productId);
if (product != null) {
// L2 命中,回填 L1 已在 @Cacheable 自动完成,此处无需操作
return product;
}
// L2 未命中,查 DB
product = productMapper.selectById(productId);
if (product != null) {
// 回填 L2,并设置随机过期时间避免雪崩
redisTemplate.opsForValue().set(
"product:" + productId, product,
Duration.ofSeconds(30 + ThreadLocalRandom.current().nextInt(300)));
// 回填 L1 由 @Cacheable 自动处理,返回后存入 Caffeine
} else {
// 防止穿透:缓存空对象,TTL 较短
redisTemplate.opsForValue().set(
"product:" + productId, new Product(), Duration.ofSeconds(60));
}
return product;
}
上述代码利用 Spring Cache 抽象,@Cacheable 优先查询 Caffeine,未命中进入方法体,方法体内再查 Redis 和 MySQL,实现 L1→L2→L3 的穿透链。回填过程由 Spring 和手动编码共同完成。
2.2 写路径与一致性选择
写路径采用 Cache‑Aside 模式:
@Transactional
public void updateProduct(Product product) {
productMapper.update(product); // 先更新 DB
// 删除 L2 缓存
redisTemplate.delete("product:" + product.getId());
// 通知所有实例删除 L1 本地缓存(可通过 Redis Pub/Sub)
stringRedisTemplate.convertAndSend("cache:evict", "product:" + product.getId());
}
如果采用 CDC 异步刷新,写路径仅更新 DB,Binlog 事件会触发缓存更新逻辑。这种方式与第 5 篇的异步归档思想相通,均通过解耦来提升写入吞吐。
2.3 故障降级策略实现
当 Redis 不可用时,利用 Resilience4j 的 CircuitBreaker 进行降级:
@Bean
public CircuitBreaker redisCircuitBreaker() {
return CircuitBreaker.of("redis", CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(10)
.build());
}
public Product getProductFallback(Long productId) {
// L2 断路器打开,直接查 L1,L1 未命中则查 DB(需限流)
Product p = caffeineCache.getIfPresent(productId);
if (p != null) return p;
return rateLimiter.executeSupplier(() -> productMapper.selectById(productId));
}
当 L1 + L2 均不可用时,执行 @RateLimiter 限流 + @Fallback 返回兜底数据,避免 DB 击穿。详细限流熔断机制见高并发与稳定性工程系列第 2 篇。
3. 缓存一致性的三大方案:Cache-Aside + 延迟双删 + CDC 异步刷新
缓存一致性问题是多级缓存架构中最棘手的挑战。主流的三种工程方案在一致性窗口、实现复杂度和适用场景上各有优劣。
3.1 Cache‑Aside(旁路缓存)模式
原理
- 读:先查缓存,未命中则查 DB 并回填缓存。
- 写:先更新 DB,然后删除缓存(不更新缓存)。
删除而非更新的原因
更新缓存可能涉及复杂计算,写入缓存增加延迟,且并发写可能导致缓存与 DB 数据不一致(如后写的 DB 却被先到的缓存更新覆盖)。删除缓存让下次读请求自然回填,避免不一致窗口扩大。
一致性问题
写 DB 成功后,删除缓存可能失败(网络超时、Redis 故障),导致缓存中保留旧数据。解决方案:
- 删除失败重试:利用 Spring Retry 的
@Retryable,最多重试 3 次,指数退避。 - 如果最终一致要求高,可采用 CDC 异步删除保证缓存最终与 DB 一致。
示例代码
@Transactional
@Retryable(value = CacheEvictException.class, maxAttempts = 3, backoff = @Backoff(delay = 100))
public void updateOrder(Order order) {
orderMapper.update(order);
try {
redisTemplate.delete("order:" + order.getId());
} catch (Exception e) {
throw new CacheEvictException("Redis delete fail", e);
}
}
3.2 延迟双删模式
原理
- 先删除缓存。
- 更新 DB。
- 等待 N 毫秒(例如 500ms),再次删除缓存。
目的
防止在更新 DB 期间,其他读请求将 DB 旧数据回填到缓存中。延迟时间应大于“业务读取 + 回填缓存最大耗时”,典型值 100‑300ms,可视情况通过压测确定。第二次删除采用异步执行 @Async。
示例代码
@Async
public void doubleDeleteCache(String key) {
redisTemplate.delete(key);
try {
Thread.sleep(500); // 可配置的延迟时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redisTemplate.delete(key);
}
局限性
延迟时间难以精确(受 GC、网络抖动影响),只能缩小不一致窗口,不能完全消除。在高并发下,仍可能读到旧数据。
3.3 CDC 异步刷新模式(推荐)
原理
通过 Debezium 订阅 MySQL Binlog,当业务表发生变更(INSERT/UPDATE/DELETE)时,Binlog 事件被捕获 → 发送到 Kafka → 缓存刷新服务消费消息 → 更新或删除 Redis/Caffeine 中的缓存数据。业务服务只需写 DB,完全无需感知缓存刷新。这本质上是一种最终一致性方案,延迟通常在 50‑200ms,达到准实时。
优势
- 缓存更新与业务代码解耦。
- Binlog 天然保证变更顺序,基于
messageKey可实现幂等消费。 - 适合复杂的缓存更新场景,例如订单状态变更需同时更新订单缓存、用户订单列表缓存、统计缓存等多个 Key。
实现示例
@KafkaListener(topics = "cdc.orderdb.orders")
public void onOrderChanged(ConsumerRecord<String, OrderEvent> record) {
OrderEvent event = record.value();
// 幂等处理:基于 offset 或者 event id 去重
redisTemplate.opsForValue().set(
"order:" + event.getOrderId(),
event.getPayloadAfter(),
Duration.ofMinutes(30));
// 同时可驱逐 L1 本地缓存(广播)
stringRedisTemplate.convertAndSend("cache:evict", "order:" + event.getOrderId());
}
CDC 方案的整体流程与分布式事务系列第 6 篇的发件箱模式一脉相承,利用了 Binlog 的可靠性保证最终一致性。
图 2:缓存一致性三大方案时序图
sequenceDiagram
participant App as 应用
participant DB as MySQL
participant Redis as Redis
participant Kafka as Kafka
participant Consumer as 缓存消费者
Note over App,Redis: Cache-Aside 模式
App->>DB: 更新数据
DB-->>App: OK
App->>Redis: 删除缓存
Note right of App: 不一致窗口:DB提交后至缓存删除完成
Note over App,Redis: 延迟双删模式
App->>Redis: 删除缓存
App->>DB: 更新数据
DB-->>App: OK
App->>App: 等待 500ms
App->>Redis: 再次删除缓存
Note right of App: 不一致窗口:两次删除之间(包含DB旧数据回填风险)
Note over App,Consumer: CDC 异步刷新模式
App->>DB: 更新数据
DB-->>App: OK
DB->>Kafka: Binlog 事件 (Debezium)
Kafka->>Consumer: 推送变更事件
Consumer->>Redis: 更新/删除缓存
Note right of Consumer: 不一致窗口:DB提交后至消费者处理完成 (50-200ms)
架构分层与组件说明
时序图对比了应用、MySQL、Redis、Kafka 和缓存消费者之间的交互。Cache‑Aside 和延迟双删都在应用层主动操作缓存,而 CDC 方案将缓存刷新职责转移至独立的消费者服务。
数据流与关键路径
Cache‑Aside 先写 DB 后删缓存;延迟双删在写 DB 前后各删一次缓存;CDC 模式则完全依赖 Binlog→Kafka→消费者链路,写 DB 与缓存更新异步解耦。每个方案的不一致窗口已在图中标注。
设计意图与权衡
Cache‑Aside 实现最简单,适合低并发或最终一致性要求不严的场景。延迟双删通过牺牲少许性能(等待与额外删除)来缩小不一致窗口,但仍存在残留风险。CDC 模式将一致性推至准实时,代价是引入 Kafka 和 Debezium 的运维复杂度。
故障场景与应对
- Cache‑Aside 删除失败可通过重试缓解;极端的删除失败会导致长期不一致,可辅以定时校对任务。
- 延迟双删的第二次删除若失败,策略降级为 Cache‑Aside。
- CDC 模式中消费者处理失败时,Kafka 消息会被重新投递,基于幂等设计保证最终一致;若 Kafka 积压严重则延迟增加。
三种方案对比表
| 方案 | 实现复杂度 | 不一致窗口 | 适用场景 | 关键风险 |
|---|---|---|---|---|
| Cache-Aside | 低 | DB 提交后到缓存删除完成(<1s) | 简单 CRUD,允许短暂不一致 | 删除失败导致长期不一致 |
| 延迟双删 | 中 | 第二次删除前(通常 0.5s 内) | 可容忍亚秒级不一致,并发较高 | 延迟时间难以精确,GC 抖动 |
| CDC 异步刷新 | 高 | Binlog 投递 + 消费(50-200ms) | 复杂缓存更新,多 Key 联动 | 依赖 Kafka/Debezium,运维复杂 |
图 7:三种一致性方案选型决策树
flowchart TD
Start[选择一致性方案] --> Q1{业务是否允许\n短暂不一致?}
Q1 -- 否 --> Direct[不使用缓存,直接读DB\n或使用Redis WAIT同步]
Q1 -- 是 --> Q2{缓存更新逻辑\n是否复杂?\n(多Key联动等)}
Q2 -- 是 --> CDC[CDC异步刷新\n(Binlog->Kafka)]
Q2 -- 否 --> Q3{能容忍的不一致\n窗口大小?}
Q3 -- <1秒 --> CA[Cache-Aside + 重试]
Q3 -- 极小但仍<1s --> DD[延迟双删]
Q3 -- 50-200ms --> CDC2[CDC异步刷新]
4. 缓存穿透防御:布隆过滤器 + 空值缓存
缓存穿透是指查询一个数据库中根本不存在的数据,由于缓存中也没有,每次请求都会直接打到 DB。典型场景如恶意攻击者用随机 ID 查询。
4.1 布隆过滤器
布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。其特性是:若判断为不存在,则一定不存在;若判断为存在,则有一定概率误判(假阳性)。利用这一特性,可以将所有合法的 Key 提前加载到布隆过滤器中,查询前先判断 Key 是否可能存在。
Redisson 集成
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("userIdFilter");
// 初始化:预期 1000 万用户,误判率 1%
bloomFilter.tryInit(10_000_000L, 0.01);
// 批量将现有用户 ID 加入布隆过滤器
List<User> users = userMapper.selectAll();
users.forEach(u -> bloomFilter.add(String.valueOf(u.getId())));
// 查询时先检查布隆过滤器
public User getUserById(String userId) {
if (!bloomFilter.contains(userId)) {
return null; // 直接返回,不查缓存和DB
}
// 正常缓存-数据库查询流程...
}
误判率与内存计算
布隆过滤器的大小公式:m = -n * ln(p) / (ln(2))^2,其中 n 为预期元素数量,p 为目标误判率。以 n=1000 万,p=1% 为例,m ≈ 95,850,000 bits ≈ 11.4 MB。每个元素约 1.2 字节,内存开销极低。
4.2 空值缓存
对于少量穿透请求,也可以缓存“空值”来应对。当 DB 查询返回 null 时,在 Redis 中缓存一个空对象或特殊标记(如 ""),并设置较短的过期时间(如 60 秒),防止同一 Key 频繁穿透到 DB。
if (product == null) {
redisTemplate.opsForValue().set("product:" + id, new Product(), Duration.ofSeconds(60));
return null;
}
图 3:缓存穿透防御原理图
flowchart TD
Request[请求 Key] --> BF{布隆过滤器\nKey 可能存在?}
BF -- 否 --> ReturnNull1[直接返回 null]
BF -- 是 --> Cache{Redis 缓存}
Cache -- 命中且值非空 --> ReturnVal[返回值]
Cache -- 命中但为空标记 --> ReturnNull2[返回 null]
Cache -- 未命中 --> DB[(MySQL)]
DB -- 存在 --> SetCache[回设 Redis + L1]
DB -- 不存在 --> SetNull[缓存空标记 TTL 60s]
SetCache --> ReturnVal
SetNull --> ReturnNull2
架构分层与组件说明
请求先经过布隆过滤器(内存或 Redis 中),再进入 L2 和 L3。布隆过滤器作为第一道防线,空值缓存作为补充。
数据流与关键路径
布隆过滤器判断不存在 → 直接返回,避免任何缓存和 DB 查询。若可能存在,则进入正常多级缓存流程;DB 查询为 null 时写入一个带有 TTL 的空标记。
设计意图与权衡
布隆过滤器大大减少了非法 Key 对 DB 的压力,其代价是内存占用的增加和极低的误判率。空值缓存实现简单,适用于穿透流量不大的场景,但若大量不同 Key 穿透,会造成 Redis 内存浪费。
故障场景与应对
布隆过滤器初始化时需全量数据,若数据量极大,可结合分区布隆过滤器。新增合法 Key 时需及时添加到过滤器,否则会被误判。可采用双写(写入 DB 同时加入布隆过滤器)或定时重建策略。
5. 缓存击穿防御:互斥锁 + 逻辑过期 + 异步刷新
缓存击穿是指某个热点 Key 在过期的瞬间,大量并发请求直接打到 DB。解决办法是保证只有一个请求去加载数据,其他请求等待或使用旧值。
5.1 互斥锁方案
利用 Redisson 的分布式锁 RLock 实现互斥,第一个线程获取锁并查询 DB 回填缓存,后续线程自旋等待或快速失败。
双重检查代码
public Product getProductWithLock(Long productId) {
String cacheKey = "product:" + productId;
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
RLock lock = redisson.getLock("lock:" + cacheKey);
try {
// 尝试加锁,等待 5 秒,锁租约 30 秒自动释放
if (lock.tryLock(5, 30, TimeUnit.SECONDS)) {
// 双重检查:其他线程可能已回填
product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
product = productMapper.selectById(productId);
if (product != null) {
// 随机过期时间防止雪崩
redisTemplate.opsForValue().set(cacheKey, product,
30 + ThreadLocalRandom.current().nextInt(300), TimeUnit.SECONDS);
} else {
// 缓存空值防穿透
redisTemplate.opsForValue().set(cacheKey, new Product(), 60, TimeUnit.SECONDS);
}
return product;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
// 未获取到锁时快速失败或返回旧值(逻辑过期方案)
return getProductFallback(productId);
}
Redisson 锁的看门狗机制
tryLock 指定 leaseTime 为 30 秒,如果业务执行未完成,看门狗(Watchdog)会每 10 秒自动续期,防止死锁。这在缓存加载可能较慢的场景下尤为关键。详细锁机制见分布式理论基石第 6 篇。
5.2 逻辑过期 + 异步刷新
逻辑过期方案不设置 Redis key 的 TTL,而是在 value 中存储一个 expireTime 字段。读取时判断是否过期:未过期直接返回;已过期则返回旧值,同时启动异步任务去刷新缓存,从而避免击穿。
实现要点
- 缓存 value 结构:
{"data": {...}, "expireAt": 1692000000000} - 读取时解析 expireAt,如果当前时间 > expireAt,则提交异步刷新任务,并返回旧数据。
- 使用互斥锁确保只有一个异步任务去加载 DB,其余线程直接返回旧值。
图 4:缓存击穿防御原理图
flowchart TD
Request[大量请求 key] --> Check{Redis 缓存存在?}
Check -- 是 --> Expired{逻辑过期?}
Expired -- 否 --> ReturnFresh[返回新鲜值]
Expired -- 是 --> TryLock[尝试获取互斥锁]
TryLock -- 成功 --> LoadDB[查询 DB 回填缓存]
LoadDB --> ReleaseLock[释放锁]
TryLock -- 失败 --> ReturnOld[返回旧值]
Check -- 否 --> LockMiss[获取锁查 DB]
LockMiss -->|锁成功| DBLoad[查询 DB 并回填]
LockMiss -->|锁超时| Fail[返回兜底或旧值]
架构分层与组件说明
利用 Redis 存储包含过期时间的复合值,应用层进行过期判定和异步刷新。Redisson 锁用于协调并发的加载操作。
数据流与关键路径
读请求检测到 Key 存在但逻辑过期,立即返回旧数据,同时通过锁竞争选出一个线程异步刷新。对于完全无缓存的击穿,线程先获取锁,双重检查后加载。
设计意图与权衡
互斥锁方案保证了数据强一致(回填后立马一致),但增加了等待时间;逻辑过期方案可保证零等待(始终返回旧值),但容忍更长时间的不一致。
故障场景与应对
- 异步刷新任务失败,旧值会一直返回直至下次刷新成功,需配合告警和兜底限流。
- 获取锁超时后快速失败,可结合断路器避免 DB 崩溃。
6. 缓存雪崩防御:过期随机化 + 多级降级 + 熔断 + Redis 高可用
缓存雪崩是指大量缓存 Key 在同一时间过期,或者 Redis 集群宕机,导致大量请求直接冲击 DB。
6.1 过期时间随机化
在设置 TTL 时,加入随机偏移,将过期时间分散到不同的时间点,避免集中失效。
int baseTTL = 30 * 60; // 30 分钟
int randomTTL = ThreadLocalRandom.current().nextInt(300); // 0-300 秒
redisTemplate.opsForValue().set(key, value, baseTTL + randomTTL, TimeUnit.SECONDS);
6.2 多级降级与熔断
- L2 Redis 宕机:本地 Caffeine 缓存保留热点数据作为兜底。应用可开启“L1‑only”模式,非热点数据降级到 DB 并限流。
- DB 压力过大:利用 Resilience4j 熔断器,当 DB 响应时间超过阈值(例如 200ms)时熔断,直接返回缓存旧值或静态默认数据。
6.3 Redis 高可用
- Sentinel 模式:监控主从节点,自动故障转移。适合中等规模、对一致性要求较高的场景。
- Cluster 模式:数据分片,每个分片主从高可用,支持水平扩展。Redis Cluster 7.x 的槽位重分配机制和自动 failover 可在部分节点宕机时保持服务。
图 5:缓存雪崩防御原理图
flowchart TB
subgraph RedisHA [Redis 高可用]
M[主节点] --> S[从节点]
Sentinel[Sentinel] --> M
Sentinel --> S
end
App --> L1[Caffeine L1]
App --> L2[Redis L2]
L2 -.->|故障| L1
L1 -.->|未命中 限流| DB[MySQL]
DB --> CB[Resilience4j 熔断器]
CB -- 打开 --> Fallback[返回兜底数据]
CB -- 半开/关闭 --> DB
RedisHA -.- App
架构分层与组件说明
Redis 层面通过 Sentinel 或 Cluster 实现高可用;应用层面通过本地缓存和熔断器实现多重降级保护。过期时间随机化是应用层的预防措施。
数据流与关键路径
在 Redis 正常时,请求优先走 L2→L1。Redis 故障时,应用启用降级策略,读 L1 和 DB,但 DB 前有熔断器保护,避免雪崩。
设计意图与权衡
过期随机化以极小的成本避免了集中失效;多级降级和熔断则保证了在部分组件故障时系统的柔性可用。Redis 高可用是基础保障,但无法解决所有故障模式。
故障场景与应对
- Redis 集群完全不可达:应用依靠 L1 和 DB 限流支撑,待 Redis 恢复后,需冷启动预热。
- 熔断器打开后,服务返回兜底数据,可能短暂损失实时性,但保障了整体可用性。
7. Redis 客户端选型:Redisson vs Lettuce vs Jedis
7.1 Redisson
核心能力
- 提供分布式对象抽象:
RMap、RList、RQueue、RAtomicLong等。 - 分布式锁全家桶:
RLock(可重入)、RFairLock(公平锁)、RReadWriteLock(读写锁),支持看门狗自动续期(lockWatchdogTimeout默认 30s,每 10s 续期一次)。 RLiveObjectService:提供 ORM 风格的缓存操作,可以将 Java 对象直接映射到 Redis Hash,简化 CRUD。- 内置布隆过滤器
RBloomFilter,以及RHyperLogLog等概率数据结构。
适用场景
- 需要分布式锁、需要将 Redis 作为高级数据结构引擎(如延迟队列、限流器)。
- 团队希望减少与 Redis 原生命令的直接交互,通过符合 Java 习惯的 API 操作。
7.2 Lettuce
核心能力
- 基于 Netty 的异步、非阻塞客户端,天然适合高并发。
ConnectionPool管理连接复用,避免频繁创建连接。ClusterClient提供拓扑感知能力:ClusterTopologyRefresh可定期拉取或通过 Pub/Sub 实时感知 Cluster 的 slot 迁移和节点变化,避免请求因集群拓扑变更而失败。- 支持
ReadFrom.REPLICA实现读写分离,提高读吞吐。
适用场景
- 高并发、低延迟要求的 Redis Cluster 部署。
- 需要精细控制连接池、超时和异步编程模型。
7.3 Jedis
特点
同步阻塞客户端,API 简洁直观,易于入门。但连接资源管理依赖连接池(如 JedisPool),在高并发下线程上下文切换开销大,且缺乏对 Cluster 的透明支持(需使用 JedisCluster)。Spring Boot 2.x 默认 Lettuce,Spring Boot 3.x 已移除 Jedis 支持。
选型决策
- 需要分布式锁、高级 ORM → Redisson
- Redis Cluster + 高并发 + 异步非阻塞 → Lettuce
- 简单同步场景或遗留系统 → 可继续使用 Jedis,但不推荐新项目。
Lettuce 拓扑感知配置示例:
@Bean
public LettuceConnectionFactory lettuceConnectionFactory() {
RedisClusterConfiguration clusterConfig =
new RedisClusterConfiguration(Arrays.asList("node1:6379", "node2:6379"));
clusterConfig.setMaxRedirects(3);
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.readFrom(ReadFrom.REPLICA_PREFERRED)
.build();
LettuceConnectionFactory factory = new LettuceConnectionFactory(clusterConfig, clientConfig);
factory.setShareNativeConnection(false); // 开启连接池需此项
return factory;
}
8. 热点数据探测与保护方案:滑动窗口 + L1 兜底 + 线程池隔离
8.1 滑动窗口热点探测
利用 Redis 的有序集合 (ZSET) 实现滑动窗口计数。Key 的每次访问都记录当前时间戳,统计窗口内的访问次数,超过阈值即判定为热点。
Lua 脚本实现原子化
-- KEYS[1]: 统计 key 例如 "hot:count:product:1001"
-- ARGV[1]: 当前毫秒时间戳
-- ARGV[2]: 窗口大小(毫秒)
-- ARGV[3]: 阈值
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2])
local count = redis.call('ZCARD', KEYS[1])
if count > tonumber(ARGV[3]) then
return 1 -- 标记为热点
else
return 0
end
应用层在每次读请求时执行此脚本,若返回 1 则标记为热点 Key,并触发保护动作。
8.2 热点保护机制
- 本地缓存兜底:将热点 Key 的值加载到 Caffeine 本地缓存(设置较短的 TTL,如 1‑3 秒),从而绝大部分请求被 L1 拦截,减轻 Redis 压力。
- 异步定时刷新:后台线程每隔 N 秒(如 2 秒)从 DB 拉取最新数据,更新 Caffeine 和 Redis,确保数据相对新鲜。
- 线程池隔离:热点请求可能瞬间爆发,若与其他普通请求共享线程池,可能导致普通请求超时。可将热点 Key 的请求路由到独立的线程池处理,实现资源隔离。
图 6:热点数据探测与保护原理图
flowchart LR
Request["请求"] --> Window["滑动窗口统计<br/>Redis Lua"]
Window -- "超过阈值" --> MarkHot["标记为热点"]
MarkHot --> Local["加载至 L1 Caffeine"]
Local --> Refresh["异步定时刷新<br/>(每2s从DB)"]
Refresh --> UpdateL1L2["更新 L1 和 L2"]
MarkHot --> Isolate["独立线程池处理"]
Request --> Normal["非热点走常规路径"]
classDef default fill:#f1f5f9,stroke:#334155,stroke-width:2px,color:#0f172a
架构分层与组件说明
热点探测模块独立于业务缓存,通过 Lua 脚本在 Redis 中完成轻量级统计。探测结果驱动本地缓存加载和线程池隔离。
数据流与关键路径
每个请求都会触发滑动窗口计数;一旦判定为热点,应用将该 Key 的数据长驻 L1,并启动后台刷新任务。热点请求被路由到独立线程池,避免影响其他请求。
设计意图与权衡
滑动窗口算法在 Redis 内存中维护计数器,占用额外内存,但能实时检测突发热点。独立线程池隔离可以有效防止热点请求挤占资源,但需合理设置线程数上限。
故障场景与应对
若 Redis 不可用,热点探测功能会降级,可以依赖预设的热点列表或本地统计。异步刷新失败后,L1 缓存会短暂持有稍旧的数据,可通过告警介入。
9. 面试高频专题
Q1: 多级缓存架构(L1/L2/L3)各层的职责、延迟、容量边界是什么?为什么需要三级而不是两级?
一句话:L1 解决极热数据微秒级延迟,L2 解决跨实例共享毫秒级,L3 解决全量持久化,三级设计是为了在延迟、成本和容量间取得平衡。
详解:L1 Caffeine 是 JVM 堆内缓存,延迟 <1μs,容量受堆内存限制(通常 1‑4GB)。L2 Redis 是分布式缓存,延迟 <1ms,容量可水平扩展至 TB 级。L3 MySQL 提供持久化,延迟 10‑100ms。若只有两级(如 Redis + DB),无法应对极高 QPS 下 Redis 的网络开销;若只有 Caffeine + DB,则无法跨实例共享数据,本地缓存冗余且一致性难维护。
追问:
- 为什么不用堆外缓存作为 L1?
- 如果 Redis 内存足够大,是否可以去掉 L1?
- 多级缓存如何协调回填策略以避免缓存污染?
加分回答:引入 Caffeine 的W‑TinyLFU能够在有限内存中精准保留高价值数据,配合 Spring Cache 的@Cacheable同步回填,实现零侵入的热数据分层。
Q2: 数据温度(极热/热/温冷)如何映射到多级缓存?热点数据如何自动探测与保护?
一句话:极热数据存 L1(TTL 秒级),热数据存 L2(TTL 分钟级),温冷数据查 L3,热点通过滑动窗口探测并自动提升到 L1 且线程池隔离。
详解:极热数据如秒杀库存要求微秒级响应,存 Caffeine,更新频繁。热数据如商品详情存 Redis,利用其丰富数据结构和过期策略。热点探测利用 Redis ZSET 滑动窗口,单位时间访问次数超阈值则触发保护:加载到本地缓存 + 异步刷新 + 独立线程池。
追问:
- 极热数据的更新如何保持一致性?
- 探测窗口大小和阈值如何选取?
- 热点消失后如何优雅下线本地缓存?
加分回答:通过动态调整窗口和阈值,结合机器学习预测热点,可提前预热,进一步降低延迟。
Q3: Cache-Aside 的“写 DB 后删缓存”为什么是删缓存而不是更新缓存?缓存删除失败怎么办?
一句话:删除避免并发写导致缓存与 DB 不一致,且更新缓存增加写延迟;删除失败可重试或降级为 CDC 异步删除。
详解:先更新 DB 再删除缓存是经典的顺序。如果选择更新缓存,当并发写请求交错执行时,可能造成后提交的 DB 事务被先完成的缓存更新覆盖,导致永久不一致。删除失败时,先通过 @Retryable 重试 3 次,若仍失败,依靠缓存本身的 TTL 过期自我修复,或通过 CDC 兜底删除。
追问:
- 为什么不能先删缓存再更新 DB?
- 分布式事务能解决缓存一致性吗?
- 在高并发下重试删除会不会加剧 Redis 压力?
加分回答:结合 Binlog 监听的删除方案,可彻底解耦业务与缓存,保证最终一致性,适用于核心交易链路。
Q4: 什么是延迟双删?延迟时间如何估算?为什么延迟双删不能完全消除不一致?
一句话:写操作前删缓存,写 DB 后等待一段时间再删一次,以清除写期间回填的旧数据;延迟时间通常取“读 DB + 回填缓存”的最大耗时,但不能完全消除不一致窗口。
详解:延迟双删的延迟时间一般为 200‑500ms,通过压测统计最大回填延迟。然而线程调度、GC 停顿、网络波动等都会导致延迟,使得第二次删除仍可能漏掉在延迟窗口内读到的旧数据回填。因此该方案只能降低不一致概率,无法根除。
追问:
- 如何通过异步方式实现第二次删除?
- 延迟双删与先更新 DB 再删缓存相比,优点在哪里?
- 有没有办法结合 CDC 替代延迟双删?
加分回答:在实际生产环境中,延迟双删可以作为低成本过渡方案,最终应演进到基于 CDC 的最终一致性。
Q5: CDC 异步刷新缓存的核心原理是什么?相比 Cache-Aside 有何优势?延迟是多少?
一句话:通过订阅 DB 的 Binlog,将数据变更事件异步传播到缓存刷新服务;优势是完全解耦业务代码、保证顺序、可处理复杂更新;延迟通常 50‑200ms。
详解:Debezium 捕获 Binlog,投递到 Kafka,缓存消费者消费消息更新 Redis 或驱逐本地缓存。相比 Cache‑Aside,业务代码无需任何缓存逻辑,且 Binlog 的顺序性保证变更被正确应用。延迟受制于 Binlog 提交、Kafka 投递和消费速度。
追问:
- CDC 方案如何保证幂等消费?
- 如果 Kafka 积压,缓存不一致时间是否会无限放大?
- CDC 方案是否适用于所有数据库?
加分回答:可以将 CDC 与 CQRS 结合,为不同查询模型构建专用缓存视图,进一步提升读取性能。
Q6: 布隆过滤器如何防止缓存穿透?误判率与内存占用如何计算和权衡?
一句话:布隆过滤器提前判断 Key 是否可能存在,若不存在则直接返回,从而保护 DB;内存占用和误判率根据公式 m = -n*ln(p)/(ln2)^2 调整。
详解:将合法 ID 加入布隆过滤器,查询前判断。误判率 1% 时,1 亿元素约需 114MB 内存。对于误判导致的穿透,可配合空值缓存兜底。内存占用与误判率成反比,需在可接受内存和误判成本间权衡。
追问:
- 布隆过滤器如何支持数据删除?
- 如何应对布隆过滤器误判导致的空值缓存失效?
- 布隆过滤器如何初始化与更新?
加分回答:采用计数布隆过滤器可支持删除,但内存占用更高;或结合 Redis 的SETBIT自定义实现,可灵活调整。
Q7: 缓存击穿与缓存穿透的本质区别是什么?互斥锁和逻辑过期各如何解决击穿?
一句话:穿透是访问不存在的数据,击穿是热点数据过期瞬间大量并发;互斥锁让一个线程加载数据,逻辑过期则返回旧值并异步刷新。
详解:穿透攻击不存在的 Key,需布隆过滤器防。击穿是由于热点 Key 过期,用互斥锁(Redisson RLock)保证只回源一次;逻辑过期方案不设 Redis TTL,在值中存过期时间,过期时返回旧值并异步更新,完全避免等待。
追问:
- 互斥锁方案是否可能导致死锁?
- 逻辑过期方案的数据一致性窗口多大?
- 在热点 Key 自动探测场景下,如何自动选择防御策略?
加分回答:可结合两种方案:正常时使用逻辑过期 + 异步刷新,当异步刷新失败时回退到互斥锁保证至少有一次加载成功。
Q8: 缓存雪崩如何防御?过期随机化、多级降级、熔断各起什么作用?
一句话:随机化防止集中过期,多级降级在 Redis 故障时由本地缓存兜底,熔断保护 DB 不被过量请求打垮。
详解:过期时间加入随机值(如 ±5min),避免同时失效。L2 故障时 L1 撑住热点数据,DB 前加限流熔断,例如用 Resilience4j,当错误率或慢调用超过阈值时打开熔断器,快速失败或返回兜底数据。
追问:
- 随机化设置多大范围合适?
- 如果本地缓存也挂了呢?
- 熔断器的半开状态如何配合缓存恢复?
加分回答:通过监控系统预测流量洪峰,提前扩容 Redis 并对热点数据预热,可极大降低雪崩概率。
Q9: Redisson 的看门狗续期在缓存击穿的互斥锁中起什么作用?如何防止死锁?
一句话:看门狗自动续期锁的租约,防止业务执行超时导致锁释放而其他线程并发加载;死锁可通过锁超时和 finally 释放避免。
详解:加载 DB 回填缓存可能因网络或慢查询耗时较长,Redisson 的看门狗每 10 秒将锁续期到 30 秒,保证持有锁的线程能完成操作。应用代码需在 finally 块中判断 lock.isHeldByCurrentThread() 并释放,防止因为异常导致锁未被释放。
追问:
- 如果不指定 leaseTime,默认的 30 秒足够吗?
- 看门狗机制在 Redis 主从切换时是否有风险?
- Redisson 的读写锁如何优化缓存刷新?
加分回答:在刷新缓存时,可使用读写锁,允许多个读并发,但写时排他,进一步提升吞吐。
Q10: Lettuce 的 ClusterClient 如何感知 Redis Cluster 拓扑变化?为什么比 Jedis 更适合高并发?
一句话:通过定时拉取 slot 信息或订阅 Pub/Sub 更新拓扑,结合异步非阻塞 IO,避免 Jedis 同步阻塞的线程开销。
详解:Lettuce 的 ClusterTopologyRefresh 会定期获取 CLUSTER SLOTS 或者接收 MOVED/ASK 重定向时自适应刷新。其基于 Netty 的响应式模型,在高并发下能使用少量连接承载大量请求,避免线程上下文切换。Jedis 同步模型需要为每个请求分配线程或连接,扩展性差。
追问:
- 拓扑刷新频率设置多少合适?
- 读写分离时如何处理主从延迟?
- Lettuce 的连接池如何配置?
加分回答:结合ReadFrom.REPLICA_PREFERRED和拓扑感知,可实现就近读取副本,显著提升吞吐。
Q11: Redis Cluster 和 Sentinel 在缓存高可用上各有什么优劣?如何选型?
一句话:Cluster 支持数据分片与水平扩展,适合海量数据;Sentinel 架构简单,适合数据量可控、强依赖主从切换的场景。
详解:Cluster 将数据分散到多个分片,每个分片可独立做主从,支持动态扩缩,但客户端需感知 slot 分布。Sentinel 只监控主从节点,故障转移简单,但数据全在一组主从中,容量受单机限制。缓存场景数据量大、要求高可用且需水平扩展时选 Cluster;数据量小、要求部署简单选 Sentinel。
追问:
- Cluster 的 MOVED 和 ASK 重定向对延迟有何影响?
- Sentinel 模式下如何实现读写分离?
- Codis 或 Twemproxy 是否还值得考虑?
加分回答:Redis Cluster 7.x 支持 Pub/Sub 跨节点广播,进一步简化了缓存驱逐通知的实现。
Q12: 多级缓存架构中 L2 Redis 宕机时如何降级?L1 Caffeine 如何撑住热点数据?
一句话:通过断路器感知 Redis 不可用,降级到 L1‑only 模式,热点数据必须提前预热或通过自动探测加载到 Caffeine。
详解:故障前需要将关键热点数据(如首页、活动商品)预热到 Caffeine,并设置较短的 refresh 策略。当 Redis 断开时,应用直接读本地缓存;对于未命中的请求,根据限流额度访问 DB,防止雪崩。同时后台尝试恢复连接,一旦恢复,逐渐从 DB 回填 Redis。
追问:
- 预热机制如何设计?
- 本地缓存内存不够怎么办?
- 降级期间如何保证最终一致性?
加分回答:结合热探测的自动升热和冷数据淘汰,可在 Redis 恢复后逐步重新建立多级缓存。
Q13: 热点 Key 为什么不建议设置 Redis TTL?逻辑过期与异步刷新如何配合?
一句话:设置 TTL 可能导致热点 Key 过期瞬间击穿;逻辑过期在值中记录过期时间,过期时返回旧值并异步刷新,避免击穿。
详解:热点 Key 使用 Redis 内置 TTL 会在过期时立即删除,造成大量请求同时回源。逻辑过期将过期控制权交给应用:读到过期值时仍返回旧数据,同时仅一个线程异步加载新数据。这需要应用层定义 value 的 expireAt 字段,并配合互斥锁保证只有一个刷新任务。
追问:
- 异步刷新失败怎么办?
- 逻辑过期如何确保各个实例的时钟一致性?
- 如何管理逻辑过期的清理?
加分回答:可以将逻辑过期与热点探测联动,仅对识别出的热点 Key 采用逻辑过期策略,普通 Key 仍用 TTL,兼顾一致性和内存。
Q14: (系统设计题)电商秒杀系统,商品库存查询 QPS 100000,要求延迟 <1ms,库存扣减由异步消息处理。请给出:
- 多级缓存架构设计(L1/L2/L3 的选型、配置、数据温度划分)
- 缓存一致性与库存扣减后的缓存刷新方案(Cache-Aside + CDC 刷新选型理由)
- 热点库存数据的探测与保护方案(滑动窗口 + L1 本地兜底 + 线程池隔离)
- Redis 客户端选型(Redisson vs Lettuce)及理由
- 缓存穿透/击穿/雪崩的防御措施与参数配置
- Redis 宕机时的降级方案与恢复策略
详细解答
整体架构概述
秒杀系统查询链路面临极高读 QPS(10 万+),需要将绝大部分流量拦截在 DB 之外,同时库存扣减的准确性依赖异步消息和最终一致性。下图展示了基于多级缓存的查询架构和库存同步流程。
秒杀系统多级缓存架构图
flowchart TB
subgraph User[用户请求]
Query[库存查询请求]
end
subgraph Gateway[接入层]
Nginx[Nginx 限流/静态化]
end
subgraph App[秒杀服务]
L1[Caffeine L1\n热点库存缓存]
HotDetector[热点探测]
IsolatePool[热点线程池]
AsyncRefresh[异步刷新任务]
end
subgraph Middleware[中间件]
Redis[(Redis Cluster L2\n库存缓存)]
Kafka[Kafka 消息队列]
end
subgraph Storage[持久层]
MySQL[(MySQL 库存主表)]
Debezium[Debezium CDC]
end
Query --> Nginx --> App
App --> L1
L1 -->|未命中| Redis
Redis -->|未命中| MySQL
MySQL -->|回填| Redis
Redis -->|回填| L1
App --> HotDetector --> Redis
HotDetector --> L1
App --> IsolatePool
App --> AsyncRefresh --> MySQL
AsyncRefresh --> Redis
库存扣减异步消息 --> Kafka --> 消费者 --> MySQL
MySQL --> Debezium --> Kafka --> 缓存刷新消费者 --> Redis
Redis -.->|故障| L1
L1 -.->|故障| MySQL
组件说明:
- Nginx:第一层限流和静态化,将非活动用户请求拦截。
- Caffeine L1:存储极热商品库存,容量 2000 条,TTL 2 秒。
- Redis Cluster L2:存储所有活动商品库存,采用 Cluster 模式分片,TTL 5 秒 + 随机偏移。
- MySQL:库存持久化存储,异步扣减。
- Kafka:承载库存扣减异步命令和 CDC 变更事件。
- Debezium:捕获库存表 Binlog,驱动缓存刷新。
库存查询业务流程与时序
读流程(如图):
- 用户请求到达秒杀服务,线程首先查 Caffeine L1。
- 若 L1 未命中,查 Redis L2;命中后回填 L1(短 TTL)。
- L2 未命中则查 MySQL,查到后回填 L2 和 L1;若库存为 0 则缓存空标记。
- 同时,每次请求触发滑动窗口计数(通过 Redis Lua),检测是否成为热点。
写流程(异步扣减):
- 秒杀业务发送库存扣减消息到 Kafka,立即返回“排队中”状态。
- 消费者消费消息,执行 MySQL 库存扣减(
update stock set count = count - 1 where id = ? and count > 0)。 - MySQL 提交后,Debezium 捕获 Binlog 事件写入 Kafka
cdc.stocktopic。 - 缓存刷新服务消费该事件,更新 Redis 库存缓存,并通过 Pub/Sub 驱逐各个实例的 Caffeine 本地缓存,实现最终一致。
一致性方案选择
由于库存扣减是异步的,查询接口允许短暂的不一致(通常数百毫秒内),选择 CDC 异步刷新 方案最佳。理由是:库存扣减与缓存更新完全解耦,减少秒杀服务复杂度;Binlog 的顺序性保证多个扣减事件的正确应用;延迟可控制在 100ms 左右。同时,为了防止 Debezium 延迟过大,还在秒杀服务中增加了“本地逻辑过期”兜底:Caffeine 中库存值包含 expireTime,过期时异步刷新并仍返回旧值,确保查询服务不受 CDC 延迟影响。
热点库存探测与保护
- 探测:每个库存查询请求触发 Lua 脚本在 Redis 中维护滑动窗口计数(窗口 1 秒,阈值 2000 次)。达到阈值即标记为热点。
- 保护:热点商品库存值被加载到 Caffeine,设置 2 秒过期;同时后台每 1 秒异步从 Redis 刷新最新库存到本地。热点查询被路由到独立线程池(核心线程数 50,队列容量 2000),与非热点请求隔离,避免线程饥饿。非热点请求不受影响。
Redis 客户端选型
- Lettuce 用于库存查询的主 Redis Cluster 连接:异步非阻塞,
ReadFrom.REPLICA读写分离,提升读吞吐;拓扑感知确保节点增减时请求不失败。 - Redisson 用于分布式锁(防超卖)、布隆过滤器(商品 ID 合法性校验)等高级功能,利用其看门狗和丰富的分布式对象。
防御措施
- 穿透:用 Redisson 布隆过滤器存储所有合法商品 ID,查询前过滤。非法 ID 直接返回“商品不存在”。
- 击穿:库存采用逻辑过期模式,不设 Redis TTL;Caffeine 中通过互斥锁控制仅一个线程异步刷新。
- 雪崩:Redis 中库存 TTL 设置
5s + random(0, 2s),Caffeine 中热点数据单独线程刷新。Redis Cluster 多副本 + Sentinel 监控。若 Redis 完全不可用,降级为 Nginx 本地缓存(静态页面) + 服务端 L1,L1 未命中直接返回“已售罄”兜底,防止 DB 崩溃。
降级与恢复
- Redis 宕机:Nginx 层开启静态缓存(30s 过期),App 层 Caffeine 撑住热点。非热点商品直接返回兜底数据。后台监控 Redis 恢复后,逐步放开动态查询,通过预加载脚本回填热数据到 Redis 和 Caffeine。
- DB 压力过大:Resilience4j 熔断器打开,直接返回缓存旧值或兜底,同时告警。半开状态时允许少量请求穿透测试。
时序图:库存查询与热点探测流程
sequenceDiagram
participant User
participant App
participant L1 as Caffeine L1
participant Redis as Redis Cluster
participant MySQL
participant Kafka
participant Consumer as 缓存消费者
User->>App: 查询库存
App->>L1: get product:123
alt L1 命中
L1-->>App: 库存数量
else L1 未命中
App->>Redis: get product:123
alt Redis 命中
Redis-->>App: 库存
App->>L1: put 短 TTL
else Redis 未命中
App->>MySQL: select stock
MySQL-->>App: 库存
App->>Redis: set 库存 (随机 TTL)
App->>L1: put 库存
end
end
App->>Redis: 执行热点检测 Lua
Redis-->>App: 热点标志
alt 是热点
App->>L1: 加载热点数据,路由到独立线程池
end
App-->>User: 返回库存
Note over Consumer,MySQL: 异步扣减与缓存刷新
Kafka->>Consumer: 扣减消息
Consumer->>MySQL: update stock -1
MySQL->>Kafka: Binlog (Debezium)
Kafka->>App: 消费缓存刷新事件
App->>Redis: 更新库存值
App->>L1: 驱逐本地缓存
架构分层与组件说明
查询链路中,应用服务同时与 Caffeine、Redis、MySQL 交互;热点检测通过 Redis Lua 脚本完成;写链路利用 Kafka 解耦扣减与缓存刷新。
数据流与关键路径
读取走三级缓存,热点数据被提升到 L1 并隔离线程。异步扣减通过消息队列落 DB,再由 CDC 反向更新缓存,形成一个闭环。最终查询的库存可能在 100ms 级延迟后体现最新值。
设计意图与权衡
为了达到 <1ms 的查询延迟,核心库存必须常驻本地内存,代价是跨实例缓存的最终一致性需由 CDC 保障。热点探测和线程隔离防止热点流量淹没服务。
故障场景与应对
Redis 不可用时,本地 Caffeine 支撑热点,非热点兜底。CDC 链路延迟增大时,本地逻辑过期机制保证查询不会阻塞。扣减消息积压时可通过消费者扩容缓解。
缓存策略核心机制速查表
| 维度 | 方案/组件 | 核心机制 | 关键参数 |
|---|---|---|---|
| 多级缓存层级 | Caffeine (L1) | W‑TinyLFU 淘汰,<1μs | maximumSize, expireAfterWrite(1‑5s) |
| Redis (L2) | allkeys‑lru 淘汰,<1ms | maxmemory-policy allkeys-lru, TTL 5‑30min | |
| MySQL (L3) | InnoDB B+ 树,10‑100ms | 索引优化,连接池 | |
| 一致性方案 | Cache‑Aside | 写 DB 后删缓存 | 删除重试 @Retryable |
| 延迟双删 | 删→写→延迟→再删 | 延迟 200‑500ms | |
| CDC 异步刷新 | Binlog → Kafka → 更新缓存 | Debezium 2.6+, 延迟 50‑200ms | |
| 穿透防御 | 布隆过滤器 | 不存在一定无 | tryInit(n, 0.01), 内存 ≈ 11.4MB/千万 |
| 空值缓存 | 缓存空对象短 TTL | TTL 60s | |
| 击穿防御 | 互斥锁 | Redisson RLock 双重检查 | tryLock(5s,30s) |
| 逻辑过期 | 过期时间存 value,异步刷新 | 互斥锁控制单刷新 | |
| 雪崩防御 | 过期随机化 | 添加随机偏移 | TTL = base + random(0,300s) |
| 多级降级 | L1 兜底 | 预热极热数据 | |
| 熔断 | Resilience4j 保护 DB | 慢调用阈值 200ms,熔断时长 30s | |
| 客户端选型 | Redisson | 分布式对象、RLock、Bloom | lockWatchdogTimeout=30s |
| Lettuce | 异步非阻塞、拓扑感知 | ClusterTopologyRefresh, ReadFrom.REPLICA | |
| Jedis (不推荐) | 同步阻塞 | 仅遗留项目 | |
| 热点保护 | 滑动窗口探测 | ZSET + Lua 统计 | 窗口 1‑2s,阈值 1000 |
| 本地兜底 | 自动加载 Caffeine | expireAfterWrite(2s) | |
| 线程池隔离 | 独立线程池处理热点 | 线程数、队列容量 |
延伸阅读
- Caffeine 官方文档(
W‑TinyLFU算法详解) - Redis 官方文档:Cluster 7.x 高可用、ACL、内存淘汰策略
- Redisson 官方文档:分布式锁、
RLiveObjectService、布隆过滤器 - Lettuce 高级配置:
ClusterClientOptions拓扑感知与自适应刷新 - Debezium 官方文档:Binlog CDC 连接器与 Outbox Event Router
- Designing Data‑Intensive Applications 第 2 章(数据模型与查询语言)
- 本系列前文:第 5 篇《冷热分离存储架构设计》、第 3 篇《多租户数据隔离》、分布式事务系列第 6 篇《CDC 发件箱模式》、分布式理论基石第 6 篇《分布式锁》
本文从多级缓存的分层职责、温度映射,到一致性权衡与防御体系,完整勾勒出缓存策略的全局架构决策。系统设计部分通过电商秒杀案例,综合展现了多级缓存、CDC 刷新、热点保护与降级策略在极端高并发下的工程实践。掌握这些知识,开发者能够在高并发场景下自信地设计出高性能、高可用的缓存系统,为分布式架构筑起坚实的“最后一道防线”。