缓存策略的全局架构决策

1 阅读46分钟

概述

系列定位说明

本文是 “分布式数据架构与存储选型” 系列的第六篇。在分库分表、读写分离、多租户数据隔离、全文搜索与数据分析引擎选型、冷热分离存储架构之后,我们将视角从“数据如何组织与存储”转移到“数据如何被高效访问”。缓存是数据架构中最接近业务响应时间的一层,是多级存储体系中“极热数据”的承载者。理解多级缓存架构的设计与一致性保障,是构建高并发、低延迟系统的核心能力。本文将以“多级缓存如何根据数据温度分层,以及如何在一致性、性能、成本之间找到平衡”为主线,贯穿全文。

总结性引言

在秒杀场景中,商品库存的查询 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)

  1. 应用查询 Key 时先访问 L1 Caffeine。若命中,直接返回,并统计命中率。
  2. L1 未命中则查询 L2 Redis。若命中,返回值并回填到 L1,设置较短的 TTL(如 2s),以应对后续的极热访问。
  3. L2 也未命中则查询 L3 MySQL。查到数据后,顺序回填 L2(设置业务 TTL)和 L1(设置短 TTL),然后返回。
  4. 若查询的数据在 DB 中不存在(例如非法用户ID),则为防止穿透,在 L2 中缓存空值("")并设置较短 TTL,L1 不缓存空值(也可视情况缓存极短的空值标记)。

写路径
为保证数据一致性,通常采用 Cache‑Aside 模式

  1. 先更新 L3 MySQL(保证持久化)。
  2. 删除 L2 Redis 中的对应缓存(而非更新缓存,避免并发写造成脏数据)。
  3. 删除 L1 Caffeine 的对应缓存(如果 L1 是以集中方式管理,可通过消息或事件通知所有应用实例驱逐本地缓存)。
  4. 或者采用更先进的 CDC 异步刷新方案,由 Binlog 事件触发缓存更新,业务代码只负责写 DB。

1.2 故障降级策略

当 Redis 集群不可用或网络分区时,必须确保系统不崩溃:

  • L2 故障 → 切断对 Redis 的依赖,降级为“L1 + L3”模式。此时需确保极热数据已通过预热机制加载到 L1 Caffeine 中(例如秒杀开始前全量预热商品库存到本地)。对于 L1 未命中的请求,直接穿透到 DB,但必须启用限流组件(如 Sentinel 或 Resilience4j RateLimiter)控制并发,防止 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 延迟双删模式

原理

  1. 先删除缓存。
  2. 更新 DB。
  3. 等待 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-AsideDB 提交后到缓存删除完成(<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

核心能力

  • 提供分布式对象抽象:RMapRListRQueueRAtomicLong 等。
  • 分布式锁全家桶: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 热点保护机制

  1. 本地缓存兜底:将热点 Key 的值加载到 Caffeine 本地缓存(设置较短的 TTL,如 1‑3 秒),从而绝大部分请求被 L1 拦截,减轻 Redis 压力。
  2. 异步定时刷新:后台线程每隔 N 秒(如 2 秒)从 DB 拉取最新数据,更新 Caffeine 和 Redis,确保数据相对新鲜。
  3. 线程池隔离:热点请求可能瞬间爆发,若与其他普通请求共享线程池,可能导致普通请求超时。可将热点 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,库存扣减由异步消息处理。请给出:

  1. 多级缓存架构设计(L1/L2/L3 的选型、配置、数据温度划分)
  2. 缓存一致性与库存扣减后的缓存刷新方案(Cache-Aside + CDC 刷新选型理由)
  3. 热点库存数据的探测与保护方案(滑动窗口 + L1 本地兜底 + 线程池隔离)
  4. Redis 客户端选型(Redisson vs Lettuce)及理由
  5. 缓存穿透/击穿/雪崩的防御措施与参数配置
  6. 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,驱动缓存刷新。

库存查询业务流程与时序

读流程(如图):

  1. 用户请求到达秒杀服务,线程首先查 Caffeine L1。
  2. 若 L1 未命中,查 Redis L2;命中后回填 L1(短 TTL)。
  3. L2 未命中则查 MySQL,查到后回填 L2 和 L1;若库存为 0 则缓存空标记。
  4. 同时,每次请求触发滑动窗口计数(通过 Redis Lua),检测是否成为热点。

写流程(异步扣减)

  1. 秒杀业务发送库存扣减消息到 Kafka,立即返回“排队中”状态。
  2. 消费者消费消息,执行 MySQL 库存扣减(update stock set count = count - 1 where id = ? and count > 0)。
  3. MySQL 提交后,Debezium 捕获 Binlog 事件写入 Kafka cdc.stock topic。
  4. 缓存刷新服务消费该事件,更新 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μsmaximumSize, expireAfterWrite(1‑5s)
Redis (L2)allkeys‑lru 淘汰,<1msmaxmemory-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/千万
空值缓存缓存空对象短 TTLTTL 60s
击穿防御互斥锁Redisson RLock 双重检查tryLock(5s,30s)
逻辑过期过期时间存 value,异步刷新互斥锁控制单刷新
雪崩防御过期随机化添加随机偏移TTL = base + random(0,300s)
多级降级L1 兜底预热极热数据
熔断Resilience4j 保护 DB慢调用阈值 200ms,熔断时长 30s
客户端选型Redisson分布式对象、RLock、BloomlockWatchdogTimeout=30s
Lettuce异步非阻塞、拓扑感知ClusterTopologyRefresh, ReadFrom.REPLICA
Jedis (不推荐)同步阻塞仅遗留项目
热点保护滑动窗口探测ZSET + Lua 统计窗口 1‑2s,阈值 1000
本地兜底自动加载 CaffeineexpireAfterWrite(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 刷新、热点保护与降级策略在极端高并发下的工程实践。掌握这些知识,开发者能够在高并发场景下自信地设计出高性能、高可用的缓存系统,为分布式架构筑起坚实的“最后一道防线”。