缓存读写模式
问题
常见的缓存读写模式有哪些?各自适用什么场景?
答案
缓存读写模式决定了数据在缓存和数据库之间如何流动。选择合适的模式是缓存架构设计的基础。
一、Cache Aside(旁路缓存)
Cache Aside 是最常用的缓存模式,应用程序同时管理缓存和数据库。
读流程
写流程
写操作先更新数据库,再删除缓存,而不是更新缓存。原因:
- 缓存值可能是复杂计算的结果,更新成本高
- 某些缓存数据不一定会被再次读取,更新是浪费
- 删除是幂等操作,更安全
为什么不先删缓存再更新数据库?
先删缓存再更新数据库,在并发场景下容易出现缓存脏数据。
为什么不先更新数据库再更新缓存?
两个并发写请求可能导致缓存和数据库的值不一致。
代码实现
@Service
public class UserService {
@Autowired
private RedisTemplate<String, User> redisTemplate;
@Autowired
private UserMapper userMapper;
// 读:先查缓存,未命中查数据库,回填缓存
public User getUser(Long userId) {
String key = "user:" + userId;
// 1. 查缓存
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 2. 查数据库
user = userMapper.selectById(userId);
if (user != null) {
// 3. 回填缓存,设置过期时间作为兜底
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}
return user;
}
// 写:先更新数据库,再删除缓存
@Transactional
public void updateUser(User user) {
// 1. 更新数据库
userMapper.updateById(user);
// 2. 删除缓存
String key = "user:" + user.getId();
redisTemplate.delete(key);
}
}
优缺点
| 优点 | 缺点 |
|---|---|
| 实现简单,适合大多数场景 | 首次请求缓存未命中,有冷启动问题 |
| 缓存数据按需加载 | 极端并发下仍存在短暂不一致窗口 |
| 删除是幂等的,失败可重试 | 应用需同时管理缓存和数据库 |
二、Read Through(读穿透)
Read Through 由缓存层负责从数据库加载数据,应用只与缓存交互。
与 Cache Aside 的区别
| 对比项 | Cache Aside | Read Through |
|---|---|---|
| 数据加载方 | 应用程序 | 缓存层 |
| 应用感知 | 需要知道缓存和数据库 | 只与缓存交互 |
| 封装性 | 低 | 高 |
实现示例
/**
* Read Through 模式:缓存层封装数据库加载逻辑
*/
public class ReadThroughCache<K, V> {
private final Map<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();
private final Function<K, V> dbLoader; // 数据库加载函数
private final Duration ttl;
public ReadThroughCache(Function<K, V> dbLoader, Duration ttl) {
this.dbLoader = dbLoader;
this.ttl = ttl;
}
public V get(K key) {
CacheEntry<V> entry = cache.get(key);
// 缓存命中且未过期
if (entry != null && !entry.isExpired()) {
return entry.getValue();
}
// 缓存未命中:由缓存层自动加载
V value = dbLoader.apply(key);
if (value != null) {
cache.put(key, new CacheEntry<>(value, ttl));
}
return value;
}
}
- Guava Cache / Caffeine:
CacheLoader就是 Read Through 的实现 - JCache (JSR-107):标准化的 Read Through 支持
三、Write Through(写穿透)
Write Through 写操作时同时更新缓存和数据库,由缓存层负责同步写入数据库。
特点
- 优点:缓存和数据库始终保持一致,读取总是最新数据
- 缺点:写延迟较高(需同步等待数据库写入),不适合写多场景
Write Through 通常与 Read Through 配合使用,一起提供完整的缓存抽象。
四、Write Behind / Write Back(异步写回)
Write Behind 写操作只更新缓存,异步批量写入数据库,牺牲一致性换取高写入性能。
实现要点
/**
* Write Behind 核心:异步队列 + 批量写入
*/
public class WriteBehindCache {
private final Map<String, Object> cache = new ConcurrentHashMap<>();
private final BlockingQueue<WriteTask> writeQueue = new LinkedBlockingQueue<>();
private final ScheduledExecutorService scheduler =
Executors.newSingleThreadScheduledExecutor();
public WriteBehindCache() {
// 每 500ms 批量刷盘一次
scheduler.scheduleAtFixedRate(this::flushToDB, 500, 500, TimeUnit.MILLISECONDS);
}
// 写操作只更新缓存,投递到异步队列
public void put(String key, Object value) {
cache.put(key, value);
writeQueue.offer(new WriteTask(key, value));
}
// 批量写入数据库
private void flushToDB() {
List<WriteTask> batch = new ArrayList<>();
writeQueue.drainTo(batch, 100); // 每次最多处理 100 条
if (!batch.isEmpty()) {
batchInsertOrUpdate(batch); // 批量 SQL
}
}
}
风险与应对
| 风险 | 应对措施 |
|---|---|
| 缓存宕机数据丢失 | Redis AOF 持久化 + 定期 checkpoint |
| 异步写入失败 | 重试队列 + 死信队列 + 告警 |
| 数据不一致窗口 | 接受最终一致性,不适合强一致场景 |
Write Behind 适合写密集但对一致性要求不高的场景,如:
- 用户行为日志、浏览记录
- 计数器、点赞数(最终一致即可)
- 物联网传感器数据批量写入
五、Refresh Ahead(预刷新)
Refresh Ahead 在缓存过期之前,提前异步刷新数据。
实现思路
/**
* Refresh Ahead:后台线程在 TTL 剩余 20% 时提前刷新
*/
public class RefreshAheadCache<K, V> {
private final Map<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();
private final Function<K, V> dbLoader;
private final ExecutorService refreshPool;
public V get(K key) {
CacheEntry<V> entry = cache.get(key);
if (entry == null) {
// 完全过期或不存在,同步加载
return loadAndCache(key);
}
// 剩余 TTL < 20% 时提前异步刷新
if (entry.remainingTtlPercent() < 0.2) {
refreshPool.submit(() -> loadAndCache(key));
}
return entry.getValue();
}
}
优点:热点 Key 几乎永不过期,读请求始终命中缓存
缺点:预测不准时会浪费刷新资源
六、模式对比与选型
| 模式 | 读延迟 | 写延迟 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|---|
| Cache Aside | 首次高 | 低 | 最终一致 | 低 | 通用场景,读多写少 |
| Read Through | 首次高 | - | 最终一致 | 中 | 需要封装缓存逻辑 |
| Write Through | 低 | 高 | 强一致 | 中 | 读多写少,一致性要求高 |
| Write Behind | 低 | 极低 | 弱一致 | 高 | 写密集,容忍丢失 |
| Refresh Ahead | 极低 | - | 最终一致 | 高 | 热点数据,对延迟敏感 |
绝大多数互联网项目使用 Cache Aside 模式。它简单可靠,配合延迟双删等策略可以满足大部分一致性需求。
常见面试问题
Q1: Cache Aside 模式中,为什么是删除缓存而不是更新缓存?
答案:
三个原因:
- 避免并发写冲突:两个并发写操作更新缓存时,可能因为时序问题导致缓存存储旧值
- 减少无效运算:缓存值可能是多表 JOIN 的结果,每次写都重新计算很浪费
- 删除是幂等的:删除操作可以安全重试,更新操作可能引入脏数据
Q2: Cache Aside 还有不一致的可能吗?
答案:
有,但概率极低。以下场景仍可能不一致:
1. 线程A 查缓存未命中
2. 线程A 查数据库(旧值)
3. 线程B 更新数据库(新值)
4. 线程B 删除缓存
5. 线程A 将旧值写入缓存
此场景需要满足:读请求的数据库查询 + 缓存写入的总时间 > 写请求的数据库更新 + 缓存删除的总时间。由于写数据库(加锁)通常比读数据库慢得多,这个条件极难满足。
应对措施:给缓存设置合理的 TTL 作为兜底。
Q3: Read Through 和 Cache Aside 的核心区别是什么?
答案:
| 对比维度 | Cache Aside | Read Through |
|---|---|---|
| 数据库加载方 | 应用程序负责查库回填 | 缓存组件自动加载 |
| 应用代码侵入 | 需要写缓存判断逻辑 | 应用只调 cache.get() |
| 典型实现 | 手动 Redis + DB 操作 | Caffeine CacheLoader |
Q4: Write Behind 如何保证数据不丢失?
答案:
Write Behind 的数据在写入数据库前只存在于缓存中,存在丢失风险。保障措施:
- Redis AOF 持久化:
appendfsync everysec最多丢 1 秒数据 - WAL(Write-Ahead Log):落盘前先写日志,重启后重放
- 定期 Checkpoint:周期性强制刷盘
- 监控告警:队列积压超阈值触发告警
Q5: 你的项目中用的什么缓存模式?
答案:
大多数项目使用 Cache Aside 模式,因为:
- 实现简单直观,团队易于理解和维护
- Redis + MySQL 是标配组合,Cache Aside 最匹配
- 业务大多是读多写少,Cache Aside 的读延迟可接受
- 配合 TTL 兜底,即使偶尔不一致也能自动收敛
对于写密集的场景(如计数器),会局部使用 Write Behind 模式:先写 Redis,定时批量同步到 MySQL。