Files

226 lines
7.9 KiB
Markdown
Raw Permalink Normal View History

2026-08-08 19:01:04 +08:00
---
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 持久化]]