跳到主要内容

缓存读写模式

问题

常见的缓存读写模式有哪些?各自适用什么场景?

答案

缓存读写模式决定了数据在缓存和数据库之间如何流动。选择合适的模式是缓存架构设计的基础。

一、Cache Aside(旁路缓存)

Cache Aside 是最常用的缓存模式,应用程序同时管理缓存和数据库

读流程

写流程

核心原则

写操作先更新数据库,再删除缓存,而不是更新缓存。原因:

  1. 缓存值可能是复杂计算的结果,更新成本高
  2. 某些缓存数据不一定会被再次读取,更新是浪费
  3. 删除是幂等操作,更安全

为什么不先删缓存再更新数据库?

先删缓存再更新数据库,在并发场景下容易出现缓存脏数据。

为什么不先更新数据库再更新缓存?

两个并发写请求可能导致缓存和数据库的值不一致。

代码实现

@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 AsideRead 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 / CaffeineCacheLoader 就是 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 模式中,为什么是删除缓存而不是更新缓存?

答案

三个原因:

  1. 避免并发写冲突:两个并发写操作更新缓存时,可能因为时序问题导致缓存存储旧值
  2. 减少无效运算:缓存值可能是多表 JOIN 的结果,每次写都重新计算很浪费
  3. 删除是幂等的:删除操作可以安全重试,更新操作可能引入脏数据

Q2: Cache Aside 还有不一致的可能吗?

答案

有,但概率极低。以下场景仍可能不一致:

1. 线程A 查缓存未命中
2. 线程A 查数据库(旧值)
3. 线程B 更新数据库(新值)
4. 线程B 删除缓存
5. 线程A 将旧值写入缓存

此场景需要满足:读请求的数据库查询 + 缓存写入的总时间 > 写请求的数据库更新 + 缓存删除的总时间。由于写数据库(加锁)通常比读数据库慢得多,这个条件极难满足。

应对措施:给缓存设置合理的 TTL 作为兜底。

Q3: Read Through 和 Cache Aside 的核心区别是什么?

答案

对比维度Cache AsideRead Through
数据库加载方应用程序负责查库回填缓存组件自动加载
应用代码侵入需要写缓存判断逻辑应用只调 cache.get()
典型实现手动 Redis + DB 操作Caffeine CacheLoader

Q4: Write Behind 如何保证数据不丢失?

答案

Write Behind 的数据在写入数据库前只存在于缓存中,存在丢失风险。保障措施:

  1. Redis AOF 持久化appendfsync everysec 最多丢 1 秒数据
  2. WAL(Write-Ahead Log):落盘前先写日志,重启后重放
  3. 定期 Checkpoint:周期性强制刷盘
  4. 监控告警:队列积压超阈值触发告警

Q5: 你的项目中用的什么缓存模式?

答案

大多数项目使用 Cache Aside 模式,因为:

  1. 实现简单直观,团队易于理解和维护
  2. Redis + MySQL 是标配组合,Cache Aside 最匹配
  3. 业务大多是读多写少,Cache Aside 的读延迟可接受
  4. 配合 TTL 兜底,即使偶尔不一致也能自动收敛

对于写密集的场景(如计数器),会局部使用 Write Behind 模式:先写 Redis,定时批量同步到 MySQL。

相关链接