title: 改一行配置12 个实例等了 5 分钟才生效我把多级缓存的一致性重新捋了一遍date: 2026-10-04tags: [Java, Redis, Caffeine, 分布式缓存, 架构设计]去年 11 月的一个周四晚上我在运营后台把一个商品的活动价从 199 改成 179点完保存前台页面还是 199。我以为是浏览器缓存强刷了三次没用。跑到机房日志平台上翻发现 12 个订单服务实例里有 4 个在改价之后 20 秒内就返回了新价格剩下 8 个实例硬生生拖了将近 5 分钟才陆续醒过来。那 5 分钟里客服群进线量翻了三倍。事后复盘问题不复杂我们的读取链路是 Caffeine 本地缓存 Redis 的两级结构写入侧只更新了数据库并删除了 Redis 里的 key但 12 个实例各自的本地缓存毫不知情它们只能等 Caffeine 的expireAfterWrite到期后重新回源。当时本地缓存的 TTL 设的是 5 分钟于是就有了改一行配置全局最长 5 分钟不一致的经典现象。这篇文章就是那次事故之后我把多级缓存一致性这件事从头到尾捋了一遍的沉淀为什么本地缓存天然带来不一致、四种失效方案的取舍、我们最后落地的代码长什么样、以及哪些场景我反而劝你别上多级缓存。一、先把问题说清楚多级缓存的不一致是结构性的很多文章讲多级缓存重点都放在性能提升多少倍上很少正面回答一个问题加了本地缓存你的系统就默认接受了一段不可控的数据陈旧期。这段陈旧期由三个东西决定本地缓存的 TTL我们当时是 5 分钟本地缓存何时被写入是定时刷新还是被动填充写入侧有没有任何机制把数据变了这个消息广播出去我们当时是零。单机场景下本地缓存不存在一致性问题因为只有一个副本。可一旦服务横向扩到多个实例同一个 key 就在 N 个 JVM 里有了 N 份独立副本而 JVM 之间没有天然的内存可见性——JMM 管得了单 JVM 内的 happens-before管不了跨进程。这就是我说的结构性不是实现上的 bug而是这个架构模式与生俱来的属性。画个时间线会更直观。假设 12 个实例的本地缓存里都缓存了price:1001这个 keyT0 实例 A 收到改价请求写库删 Redis key T0ε 实例 A 自己的本地缓存 invalidate如果做了的话 T0ε 实例 B~L 的本地缓存毫无感知继续返回旧值 T01s~T0300s 各实例本地缓存陆续过期回源 Redis拿到新值最坏情况的不一致窗口等于本地缓存的 TTL。所以有一个朴素的直觉解法把 TTL 调短。TTL 从 5 分钟调到 10 秒不一致窗口就缩到 10 秒。但代价是本地缓存命中率暴跌——我们实测过TTL 5 分钟时命中率 96%调到 10 秒只剩 71%回源 Redis 的 QPS 直接翻了四倍多。用命中率换一致性本质是把压力往下一层转移这不是解决问题是挪问题。真正要解决的是当数据变化时如何让 12 个实例尽快知道。这就引出了失效通知的问题。在讨论失效通知之前还得先回答一个更上游的问题写路径用哪种模式。这个选择直接决定了失效通知要在哪个位置插进去。常见的写路径模式有三种Cache Aside旁路缓存是最主流的选择读时先查缓存miss 回源 DB 并回填写时先更新 DB再删除缓存。注意是删除而不是更新缓存——更新缓存在并发写下会出现后写的 DB、先写的缓存交错把旧值覆盖回缓存而删除让下一次读自己去回源拿新值天然规避了这个竞态。它的缺点是删除之后的那次读必然 miss以及 DB 与缓存两步操作不在一个事务里存在短暂不一致窗口。Read/Write Through读写穿透把缓存层做成数据访问的代理业务代码只跟缓存打交道缓存组件自己负责与 DB 的同步。一致性由组件内部保证业务侧心智负担小但你要么引入一个成熟的代理比如某些商用缓存中间件要么自己写一个可靠的缓存层组件——后者在中小团队里通常意味着重新发明轮子。Write Behind异步写回写请求只落缓存就返回缓存再异步批量刷回 DB写性能拉满但代价是缓存宕机时未刷盘的数据直接丢失。这个模式我只推荐给丢了能重算的数据比如计数、统计快照任何涉及钱的写路径用它都是在赌命。我们当时的系统用的是 Cache Aside这也是后面所有失效广播代码的前提。我不觉得 Cache Aside 是什么优雅的设计——它其实是一堆妥协的集合——但在没有代理层、团队规模有限的现实约束下它是出问题时最容易排查的一种每一步都是显式的日志里看得见。工程里可排查的价值经常被低估。二、四种失效通知方案的取舍以及我的判断方案一Redis Pub/Sub 广播失效消息写入侧在删完 Redis key 之后往一个专门的 channel 发一条key X 失效了的消息所有实例订阅这个 channel收到后各自invalidate本地缓存。优点是延迟低通常 100ms 以内全实例感知实现也简单Redis 原生支持。缺点有两个一是 Pub/Sub 不保证送达订阅者断线重连的瞬间错过的消息就永远丢了二是所有实例都收到所有消息失效风暴时 channel 上的消息量 key 变化量 × 实例数会放大广播压力。方案二MQ 广播失效消息写入侧发一条消息到 MQ每个实例用一个独立的消费组或者广播模式消费消费到就失效本地缓存。送达可靠性比 Pub/Sub 高一个量级消息还能重放。代价是链路变长MQ 的投递延迟通常在几十毫秒到秒级而且为此引入一个 MQ 组件、多一套消费者运维复杂度不低。如果你系统里本来就有 RocketMQ 5.x 这类基础设施顺手用它的广播模式是很自然的如果没有为一个失效通知引入 MQ 就太重了。方案三版本号 / 时间戳比对本地缓存里不存数据本身而是存数据版本。每次读请求先从 Redis 拿一个轻量的版本号可以就是 key 对应的一个 hash 或者逻辑时钟版本变了才重新拉全量数据。这个方案把失效通知变成了读时校验天然不丢消息。但问题是每次读都要多一次 Redis 往返——那你本地缓存省下的那次 Redis 调用又被吃回去了对纯读优化的场景相当于白忙。它更适合数据体量大、校验成本远低于全量传输的场景比如配置快照、商品详情大 JSON。方案四短 TTL 兜底不做主动失效干脆不广播承认秒级到分钟级的不一致用短 TTL 兜底。听起来偷懒但我觉得它是被低估的方案。对于数据变了晚 30 秒生效也无妨的业务——比如榜单、推荐位、运营文案——主动失效那套机制纯属过度设计。判断标准就一条业务方能接受的不一致窗口是多少。把这个数字问出来很多一致性问题根本不用解。把四个方案放在一张表里对比会更直观方案感知延迟送达保证额外组件失效风暴风险适合场景Redis Pub/Sub100ms不保证无中消息量变更×实例数中型系统、秒级容忍MQ 广播100ms~秒级高可重放MQ低金额展示类、已有 MQ 的团队版本号比对读时即时天然保证无无大 value、校验便宜短 TTL 兜底TTL 时长天然保证无TTL 集中过期有尖刺陈旧不敏感业务逐行看这张表你会发现一个规律感知延迟和工程复杂度几乎是严格正相关的没有又快又省的免费午餐。Pub/Sub 快但会丢消息MQ 可靠但链路长版本号稳但每次读多一跳短 TTL 简单但慢。做架构决策时真正要回答的问题从来不是哪个方案更先进而是我的业务在延迟、可靠性、复杂度这三个维度上各自愿意付多少。这张表没有给出答案但它至少把要付的价码标清楚了——我认为这才是方案对比该有的样子。我的整体判断是Pub/Sub 主动失效 TTL 兜底的组合适合大多数中型系统——Pub/Sub 负责快TTL 负责兜住 Pub/Sub 丢消息的洞对一致性要求再高一个档位比如金额、库存展示上 MQ 广播对一致性要求极高比如实际扣减别靠缓存失效直接让写路径旁路缓存或者加分布式锁。缓存体系的天花板是最终一致想要强一致就不该让数据走缓存这是两个世界。如果让我再选一次当时那套系统我会直接上 Pub/Sub 兜底 TTL而不是先裸奔半年再补课——因为补广播机制的窗口期本地缓存里那些 5 分钟的旧数据每一分钟都在生产风险。三、为什么选 Caffeine从淘汰算法的角度看本地缓存进入动手环节之前值得花几分钟讲清楚为什么本地缓存选 Caffeine 而不是 Guava Cache 或者手写 ConcurrentHashMap。Guava Cache我们事故前用的是 Guava 32.0.1-jre的淘汰算法是 LRU 变体用分段实现并发。LRU 的问题是它只看最近有没有被访问对突发性的扫描式访问毫无抵抗力一次离线任务把 50 万个冷 key 顺序读一遍LRU 会把这些冷 key 全部塞进队列头部把真正的热数据挤出去这就是经典的缓存污染。事故复盘时我们查过一段流量日志发现改价当晚之前的一次全量商品巡检任务就干过这事只是当时没人把命中率下跌和它联系起来。Caffeine 3.x 用的是 W-TinyLFU 算法核心思路是访问频率 新近度的加权新 key 想进入主缓存区要和一个被称作候选人受害者的老 key 比频率分数赢了才有资格把对方挤出去。频率用 Count-Min Sketch 估算空间开销远小于精确计数。同时 W-TinyLFU 保留了一个小规模的新生代区约占容量 1%刚进来的 key 先在新生代里攒访问记录攒够了再去主区挑战——这样既挡住了扫描污染又不会误杀刚变热的 key。另外 Caffeine 基于 RingBuffer 和异步的维护线程实现了完全无锁读读写吞吐比 Guava 高出一截官方基准测试里大概是两倍上下的差距。一句话总结这段选型逻辑Guava Cache 解决的是有缓存Caffeine 解决的是缓存的命中率。在多级缓存里本地缓存层的容量远小于全量数据淘汰策略的命中率差异会被放大到回源流量上所以这一层的选型不是拍脑袋的事。四、动手一套可落地的两级缓存 广播失效实现下面是我们事故后重构的核心代码有删减技术栈是 Spring Boot 3.2 Caffeine 3.1.8 Redis 7.0 Lettuce 6.3。先看读取侧两级缓存的查询骨架Service public class TwoLevelCacheService { private final CacheString, Object localCache; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; private final ScheduledExecutorService refresher Executors.newSingleThreadScheduledExecutor(); public TwoLevelCacheService(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { this.redisTemplate redisTemplate; this.objectMapper objectMapper; // 本地缓存容量上限 写后过期兜底 this.localCache Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(Duration.ofSeconds(90)) // 兜底 TTL防广播丢失 .build(); // 定时对热点 key 做主动刷新避免过期瞬间的回源尖刺 refresher.scheduleAtFixedRate(this::refreshHotKeys, 30, 30, TimeUnit.SECONDS); } SuppressWarnings(unchecked) public T T get(String key, ClassT type, SupplierT loader) { // L1本地缓存 Object cached localCache.getIfPresent(key); if (cached ! null) { return (T) cached; } // L2Redis String redisValue redisTemplate.opsForValue().get(key); if (redisValue ! null) { try { T value objectMapper.readValue(redisValue, type); localCache.put(key, value); return value; } catch (JsonProcessingException e) { // 反序列化失败视作脏数据删掉继续回源 redisTemplate.delete(key); } } // L3回源 DB采用谁先加载完谁写缓存的竞速不做单飞合并 T value loader.get(); if (value ! null) { redisTemplate.opsForValue().set(key, toJson(value), Duration.ofMinutes(10)); localCache.put(key, value); } return value; } }逐段解释一下这段代码。构造函数里 Caffeine 的maximumSize(50_000)是为了防止本地缓存无限膨胀把堆打爆——我们单实例配置了 2C4G 的容器5 万个条目按平均 2KB 估算约 100MB在预算内。expireAfterWrite(90s)就是前面说的兜底 TTL正常情况下广播会让本地缓存在几百毫秒内失效这个 90 秒只在广播彻底丢失时兜底把最坏不一致窗口从 5 分钟压到 90 秒。refreshHotKeys的定时任务是给头部 200 个热点 key 做异步预热的避免它们过期那一瞬间几十个并发请求同时打到 Redis——这是另外一个小坑本文不展开。get方法的三级查找顺序是本地、Redis、DB每一层的 miss 代价递增。有两个细节值得注意一是 Redis 命中但反序列化失败时我们选择删除脏 key 并继续回源而不是抛异常——线上跑过一段时间后你会发现因为发布期间的类结构变更反序列化失败是常态而非意外二是回源那段我故意没做 singleflight同 key 并发只放一个请求去 DB因为我们回源的数据 99% 命中 Redis 的另一张表DB 直连压力可控为这个场景引入 per-key 的锁反而增加复杂度。这又是一个取舍如果你的 loader 是昂贵的 DB 聚合查询singleflight 或者 Caffeine 的CacheLoader.loadAll就是值得加的。再看写入侧和广播失效Service public class CacheInvalidationPublisher { private final StringRedisTemplate redisTemplate; private static final String INVALIDATION_CHANNEL cache:invalidation; public CacheInvalidationPublisher(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } /** * 写路径先更新 DB外部完成再删 Redis最后广播失效。 * 必须先删 Redis 再广播顺序反了会有一类丢更新的窗口。 */ public void invalidate(String key) { redisTemplate.delete(key); String payload key | System.currentTimeMillis(); redisTemplate.convertAndSend(INVALIDATION_CHANNEL, payload); } } Component public class CacheInvalidationSubscriber implements MessageListener { private final CacheString, Object localCache; private static final String INVALIDATION_CHANNEL cache:invalidation; public CacheInvalidationSubscriber(CacheString, Object localCache) { this.localCache localCache; } Override public void onMessage(Message message, byte[] pattern) { String payload new String(message.getBody(), StandardCharsets.UTF_8); String key payload.split(\\|)[0]; // 失效本地缓存。这里删除比逐个更新快代价是下次读会回源一次 localCache.invalidate(key); } }invalidate方法里三步的顺序是这段代码里最值钱的部分更新 DB → 删 Redis key → 发广播。如果先发广播再删 Redis就会出现实例 B 收到广播、失效本地缓存、立刻回源但此时 Redis 还没删又把旧值捞回来写进本地缓存的时序错乱广播等于白发。所以删缓存必须在广播之前让广播到达时 Redis 里已经是已删除状态回源自然拿到新值。消息体里带上时间戳是为了给后续排查哪次失效广播发出后多久生效留证据。订阅端的onMessage做的事情非常克制只invalidate不做删除后立刻预热。原因是我见过带预热的实现——收到失效消息就主动从 DB 加载最新值塞回本地缓存——结果一次批量改 3000 个 key12 个实例同时收到 3000 条消息、同时发起 3000 个 DB 查询广播机制自己制造了一次缓存雪崩。失效就让它失效回源交给正常读请求的节奏去摊平这是广播失效实现里我认为必须坚守的原则。订阅者还需要注册到 Lettuce 的连接上Spring 里可以这样挂Configuration public class RedisSubscriberConfig { Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory, CacheInvalidationSubscriber subscriber) { RedisMessageListenerContainer container new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); // 失效消息不需要保证顺序和处理速度线程池给 1 条线程足够 container.setTaskExecutor(Executors.newSingleThreadExecutor()); container.addMessageListener(subscriber, new ChannelTopic(cache:invalidation)); return container; } }这个配置类做的事情就是把自定义的MessageListener绑到cache:invalidation这个 channel 上。我把处理线程池明确设成了单线程这不是疏忽失效处理逻辑本身就是一次map.remove微秒级单线程每秒能消化几十万条而单线程还免费保证了同一个实例上失效消息的串行处理不用考虑并发失效的竞态。只有在你的onMessage里要回源、要发 RPC 时才需要考虑扩线程池——但按前面的原则那本来就不该出现在这里。上线这套机制后我们做了验证改价请求发出后P99 场景下全实例新价格生效时间从原来的最长 300 秒降到了 400ms 以内Pub/Sub 投递 下一次读请求回源最坏情况广播全丢也只有 90 秒兜底 TTL 的窗口。客服那晚之后再没因为价格不同步进过线。五、几个我踩过、你也大概率会踩的坑坑一改完数据库忘了删缓存之外的任何一环。双删、延迟双删这些方案都是围绕DB 与缓存的一致性打的补丁而多级缓存是在这个补丁之上又加了一层Redis 与本地缓存的一致性。链路每多一级能出错的时序组合就翻一倍。我的建议是把写入路径收敛成一个方法像上面的invalidate那样别让业务代码各写各的。坑二把本地缓存当成了数据库的镜像。本地缓存里放了什么决定了广播的粒度。我们最初把用户维度的个性化数据也放进了本地缓存结果一次用户资料修改要广播几十万个 keychannel 直接被打成热点。后来定了一条纪律只有全局共享、读多改少的数据才进本地缓存配置、商品基础信息、字典表个性化数据一律只走 Redis。这条纪律比任何技术方案都有效。坑三忽视了 Caffeine 的expireAfterWrite与expireAfterAccess的区别。有同事曾经照抄网上的配置用了expireAfterAccess(5, MINUTES)——只要 key 一直被访问就永不过期广播万一丢了这条数据理论上可以陈旧到天荒地老。兜底 TTL 必须用expireAfterWrite从写入那一刻起算这是硬性的。坑四Redis 客户端连接数失控。12 个实例每个都订阅 channel如果订阅用的连接和业务读写共用同一个 Lettuce 连接Pub/Sub 消息会阻塞在同一个连接的事件循环上。让订阅走独立的RedisMessageListenerContainer配置如上面的代码并且监控 Lettuce 的ClusterConnection数量这在实例数上到几十个之后是必须项。坑五没有给不一致窗口本身建监控。前面四个坑都是机制层面的这一个坑是可观测性层面的。我们后来加了一个很土但很有效的指标每个实例每隔 10 秒随机挑 20 个本地缓存 key和 Redis 里的值做一次比对把本地与 Redis 不一致的 key 比例作为 gauge 上报。正常情况下这个值在 0.1% 以下浮动广播机制出问题时它会立刻抬头。有了这个指标一致性是否还正常从一个需要人肉验证的问题变成了一个图表上的曲线。我不建议跳过这一步——广播失效这套机制本身就是一个异步的、尽力而为的系统没有观测的异步系统等于没有系统。容量规划上再补一条经验数据本地缓存的maximumSize不要按全量数据能装下去设而要按热点数据能装下去设。我们那个场景全量商品配置 800 万条但按访问频次排序前 5% 的 key 覆盖了 92% 的读请求所以 5 万容量的本地缓存就能支撑 96% 的命中率。算这笔账的方法很简单拿一段正常的访问日志做 key 级别的频次统计画一条洛伦兹曲线然后结合实例的堆内存预算选容量。跳过这一步直接抄别人的容量配置是我见过最多的配置文件里随手一填的源头。六、什么情况下我会劝你别上多级缓存写了这么多怎么做但回头看我更想强调的是什么时候不做。如果让我给自己的项目打分加了本地缓存之后接口 P99 从 28ms 降到 6ms但为此付出的代价是一套广播失效机制、一次线上事故、两份缓存监控面板、以及每个新人在改写路径时都要被教育一遍记得广播。这笔账在你的场景里未必划算。具体来说实例数少于 3 个、Redis 本身延迟稳定在 1ms 以内的系统本地缓存带来的收益通常撑不起它引入的一致性心智负担直接 Redis 单层足够写多读少的数据比如状态机流转类数据写一次广播一次本地缓存大部分时间都在失效纯属负优化没有监控本地缓存命中率的团队我建议先别加。加了本地缓存却不知道命中率等于给自己埋了一颗不知道坐标的雷——我们事故复盘时翻监控才发现本地缓存命中率面板压根没人配。反过来说当你的读 QPS 涨到 Redis 网络往返成为瓶颈经验值单实例 Redis 撑到 8~10 万 QPS、业务 P99 里有明显的一段 Redis RTT 时或者 Redis 需要抗住突发流量做隔离层时多级缓存的价值才真正兑现。它是一种用一致性窗口换吞吐和延迟的交易交易是否划算取决于你的业务对那个窗口敏不敏感。给一个可以直接拿去用的决策清单五问五答业务方能接受的最长不一致窗口是多少答不出就别动手先去问进本地缓存的数据是不是全局共享、读多改少个性化数据直接出局兜底 TTL 有没有用expireAfterWrite数值是否小于第 1 问的答案写路径是否收敛成单一方法且顺序是DB → 删缓存 → 广播有没有一条本地与 Redis 不一致比例的监控曲线五问里有一问答不上来我的建议是先补齐再上线或者干脆退回 Redis 单层。这套清单在我们团队后来的两次新服务评审里都被直接引用过省下的返工时间远大于填写它的成本——架构规范最实用的形态就是这样不是一个需要领悟的思想而是一张能逐项打勾的表。写在复盘之后那次 5 分钟的不一致直接的金钱损失不大运营手动改价时段没有下单纠纷但它教会我一件比任何技术方案都重要的事缓存架构的评审问题不该是能快多少而该是数据可以旧多久以及谁在保证这一点。前者是性能问题后者才是架构问题。留一个问题给读到这里的你你们系统里本地缓存的兜底 TTL 是多少这个数字是有人基于业务的不一致容忍度算出来的还是某次配置文件里随手一填、然后就再没人动过如果是后者也许今晚就该去看看——毕竟我是等到客服群里炸了之后才去看的那个体验并不愉快。如果这篇复盘对你有参考价值欢迎点赞收藏评论区聊聊你们的多级缓存失效方案是 Pub/Sub、MQ 还是干脆短 TTL 裸奔我想看看不同规模团队的真实分布。
阅读完成 · 觉得有帮助?