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 那种应用漏写漏删的窗口。代价是每次写都要穿透数据库、写延迟高,且缓存层要「全量」承载数据(不再只存热数据),内存利用率下降——一致性是用写延迟和内存换来的。
适用场景
- 使用本地缓存(如 Caffeine)配合 loader 函数实现 Read Through。
- 数据一致性要求极高的场景(金融记账)。
- 写操作不频繁的场景。
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 多次更新只落一次;危险也来自同一点——刷库前宕机,缓存里还没落库的数据就永久丢失。所以它是「用一致性换吞吐」的极端选择,只适合计数器、日志、埋点这类「允许少量丢失」的场景。
实际应用
- 操作系统页面缓存:内存脏页定期 flush 到磁盘。这就是 Write Back 思想的典型体现。
- Redis RDB 持久化:Redis 在内存中积累数据,定期 fork 子进程写 RDB 快照到磁盘。
- Elasticsearch 的 translog:先写内存 buffer,定期 flush 到 Lucene segment。
改进: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 Aside | Read/Write Through | Write Back |
|---|---|---|---|
| 写延迟 | 中(删缓存 + 写DB) | 高(写缓存 + 写DB同步) | 低(只写缓存) |
| 读延迟 | 中(miss时查DB) | 中(miss时缓存自动查DB) | 低(缓存全量) |
| 一致性 | 最终一致 | 强一致 | 弱一致 |
| 数据丢失风险 | 无(DB 是真相源) | 无(同步写入DB) | 高(宕机丢数据) |
| 应用复杂度 | 高(应用管理缓存逻辑) | 低(缓存层封装) | 低(只写缓存) |
| 缓存利用率 | 高(只存热数据) | 低(全量存储) | 低(全量存储) |
| 适用场景 | 通用场景,读写分离 | 强一致性要求 | 写密集、允许少量丢失 |
如何选择?
你的场景是不是"写极多、容忍少量数据丢失"?
├── 是 → Write Back(计数器、日志、埋点统计)
└── 否 → 你的场景是不是需要"强一致性"?
├── 是 → Read/Write Through(金融、订单状态)
└── 否 → Cache Aside(90% 的业务场景都选这个)
最终建议:
- 首选 Cache Aside。先更新数据库、再删除缓存,配合过期时间和延迟双删兜底。这是业界最成熟、坑最少、资料最多的方案。
- 如果使用了本地缓存(Caffeine/Guava Cache),利用其内置的 loader 机制实现 Read Through,让缓存层自动加载数据。
- 只有写入量级确实大到数据库扛不住时,才考虑 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 持久化上。