Skip to content
Go back

Redis 缓存更新策略三大模式:Cache Aside、Read/Write Through、Write Back

Redis 缓存更新策略三大模式

一句话结论(30s)

三大模式的本质是解决「缓存与数据库两份数据如何一致」:因为 Cache Aside 由应用控流、数据库是真相源、实现最简单,所以 90% 场景选它(先更新数据库再删缓存 + 延迟双删兜底);Read/Write Through 让缓存层做代理、同步穿透保证强一致但写延迟高,Write Back 先写缓存异步刷库、写性能最好但宕机丢数据。

核心原理(2min)

Cache Aside(旁路缓存):读先查缓存、miss 回源写缓存,写采用「先更新数据库再删缓存」,配合 TTL 和延迟双删兜底。Read/Write Through:应用只和缓存对话,缓存层内部负责查库/写库,读未命中缓存自动加载、写同步穿透数据库,一致性最强。Write Back:写缓存立即返回,异步定时批量刷库(可合并同 key 多次更新),把随机写变顺序写、吞吐提升 1-2 个数量级,但刷库前宕机会永久丢失数据。核心心法:缓存是数据库的快照,不是真相源,一致性冲突时用 TTL 兜底。

底层深入(5-10min)

缓存更新是一个老问题,但选错代价很大

缓存和数据库之间永远存在一个核心矛盾:两份数据,如何保持一致? 选了错误的更新策略,轻则短暂不一致,重则数据永久错乱。这篇文章把三种经典模式说透。

模式一:Cache Aside(旁路缓存)

这是最常见、最推荐的模式。 也叫”Lazy Loading”,缓存不主动介入数据流,由应用程序自己管理缓存和数据库的读写。

读操作

1. 先查缓存
2. 命中 → 直接返回
3. 未命中 → 查数据库 → 结果写入缓存 → 返回

代码示例:

public User getUser(long id) {
    String key = "user:" + id;
    User user = redis.get(key, User.class);
    if (user != null) return user;

    user = db.selectById(id);
    if (user != null) {
        redis.setex(key, 3600, user);  // 写缓存 + 设置过期时间
    }
    return user;
}

写操作:先删缓存还是先更新数据库?

这是 Cache Aside 最经典的争议点。 有两种顺序:

顺序 A:先删缓存,再更新数据库

时间轴 ───────────────────────────>

线程1(写): 删除缓存 ─── 更新数据库
线程2(读):         查缓存(空) ─── 查数据库(旧值) ─── 写缓存(旧值!!!)

问题:线程 1 删缓存后、更新数据库前,线程 2 把旧值写回了缓存。之后缓存里一直是旧值,直到过期。

顺序 B:先更新数据库,再删缓存(推荐)

时间轴 ───────────────────────────>

线程1(写): 更新数据库 ─── 删除缓存
线程2(读):         查缓存(命中,旧值,但很快就被删了)

问题:线程 2 在删缓存前读到了旧值,但只是瞬间不一致。缓存被删后,下一次读会从数据库取新值。

理论上仍有极小概率不一致

时间轴 ───────────────────────────>

线程1(写): 更新数据库 ─────────── 删除缓存
线程2(读):         查缓存(未命中) ─── 查数据库(线程1还没更新完 = 旧值) ─── 写缓存(旧值)

但这种情况发生的条件极苛刻,概率远低于先删缓存。

> **思考**:为什么「先更新数据库再删缓存」比「先删缓存再更新数据库」更安全?对比两种顺序的竞态窗口——先删缓存:删完后、更新 DB 前,任何读请求都会把旧值写回缓存,且这个旧值要一直待到 TTL 过期,不一致窗口「又长又必现」;先更新 DB 再删缓存:读到旧值的前提是「读请求恰好落在更新 DB 之后、删缓存之前的极小窗口」,概率低得多。所以推荐的顺序是「先把真相源(DB)写对,再让缓存失效」,让不一致只发生在极小的瞬态。

延迟双删:进一步降低风险

public void updateUser(User user) {
    db.update(user);       // 1. 更新数据库
    redis.del(key);        // 2. 删除缓存
    Thread.sleep(500);     // 3. 等一小段时间
    redis.del(key);        // 4. 再删一次(兜底)
}

第二次删除是”擦屁股”——如果在步骤 1-2 之间,有其他线程把旧值写入了缓存,第二次删除能清理掉。但这引入了延迟,对性能有影响。

Cache Aside 的优缺点

优点缺点
实现简单,应用层控制首次访问一定 miss,冷启动慢
缓存只存热数据,内存利用率高写操作需要删除缓存(多一次网络调用)
缓存失效不会丢数据(数据在数据库)极端并发下有短暂不一致
缓存层出问题不影响数据库业务代码需要同时处理缓存和数据库

模式二:Read/Write Through(读写穿透)

让缓存层充当数据库的代理。 应用只和缓存打交道,缓存负责同步数据库。这种模式中,应用代码不需要感知数据库的存在。

Read Through

应用 → 读缓存 → 命中 → 返回
              → 未命中 → 缓存自己去查数据库 → 写入自己 → 返回给应用

跟 Cache Aside 的区别:查数据库并写缓存的逻辑从应用代码移到了缓存层内部。

Write Through

应用 → 写缓存 → 缓存同步写数据库 → 返回成功

两个写操作同步完成。写入延迟 = 缓存写入 + 数据库写入。

┌──────────┐     ┌──────────┐     ┌──────────┐
│  应用程序  │────▶│   缓存层   │────▶│  数据库   │
│          │     │ (代理)    │     │          │
└──────────┘     └──────────┘     └──────────┘
     ↑                │
     └────────────────┘
        应用只和缓存对话

Write Through 的一致性

因为数据库和缓存同时写入,一致性最好。但代价是每次写都要穿透到数据库,延迟较高。

思考:Read/Write Through 为什么一致性最强,代价又是什么?因为它把「查库/写库」的逻辑从应用代码挪进缓存层内部,应用只跟缓存对话,缓存自己保证「读未命中就加载、写就同步穿透 DB」,天然不会出现 Cache Aside 那种应用漏写漏删的窗口。代价是每次写都要穿透数据库、写延迟高,且缓存层要「全量」承载数据(不再只存热数据),内存利用率下降——一致性是用写延迟和内存换来的。

适用场景

Redis 本身不支持 Read/Write Through 模式(这是缓存中间件的能力,如 Apache Ignite、Hazelcast),但可以用 Lua 脚本或 RedisGears 模拟。


模式三:Write Back(写回 / Write Behind)

数据先写缓存,异步批量刷入数据库。 这是三个模式中写入性能最好的,但一致性最差。

写入流程

应用 → 写缓存 → 立即返回成功

       └── 异步定时任务 → 批量写入数据库(如每秒一次,或积攒到 N 条)

数据流示意

时间 ────────────────────────────────────────────>
t1: 写A到缓存(立即返回)
t2: 写B到缓存(立即返回)
t3: 写C到缓存(立即返回)
t4: 定时任务触发 → A、B、C 批量写数据库
t5: 写D到缓存(立即返回)
──────────────────────── 宕机!!!─────────────
    D 在缓存里,还没写到数据库 → 永久丢失

核心风险:数据丢失

如果在批量写入数据库之前,缓存层(或整台机器)宕机,那些还在缓存中未持久化的数据就永远丢失了。这是 Write Back 的致命弱点。

为什么还要用它?

极致的写入性能。 对于写入密集型场景(如计数器、浏览统计、IoT 传感器数据),Write Back 把数据库的随机写变成了缓存的批量顺序写,吞吐量可以提升 1-2 个数量级。

思考:Write Back 为什么写入性能能高一个数量级,又为什么最危险?性能来源——应用只写缓存立即返回,数据库的随机写被合并成定时批量顺序写,同 key 多次更新只落一次;危险也来自同一点——刷库前宕机,缓存里还没落库的数据就永久丢失。所以它是「用一致性换吞吐」的极端选择,只适合计数器、日志、埋点这类「允许少量丢失」的场景。

实际应用

改进:Write Behind 模式

Write Back 的变体,关键在于”批量 + 合并”:

┌──────────────────────────────────┐
│         写入队列(内存)           │
│  key=user:1, val=100, ts=10:00  │
│  key=user:1, val=200, ts=10:01  │ → 同一 key 合并,只写最新值
│  key=user:2, val=300, ts=10:02  │
└──────────────────────────────────┘


     批量写入数据库(只写 user:1=200, user:2=300)

通过合并同一 key 的多次更新,减少数据库写入次数。


三种模式对比总表

维度Cache AsideRead/Write ThroughWrite Back
写延迟中(删缓存 + 写DB)高(写缓存 + 写DB同步)低(只写缓存)
读延迟中(miss时查DB)中(miss时缓存自动查DB)低(缓存全量)
一致性最终一致强一致弱一致
数据丢失风险无(DB 是真相源)无(同步写入DB)高(宕机丢数据)
应用复杂度高(应用管理缓存逻辑)低(缓存层封装)低(只写缓存)
缓存利用率高(只存热数据)低(全量存储)低(全量存储)
适用场景通用场景,读写分离强一致性要求写密集、允许少量丢失

如何选择?

你的场景是不是"写极多、容忍少量数据丢失"?
├── 是 → Write Back(计数器、日志、埋点统计)
└── 否 → 你的场景是不是需要"强一致性"?
        ├── 是 → Read/Write Through(金融、订单状态)
        └── 否 → Cache Aside(90% 的业务场景都选这个)

最终建议

  1. 首选 Cache Aside。先更新数据库、再删除缓存,配合过期时间和延迟双删兜底。这是业界最成熟、坑最少、资料最多的方案。
  2. 如果使用了本地缓存(Caffeine/Guava Cache),利用其内置的 loader 机制实现 Read Through,让缓存层自动加载数据。
  3. 只有写入量级确实大到数据库扛不住时,才考虑 Write Back,同时必须接受数据丢失的风险,并做好监控和故障恢复方案。

核心心法:缓存是数据库的快照,不要把它当作真相源。当一致性和性能冲突时,优先用过期时间(TTL)兜底——即使短期不一致,这段时间过去后一定会一致。

章末提问

Q1:Cache Aside 为什么推荐「先更新数据库再删缓存」,而不是「先删缓存再更新数据库」?

回答思路:结论——因为「先删缓存」会让删缓存后、更新 DB 前的任何读请求把旧值写回缓存、且旧值存活到 TTL 过期,不一致窗口又长又必现。因为:先删缓存时读请求 miss 会回源读到旧值并写回;而「先更新 DB 再删缓存」时,读旧值只发生在「更新 DB 后、删缓存前」的极小瞬态窗口,概率低得多,再配合延迟双删和 TTL 兜底。

Q2:Read/Write Through 和 Cache Aside 的本质区别是什么?

回答思路:结论——本质区别是「谁来保证缓存与 DB 一致」:Cache Aside 由应用层自己控流,Read/Write Through 让缓存层做代理、应用只和缓存对话。因为:Read Through 未命中由缓存自动查库加载,Write Through 写缓存时同步穿透 DB,应用代码无需感知数据库,一致性强但写延迟高、缓存需全量存储;Cache Aside 简单灵活但应用要自己处理 miss 回源和删缓存。

Q3:Write Back 为什么写入性能最好?它最大的风险是什么?哪些场景用?

回答思路:结论——Write Back 先写缓存立即返回、异步定时批量刷库,把数据库随机写变批量顺序写、同 key 更新可合并,吞吐提升 1-2 个数量级;最大风险是刷库前宕机会永久丢数据。因为:缓存和 DB 之间存在未刷窗口,宕机即丢;所以只适合计数器、日志、埋点等「写密集 + 允许少量丢失」的场景,其思想也体现在 OS 页缓存和 Redis RDB 持久化上。


Share this post on:

Previous Post
Redisson分布式锁的Lua脚本实现
Next Post
Redis 淘汰策略详解:LRU 与 LFU