缓存架构设计模式:Cache-Aside, Read-Through/Write-Through详解

在高并发、高性能的系统架构中,缓存扮演着至关重要的角色,是应对高并发、提升读写性能、降低数据库负载的利器。然而,引入缓存也带来了架构的复杂性,其中最经典、最棘手的问题便是数据一致性。如何设计缓存的读写策略,成为了架构师们必须深思熟虑的问题。本文将深入剖析几种主流的缓存设计模式,并重点探讨一致性问题及其解决方案。

一、 为什么需要缓存?

在深入模式之前,我们先快速回顾缓存的核心价值:

  1. 提升性能:将数据存储在访问速度更快的介质中(如内存),减少对慢速数据源(如数据库)的直接访问,大幅降低响应延迟。
  2. 降低后端负载:通过缓存"消化"大量读请求,有效保护后端数据库,防止其被压垮。
  3. 提高系统吞吐量:更快的响应意味着系统在单位时间内可以处理更多的请求。

最常用的缓存代表就是 RedisMemcached

二、 核心缓存设计模式详解

模式一:Cache-Aside(旁路缓存)

这是应用最广泛、最经典的缓存模式,其核心思想是由应用程序代码直接、显式地管理缓存

工作流程

Cache-Aside 模式包含读和写两个核心操作:

  • 读操作 (Lazy Loading)

    1. 应用程序接收到读请求。
    2. 首先查询缓存(如 Redis)。
    3. 如果缓存中存在数据(缓存命中),则直接返回。
    4. 如果缓存中不存在数据(缓存未命中),则从数据库中查询。
    5. 从数据库获取数据后,将数据写入缓存,以便后续请求命中。
    6. 返回数据。
    pseudocode 复制代码
    // 伪代码示例
    function getData(key) {
        // 1. 查缓存
        data = cache.get(key);
        if (data != null) {
            return data; // 缓存命中
        }
        
        // 2. 缓存未命中,查数据库
        data = db.query("SELECT * FROM table WHERE key = ?", key);
        
        if (data != null) {
            // 3. 将数据库数据写入缓存
            cache.set(key, data, expire_time);
        }
        
        return data;
    }
  • 写操作

    1. 应用程序发起更新操作。
    2. 直接更新数据库。
    3. 然后,删除(Invalidate) 缓存中对应的数据。
    pseudocode 复制代码
    // 伪代码示例
    function updateData(key, newData) {
        // 1. 更新数据库
        db.execute("UPDATE table SET ... WHERE key = ?", key);
        
        // 2. 删除缓存
        cache.delete(key);
    }

为什么是删除缓存,而不是更新缓存?

这是一个关键设计点。直接更新缓存可能会引入严重的数据一致性问题。考虑以下并发场景:

  1. 请求A更新数据库(值设为10)。
  2. 请求B更新数据库(值设为20)。
  3. 由于网络等原因,请求B先完成了缓存更新(缓存=20)。
  4. 然后请求A才更新缓存(缓存=10)。
    此时,缓存中是旧数据(10),数据库是新数据(20),发生了不一致。而采用先更新数据库,后删除缓存的策略,即使步骤2和3顺序颠倒,也顶多造成一次缓存未命中,最终会通过读操作从数据库加载到正确数据,一致性更强。

优点

  • 高灵活性:应用程序对缓存和数据库的读写有完全的控制权。
  • 缓存命中率高:只缓存被实际请求的数据,避免缓存无用数据。
  • 实现简单:逻辑直观,易于理解和实现。

缺点

  • 缓存未命中成本高:首次或缓存失效后的请求,需要同时访问缓存和数据库,延迟较高(所谓的"惊群效应")。
  • 数据一致性需谨慎处理:虽然"先更新DB,后删除缓存"是推荐做法,但在极端并发下仍可能有不一致风险(下文详述)。

适用场景

  • 读多写少的场景。
  • 对一致性要求不是极度苛刻(如1-2秒的延迟可接受)的业务。
  • 几乎所有的大型互联网公司都在广泛使用此模式。

模式二:Read-Through / Write-Through(读写穿透)

该模式将缓存作为主要的数据入口,应用程序不再直接与数据库交互,而是与一个抽象的缓存提供者(Cache Provider) 交互。这个提供者负责与缓存和数据库打交道。

工作流程

  • Read-Through(读穿透)

    1. 应用程序查询缓存。
    2. 缓存提供者检查缓存。
    3. 如果缓存命中,直接返回。
    4. 如果缓存未命中,由缓存提供者负责从数据库加载数据,填入缓存,然后返回。
    5. 应用程序对这一切无感知。

    看起来和 Cache-Aside 的读操作很像?区别在于:逻辑由谁实现。Cache-Aside 的逻辑在应用代码里,Read-Through 的逻辑在缓存库或缓存服务本身。

  • Write-Through(写穿透)

    1. 应用程序更新缓存。
    2. 缓存提供者先更新缓存,然后同步地更新数据库。
    3. 写操作只有在两者都成功后才会返回。
    pseudocode 复制代码
    // 伪代码示例 - 应用程序视角
    function getData(key) {
        // 逻辑在缓存提供者内部,应用无需关心
        return cacheProvider.readThrough(key);
    }
    
    function updateData(key, newData) {
        // 逻辑在缓存提供者内部,应用无需关心
        cacheProvider.writeThrough(key, newData);
    }

优点

  • 代码解耦:应用程序代码更简洁,无需关心数据来源和同步细节。
  • 保证强一致性:Write-Through 保证了缓存和数据库的强一致性(在单次写操作内)。
  • 减少缓存未命中惩罚:缓存提供者可以更智能地预加载数据。

缺点

  • 写延迟高:每次写操作都涉及缓存和数据库两个慢速I/O,性能较差。
  • 灵活性差:应用程序失去了对数据加载和写入过程的控制。
  • 可能存在写放大:即使某些数据不再需要,也可能因为写操作而更新到缓存。

适用场景

  • 写操作不多,但对数据一致性要求非常高的场景。
  • 适合使用提供了 Read/Write-Through 功能的缓存客户端或中间件。

模式对比与衍生
  • Write-Behind (Write-Back)

    它是 Write-Through 的一个变种,性能更高。

    1. 应用程序更新缓存。
    2. 缓存提供者立即返回成功。
    3. 缓存提供者异步地、批量地 将缓存更新同步到数据库。
      优点 :写性能极高。
      缺点:有数据丢失风险(缓存宕机导致数据未落库),一致性最弱。常用于写操作极其频繁,且能容忍少量数据丢失的场景(如点击计数器、日志)。
  • Refresh-Ahead

    缓存提供者预测即将到来的数据访问,在缓存数据过期之前 就主动从数据库加载最新数据。
    优点 :几乎可以完全消除缓存未命中的延迟。
    缺点:可能加载了不会被访问的数据,造成资源浪费。

三、 经典难题:缓存与数据库的数据一致性

无论哪种模式,都绕不开一致性问题。我们以最常用的 Cache-Aside 模式为例,分析其经典困境。

1. 先更新数据库,再删除缓存(推荐方案)

这个方案在大多数情况下是可靠的,但在高并发下有一个著名的极端场景:

  1. 缓存恰好失效
  2. 请求A发起读操作,缓存未命中,查询数据库得到一个旧值(假设为oldValue)。
  3. 请求B发起写操作,更新数据库为新值(newValue)。
  4. 请求B删除缓存
  5. 请求A将查到的旧值 (oldValue) 写入缓存。

结果:缓存中变成了脏数据 (oldValue),直到下次更新或过期。

概率 :这个条件非常苛刻,因为它要求缓存失效,并且步骤2的读数据库操作必须在步骤3的写数据库操作之后、步骤4的删除缓存操作之前完成。由于数据库写操作通常比读操作更慢,所以步骤3很难插队到步骤2和步骤5之间,因此发生概率较低

解决方案

  • 设置合理的过期时间 :给所有缓存数据设置一个不太长的 TTL(生存时间)。这样即使出现不一致,脏数据也会在最多 TTL 时间后自动清除,实现最终一致性。这是最简单有效的方案。

  • 延时双删

    在写操作中,我们执行两次删除缓存的操作。

    1. 更新数据库。
    2. 第一次删除缓存。
    3. 等待一个短暂的时间(比如几百毫秒,这个时间需要根据业务读耗时评估)。
    4. 再次删除缓存。
    pseudocode 复制代码
    function updateData(key, newData) {
        // 1. 更新数据库
        db.update(...);
        // 2. 第一次删除
        cache.delete(key);
        // 3. 等待一段时间,确保读请求A已经完成并写入了旧缓存
        Thread.sleep(500);
        // 4. 第二次删除,清理可能由请求A写入的脏数据
        cache.delete(key);
    }

    第二次删除的目的,就是为了清除在"等待间隔"内可能被写入的脏数据。为了不影响主流程性能,第二次删除可以异步执行。

  • 使用 Canal 订阅数据库 Binlog

    这是一个更彻底、解耦的方案。通过订阅 MySQL 的 Binlog,当数据库有任何变更时,一个独立的中间件(如 Canal)会解析 Binlog,然后通知缓存系统删除对应的数据。这样,应用代码就不再需要关心删除缓存的操作,架构更清晰。

    App -> MySQL -> Binlog -> Canal -> 消息队列 -> 删除缓存服务

2. 先删除缓存,再更新数据库(不推荐)

这个方案问题更大:

  1. 请求A删除缓存。
  2. 请求B读请求,缓存未命中,从数据库读到旧值
  3. 请求B将旧值写入缓存。
  4. 请求A更新数据库为新值

结果:缓存中永久是旧数据,直到下一次更新。

结论"先更新数据库,后删除缓存"是更优的选择。

四、 总结与最佳实践

模式 优点 缺点 适用场景
Cache-Aside 灵活、命中率高、实现简单 缓存未命中成本高、一致性需处理 通用场景,读多写少,绝大多数业务
Read/Write-Through 代码解耦、强一致性 写延迟高、灵活性差 写少读多,要求强一致性
Write-Behind 写性能极高 有数据丢失风险、一致性弱 超高并发写,可容忍数据丢失

架构选择建议:

  1. 首选 Cache-Aside:对于大多数业务,这是平衡了复杂性、性能和一致性的最佳选择。
  2. 一致性策略
    • 基础 :采用 "先更新数据库,后删除缓存" 策略。
    • 兜底 :为缓存数据设置合理的过期时间
    • 进阶 :对于一致性要求极高的核心业务,可以考虑结合 "延时双删"
    • 治本 :在架构复杂度允许的情况下,采用 "订阅 Binlog" 方案是终极武器。
  3. 关于强一致性 :在分布式系统中,追求缓存和数据库的瞬间强一致性成本极高,甚至不现实。最终一致性是更务实、更常见的设计目标。
  4. 缓存不是银弹:要警惕缓存穿透、缓存击穿、缓存雪崩等问题,并通过布隆过滤器、互斥锁、随机过期时间等手段进行防护。

希望这篇详尽的解析能帮助你更好地理解缓存架构设计,在你的系统中做出最合适的技术选型。

你的点赞、收藏和关注这是对我最大的鼓励。如果有任何问题或建议,欢迎在评论区留言讨论。

相关推荐
低调波1 小时前
springboot实现批量下载
windows·spring boot·后端
7ioik3 小时前
什么是线程池?线程池的作用?线程池的四种创建方法?
java·开发语言·spring
Charles_go4 小时前
C#中级8、什么是缓存
开发语言·缓存·c#
q***04054 小时前
Nginx 缓存清理
运维·nginx·缓存
j***12157 小时前
Spring Boot中Tomcat配置
spring boot·tomcat·firefox
z***67777 小时前
SpringBoot(整合MyBatis + MyBatis-Plus + MyBatisX插件使用)
spring boot·tomcat·mybatis
movie__movie8 小时前
秒杀库存扣减可以用redis原子自增么
数据库·redis·缓存
Filotimo_9 小时前
Spring Boot 整合 JdbcTemplate(持久层)
java·spring boot·后端
李慕婉学姐9 小时前
【开题答辩过程】以《“饭否”食材搭配指南小程序的设计与实现》为例,不知道这个选题怎么做的,不知道这个选题怎么开题答辩的可以进来看看
java·spring·小程序