2. 缓存基础与常见模式:本地缓存 vs 分布式缓存、Cache-Aside / Read-Through / Write-Through 模式详解
2.1 缓存的核心价值与分类
缓存是后端系统中提升读取性能、降低数据库负载的核心手段。根据数据存储的位置与共享范围,缓存主要分为本地缓存与分布式缓存两大类。选择错误的缓存类型会导致数据不一致、内存浪费甚至系统雪崩。
| 对比维度 | 本地缓存 (Local Cache) | 分布式缓存 (Distributed Cache) |
|---|---|---|
| 存储位置 | 进程内堆内存 (JVM Heap / 进程内存) | 独立缓存服务 (如 Redis / Memcached 集群) |
| 访问延迟 | 纳秒级 (无网络开销) | 亚毫秒级 (存在网络 RTT) |
| 数据一致性 | 单机强一致,多实例间需广播同步 | 中心化存储,天然全局一致 |
| 容量上限 | 受限于单机内存,通常 ≤ 几 GB | 可水平扩展至 TB 级 |
| 典型实现 | Caffeine, Guava Cache, Ehcache | Redis, Redis Cluster, Memcached |
| 适用场景 | 配置信息、字典数据、不变热点数据 | 用户会话、商品详情、高并发共享数据 |
架构建议:生产环境中通常采用多级缓存架构——本地缓存作为 L1 极速层,分布式缓存作为 L2 共享层。L1 未命中时回源 L2,L2 未命中时回源数据库。这种组合能兼顾速度与容量,但需要额外处理 L1 与 L2 之间的数据失效同步。
2.2 本地缓存 vs 分布式缓存:选型决策树
在架构选型时,可依据以下三个关键问题快速决策:
- 数据是否需要跨实例共享? 是 → 分布式缓存;否 → 本地缓存。
- 数据量是否超过单机内存 50%? 是 → 分布式缓存;否 → 本地缓存。
- 对一致性要求是否为强一致? 强一致 → 分布式缓存 + 锁;最终一致 → 本地缓存 + 失效广播。
常见陷阱:将用户登录态、分布式锁等需要全局唯一性的数据放入本地缓存,会导致不同实例间状态不一致,引发用户反复登录或并发竞态问题。此类数据必须使用分布式缓存。
2.3 缓存读写模式详解
缓存模式决定了应用程序如何与缓存及数据库交互。以下四种模式是后端架构中最核心的缓存操作范式。
2.3.1 Cache-Aside (旁路缓存)
最常用的模式,应用程序直接同时操作缓存和数据库。读取时先查缓存,未命中则查数据库并回填缓存;写入时先更新数据库,再删除缓存。
// 读取流程 (Cache-Aside Read)
public User getUser(String userId) {
// 1. 先查缓存
User user = cache.get("user:" + userId);
if (user != null) {
return user;
}
// 2. 缓存未命中,查数据库
user = database.query("SELECT * FROM users WHERE id = ?", userId);
if (user != null) {
// 3. 回填缓存,设置过期时间
cache.set("user:" + userId, user, 300); // 5分钟过期
}
return user;
}
// 写入流程 (Cache-Aside Write)
public void updateUser(User user) {
// 1. 先更新数据库
database.execute("UPDATE users SET name = ? WHERE id = ?", user.getName(), user.getId());
// 2. 再删除缓存(而非更新缓存)
cache.del("user:" + user.getId());
}
为什么写入时是删除缓存而不是更新缓存? 因为并发环境下,更新缓存可能引入“脏写”。例如两个线程同时更新同一数据,后更新数据库的线程可能先更新缓存,导致缓存中残留旧值。删除缓存则让下一次读取时自然回填最新数据,更安全。
2.3.2 Read-Through (通读缓存)
缓存层自身承担“未命中时回源数据库”的职责。应用程序只与缓存交互,缓存组件内部实现 Cache Loader 逻辑。这种模式对业务代码侵入性更低。
// 使用 Caffeine 实现 Read-Through
LoadingCache<String, User> userCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(key -> {
// 缓存自动回源数据库
return database.query("SELECT * FROM users WHERE id = ?", key);
});
// 业务代码只需调用缓存
User user = userCache.get("user:123");
2.3.3 Write-Through (通写缓存)
写入时,应用程序先写入缓存,缓存组件同步将数据写入数据库。写入操作对业务代码透明,由缓存层保证数据库与缓存的一致性。
// 伪代码:Write-Through 缓存组件内部逻辑
public void put(String key, User value) {
// 1. 先写入缓存
this.localMap.put(key, value);
// 2. 同步写入数据库(事务性保证)
database.execute("INSERT INTO users (id, name) VALUES (?, ?) ON DUPLICATE KEY UPDATE name = ?",
value.getId(), value.getName(), value.getName());
}
2.3.4 Write-Behind (异步回写 / Write-Back)
写入时只更新缓存,异步批量将数据刷入数据库。适用于写入吞吐量极高、允许短时间数据丢失的场景(如日志收集、访问计数)。
风险提示:Write-Behind 模式下,如果缓存节点宕机且数据尚未持久化到数据库,将导致数据丢失。必须配合持久化队列(如 Kafka + 本地文件)使用,并接受最终一致性窗口。
2.4 四种模式对比总结
| 模式 | 读取路径 | 写入路径 | 一致性保证 | 业务侵入性 | 典型场景 |
|---|---|---|---|---|---|
| Cache-Aside | App → Cache → DB | App → DB → 删 Cache | 最终一致(需处理并发) | 中 | 通用 CRUD,用户信息 |
| Read-Through | App → Cache → (Loader → DB) | App → DB → 删 Cache | 最终一致 | 低 | 配置中心,字典数据 |
| Write-Through | App → Cache → (Loader → DB) | App → Cache → DB | 强一致(同步写 DB) | 低 | 金融交易,库存扣减 |
| Write-Behind | App → Cache → (Loader → DB) | App → Cache → (异步 → DB) | 最终一致(有丢失风险) | 低 | 访问计数,日志聚合 |
2.5 缓存模式架构图
下图展示了 Cache-Aside 与 Read-Through / Write-Through 的核心数据流向差异:
2.6 模式选型建议
- 通用业务系统:优先选择 Cache-Aside,灵活性最高,易于排查问题。
- 读多写少、数据源固定:使用 Read-Through 减少业务代码中的回源逻辑。
- 需要强一致性写入:使用 Write-Through,但注意写入延迟会增加。
- 高写入吞吐、可接受最终一致:使用 Write-Behind,务必做好宕机恢复预案。
实战经验:在微服务架构中,建议每个服务内部使用本地缓存(Caffeine)做 L1,Redis 做 L2。L1 采用 Read-Through 模式自动加载热点数据,L2 采用 Cache-Aside 模式由服务层手动管理失效。这种组合在日均亿级请求的系统中已验证,可将数据库 QPS 降低 95% 以上。