周三下午,小刘兴冲冲地找到杨工:"杨哥,我按你说的给商品服务加了 Caffeine 本地缓存,QPS 直接从 3000 涨到 12000,Redis 压力降了三分之二!"
杨工点点头:"不错。但你有没有想过一个问题------如果服务部署了 10 个实例,某个实例更新了商品价格,其他 9 个实例的本地缓存怎么办?"
小刘愣了一下:"其他实例......不知道?"
"对,"杨工在白板上画了 10 个方框,"这就是两级缓存最头疼的问题:Redis 是共享的,所有实例看到同一份数据;但本地缓存是每个实例私有的,某个实例更新了,其他实例毫不知情,就会一直返回旧数据。 用户在不同节点上看到的价格可能不一样,这就叫'数据分裂'。"
一、先理清读路径和写路径
杨工先画了读取流程:
请求 → L1 本地缓存 → L2 Redis → DB
- L1 命中:直接返回,零网络开销,性能最好;
- L1 未命中、L2 命中:回填 L1 并返回;
- L2 也未命中:查 DB,然后回填 L2 和 L1。
"这里有个细节,"杨工提醒,"回填 L1 时最好做深拷贝。否则某个线程拿到对象后修改了字段,可能会污染本地缓存里的共享对象,其他线程读到的就是脏数据。"
然后是写流程,杨工写下四个字:Cache Aside。
更新数据库 → 删除 Redis 缓存 → 广播删除各节点本地缓存
"为什么是删除而不是更新?"小刘问。
"因为并发场景下,'更新缓存'容易出现旧值覆盖新值的问题。"杨工解释,"比如线程 A 更新 DB 后正在写缓存,线程 B 也更新了 DB 并先写完了缓存,然后线程 A 才把旧值写进去------缓存就脏了。删除更安全,下次读取时再回源重建,天然串行化。"
二、核心难题:其他节点的本地缓存怎么知道要失效?
小刘盯着白板:"Redis 缓存删了,下次请求自然会回源。但其他 9 个实例的本地缓存还在,它们怎么知道要删?"
杨工笑了:"这就是今天的重点。有四种方案,从简单到复杂,咱们一个一个看。"
方案一:失效广播(生产最常用)
"某个实例更新数据后,通过 Redis Pub/Sub 或 MQ 广播一条失效消息。"杨工写下伪代码:
// 更新方
public void updateProduct(Product product) {
productMapper.updateById(product); // 1. 更新 DB
redis.del("product:" + product.getId()); // 2. 删除 Redis
redisTemplate.convertAndSend( // 3. 广播失效消息
"cache:invalidation",
"product:" + product.getId()
);
}
// 所有实例订阅该频道
@RedisListener("cache:invalidation")
public void onInvalidation(String key) {
localCache.invalidate(key); // 4. 删除本地缓存
}
小刘问:"用 Redis Pub/Sub 和 MQ 有什么区别?"
杨工画了个对比表:
表格
| 维度 | Redis Pub/Sub | MQ(如 RocketMQ/Kafka) |
|---|---|---|
| 实时性 | 毫秒级 | 毫秒级 |
| 消息必达 | ❌ 不保证,断线期间丢消息 | ✅ 持久化,可重试 |
| 架构复杂度 | 低,复用现有 Redis | 高,需额外维护 MQ |
| 适用场景 | 对短暂不一致可容忍 | 对一致性要求较高 |
"实际生产里,"杨工说,"如果业务能容忍几秒的短暂不一致(比如商品详情),用 Redis Pub/Sub 就够了,简单轻量。如果是订单状态、账户余额这种强一致场景,建议上 MQ。"
方案二:短 TTL 兜底
"广播不是 100% 可靠,"杨工说,"万一消息丢了怎么办?所以本地缓存必须设置短 TTL,比如 1~5 秒或 10~30 秒,而 Redis 可以设更长的 TTL,比如 5 分钟。"
小刘问:"那 Caffeine 的容量淘汰(eviction)能当兜底吗?比如容量满了自动挤掉旧数据。"
"不能。"杨工很干脆,"淘汰是不可预期的 ------可能数据刚放进去就被挤掉了,也可能一直不被淘汰。一致性方案不能依赖淘汰,只能依赖失效(TTL 到期或主动删除)。淘汰只是内存管理手段,不是同步机制。"
方案三:版本号校验
杨工在白板写下数据结构:
{
"data": { "id": 1001, "name": "手机", "price": 2999 },
"version": 12,
"updateTime": 1697000000000
}
"读取时,本地缓存比对本地版本和 Redis 版本。如果本地版本落后,就重新从 Redis 加载。"
CachedItem local = localCache.get(key);
CachedItem remote = redis.get(key);
if (local != null && local.getVersion() >= remote.getVersion()) {
return local.getData(); // 本地是最新的
}
// 本地落后,重新加载
localCache.put(key, remote);
return remote.getData();
"这个方案的好处是,"杨工说,"即使广播消息丢了,后续请求也能通过版本号发现旧数据并修正。缺点是每次读取可能要多一次版本查询,成本更高。一般作为辅助手段,不单独使用。"
方案四:Binlog 异步失效
"如果多个服务都会写同一张表,比如订单服务、库存服务、营销服务都改商品表,那在每个服务里写广播逻辑就很麻烦。"杨工画了新的架构:
数据库 Binlog → Canal/Debezium → MQ → 各服务消费 → 删除本地缓存
"通过监听数据库 Binlog,发现数据变更后异步删除 Redis 缓存,并广播本地缓存失效。优点是对业务代码侵入小,不管哪个服务改的数据都能捕获。缺点是链路更长,有一定延迟。"
三、一个比较稳的组合方案
杨工在白板上画了完整的流程图:
更新DB → 删除Redis → 广播失效消息 → 各节点删除L1
↓
删除失败重试 / 告警
↓
L1 设置短 TTL 兜底(如 30 秒)
↓
必要时加版本号校验
"实际生产里,"杨工说,"我们商品服务用的是 Redis Pub/Sub 广播 + 30 秒短 TTL 兜底 。订单服务因为对一致性要求更高,用的是 MQ 广播 + 10 秒短 TTL + 版本号校验。没有银弹,只有组合拳。"
小刘问:"如果广播消息丢了,短 TTL 也还没过期,用户看到的旧数据怎么办?"
"看业务。"杨工说,"商品详情页显示 30 秒前的价格,用户大概率感知不到;但如果是在下单页,价格变了用户会投诉。所以下单页我们不走本地缓存,直接读 Redis + DB,牺牲一点性能换一致性。缓存策略要跟着业务场景走,不是一刀切。"
四、面试怎么答?
杨工放下笔:"如果面试官问'两级缓存怎么保证一致性',你按这个结构答:"
- 先说问题本质:Redis 是共享缓存,所有实例看到同一份数据;本地缓存是每个实例私有的,某个实例更新后,其他实例的本地缓存不会自动失效,这是两级缓存一致性的核心难点。
- 再说写路径:采用 Cache Aside 模式,先更新数据库,再删除 Redis 缓存,最后广播删除各节点的本地缓存。
- 再说广播方式:可以用 Redis Pub/Sub 做轻量广播,也可以用 MQ 做可靠广播,根据业务对一致性的要求选择。
- 最后说兜底:本地缓存必须设置短 TTL,防止广播丢失导致长期脏数据;必要时加版本号校验,作为最后一道防线。
- 补充一句:对于强一致场景(如下单、支付),建议不走本地缓存,直接读 Redis + DB,用一致性换可靠性。
五、一句话总结
小刘合上笔记本,念出了杨工写在白板最上方的那句话:
两级缓存的一致性,本质上是"失效广播 + 短 TTL 兜底 + 版本号校验"的组合问题,而不是单纯靠删除 Redis 就能解决的。
杨工笑着拍了拍他肩膀:"记住,缓存是为了加速,不是为了添乱。任何缓存策略都要问自己三个问题:数据脏了怎么办?消息丢了怎么办?极端情况怎么兜底? 答得上来,你的方案才经得起生产环境的考验。"