
如果你维护过一个线上服务一定遇到过这类事本地缓存命中率高达99%但就是有那么几次用户刚提交的数据刷新之后页面还是旧值运维重启某个节点问题又瞬间消失。这不是玄学而是典型的本地缓存与数据源之间的数据一致性差异。我最早被这个问题逼疯是在一次大促前的压测里商品库存明明已经扣减但详情页在部分服务器上仍然显示有货。从那以后我花了不少时间去梳理本地缓存的一致性问题本文就是这些经验的整理希望能给正在跟缓存斗智斗勇的你一点参考。1. 缓存一致性问题的真实起点读路径与写路径的不对称1.1 本地缓存的读路径为什么快本地缓存比如 Caffeine、Guava Cache或者你手写的一个 ConcurrentHashMap之所以快本质上是因为它把热点数据放进了应用进程自己的内存里。一次缓存读取是纯粹的内存寻址没有网络往返没有磁盘 IO也没有序列化和反序列化的开销。我在一个高并发的用户资料接口上做过对比直接查数据库时接口 P99 在 30ms 左右加上 Redis 后降到 8ms再在应用内加一层本地缓存P99 直接落到了 2~3ms。这也是为什么很多团队明知道本地缓存一致性难搞还是忍不住要上它——性能收益太直观了。但请注意本地这两个字是双刃剑。它快是因为它把数据副本放在了离计算最近的地方它容易出问题也是因为每个进程里的副本是各自独立的没有人替你保证它们能跟数据源同步。1.2 写路径的滞后性不一致从哪来一致性问题几乎都源自读路径和写路径的不对称。读路径是请求进应用 - 查本地缓存 - 命中直接返回。写路径是请求进应用 - 写数据库或者先写数据库再更新 Redis- 返回成功。问题是写路径并没有同时告诉所有本地缓存副本你那份已经过期了于是就会存在一个时间差在写请求成功到本地缓存被动失效/主动更新之间读请求仍然可能读到旧值。举个例子。用户修改了手机号数据库里的记录已经更新成功接口也返回了修改成功但同一用户紧接着刷新页面请求被负载均衡转发到了另一个还没收到失效通知的节点那个节点的本地缓存里还存着旧的手机号。于是用户看到的数据和数据库里的数据就不一致了。这个时间差业内一般叫不一致窗口。我们做缓存一致性说白了都是在跟这个窗口作斗争。1.3 本文所说的一致性边界先给一致性划个边界。缓存场景里讲的一致性不是数据库事务里的那种强一致而是收敛性允许某一小段时间内读到旧值但必须保证在可预期的时间内所有副本最终能看到新值。我见过不少人一上来就要求绝对强一致结果走入了极端读请求全部回源数据库缓存形同虚设或者用全局锁把并发读全部串行化性能比不用缓存还差。真正合理的工程做法是根据业务容忍度选择一个你能接受的不一致窗口然后用手段把它压到窗口以内同时保证它能收敛。后文所有方案都围绕缩短窗口保证收敛这两个目标展开。2. 单机本地缓存的一致性保障主动失效是最好的武器2.1 三种失效策略的对比TTL、容量淘汰、主动失效单机环境里保证本地缓存一致性基本就三种手段我放在一张表里对比。失效策略不一致窗口实现成本适用场景TTL 固定过期最长可达整个 TTL 周期最低配置即用配置字典、变动极少的元数据容量淘汰LRU/LFU不可控和淘汰时机绑死低缓存框架自带只解决内存上限不解决一致性主动失效毫秒到秒级取决于调用链中需要写路径联动业务热点数据、更新频繁的数据TTL 最大的问题是过期时间怎么定。设短了缓存命中率下降设长了不一致窗口就大。它适合那些即使延迟几分钟也无所谓的数据比如菜单配置、城市列表。容量淘汰更别指望它它优先淘汰的是最久没被访问的 key跟数据新旧没有关系完全不能作为一致性保障手段。我自己的经验是凡是业务能感知的数据一律上主动失效只有那些晚几分钟大家都不介意的数据才交给 TTL 兜底。2.2 主动失效的触发时机事务边界主动失效不是简单在改完数据后调一下 invalidate就完事时机很有讲究。我踩过最深刻的一个坑是在事务提交之前就删了缓存。当时代码如下先调缓存清理再执行数据库更新。某次更新事务因为唯一键冲突回滚了但缓存已经被删掉。这时候另一个线程回源数据库读到的是回滚前的旧值于是把旧值重新塞进了本地缓存。更麻烦的是后续请求一直命中这个旧值而数据库里的值其实还是旧值——看上去没问题但如果还有一个并发事务已经提交了新值就会出现新库旧缓存的局面而且这种状态会持续到缓存再次被其他操作触发失效。所以我现在统一遵循一个原则缓存失效一定要在事务成功提交之后执行。用 Spring 的话可以挂在TransactionSynchronization#afterCommit里或者用TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT)确保事务真的落地了才去动缓存。2.3 用 Caffeine 实现主动失效的完整代码以 Caffeine 为例一个最基础的主动失效实现大概是这样的CacheString, UserProfile localCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .build(); public UserProfile getUserProfile(String userId) { UserProfile profile localCache.getIfPresent(userId); if (profile ! null) { return profile; } // 回源数据库加载并写入本地缓存 profile userRepository.findById(userId); if (profile ! null) { localCache.put(userId, profile); } return profile; } Transactional public void updateUserProfile(String userId, UserProfile newProfile) { userRepository.update(userId, newProfile); // 事务提交后再失效本地缓存避免回滚导致的脏缓存 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { localCache.invalidate(user: userId); } }); }这里有一个关键点失效比更新更可靠。有人喜欢在写路径里直接localCache.put(userId, newProfile)觉得少一次下次读的回源性能更好。但我强烈不建议这么做。原因有两个一是你未必能轻松拿到所有变更字段的最终值尤其是大对象里只有一两个字段变化时组装完整新对象成本很高二是如果缓存结构升级比如序列化格式变化、字段改名残留的旧对象会和代码不兼容。删除缓存让读路径重新加载是最简单、最不容易出错的做法。2.4 主动失效的并发窗口读旧值与补偿哪怕你严格做了事务提交后失效也仍然存在一个瞬时窗口写事务提交成功、但还没执行invalidate的极短间隙里一个并发读请求可能刚好命中旧缓存并返回。这个窗口有多小通常只有几毫秒。大多数业务可以接受。如果你连这几毫秒都不能忍那就得升级方案比如给缓存 value 加版本号读的时候比较版本号或者读请求也参与分布式锁在锁内校验缓存版本。这样做的代价非常高不到万不得已别上。提示单机环境下主动失效 秒级 TTL 兜底是性价比最高的组合。主动失效负责缩短窗口TTL 负责兜住那些漏网之鱼。3. 多实例与多级缓存从单机一致到集群一致的跨越3.1 为什么单机方案在多实例下失效单机方案看起来已经很完善了但一旦部署多个实例问题立刻就来了你调用localCache.invalidate(user:123)清理的只是当前节点自己的缓存其他节点的本地缓存里还是旧值。这就是多副本部署下的一致性难题。负载均衡器把用户请求随机分发到不同节点节点 A 处理了修改手机号请求并清掉了 A 节点的缓存但随后这个用户的查询被转发到节点 B节点 B 的本地缓存还保留着旧手机号。所以在多实例环境里失效必须是一个广播动作而不是本地动作。3.2 两级缓存架构中的更新时序国内很多团队的实际架构是两级缓存本地缓存做一级Redis 做二级。读取时先查本地没命中再查 Redis再没有才回源数据库并逐级填充。这种架构下的更新时序非常容易出问题。只删除 Redis 是不够的因为 Redis 只是共享的二级缓存各节点的本地缓存还各自为政。你删了 Redis节点 A 的本地缓存仍然命中旧值根本不会走到 Redis 那一层。所以两级缓存的更新必须同时做两件事更新/删除 Redis 这个共享层同时想办法通知其他节点删除各自的本地缓存。3.3 先写 DB 还是先删缓存我推荐的顺序关于 Cache Aside Pattern 的更新顺序业界争论很多。我用一个最简单的逻辑推导出我的结论先写数据库再删缓存。先删缓存、再写数据库的坏处是如果写数据库失败缓存已经被删了下一波读请求会直接打穿到数据库。虽然最终数据没变但数据库瞬间扛上全量读压力在流量高峰期这是致命的。先写数据库、再删缓存的坏处是如果删除缓存那一下失败了缓存里会留着旧值。但这个问题有一个自然的兜底——缓存会过期且业务方可以做补偿重试。相比之下缓存被误删导致数据库压力暴增的后果要严重得多。另一个细节如果走消息队列做失效广播消息里带的数据也建议是这个 key 需要失效而不是这个 key 的新值是什么。理由跟前面一样传输并组装完整新对象的成本高且容易受结构变更影响删缓存让下一次读重新加载最稳。3.4 消息广播失效的完整链路要让所有节点都知道某个 key 失效了最通用的手段是引入一个轻量级的广播通道。我用 Kafka 做过用 RocketMQ 也做过整体思路一致。完整链路如下写请求进入任意节点事务提交成功。该节点发送一条缓存失效消息到 MQtopic 比如叫local-cache-invalidate消息内容是cacheName:cacheKey。所有服务节点包括发送方自己都订阅这个 topic。消费到消息后从本地缓存中invalidate(cacheKey)。如果消息丢了或消费失败用本地缓存的 TTL 做兜底。关键实现大概长这样Component public class CacheInvalidationConsumer { KafkaListener(topics local-cache-invalidate) public void onMessage(String message) { // 消息格式cacheName:cacheKey消费时做幂等删除 String[] parts message.split(:); String cacheName parts[0]; String cacheKey parts[1]; cacheManager.getCache(cacheName).invalidate(cacheKey); } }这里有几个容易踩的坑。第一个是消费必须幂等。同一条失效消息可能因为重试被消费多次但invalidate本身天然幂等多删一次没坏处所以还好。第二个是消息不能积压。如果消费者处理不过来失效消息被延迟处理就会出现数据库已经更新但各节点缓存过了很久才被清掉的现象。我在第 5.3 节会讲一个具体的线上案例。第三个是Redis Pub/Sub 并不适合做这个广播。Pub/Sub 在断连期间的已发布消息会直接丢失消费者重启后没有历史消息可补这就等于失效事件永久丢失。用 MQ 的好处是消息会被持久化消费者重启后可以重新消费至少不会永远漏掉。4. 多核数据一致性JMM 与并发读写下的本地缓存4.1 CPU 缓存一致性与 JMM 的对应多核数据一致性这个热搜词看起来是硬件话题但实际上和本地缓存开发直接相关。CPU 有 L1/L2/L3 缓存主存是内存多核 CPU 各核的缓存内容可能不一致于是就有了 MESI 等缓存一致性协议保证各核在必要时能看到相同的数据。Java 内存模型JMM做的事情和这个很像它定义了线程之间何时能看到另一个线程的修改核心是 happens-before 原则。一个线程对共享变量的写入必须通过 volatile、synchronized、锁等机制才能保证对其他线程可见。本地缓存的本质恰恰就是一份数据在多线程之间共享。如果你用了一个没有正确处理内存可见性的缓存实现在多核服务器上就会出现极其诡异的行为读线程明明在写线程之后执行却读不到新值。4.2 volatile、并发容器在本地缓存中的角色我见过不少自己写的简易本地缓存实现方式是 HashMap 加一把锁public class SimpleCache { private final MapString, Object map new HashMap(); private final ReentrantLock lock new ReentrantLock(); public Object get(String key) { lock.lock(); try { return map.get(key); } finally { lock.unlock(); } } }这种写法有并发瓶颈而且如果 get 用锁、put 忘了用锁或者反过来可见性立刻崩掉。更推荐的简易实现是用 volatile 引用保存整个 map更新时直接替换引用public class SimpleLocalCache { private volatile MapString, String holder new HashMap(); public String get(String key) { return holder.get(key); } public void updateAll(MapString, String newData) { holder new HashMap(newData); } }volatile 保证了holder引用本身的可见性写线程发布新 map读线程之后立刻能看到。这种模式叫发布不可变对象非常适合低频全量更新的配置缓存。如果你的缓存是高频单 key 更新直接用 Caffeine 或者 ConcurrentHashMap 就行它们内部已经处理好了并发可见性问题。自己造的轮子往往就在这里翻车。4.3 可见性导致的缓存不一致案例我说一个真实翻车的例子。某个服务用了一个自研配置缓存本质上就是一个 HashMap。代码里 get 方法加了 synchronized但配置刷新线程在更新时只调用了map.putAll(...)没有加锁也没做任何同步处理。在高并发下部分线程能看到新配置另一部分线程却拿着旧配置而且这个现象不是每次都能复现靠重启节点也治标不治本。后来排查了很久才发现是可见性问题写线程的 putAll 没有和读线程的 get 建立 happens-before 关系读线程既可能读到部分写入的中间状态也可能长期看不到新值。修复方案就是 Java 并发编程教科书里反复强调的那句话要么用ConcurrentHashMap要么用 volatile 引用。我们最后改成了 volatile 引用持有整个配置快照问题立刻消失。这个案例告诉我们本地缓存的一致性保障不只是缓存和数据源的一致还包括并发线程之间的一致。后者往往更容易被忽略但破坏力一点都不小。5. 一致性强度设计研发应该按业务场景做取舍5.1 业务对一致性的容忍度分级不是所有数据都值得用同一种缓存策略。我在设计缓存方案时会把业务数据分成三档每一档有完全不同的做法。一致性强档典型场景推荐方案强一致库存扣减、账户余额、支付状态不使用本地缓存或使用极短 TTL秒级以下必要时加分布式锁最终一致用户资料、商品信息、文章详情Cache Aside 主动失效 失效广播 秒级到分钟级 TTL 兜底弱一致首页推荐、排行榜、Feed 流纯 TTL甚至允许主动延后刷新追求极致的命中率库存这种数据我从来不敢放本地缓存。就算放进去也会因为它变化太快而频繁失效缓存命中率极低还容易出事故。用户资料这种低频修改、高频读取的数据才是本地缓存的主场。5.2 如何度量不一致命中率、对账、告警一致性做得好不好不能靠感觉。我建议至少做三件事。第一统计本地缓存命中率。Caffeine 自带recordStats()可以定期上报命中率、加载时间、淘汰数量。但注意命中率高并不能证明一致性没问题——如果数据已经变了但缓存不失效命中率反而会更高。所以命中率只能作为辅助指标。第二做抽样对账。写一个定时的巡检任务随机挑选一批缓存 key比较本地缓存里的值和数据库里的值计算不一致率。如果在业务容忍的时间窗口之外还频繁不一致说明失效链路出了问题。第三给失效链路加监控。生产环境里缓存失效消息的发送量、消费耗时、消费积压数都应该有指标。积压数一旦上涨就该有告警因为那是不一致窗口正在被放大最直接的信号。5.3 一个线上排查案例失效广播延迟最后分享一个真实的线上事故排查过程。现象某个订单状态在数据库里已经是已发货但用户在部分页面上看到的还是待发货持续了大约 10 秒后自动恢复。由于订单状态属于强业务感知数据这个 10 秒的窗口引起了客诉。排查链路是这样的。先确认代码逻辑订单状态更新走的是 Cache Aside 消息广播失效方案本身没有明显的错误。于是我看消费链路发现出问题的节点上订单状态缓存的 TTL 设置是 15 分钟。这意味着只要失效消息没及时处理缓存就有可能在长达 15 分钟的时间里保持旧值。再查消费监控果然那条时刻的失效消息队列发生了积压消费者线程池短暂阻塞导致消息排队了几秒才被处理。加上网络抖动最终用户感知到的不一致窗口被放大了。这次事故的教训有三点即使有主动失效TTL 也不意味着可以随便设很长。它是一道安全网长度取决于你对失败链路能容忍多久。失效广播的消费速度必须纳入监控。消息积压就是不一致窗口被拉大的预警。对订单状态这类强感知数据我后来直接关头把 TTL 调到了 10 秒以下确保即使广播链路完全中断业务也能快速收敛。6. 别忽略元数据的一致性从视频导出谈本地缓存资源的自描述6.1 同为本地缓存语义完全不同热搜词里有一个很有意思的问题小米浏览器本地缓存视频怎么导出。这个问题的背景是浏览器会把从网络上加载的视频资源存到本地缓存目录用户想把这些缓存文件找出来、导出成为可以播放的视频文件。从技术上讲这和服务器端的本地缓存是两回事但底层有一个共通点数据如果没有元数据索引就等同于不可用。服务器端本地缓存如果 key 和 value 散乱无序程序无法读取浏览器缓存文件如果索引文件和资源文件不一致用户也没法导出。这其实是一种缓存不一致——文件与索引不一致、资源与 URL 不一致、编码信息与文件内容不一致。6.2 浏览器缓存文件的元数据结构浏览器本地缓存目录一般由两张结构组成索引文件记录 URL、存储时间、文件偏移、大小、校验和等信息和资源块文件实际保存响应体通常会经过压缩编码比如 gzip、br。资源能不能被正确恢复完全依赖索引文件和资源块的偏移关系是否精确。一旦清理策略没做原子更新索引指着旧位置、资源块已经被覆盖缓存数据就彻底损坏了。用户在网上求怎么导出视频往往就是因为这些元数据在浏览器实现里不是面向用户开放的普通人拿到的只是一堆没有扩展名的二进制文件。如果要用一句话回答那个热搜词背后的原理你要做的就是拿着索引文件里的偏移、大小和编码信息把资源块内容正确拼接并解码出来。网上流传的各种清理后找回视频工具本质上干的就是这个活。6.3 元数据一致性对后端设计的四点启示从浏览器缓存这个案例我得到几个对服务器端本地缓存设计同样有价值的启示。第一缓存值应该自带元信息。我在实践里会给缓存的 value 包一层封装包含数据本身的版本号或最后更新时间。这样即使某个环节忘了主动失效读逻辑也可以通过版本号判断当前缓存是否已经陈旧从而主动回源。第二缓存 key 要能反映数据的语义版本。应用代码升级后缓存里的旧结构往往无法被新代码正确处理。我习惯在 key 里带上一个大版本号比如user:detail:v2:123代码升级时自然切换 v3旧 key 慢慢淘汰避免结构不兼容。第三缓存失效和重建之间要保证原子、幂等。删除 key 这个动作天然幂等所以永远优先删而不是写重建动作必须保证只由回源方执行避免多个线程同时回源导致雪崩可以在回源路径加单飞锁。第四任何缓存系统都应该有天然的兜底收敛机制。浏览器的尺寸清理策略靠 LRU 保证磁盘不爆服务器端则靠 TTL 保证数据最终收敛。完全没有兜底策略的缓存等于把一致性寄托在所有链路永远不出错上这在实际运维里是不存在的。6.4 落地时的一些具体体会文末想再分享一点个人落地经验。我做本地缓存一致性改造一般不是一次性重构而是渐进式推进。新项目直接在上层统一封装一个LocalCacheManager所有本地缓存都走这个入口。封装的好处是底层的 Caffeine 配置、消息广播、监控上报、兜底 TTL 都可以集中控制而不是散落在各个业务代码里。我见过太多团队一开始用 Caffeine 裸 API后面加监控、加失效广播时不得不全量改动调用点非常痛苦。旧的存量代码我会按数据的重要性分批接入先接订单状态、用户资料这类敏感数据再接文章详情这类中等敏感数据最后才是榜单、推荐这类弱一致数据。每接一批都观察监控数据确认不一致率和消费积压处于安全范围。按我自己的经验把一致性这件事做好技术方案的占比可能只有一半另一半是对数据分级、对失效链路监控、对突发情况的预案。本地缓存用得好是性能利器用不好就是线上的定时炸弹。希望这篇文章能帮你把它管得明明白白。