4、缓存模式(上):Cache-Aside 模式详解、Read-Through 模式、Write-Through 模式
聊到缓存模式,很多人第一反应就是「缓存不就是在数据库前面加一层 Redis 吗?」。嗯,这话没错,但太粗糙了。你想想看,缓存和数据库之间的数据怎么同步?谁来负责加载?谁来负责写入?这些细节决定了你的系统是稳定高效,还是三天两头出数据不一致的 Bug。
我个人习惯把缓存模式分成两大类:应用端主动管理 和 缓存层自动管理。今天我们先聊三个最基础也最常用的模式:Cache-Aside、Read-Through 和 Write-Through。别小看它们,我见过不少团队用了好几年 Redis,其实一直在用 Cache-Aside,只是自己不知道这个叫法而已。
Cache-Aside:最常用的缓存模式
Cache-Aside,也叫「旁路缓存」。说白了,就是应用自己管理缓存的读写。这是最灵活的模式,也是我工作中用得最多的。
它的核心流程很简单:
- 读请求:先查缓存,命中就直接返回;没命中就去查数据库,然后把结果写回缓存,再返回。
- 写请求:先更新数据库,然后删除缓存(或者更新缓存)。
这里有个关键点——为什么写的时候是「删除缓存」而不是「更新缓存」?
我曾经在一个电商项目中踩过这个坑。当时我们写订单数据时直接更新缓存,结果并发场景下出现了「脏写」:两个线程同时更新,后写的反而覆盖了先写的正确数据。从那以后,我团队里的代码规范就加了一条:Cache-Aside 模式下,写操作一律删除缓存,不做原地更新。
核心原则:Cache-Aside 的写操作,优先删除缓存而非更新缓存。这样可以避免并发写导致的脏数据问题。
来看一段伪代码,这是我个人习惯的写法:
// 读操作
public User getUser(String userId) {
// 1. 先查缓存
User user = cache.get("user:" + userId);
if (user != null) {
return user;
}
// 2. 缓存未命中,查数据库
user = db.query("SELECT * FROM users WHERE id = ?", userId);
// 3. 回写缓存(设置过期时间)
if (user != null) {
cache.set("user:" + userId, user, 3600);
}
return user;
}
// 写操作
public void updateUser(User user) {
// 1. 先更新数据库
db.execute("UPDATE users SET name = ? WHERE id = ?", user.getName(), user.getId());
// 2. 删除缓存(不是更新!)
cache.del("user:" + user.getId());
}
小技巧:回写缓存时一定要设置 TTL(过期时间)。我见过有人不设过期时间,结果缓存里堆满了冷数据,内存被打满,Redis 直接 OOM。设个合理的 TTL,既保证数据最终一致性,又能自动清理冷数据。
Read-Through:让缓存层帮你干活
Read-Through 和 Cache-Aside 很像,但有一个本质区别:加载数据的逻辑从应用移到了缓存层。
在 Read-Through 模式下,应用只跟缓存打交道。缓存没命中时,缓存组件(比如 Redis 的客户端库或代理层)会自动去数据库加载数据,然后返回给应用。应用根本不知道数据库的存在——至少从代码层面看是这样。
这样做的好处很明显:
- 代码更干净:应用层不需要写「查缓存→查数据库→回写缓存」这一套模板代码。
- 逻辑统一:数据加载策略集中在缓存层管理,修改起来更方便。
但缺点也很实在:
- 灵活性降低:复杂的查询逻辑很难在缓存层表达。比如你需要做多表联查、聚合计算,Read-Through 就有点力不从心了。
- 延迟敏感:缓存层加载数据时如果数据库响应慢,会直接拖垮缓存层的线程池。
我记得之前帮一个团队做架构评审,他们用了一个开源的缓存代理组件来实现 Read-Through。结果上线后频繁超时,一查才发现缓存代理的数据库连接池配得太小,并发一高就全堵住了。说白了,Read-Through 是把双刃剑——用好了代码简洁,用不好就是新的瓶颈点。
注意:Read-Through 模式适合「读多写少」且查询逻辑相对固定的场景。如果你的查询条件千变万化,或者需要做复杂的业务计算,建议还是用 Cache-Aside,把控制权留在应用层。
Write-Through:写入必须经过缓存
Write-Through 模式,顾名思义——写操作必须经过缓存。应用只写缓存,缓存负责把数据同步到数据库。
这个模式的核心流程:
- 应用向缓存发起写请求。
- 缓存先更新自身数据,然后同步写入数据库。
- 只有数据库写入成功,缓存才返回「写入成功」给应用。
你可能会问:「这不就是多此一举吗?直接写数据库不就行了?」
嗯,这里的关键在于数据一致性。Write-Through 保证缓存和数据库的数据是强一致的——要么都成功,要么都失败。对于某些对一致性要求极高的场景(比如金融交易、库存扣减),这个模式很有价值。
但代价也很明显:
- 写入延迟增加:每次写操作都要等数据库确认,相当于在缓存后面又串了一个同步操作。
- 缓存压力变大:所有写流量都先打到缓存,缓存成了写入路径上的单点。
我曾经在一个支付系统中用过 Write-Through。当时的需求是「余额变更必须实时同步到数据库,不能有任何延迟」。我们用了 Write-Through 模式,配合本地事务,确保缓存和数据库的写入在同一个事务边界内。效果不错,但代价是写入性能比直接写数据库慢了 20% 左右。
适用场景总结:Write-Through 适合「写操作频繁但数据一致性要求极高」的场景。如果你的业务允许秒级的数据不一致,用 Cache-Aside + 异步删除缓存就足够了,没必要上 Write-Through 增加复杂度。
三种模式对比
说了这么多,我们来个直观的对比:
| 特性 | Cache-Aside | Read-Through | Write-Through |
|---|---|---|---|
| 谁负责加载数据 | 应用 | 缓存层 | 缓存层 |
| 谁负责写入数据库 | 应用 | 应用(读由缓存层负责) | 缓存层 |
| 数据一致性 | 最终一致(需处理并发) | 最终一致 | 强一致(事务内) |
| 代码复杂度 | 中等(需写模板代码) | 低(应用层简单) | 低(应用层简单) |
| 写入性能 | 高(直接写DB) | 高 | 中(需等待DB确认) |
| 适用场景 | 大多数业务系统 | 读多写少、查询固定 | 强一致性要求高 |
我的建议:如果你刚开始做缓存设计,先从 Cache-Aside 入手。它最灵活,也最容易排查问题。等你对业务和数据流有了足够理解,再考虑 Read-Through 或 Write-Through 来简化代码或提升一致性。
这三种模式是缓存世界的基石。你想想看,不管多复杂的缓存架构,底层无非就是「谁来读、谁来写、谁来同步」这三个问题的不同组合。理解了它们,后面讲 Write-Behind、Refresh-Ahead 这些进阶模式时,你就能更快上手了。