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

7.9 KiB
Raw Blame History

tags, create time, update time
tags create time update time
redis
cache-strategy
cache-aside
read-through
write-through
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
}

实践场景

  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 中维护完整数据结构,绕过缓存刷新策略的困扰。

关联笔记