7.9 KiB
tags, create time, update time
| tags | create time | update time | |||||
|---|---|---|---|---|---|---|---|
|
2026-08-08 18:43 | 2026-08-08 18:43 |
旁路缓存与读写策略
概述
在引入 Redis 作为数据加速层之后,应用与 Redis + MySQL 之间的读写交互模式就成了一个核心设计决策。业界主要有四种经典策略:Cache Aside、Read Through、Write Through 和 Write Behind。它们的核心差异在于"谁负责将数据写入或读取到缓存",以及在一致性、延迟和复杂度之间的不同取舍。
各方案详解
1. Cache Aside(旁路缓存)—— 最常用
核心思路:应用同时管理缓存和数据库,读操作先看缓存、miss 再查 DB 并回填;写操作先更新 DB、再删除缓存。
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 加载数据的逻辑。
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。对应用而言写操作只返回"缓存已接受"即可认为成功。
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。这是最快但风险最高的策略。
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 只用于能接受短暂数据丢失的非关键场景。
具体决策树:
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 的常见实现:
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
}
实践场景
-
电商商品查询:典型 Cache Aside 场景。商品更新频率低(运营后台发布),读 QPS 远高于写。先更新 DB 再 DEL 缓存,配合短 TTL 兜底。
-
用户 Session 存储:可以考虑 Read Through + Write Through。Session 是主存储(非持久化),缓存层挂掉需要降级方案(如临时回退到 Cookie 存储)。
-
统计数据实时看板:指标聚合数据可以用 Write Behind。写入 Redis 后立即返回,后台批量 flush 到 ClickHouse/TimescaleDB。偶尔丢失几条指标完全可接受。
-
微博关注关系:Follower/Following 列表频繁变更,Cache Aside 中的 DEL 频率过高反而成为瓶颈。此时应直接用 ZSet 在 Redis 中维护完整数据结构,绕过缓存刷新策略的困扰。