跳转至

多级缓存与读写策略

💡 一句话概述

多级缓存(本地缓存 → Redis → DB)用于"读多写少、热点集中、可容忍秒级延迟"的数据;读写策略决定缓存与 DB 之间的数据流,最主流是 Cache-Aside(旁路缓存),再按一致性要求演进为读写穿透与异步写穿。


🔑 核心概念

  1. 多级缓存:本地进程缓存(如 Caffeine)→ Redis → DB,逐层兜底、逐层加速。
  2. Cache Aside(旁路缓存):读 miss 才查 DB 回填,写先更新 DB 再失效缓存——最主流。
  3. Read-Through / Write-Through / Write-Behind:把"回源与回填"封装进缓存层的三种读写变体。
  4. 读写竞态:先更 DB 再删缓存的瞬间,并发读可能读到旧值,需双删/版本号兜底。
  5. Caffeine:Java 进程内缓存的事实标准,多级缓存的 L1 首选实现。

📝 详细说明

为什么需要缓存,以及三大问题

缓存是"空间换时间"——把热点数据放进内存(Redis 纳秒级 / Caffeine 纳秒级),避免每次请求都走慢速磁盘/DB(毫秒级)。但缓存引入三个经典问题,核心区别如下:

缓存穿透 缓存击穿 缓存雪崩
根因 查询不存在的数据 单个热点 key 过期 大面积 key 同时过期 / 缓存宕机
特征 缓存永远 miss 单 key、高并发 多 key、集体失效
危害 DB 持续高压 DB 瞬时尖峰 DB 被冲垮
核心解法 缓存空值 + 布隆过滤器 互斥锁 + 逻辑过期 TTL 加随机 + 多级缓存

穿透/击穿/雪崩各有单独文档,本节重点讲**多级缓存**与**读写策略**。

2. 什么时候该用多级缓存

判定三问:① 读取频率高吗?② 数据更新是否少、能否容忍秒级落后?③ 对延迟是否极为敏感?

本地缓存(Caffeine)零网络开销,Redis 是跨进程网络 I/O(几百纳秒~毫秒),两者差着数量级。只有"命中本地缓存省下的那一跳网络"足够值钱时才值得加一层。

场景 用不用多级缓存
热点商品详情(大促)、读多写少榜单、静态字典 ✅ 用
可容忍秒级落后的配置、开关、码表 ✅ 用
库存、余额、订单、实时价格等强一致数据 ❌ 不用,回 DB / 单一致性
写密集、数据实时刷新 ❌ 不用
请求量本身不高 ❌ 不用,过度设计

3. 多级缓存的读写路径

统一读路径(Cache Aside):逐层 miss,逐层回填。

graph TB
    C["客户端"] -->|"1. 查本地缓存"| L1["L1 进程内缓存<br/>如 Caffeine"]
    L1 -->|"hit<br/>2. 返回最快"| C
    L1 -->|"miss"| L2["L2 Redis 缓存"]
    L2 -->|"hit<br/>3. 返回"| C
    L2 -->|"miss<br/>4. 查DB"| DB["数据库"]
    DB -->|"5. 回填 Redis"| L2
    L2 -->|"6. 回填本地"| L1

统一写路径:先写 DB(真相源),再逐层失效缓存,避免脏数据。

sequenceDiagram
    participant C as 客户端
    participant DB as 数据库
    participant MQ as MQ 广播
    participant L1 as 本地缓存(各节点)
    participant R as Redis

    C->>DB: 1. 更新数据(事务提交)
    C->>MQ: 2. 发失效消息
    MQ->>R: 3. 删除 Redis
    MQ->>L1: 4. 广播删除所有节点本地缓存
    Note over L1: 本地缓存配短 TTL 兜底<br/>保证最终一致

4. 写后删缓存:如何避免读到旧值

先写 DB、再删缓存是 Cache Aside 的写策略,但其中存在一个**竞态窗口**——读旧值有两个来源,处理方式不同:

  • 来源 1(短暂):读请求在"删缓存"完成之前命中了缓存旧值。这个窗口只有几毫秒,通常无需处理。
  • 来源 2(更严重):读线程查 DB 时正好读到旧值,又把它**回填**进缓存,覆盖掉写线程后来删除的干净缓存 → 你旧数据永久留在缓存中。
sequenceDiagram
    participant W as 写线程
    participant R as Redis
    participant G as 读线程
    participant DB as DB

    W->>DB: 更新(新值)
    G->>DB: 查询(读到旧值)
    W->>R: 删缓存
    G->>R: 写回旧值 ← 脏数据回填,毒害缓存

针对性解法(按性价比排序):

方案 原理 适用
双删 + 延时 DB 更新后删缓存,延时 ~50ms 再删一次,清掉被回填的旧值 ✅ 大多数系统
延迟双删 + MQ 第二次删除通过 MQ 延时消息兜底,失败可重试 更稳
版本号回填 缓存存数据带版本号,回填前比较,旧版本直接丢弃 根治来源 2
串行化 关键 key 的读想起通过锁 / MQ 串行化 强一致核心数据
// 双删 + 版本号回填的核心判断逻辑
func (s *Service) writeBack(ctx context.Context, key string, data any, version int64) {
    cur, err := s.rdb.Get(ctx, key).Int64()
    if err == nil && cur > version {
        return // 缓存里的值更新,丢弃本次回填,避免旧值覆盖新值
    }
    s.rdb.Set(ctx, key, data, baseTTL)
}

先删缓存再写 DB 的并发问题

删除缓存后,更新 DB 之前,并发读者可能把 DB 旧值查回来写进缓存,然后写线程才更新 DB——脏数据冲突。演进方向是"先写 DB,再删缓存/异步失效",这也是 Cache Aside 的主流选择。

5. 缓存读写策略全家桶

统一"Read/Write-XX"策略,本质是把回源与回写动作**封装进缓存层**,通过下面的行为定型:

策略 读 写 一致性 吞吐 典型场景
Cache Aside 应用自己查、自己回填 先写 DB 再失效缓存 差(有竞态窗口) 中 大多数系统的默认选择
Cache Aside miss 由缓存层自动查 DB 回填 同上 差 中 框架封装(Spring Cache、Caffeine loader)
Write-Through 同读穿透 同步先写缓存再写 DB 较强 中(慢) 一致性要求高
Write-Behind / Lazy 同前 **异步批量**攒着,后台刷 DB 差(有丢失风险) 高 高频、可容忍丢失落库数据
graph LR
    subgraph 读
        Aside["Cache Aside<br/>应用自己回填"]
        RT["Read-Through<br/>缓存层自动回填"]
    end
    subgraph 写
        WT["Write-Through<br/>同步写缓存+DB"]
        WB["Write-Behind<br/>异步批量落DB"]
    end

**Write-Behind(异步写穿,即"先写缓存再异步改 DB")**特别值得拎出来讲:

写请求 → 直接更新缓存(标记 1) → 立刻返回 ✅
后台异步:
  攒满 N 条 或 定时(如每秒/每批)
  → 一次性 batch 写入 DB(upsert)

优点:吞吐极高,多次写可合并成一次 DB 写。**代价**非常关键:

代价 说明 应对
🚨 写丢失 缓存未落库前宕机,这段时间所有写永久丢失 必须缓存持久化(AOF)+ 日志/队列兜底;绝不该把缓存当唯一真源
顺序错乱 批量落库可能与写入顺序不一致 用时间戳/版本号 =
重放重复 落库失败重试可能重复写 落库幂等键
外部读旧值 DB 比缓存滞后,报表/统计等直查 DB 的系统读到旧值 只让走缓存的内部读写这套链路

结论:Write-Behind 适合"最终一致 + 可容忍丢失"的高频写(计数、会话状态、中间态),绝不适合交易/余额/库存。

6. 客户端只读写缓存 + 异步落库 = 读穿 + 写穿(Write-Behind)

如果客户端**完全不碰 DB**,统一 gateway 到缓存层读写,再由缓存层异步批量同步 DB,这个架构在国际上叫:

Read-Through(读穿) + Write-Behind / Write-Back(异步写穿),又称 回写式缓存 / 懒更新缓存。

可行性边界

该架构吞吐最高,但把可靠性与一致性完全押在缓存层上。使用必须满足:

  1. 不丢写:缓存必须持久化 + 兜底日志/队列。
  2. 不乱序:批量落库要带时间戳/版本号,防乱序覆盖。
  3. 未见 DB 旧值:任何"读 DB 的外部系统"会读到滞后数据,只适合内部读、可容忍一致的数据;交易/余额/库存绝不放入这条链路。

7. Caffeine:多级缓存的 L1 首选

Caffeine 是 Java 最有名的**进程内缓存**库,多级缓存的 **L1 层**最常见实现(Go 栈对应 bigcache / freecache)。

// Caffeine 关键用法(Java 库):配置 + 带加载的本地缓存
Cache<String, User> cache = Caffeine.newBuilder()
    .maximumSize(10_000)                       // 最多 1 万条
    .expireAfterWrite(5, TimeUnit.MINUTES)     // 写后 5 分钟过期
    .build();                                  // 普通缓存

Cache<String, User> async = Caffeine.newBuilder()
    .maximumSize(10_000)
    .build(key -> userDao.findByKey(key));     // miss 时自动去 DB 加载(Read-Through)
特性 说明
淘汰算法 TinyLFU(比 LRU 更聪明,保留高频 key)
过期策略 基于大小、TTL(写后/访问后)
并发 读无锁优化,纳秒级命中
自动加载 CacheLoader(Read-Through 本地版)、AsyncCache

通过与 Redis 对比理解定位:

Caffeine Redis
位置 进程内(本机内存) 独立进程(可跨机器)
速度 纳秒级(无网络) 毫秒级(网络 I/O)
共享 不共享(单机副本) 可共享、分布式、多副本
持久化 无(重启即失) 有(AOF / 主从 / 集群)

经典组合:Caffeine(L1) → Redis(L2 共享) → DB,让缓存的各层物尽其用。

Caffeine 的一致性代价

Caffeine 是每台机器各自的独立副本,会带来多级缓存"节点间不一致"。写后需**广播清理所有节点的本地副本 + 短 TTL 兜底**(见第 3 节写路径)。

8. 单机环境还要 Redis 吗

核心判断:不是"单机"决定要不要 Redis,而是"缓存是否需要多进程/多能力共享"决定。

情况 Redis 是否多余
单进程、缓存可重启重查、无需改造 ❌ 多余,本地缓存 + DB 足够
一个机器跑**多个进程/服务**,需共享同一份缓存 ✅ 必需(每个进程一份本地缓存会不一致)
依赖 Redis 持久化(重启不丢)/ 分布式锁 / 原子计数 / 榜单 / PubSub ✅ 必需(不是缓存需求)
数据量太大,进程内内存扛不住,需外部大缓存 ✅ 有必要

常见误判:把"单机"当成"单进程"。一台机器常跑多个进程,那时 Redis 才是必需。决定因素是 ZK,不是主机数量。


⚠️ 常见陷阱

陷阱一:无脑套多级缓存

对一般并发、下更新多的数据用多级缓存,反而引入**跨节点不一致**和广播失效的复杂度,得不偿失。先用单机/两通道,需压力了再上下一层。

陷阱二:Write-Behind 不持久化缓存就丢写

先写缓存再异步落库,一旦缓存宕机,未落库的写永久丢失。绝不把缓存当唯一真源,必须持久化 + 兜底日志/MQ,否则交易、余额这类数据会流失。

陷阱三:删缓存后并发回填旧值(竞态)

只为"先删缓存再写库",并发读可能把 DB 旧值回填进缓存。认准"先写 DB 再失效缓存",并用**双删延时 + 版本号回填**彻底压住。

陷阱四:把"单机"当"单进程"

一台机器跑多个服务时,每个进程的本地 Caffeine 各存一份缓存、永不一致,这时必须有 Redis 作共享缓存层,否则互相读不到对方更新的数据。


🏋️ 练习题

练习 1:为什么说先写 DB 再去删缓存比"先删缓存再关 DB"更稳?

先删缓存再关 DB 的窗口里,并发请求可能把 DB 旧值查回并回填,随后 DB 更新为细节,旧值永远留在缓存。

答案

先写 DB(真源)再删缓存,是读穿透的经典写路径。但删缓存本身仍有"回填竞态",因此配合**延迟双删 + 版本号回填**来根除来源 2。否则读线程会把 DB 旧值写回缓存,污染后续所有读。

练习 2:Write-Behind 与 Cache Aside 最大的不同是布是?

Cache Aside 是读时核、写时先 DB,缓存是「副手」;Write-Behind 是把缓存当主写入入口,DB 通过批量异步补写。

答案

最大不同"缓存与 DB 的写入顺序。Cache Aside:写 → DB,缓存失效后读取时自动回填。Write-Behind:写 → 缓存,后台批量刷入 DB。前者不丢数据、吞吐低;后者吞吐高、但有丢失 / 乱序 / 外部读旧值三个风险。

练习 3:单机 + 单进程的 JD 应用,本地 Caffeine 已够,为什么还需要 Redis?

如果应用程序没有多进程共享,也没有依赖 Redis 持久化/锁/原子能力,那么 Redis 就是多余的,加它反而多一层。

【答案】单机单进程数据只需一份缓存,Caffeine 重启后可从 DB 重查(可接受)。只有出现**跨进程共享一致缓存**、需要持久化/锁/计数 或 多机扩展 时才需要 Redis(见第 8 节)。


🔗 相关链接