226 lines
7.9 KiB
Markdown
226 lines
7.9 KiB
Markdown
|
|
---
|
|||
|
|
tags: [redis, cache-strategy, cache-aside, read-through, write-through]
|
|||
|
|
create time: 2026-08-08 18:43
|
|||
|
|
update time: 2026-08-08 18:43
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# 旁路缓存与读写策略
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
在引入 Redis 作为数据加速层之后,应用与 Redis + MySQL 之间的读写交互模式就成了一个核心设计决策。业界主要有四种经典策略:Cache Aside、Read Through、Write Through 和 Write Behind。它们的核心差异在于"谁负责将数据写入或读取到缓存",以及在一致性、延迟和复杂度之间的不同取舍。
|
|||
|
|
|
|||
|
|
## 各方案详解
|
|||
|
|
|
|||
|
|
### 1. Cache Aside(旁路缓存)—— 最常用
|
|||
|
|
|
|||
|
|
**核心思路**:应用同时管理缓存和数据库,读操作先看缓存、miss 再查 DB 并回填;写操作先更新 DB、再删除缓存。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant C as Client
|
|||
|
|
participant App as Application
|
|||
|
|
participant Redis as L2 Cache
|
|||
|
|
participant DB as Database
|
|||
|
|
|
|||
|
|
alt 读请求
|
|||
|
|
C->>App: GET key
|
|||
|
|
App->>Redis: GET key
|
|||
|
|
alt hit
|
|||
|
|
Redis-->>App: value
|
|||
|
|
else miss
|
|||
|
|
Redis-->>App: nil
|
|||
|
|
App->>DB: SELECT * FROM ... WHERE id = ?
|
|||
|
|
DB-->>App: result
|
|||
|
|
App->>Redis: SET key value EX ttl
|
|||
|
|
end
|
|||
|
|
App-->>C: result
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
alt 写请求
|
|||
|
|
C->>App: UPDATE key = new_val
|
|||
|
|
App->>DB: UPDATE ... SET val = new_val
|
|||
|
|
DB-->>App: OK
|
|||
|
|
App->>Redis: DEL key
|
|||
|
|
end
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**优点**:
|
|||
|
|
- 实现简单,解耦彻底,Redis 只是可选优化
|
|||
|
|
- DB 是权威数据源,缓存永远从 DB 重建
|
|||
|
|
|
|||
|
|
**缺点**:
|
|||
|
|
- 写操作多一次 DEL(虽然 DEL 比 SET 便宜很多)
|
|||
|
|
- 存在"竞态窗口":写完后 DEL 之前,并发读可能把旧值回填到缓存(极端情况)
|
|||
|
|
|
|||
|
|
### 2. Read Through(通明读取)
|
|||
|
|
|
|||
|
|
**核心思路**:应用只和缓存层交互,不直接接触 DB。缓存层封装了从 DB 加载数据的逻辑。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant C as Client
|
|||
|
|
participant App as Application
|
|||
|
|
participant CacheLayer as Cache (Read Through)
|
|||
|
|
participant DB as Database
|
|||
|
|
|
|||
|
|
C->>App: GET key
|
|||
|
|
App->>CacheLayer: GET key
|
|||
|
|
alt hit
|
|||
|
|
CacheLayer-->>App: value
|
|||
|
|
else miss
|
|||
|
|
CacheLayer->>DB: SELECT * FROM ... WHERE id = ?
|
|||
|
|
DB-->>CacheLayer: result
|
|||
|
|
CacheLayer->>CacheLayer: SET key value EX ttl
|
|||
|
|
CacheLayer-->>App: result
|
|||
|
|
end
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**优点**:
|
|||
|
|
- 应用层代码更简洁,无需关心 DB 逻辑
|
|||
|
|
- 缓存加载逻辑集中在缓存层,便于统一调优
|
|||
|
|
|
|||
|
|
**缺点**:
|
|||
|
|
- 缓存层需要知道 DB schema,耦合增加
|
|||
|
|
- 通常配合 Write Behind 使用(纯 Read Through + Cache Aside 混合较少)
|
|||
|
|
|
|||
|
|
### 3. Write Through(通明写入)
|
|||
|
|
|
|||
|
|
**核心思路**:写操作时,应用先写缓存,由缓存层负责同步写入 DB。对应用而言写操作只返回"缓存已接受"即可认为成功。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant C as Client
|
|||
|
|
participant App as Application
|
|||
|
|
participant CacheLayer as Cache (Write Through)
|
|||
|
|
participant DB as Database
|
|||
|
|
|
|||
|
|
C->>App: UPDATE key = new_val
|
|||
|
|
App->>CacheLayer: SET key new_val
|
|||
|
|
CacheLayer-->>App: OK // 缓存写入成功即返回
|
|||
|
|
CacheLayer->>DB: UPDATE ... SET val = new_val
|
|||
|
|
DB-->>CacheLayer: OK
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**优点**:
|
|||
|
|
- 读操作可以直接命中缓存,不会读到过期或不一致的数据
|
|||
|
|
- 缓存和 DB 之间天然的一致性保障
|
|||
|
|
|
|||
|
|
**缺点**:
|
|||
|
|
- 写延迟增加(必须等 DB 确认后才返回)
|
|||
|
|
- 如果 DB 不可用但缓存可用,整个写操作会失败
|
|||
|
|
|
|||
|
|
### 4. Write Behind(异步回写)
|
|||
|
|
|
|||
|
|
**核心思路**:写操作只写缓存,由缓存层的后台线程异步批量刷盘到 DB。这是最快但风险最高的策略。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
sequenceDiagram
|
|||
|
|
participant C as Client
|
|||
|
|
participant App as Application
|
|||
|
|
participant CacheLayer as Cache (Write Behind)
|
|||
|
|
participant DB as Database
|
|||
|
|
participant BGThread as Background Writer
|
|||
|
|
|
|||
|
|
C->>App: UPDATE key = new_val
|
|||
|
|
App->>CacheLayer: SET key new_val
|
|||
|
|
CacheLayer-->>App: OK // 极快返回
|
|||
|
|
CacheLayer->>BGThread: 加入批量队列
|
|||
|
|
|
|||
|
|
loop 每秒或每 N 条
|
|||
|
|
BGThread->>DB: 批量执行多条 UPDATE
|
|||
|
|
BGThread->>CacheLayer: 标记批次成功/失败
|
|||
|
|
end
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**优点**:
|
|||
|
|
- 极致性能,写操作几乎无额外开销
|
|||
|
|
- 批量刷盘减少 DB IO 次数
|
|||
|
|
|
|||
|
|
**缺点**:
|
|||
|
|
- **崩溃丢数据风险**:缓存未刷盘前宕机,该批次数据丢失
|
|||
|
|
- 需要复杂的故障恢复机制
|
|||
|
|
|
|||
|
|
## 对比总结
|
|||
|
|
|
|||
|
|
| 维度 | Cache Aside | Read Through | Write Through | Write Behind |
|
|||
|
|
|------|-------------|--------------|---------------|-------------|
|
|||
|
|
| **缓存由谁维护** | 应用层 | 缓存层 | 缓存层 | 缓存层 |
|
|||
|
|
| **读的延迟** | 中(miss 时有 DB RTT) | 中(miss 时有 DB RTT) | 低(始终从缓存读) | 低(始终从缓存读) |
|
|||
|
|
| **写的延迟** | 中(DB + DEL) | 低(仅写缓存) | 中(缓存 + 同步 DB) | 极低(仅写缓存) |
|
|||
|
|
| **一致性保证** | 最终一致(有竞态窗口) | 强一致(缓存层代理) | 强一致 | 弱一致(异步延迟) |
|
|||
|
|
| **系统复杂度** | 低 | 中 | 高 | 最高 |
|
|||
|
|
| **容错性** | 好(DB 独立于缓存) | 中(缓存层挂了需降级) | 中 | 差(缓存单点故障影响大) |
|
|||
|
|
| **适用场景** | 通用推荐方案 | 缓存即主要数据源 | 强一致+可容忍写延迟 | 日志/指标等非关键数据 |
|
|||
|
|
|
|||
|
|
## 选型建议
|
|||
|
|
|
|||
|
|
> [!TIP]
|
|||
|
|
> **面试标准答案**:绝大多数场景首选 Cache Aside。它简单、解耦、容错好。只有当你的架构本身就是"缓存为主存储"(如会话存储、配置中心)时,才考虑 Read Through + Write Through。Write Behind 只用于能接受短暂数据丢失的非关键场景。
|
|||
|
|
|
|||
|
|
具体决策树:
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
graph TD
|
|||
|
|
A["是否需要强一致?"] -->|"否"| B["是否能接受写延迟?" ]
|
|||
|
|
A -->|"是"| C["Cache Aside / Write Through"]
|
|||
|
|
B -->|"是 (要极致速度)"| D["Write Behind"]
|
|||
|
|
B -->|"否"| E["Can 承受 DB RTT?"]
|
|||
|
|
E -->|"否"| F["Read Through + Write Through"]
|
|||
|
|
E -->|"是"| C
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
实际工程中还有一个变体叫做 **"Cache Aside with Delayed Delete"**:写操作后延时几百毫秒再删缓存,可以解决部分竞态问题,但不适合高一致性要求场景。
|
|||
|
|
|
|||
|
|
## 代码示例
|
|||
|
|
|
|||
|
|
Go 中 Cache Aside 的常见实现:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func GetProduct(ctx context.Context, id string) (*Product, error) {
|
|||
|
|
// 1. 读:查缓存
|
|||
|
|
val, err := redis.Get(ctx, "product:"+id).Bytes()
|
|||
|
|
if err == nil {
|
|||
|
|
var p Product
|
|||
|
|
json.Unmarshal(val, &p)
|
|||
|
|
return &p, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 缓存 miss:查 DB
|
|||
|
|
p, err := db.GetProduct(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 回填缓存(带随机 TTL 防雪崩)
|
|||
|
|
ttl := randomTTL(10*time.Minute, 30)
|
|||
|
|
redis.SetEX(ctx, "product:"+id, p, ttl)
|
|||
|
|
return p, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func UpdateProduct(ctx context.Context, id string, data Product) error {
|
|||
|
|
if err := db.UpdateProduct(ctx, id, data); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
// 写:先写 DB 再删缓存
|
|||
|
|
redis.Del(ctx, "product:"+id)
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 实践场景
|
|||
|
|
|
|||
|
|
1. **电商商品查询**:典型 Cache Aside 场景。商品更新频率低(运营后台发布),读 QPS 远高于写。先更新 DB 再 DEL 缓存,配合短 TTL 兜底。
|
|||
|
|
|
|||
|
|
2. **用户 Session 存储**:可以考虑 Read Through + Write Through。Session 是主存储(非持久化),缓存层挂掉需要降级方案(如临时回退到 Cookie 存储)。
|
|||
|
|
|
|||
|
|
3. **统计数据实时看板**:指标聚合数据可以用 Write Behind。写入 Redis 后立即返回,后台批量 flush 到 ClickHouse/TimescaleDB。偶尔丢失几条指标完全可接受。
|
|||
|
|
|
|||
|
|
4. **微博关注关系**:Follower/Following 列表频繁变更,Cache Aside 中的 DEL 频率过高反而成为瓶颈。此时应直接用 ZSet 在 Redis 中维护完整数据结构,绕过缓存刷新策略的困扰。
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[03.Redis/strategies/多级缓存架构设计]]
|
|||
|
|
- [[03.Redis/strategies/缓存穿透击穿雪崩解决方案]]
|
|||
|
|
- [[03.Redis/core/RDB 与 AOF 持久化]]
|