2. 缓存基础与常见模式:本地缓存 vs 分布式缓存、Cache-Aside / Read-Through / Write-Through 模式详解

2.1 缓存的核心价值与分类

缓存是后端系统中提升读取性能、降低数据库负载的核心手段。根据数据存储的位置与共享范围,缓存主要分为本地缓存与分布式缓存两大类。选择错误的缓存类型会导致数据不一致、内存浪费甚至系统雪崩。

对比维度 本地缓存 (Local Cache) 分布式缓存 (Distributed Cache)
存储位置 进程内堆内存 (JVM Heap / 进程内存) 独立缓存服务 (如 Redis / Memcached 集群)
访问延迟 纳秒级 (无网络开销) 亚毫秒级 (存在网络 RTT)
数据一致性 单机强一致,多实例间需广播同步 中心化存储,天然全局一致
容量上限 受限于单机内存,通常 ≤ 几 GB 可水平扩展至 TB 级
典型实现 Caffeine, Guava Cache, Ehcache Redis, Redis Cluster, Memcached
适用场景 配置信息、字典数据、不变热点数据 用户会话、商品详情、高并发共享数据
架构建议:生产环境中通常采用多级缓存架构——本地缓存作为 L1 极速层,分布式缓存作为 L2 共享层。L1 未命中时回源 L2,L2 未命中时回源数据库。这种组合能兼顾速度与容量,但需要额外处理 L1 与 L2 之间的数据失效同步。

2.2 本地缓存 vs 分布式缓存:选型决策树

在架构选型时,可依据以下三个关键问题快速决策:

  1. 数据是否需要跨实例共享? 是 → 分布式缓存;否 → 本地缓存。
  2. 数据量是否超过单机内存 50%? 是 → 分布式缓存;否 → 本地缓存。
  3. 对一致性要求是否为强一致? 强一致 → 分布式缓存 + 锁;最终一致 → 本地缓存 + 失效广播。
常见陷阱:将用户登录态、分布式锁等需要全局唯一性的数据放入本地缓存,会导致不同实例间状态不一致,引发用户反复登录或并发竞态问题。此类数据必须使用分布式缓存。

2.3 缓存读写模式详解

缓存模式决定了应用程序如何与缓存及数据库交互。以下四种模式是后端架构中最核心的缓存操作范式。

2.3.1 Cache-Aside (旁路缓存)

最常用的模式,应用程序直接同时操作缓存和数据库。读取时先查缓存,未命中则查数据库并回填缓存;写入时先更新数据库,再删除缓存。

// 读取流程 (Cache-Aside Read)
public User getUser(String userId) {
    // 1. 先查缓存
    User user = cache.get("user:" + userId);
    if (user != null) {
        return user;
    }
    // 2. 缓存未命中,查数据库
    user = database.query("SELECT * FROM users WHERE id = ?", userId);
    if (user != null) {
        // 3. 回填缓存,设置过期时间
        cache.set("user:" + userId, user, 300); // 5分钟过期
    }
    return user;
}

// 写入流程 (Cache-Aside Write)
public void updateUser(User user) {
    // 1. 先更新数据库
    database.execute("UPDATE users SET name = ? WHERE id = ?", user.getName(), user.getId());
    // 2. 再删除缓存(而非更新缓存)
    cache.del("user:" + user.getId());
}
为什么写入时是删除缓存而不是更新缓存? 因为并发环境下,更新缓存可能引入“脏写”。例如两个线程同时更新同一数据,后更新数据库的线程可能先更新缓存,导致缓存中残留旧值。删除缓存则让下一次读取时自然回填最新数据,更安全。

2.3.2 Read-Through (通读缓存)

缓存层自身承担“未命中时回源数据库”的职责。应用程序只与缓存交互,缓存组件内部实现 Cache Loader 逻辑。这种模式对业务代码侵入性更低。

// 使用 Caffeine 实现 Read-Through
LoadingCache<String, User> userCache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(5, TimeUnit.MINUTES)
    .build(key -> {
        // 缓存自动回源数据库
        return database.query("SELECT * FROM users WHERE id = ?", key);
    });

// 业务代码只需调用缓存
User user = userCache.get("user:123");

2.3.3 Write-Through (通写缓存)

写入时,应用程序先写入缓存,缓存组件同步将数据写入数据库。写入操作对业务代码透明,由缓存层保证数据库与缓存的一致性。

// 伪代码:Write-Through 缓存组件内部逻辑
public void put(String key, User value) {
    // 1. 先写入缓存
    this.localMap.put(key, value);
    // 2. 同步写入数据库(事务性保证)
    database.execute("INSERT INTO users (id, name) VALUES (?, ?) ON DUPLICATE KEY UPDATE name = ?",
                     value.getId(), value.getName(), value.getName());
}

2.3.4 Write-Behind (异步回写 / Write-Back)

写入时只更新缓存,异步批量将数据刷入数据库。适用于写入吞吐量极高、允许短时间数据丢失的场景(如日志收集、访问计数)。

风险提示:Write-Behind 模式下,如果缓存节点宕机且数据尚未持久化到数据库,将导致数据丢失。必须配合持久化队列(如 Kafka + 本地文件)使用,并接受最终一致性窗口。

2.4 四种模式对比总结

模式 读取路径 写入路径 一致性保证 业务侵入性 典型场景
Cache-Aside App → Cache → DB App → DB → 删 Cache 最终一致(需处理并发) 中 通用 CRUD,用户信息
Read-Through App → Cache → (Loader → DB) App → DB → 删 Cache 最终一致 低 配置中心,字典数据
Write-Through App → Cache → (Loader → DB) App → Cache → DB 强一致(同步写 DB) 低 金融交易,库存扣减
Write-Behind App → Cache → (Loader → DB) App → Cache → (异步 → DB) 最终一致(有丢失风险) 低 访问计数,日志聚合

2.5 缓存模式架构图

下图展示了 Cache-Aside 与 Read-Through / Write-Through 的核心数据流向差异:

缓存读写模式核心架构对比 Cache-Aside (旁路缓存) Application Cache Database ① 读缓存 ② 未命中回填 ① 写 DB ② 删缓存 Read/Write-Through (通读/通写) Application Cache (含 Loader) Database 读/写请求 Loader 自动回源 Write-Through 同步写 关键区别: • Cache-Aside:应用层同时感知缓存和数据库,手动管理一致性。 • Read-Through:缓存层封装回源逻辑,应用只与缓存交互。 • Write-Through:缓存层同步写入数据库,保证缓存与数据库强一致。 • 实际项目中常组合使用:Read-Through + Cache-Aside 写入策略。

2.6 模式选型建议

  • 通用业务系统:优先选择 Cache-Aside,灵活性最高,易于排查问题。
  • 读多写少、数据源固定:使用 Read-Through 减少业务代码中的回源逻辑。
  • 需要强一致性写入:使用 Write-Through,但注意写入延迟会增加。
  • 高写入吞吐、可接受最终一致:使用 Write-Behind,务必做好宕机恢复预案。
实战经验:在微服务架构中,建议每个服务内部使用本地缓存(Caffeine)做 L1,Redis 做 L2。L1 采用 Read-Through 模式自动加载热点数据,L2 采用 Cache-Aside 模式由服务层手动管理失效。这种组合在日均亿级请求的系统中已验证,可将数据库 QPS 降低 95% 以上。