Files
autumn-recruitment/03.Redis/strategies/旁路缓存与读写策略.md
T

226 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 持久化]]