两级缓存一致性实战:Redis + 本地缓存,如何不让数据“分裂”?

周三下午,小刘兴冲冲地找到杨工:"杨哥,我按你说的给商品服务加了 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,牺牲一点性能换一致性。缓存策略要跟着业务场景走,不是一刀切。"

四、面试怎么答?

杨工放下笔:"如果面试官问'两级缓存怎么保证一致性',你按这个结构答:"

  1. 先说问题本质:Redis 是共享缓存,所有实例看到同一份数据;本地缓存是每个实例私有的,某个实例更新后,其他实例的本地缓存不会自动失效,这是两级缓存一致性的核心难点。
  2. 再说写路径:采用 Cache Aside 模式,先更新数据库,再删除 Redis 缓存,最后广播删除各节点的本地缓存。
  3. 再说广播方式:可以用 Redis Pub/Sub 做轻量广播,也可以用 MQ 做可靠广播,根据业务对一致性的要求选择。
  4. 最后说兜底:本地缓存必须设置短 TTL,防止广播丢失导致长期脏数据;必要时加版本号校验,作为最后一道防线。
  5. 补充一句:对于强一致场景(如下单、支付),建议不走本地缓存,直接读 Redis + DB,用一致性换可靠性。

五、一句话总结

小刘合上笔记本,念出了杨工写在白板最上方的那句话:

两级缓存的一致性,本质上是"失效广播 + 短 TTL 兜底 + 版本号校验"的组合问题,而不是单纯靠删除 Redis 就能解决的。

杨工笑着拍了拍他肩膀:"记住,缓存是为了加速,不是为了添乱。任何缓存策略都要问自己三个问题:数据脏了怎么办?消息丢了怎么办?极端情况怎么兜底? 答得上来,你的方案才经得起生产环境的考验。"

相关推荐
步行cgn1 小时前
Spring AOP 实战:安全日志
python·安全·spring
做个文艺程序员1 小时前
MQ第04篇:ActiveMQ深度解析:JMS规范、Spring JMS整合与企业级异步通信实战
spring·消息队列·activemq·java-activemq·jms
Rain的Java大神之路2 小时前
[特殊字符]8年Java老兵转型AI Agent:90%的人挂在同一个坑,根本不用学Python!(万字实战复盘,建议收藏)
java·数据库·人工智能·后端·python·spring·ai编程
码途行者2 小时前
缓存一致性难题:Redis 和数据库如何做到数据一致?
数据库·redis
桃西西呀3 小时前
Spring AI Alibaba 之九:我让 Agent 接着上一轮往下聊,它却只还给我一条消息,多轮记忆到底藏在哪
人工智能·spring·llm
linweidong4 小时前
Redis 集群故障排查与修复实战:从 Connection refused 到内存打满的七个现场
运维·数据库·redis·缓存·redis集群·运维面经·实施面经
用户6222884048194 小时前
Redis 内存管理与过期淘汰策略:从 maxmemory 到 LRU / LFU,一篇讲透
java·redis
开开心心_Every5 小时前
内存清理工具定时自动清理开机自启动
java·开发语言·spring·计算机外设·ocr·intellij-idea·excel
我是人✓5 小时前
Spring框架的事务管理
java·后端·spring