4、缓存读写策略:Cache Aside、Read/Write Through、Write Behind
好,咱们今天聊聊缓存读写策略。说实话,这是我在实际项目中踩坑最多的一个模块。
你想想看,缓存说白了就是一层加速层。但怎么读、怎么写,策略选不对,轻则数据不一致,重则直接雪崩。我见过太多团队,Redis 用得挺溜,但一问到读写策略,就含糊了。
今天我把三种主流策略掰开揉碎讲清楚。分别是:Cache Aside、Read/Write Through、Write Behind。
4.1 Cache Aside(旁路缓存)
这是最常用的策略。我敢说,90% 的互联网公司都在用这个模式。
它的核心思想很简单:应用程序自己管理缓存。缓存不跟数据库直接打交道,应用层负责读写两边。
读流程
- 先查缓存。命中,直接返回。
- 没命中,查数据库。
- 把数据库结果写入缓存。
- 返回数据。
写流程
- 更新数据库。
- 删除缓存(或者更新缓存)。
嗯,这里要注意。为什么是「删除缓存」而不是「更新缓存」?
我个人习惯是先更新数据库,再删缓存。为什么?因为更新缓存是个「写操作」,如果并发高,多个线程同时更新,很容易出现脏数据。删除缓存就简单了,下次读的时候自然会把新数据拉进来。
核心要点:Cache Aside 模式下,写操作一定要先更新数据库,再操作缓存。顺序不能反。
我的经验:我在做券商行情系统时,就遇到过「先删缓存再更新数据库」的坑。删完缓存后,另一个线程读到了旧数据并写回缓存,导致缓存里一直是旧数据。后来改成「先更新数据库再删缓存」,问题解决。
代码示例
// 读操作
public Order getOrder(String orderId) {
// 1. 先查缓存
Order order = redis.get("order:" + orderId);
if (order != null) {
return order;
}
// 2. 缓存没命中,查数据库
order = orderDao.getById(orderId);
// 3. 写入缓存(设置过期时间)
if (order != null) {
redis.set("order:" + orderId, order, 300);
}
return order;
}
// 写操作
public void updateOrder(Order order) {
// 1. 先更新数据库
orderDao.update(order);
// 2. 再删除缓存
redis.del("order:" + order.getId());
}
4.2 Read/Write Through(读写穿透)
这个策略,说白了就是缓存层自己承担了所有责任。应用层只跟缓存打交道,缓存再跟数据库同步。
我刚开始接触这个模式时觉得挺酷的——应用层代码干净多了。但后来发现,它其实是把复杂度从应用层转移到了缓存层。
读流程
- 应用请求缓存。
- 缓存命中,直接返回。
- 缓存没命中,缓存自己查数据库。
- 缓存把结果写入自己,再返回给应用。
写流程
- 应用写缓存。
- 缓存自己负责写数据库。
- 返回成功给应用。
注意:Read/Write Through 需要缓存中间件支持「代理模式」。比如 Redis 本身不支持,需要自己封装一层。我一般用 Guava Cache 或 Caffeine 来实现这种模式,它们内置了 LoadingCache 机制。
代码示例(使用 Caffeine)
LoadingCache<String, Order> orderCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(key -> {
// 缓存未命中时,自动从数据库加载
return orderDao.getById(key);
});
// 读操作
public Order getOrder(String orderId) {
return orderCache.get(orderId);
}
// 写操作(需要自己封装)
public void updateOrder(Order order) {
// 先更新数据库
orderDao.update(order);
// 再更新缓存
orderCache.put(order.getId(), order);
}
你看,读操作确实简洁了。但写操作还是得自己处理。说白了,完全自动化的 Write Through 需要缓存中间件层面支持,比如 Hazelcast 或 Apache Ignite。
4.3 Write Behind(写回/异步写)
这个策略,我愿称之为「性能怪兽」。它的核心思想是:先写缓存,异步批量写数据库。
我在券商交易系统中用过这个模式。当时做行情快照的落地,每秒几万笔写入,如果每笔都写数据库,IO 直接打满。用 Write Behind 后,性能提升了 10 倍不止。
写流程
- 应用写缓存。
- 缓存标记数据为「脏数据」。
- 立即返回成功。
- 后台线程定期批量刷到数据库。
读流程
- 先查缓存。
- 命中直接返回。
- 没命中,查数据库,写回缓存。
核心优势:写性能极高。适合写多读少、允许短暂数据不一致的场景。
风险提示:如果缓存宕机,未落盘的数据会丢失。我曾经因为这个原因,在凌晨被电话叫醒过……所以,一定要配合持久化机制(比如 Redis AOF/RDB)或者 WAL 日志来保证数据安全。
代码示例(简化版)
// 使用一个队列暂存待写入的数据
private final BlockingQueue<Order> writeQueue = new LinkedBlockingQueue<>();
// 写操作
public void updateOrder(Order order) {
// 1. 先更新缓存
redis.set("order:" + order.getId(), order);
// 2. 放入写队列
writeQueue.offer(order);
}
// 后台线程批量刷库
@Scheduled(fixedDelay = 1000) // 每秒刷一次
public void flushToDatabase() {
List<Order> batch = new ArrayList<>();
writeQueue.drainTo(batch, 100); // 每次最多取100条
if (!batch.isEmpty()) {
orderDao.batchUpdate(batch);
}
}
三种策略对比
| 策略 | 读性能 | 写性能 | 数据一致性 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|---|
| Cache Aside | 高 | 中 | 强(最终一致) | 低 | 通用场景,大部分业务系统 |
| Read/Write Through | 高 | 中 | 强 | 中 | 需要缓存层统一管理的场景 |
| Write Behind | 高 | 极高 | 弱(最终一致) | 高 | 写密集型、日志、统计、行情快照 |
知识体系结构图
如何选择?
说实话,没有银弹。我一般这样选:
- 通用业务系统:Cache Aside 就够了。简单、可靠、容易排查问题。
- 需要缓存层统一管理:比如微服务架构中,多个服务共享同一套缓存逻辑,用 Read/Write Through 封装一层。
- 写密集型场景:比如日志、统计、行情快照,用 Write Behind。但一定要做好容灾。
我的建议:刚开始做缓存设计时,先从 Cache Aside 入手。等你对业务场景和数据一致性要求有了深刻理解后,再考虑其他策略。别一上来就整 Write Behind,容易翻车。
嗯,这三种策略就讲到这里。每种都有它的脾气,选对了事半功倍,选错了……嗯,你懂的。