多级缓存与读写策略¶
💡 一句话概述
多级缓存(本地缓存 → Redis → DB)用于"读多写少、热点集中、可容忍秒级延迟"的数据;读写策略决定缓存与 DB 之间的数据流,最主流是 Cache-Aside(旁路缓存),再按一致性要求演进为读写穿透与异步写穿。
🔑 核心概念¶
- 多级缓存:本地进程缓存(如 Caffeine)→ Redis → DB,逐层兜底、逐层加速。
- Cache Aside(旁路缓存):读 miss 才查 DB 回填,写先更新 DB 再失效缓存——最主流。
- Read-Through / Write-Through / Write-Behind:把"回源与回填"封装进缓存层的三种读写变体。
- 读写竞态:先更 DB 再删缓存的瞬间,并发读可能读到旧值,需双删/版本号兜底。
- 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")**特别值得拎出来讲:
优点:吞吐极高,多次写可合并成一次 DB 写。**代价**非常关键:
| 代价 | 说明 | 应对 |
|---|---|---|
| 🚨 写丢失 | 缓存未落库前宕机,这段时间所有写永久丢失 | 必须缓存持久化(AOF)+ 日志/队列兜底;绝不该把缓存当唯一真源 |
| 顺序错乱 | 批量落库可能与写入顺序不一致 | 用时间戳/版本号 = |
| 重放重复 | 落库失败重试可能重复写 | 落库幂等键 |
| 外部读旧值 | DB 比缓存滞后,报表/统计等直查 DB 的系统读到旧值 | 只让走缓存的内部读写这套链路 |
结论:Write-Behind 适合"最终一致 + 可容忍丢失"的高频写(计数、会话状态、中间态),绝不适合交易/余额/库存。
6. 客户端只读写缓存 + 异步落库 = 读穿 + 写穿(Write-Behind)¶
如果客户端**完全不碰 DB**,统一 gateway 到缓存层读写,再由缓存层异步批量同步 DB,这个架构在国际上叫:
Read-Through(读穿) + Write-Behind / Write-Back(异步写穿),又称 回写式缓存 / 懒更新缓存。
可行性边界
该架构吞吐最高,但把可靠性与一致性完全押在缓存层上。使用必须满足:
- 不丢写:缓存必须持久化 + 兜底日志/队列。
- 不乱序:批量落库要带时间戳/版本号,防乱序覆盖。
- 未见 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 节)。