docs: 添加多级缓存与读写策略
Deploy Docs / deploy (push) Successful in 11s

This commit is contained in:
2026-09-05 11:49:49 +08:00
parent 5db33b3243
commit 3fbc811e0a
2 changed files with 275 additions and 0 deletions
+274
View File
@@ -0,0 +1,274 @@
# 多级缓存与读写策略
!!! note "💡 一句话概述"
多级缓存(本地缓存 → 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,逐层回填。
```mermaid
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(真相源),再逐层失效缓存,避免脏数据。
```mermaid
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 时正好读到旧值,又把它**回填**进缓存,覆盖掉写线程后来删除的干净缓存 → `你旧数据永久留在缓存中`。
```mermaid
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 串行化 | 强一致核心数据 |
```go
// 双删 + 版本号回填的核心判断逻辑
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)
}
```
!!! warning "先删缓存再写 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 | 差(有丢失风险) | 高 | 高频、可容忍丢失落库数据 |
```mermaid
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(异步写穿)**,又称 **回写式缓存 / 懒更新缓存**。
!!! warning "可行性边界"
该架构吞吐最高,但把可靠性与一致性完全押在缓存层上。使用必须满足:
1. **不丢写**:缓存必须持久化 + 兜底日志/队列。
2. **不乱序**:批量落库要带时间戳/版本号,防乱序覆盖。
3. **未见 DB 旧值**:任何"读 DB 的外部系统"会读到滞后数据,只适合内部读、可容忍一致的数据;交易/余额/库存绝不放入这条链路。
### 7. Caffeine:多级缓存的 L1 首选
**Caffeine** 是 Java 最有名的**进程内缓存**库,多级缓存的 **L1 层**最常见实现(Go 栈对应 bigcache / freecache)。
```java
// 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`,让缓存的各层物尽其用。
!!! warning "Caffeine 的一致性代价"
Caffeine 是每台机器各自的独立副本,会带来多级缓存"节点间不一致"。写后需**广播清理所有节点的本地副本 + 短 TTL 兜底**(见第 3 节写路径)。
### 8. 单机环境还要 Redis 吗
核心判断:**不是"单机"决定要不要 Redis,而是"缓存是否需要多进程/多能力共享"决定。**
| 情况 | Redis 是否多余 |
|------|:---:|
| 单进程、缓存可重启重查、无需改造 | ❌ 多余,本地缓存 + DB 足够 |
| 一个机器跑**多个进程/服务**,需共享同一份缓存 | ✅ 必需(每个进程一份本地缓存会不一致) |
| 依赖 Redis 持久化(重启不丢)/ 分布式锁 / 原子计数 / 榜单 / PubSub | ✅ 必需(不是缓存需求) |
| 数据量太大,进程内内存扛不住,需外部大缓存 | ✅ 有必要 |
> 常见误判:把"单机"当成"单进程"。一台机器常跑多个进程,那时 Redis 才是必需。**决定因素是 ZK,不是主机数量。**
---
## ⚠️ 常见陷阱
!!! warning "陷阱一:无脑套多级缓存"
对一般并发、下更新多的数据用多级缓存,反而引入**跨节点不一致**和广播失效的复杂度,得不偿失。先用单机/两通道,需压力了再上下一层。
!!! warning "陷阱二:Write-Behind 不持久化缓存就丢写"
先写缓存再异步落库,一旦缓存宕机,未落库的写永久丢失。**绝不把缓存当唯一真源**,必须持久化 + 兜底日志/MQ,否则交易、余额这类数据会流失。
!!! warning "陷阱三:删缓存后并发回填旧值(竞态)"
只为"先删缓存再写库",并发读可能把 DB 旧值回填进缓存。认准"**先写 DB 再失效缓存**",并用**双删延时 + 版本号回填**彻底压住。
!!! warning "陷阱四:把"单机"当"单进程""
一台机器跑多个服务时,每个进程的本地 Caffeine 各存一份缓存、永不一致,这时必须有 Redis 作共享缓存层,否则互相读不到对方更新的数据。
---
## 🏋️ 练习题
??? question "练习 1:为什么说先写 DB 再去删缓存比"先删缓存再关 DB"更稳?"
先删缓存再关 DB 的窗口里,并发请求可能把 DB 旧值查回并回填,随后 DB 更新为细节,旧值永远留在缓存。
??? success "答案"
先写 DB(真源)再删缓存,是读穿透的经典写路径。但删缓存本身仍有"回填竞态",因此配合**延迟双删 + 版本号回填**来根除来源 2。否则读线程会把 DB 旧值写回缓存,污染后续所有读。
??? question "练习 2:Write-Behind 与 Cache Aside 最大的不同是布是?"
Cache Aside 是读时核、写时先 DB,缓存是「副手」;Write-Behind 是把缓存当主写入入口,DB 通过批量异步补写。
??? success "答案"
最大不同"缓存与 DB 的写入顺序。**Cache Aside**:写 → DB,缓存失效后读取时自动回填。**Write-Behind**:写 → 缓存,后台批量刷入 DB。前者不丢数据、吞吐低;后者吞吐高、但有丢失 / 乱序 / 外部读旧值三个风险。
??? question "练习 3:单机 + 单进程的 JD 应用,本地 Caffeine 已够,为什么还需要 Redis?"
如果应用程序没有多进程共享,也没有依赖 Redis 持久化/锁/原子能力,那么 Redis 就是多余的,加它反而多一层。
【答案】单机单进程数据只需一份缓存,Caffeine 重启后可从 DB 重查(可接受)。只有出现**跨进程共享一致缓存**、**需要持久化/锁/计数** 或 **多机扩展** 时才需要 Redis(见第 8 节)。
---
## 🔗 相关链接
- [缓存击穿](cache-breakdown.md) — 单个热点 key 过期的并发涌入
- [缓存雪崩](cache-avalanche.md) — 大量 key 同时失效的集体崩溃
- [缓存穿透](cache-penetration.md) — 查询不存在数据的穿透防护
- [Caffeine](https://github.com/ben-manes/caffeine) — Java 本地进程内缓存
- [Redis 官网](https://redis.io) — 分布式 KV 缓存 / 存储
- [云原生 MySQL 数据库](https://dev.mysql.com/doc/) — DB 兜底存储层
+1
View File
@@ -111,6 +111,7 @@ nav:
- 缓存击穿: architecture/cache/cache-breakdown.md
- 缓存雪崩: architecture/cache/cache-avalanche.md
- 缓存穿透: architecture/cache/cache-penetration.md
- 多级缓存与读写策略: architecture/cache/cache-multilevel-read-write.md
- 算法:
- algorithm/index.md
- 布隆过滤器: