本文目录导读:

这是一个非常经典且重要的后端开发问题,缓存更新的目标是保证数据库(DB)和缓存(如Redis)中的数据最终一致,同时兼顾高并发下的性能。
下面我来为你详细梳理常见的缓存更新案例,包括其策略、优缺点和适用场景。
核心难点:并发写与缓存一致性问题
假设我们有一个读取流程:
- 读请求,先查缓存。
- 缓存未命中,查数据库。
- 将数据库结果写入缓存。
- 返回数据。
问题发生在写操作时:当有并发写请求(更新DB)和读请求(更新缓存)同时发生时,很容易出现数据不一致。
四大主流缓存更新策略
Cache Aside Pattern(旁路缓存)—— 最常用、最推荐
这是业界最标准的模式,具体做法是:
- 读: 先读缓存,命中则直接返回;未命中则读数据库,然后设置缓存,最后返回。
- 写: 先更新数据库,然后删除缓存。
为什么是“先更新DB,再删除缓存”而不是“先更新缓存”或“先删除缓存”?
原因1:避免并发读写导致的脏数据
- 场景: 线程A发起写操作,线程B发起读操作。
- 错误做法(先删缓存,再更新DB):
- A删除缓存。
- B读缓存未命中,从DB读到旧数据。
- B将旧数据写入缓存。
- A将新数据写入DB。
- 结果: 缓存中是旧数据(脏数据),DB是新数据。
- 正确做法(先更新DB,再删缓存):
- A更新DB(新数据)。
- B读缓存未命中,从DB读到新数据(或旧数据,取决于隔离级别,但在大多数RC级别下读到新数据概率大)。
- B将数据写入缓存。
- A删除缓存。
- 结果: 最终缓存会被删除,下一次读请求会重新从DB加载新数据,保证最终一致性。
原因2:避免删除操作本身失败
- 如果先删除缓存,然后更新DB失败,缓存已删除,DB数据未变,下次读请求会查到旧数据写入缓存,导致缓存和DB不一致(缓存是旧数据,DB也是旧数据,但业务上可能期望新的)。
- 如果先更新DB,再删除缓存,即使删除缓存失败,我们还可以通过重试机制(如消息队列、定时任务)来补偿删除操作。
优点: 简单、高效、对并发读友好。 缺点: 短暂的不一致窗口(删除缓存成功前,可能有读请求读到旧缓存),在高并发下,这个窗口非常小,通常可以接受。
Read Through / Write Through(穿透缓存)
应用层不直接与数据库交互,而是通过缓存层(如提供一个缓存抽象层,Caffeine + Redis 的组合)来读写数据。
- Read Through: 缓存层负责读取数据库,如果缓存未命中,缓存自身会去加载数据库并填充,对应用透明。
- Write Through: 写请求直接写入缓存,缓存层负责同步写入数据库(同步阻塞)。
- 优点: 一致性高(写操作必须同时成功或失败)。
- 缺点: 写性能差(因为要同步写DB),不适合写密集型场景。
Write Behind Caching(异步写回)
数据只写缓存,缓存异步批量写入数据库。
- 优点: 写性能极高(只写内存),适合写入量巨大、对数据一致性要求不严格的场景(如日志、点赞数、PV/UV统计)。
- 缺点: 数据有丢失风险(如果缓存宕机),一致性窗口较长,业务需要能容忍不一致。
先更新缓存,再更新数据库(不推荐)
- 严重缺陷: 如果缓存更新成功,数据库更新失败,则数据永久不一致(缓存有脏数据),几乎只有特殊业务(如并发控制要求极强且必须立即失效的场景,但通常用分布式锁替代)才会使用。
实战中的权衡与优化
在实际业务中,你通常会遇到以下问题,需要做出权衡:
问题1:删除缓存失败怎么办?
方案: 引入重试机制。
- 设计模式: 在删除操作失败时,将任务(key + 操作类型)写入消息队列(如Kafka、RabbitMQ)。
- 异步消费者: 一个异步的消费者监听队列,不断尝试删除该缓存。
- 兜底: 可以设置重试次数上限或者延迟重试,确保最终删除成功。
代码示例(伪代码):
public void updateData(Long id, Data data) {
// 1. 更新数据库
database.update(id, data);
// 2. 尝试删除缓存
try {
redisCache.delete("data:" + id);
} catch (Exception e) {
// 3. 如果删除失败,发送消息到MQ,由MQ消费者重试
mq.send(new CacheDeleteMessage("data:" + id));
}
}
问题2:高并发下短时间内不一致怎么办?
方案: 延迟双删。
- 做法: 在“先更新DB,再删缓存”的基础上,延迟一段时间(比如1秒)后再删一次缓存。
- 原理: 这个延迟时间要大于“读请求从DB读数据到写入缓存”的时间,这样即使有并发读请求在此期间写入了旧缓存,延迟删除也会把它清掉。
- 缺点: 增加了实现复杂度,且这个“足够长的时间”很难精确估算。
问题3:是否需要加锁?
- 不需要: 对于绝大多数业务场景,删除缓存策略 + 重试机制已经足够,因为不一致窗口极短,且最终一致。
- 需要: 如果业务要求“强一致性”(如银行账户余额、秒杀库存),那么应该放弃使用缓存,直接访问数据库,并配合数据库的乐观锁或悲观锁,或者使用分布式锁来对某个Key的读写进行串行化。
如何选择?
| 业务场景 | 推荐策略 | 理由 |
|---|---|---|
| 读多写少,对一致性要求中等 | Cache Aside(先更新DB,再删缓存) | 简单、高效,不一致窗口可控。 |
| 读写比例接近,对一致性要求高 | Read Through / Write Through (或 直接读DB) | 一致性高,但写性能下降。 |
| 大量写操作,偶尔读,允许最终一致 | Write Behind (异步写回) | 写性能极佳,但有一定数据丢失风险。 |
| 高并发,对延迟敏感 | 在Cache Aside基础上,采用延迟双删 | 最大程度降低不一致窗口。 |
最终建议:
在大多数业务系统中,Cache Aside(先更新DB,再删缓存)+ 删除失败的重试机制 是最通用、最稳妥的选择,它平衡了性能、一致性和实现复杂度。