ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Ehcache集群大坑:缓存不一致问题与本地+Redis两级缓存方案

Ehcache集群大坑:缓存不一致问题与本地+Redis两级缓存方案 集群环境里用Ehcache十个团队有八个会在上线之后收到缓存不一致的告警剩下的两个要么数据没有并发写要么早就把缓存拆了。这不是Ehcache本身不行而是很多人下意识把它当成了分布式缓存来用。Ehcache的默认形态是每个应用节点各自维护一份本地缓存单机下香得很进程内访问速度极快但一旦上了集群节点之间怎么同步、失效如何广播、序列化兼容怎么做、网络分区时会不会把服务拖垮这些问题全都会暴露出来。这篇文章是我在多个真实项目里踩过这些坑之后的完整复盘从故障现象到根因分析再到本地Redis两级缓存这套可落地的实现方案一次性说清楚。如果你正在用Ehcache做集群改造或者被线上缓存不一致搞得焦头烂额这篇文章应该能帮你少走半个月弯路。1. 为什么集群环境下Ehcache会成为麻烦制造者1.1 本地缓存的本质与集群的矛盾Ehcache的设计初衷非常简单在JVM进程内维护一份数据副本用内存换时间。它直接挂在应用进程里读写都不经过网络所以单机下性能非常漂亮读延迟是纳秒微秒级别。但这个快是建立在“这份数据只属于当前进程”的前提上的。一旦部署成集群同样的数据在每个节点上都会有一份副本问题立刻出现。如果某个节点更新了缓存其他节点是感知不到的。数据库里的数据可能已经变了但老节点还在返回旧值。这就是分布式系统里最经典的数据一致性难题而Ehcache默认的实现里根本没有任何跨节点同步机制。很多人以为配置了一个CacheManager就自动有了集群能力这是最大的误解。为了让大家更好理解我举个生活化的例子。一个办公室有五个工位每个工位都贴着一张项目进度表大家各自维护自己那张。A工位更新了表格B工位还在看旧进度。除非你有一套机制确保所有人同步更新否则每个人手上的表永远不一样。Ehcache在集群里就是这个状态每个JVM都是一张独立的表彼此之间没有约定自然就各说各话。1.2 从单机到集群三个最常见的翻车场景第一个翻车场景是用户会话或状态数据。比如用Ehcache存登录态、购物车、验证码这类与用户强相关的数据。单机部署时一切正常一旦前面挂了负载均衡同一个用户的两个请求落在不同节点上一会儿登录成功一会儿又要重新登录。这种情况最容易在灰度发布或扩缩容之后爆发而且Bug复现起来非常随机不仔细抓请求链路根本定位不到。第二个翻车场景是热点配置和元数据。系统里有一份配置表或字典表原本用Ehcache缓存每天后台更新一次。单机时更新缓存很简单集群后A节点的缓存更新了B节点还在用旧配置导致不同节点行为不一致。我见过最离谱的现象是同一套接口在两个节点上返回完全相反的逻辑结果因为A节点加载了新开关配置B节点还停留在旧版本。第三个翻车场景是高并发缓存击穿。单机时一次缓存miss只有一个请求回源集群有10个节点一次miss就会有10个请求同时回源。平时没事一旦某个热点Key的缓存正好过期数据库瞬间被放大10倍的并发打到这就是缓存雪崩的前兆。这个问题在纯本地Ehcache场景下几乎无法避免因为每个节点各自维护失效时间即使设置相同过期时间节点间任务执行先后也会造成错峰反而让故障更加随机难查。2. 集群Ehcache常见故障与根因分析2.1 缓存不一致节点间数据各玩各的故障现象很典型某次上线后A/B两个节点返回的数据不一致页面一会儿显示新配置一会儿显示老配置。排查下来缓存TTL设置了10分钟数据库更新后最坏情况下要等10分钟才能收敛如果持续有流量打到旧节点不一致时间还会更长用户投诉都是一波一波来的。根因是Ehcache的缓存更新事件只在本地生效不存在跨节点通知。Ehcache的API设计里其实有一个CacheEventListener扩展点但默认实现是空实现什么都不会做。你需要自己实现监听器并注册而很多团队根本不知道有这个扩展点的存在更别说系统性地设计跨节点同步了。要解决这个问题思路其实很清晰任何节点上的缓存失效动作必须广播到其他节点。常见做法有两种。一种是失效消息广播即A节点更新了Key发送一条“这个Key失效了”的消息给其他所有节点其他节点收到后从本地缓存删除对应项。另一种是版本号对比所有节点定期向一个中心化存储如Redis拉取版本号发现版本变化再主动清理本地缓存。两种做法都能用但很多团队会在这地方踩一个坑广播失效时只删本节点忘了删其他节点或者直接广播“更新后的值”而不是“失效通知”。广播值会带来两个问题一是消息体大、序列化开销高二是并发场景下可能先到的新值被后到的旧值覆盖。只广播失效通知则安全得多其他节点收到后去数据库拉最新值天然规避了乱序问题。2.2 序列化与类加载器同步消息里的隐形炸弹Ehcache在进程中可以用对象引用直接存取速度极快这也是很多人喜欢它的原因。但一旦涉及集群同步、持久化或者跨JVM传输就必须要做序列化。这个坑通常出现在缓存了框架自定义对象的时候。比如把一个DTO对象放进缓存本地跑得好好的上了集群同步就报NotSerializableException或者反序列化时直接ClassNotFound。原因是不同节点的应用包版本不完全一致或者对象的类定义在某个依赖Jar里没被打进部署包。更隐蔽的情况是缓存了内部类、Lambda表达式、动态代理对象这些对象序列化时尤其容易踩坑。ORM实体对象经过代理后往往带有大量内部字段序列化后要么体积巨大要么反序列化失败这类问题最折磨人因为日志里根本看不出是哪一层引入的代理。我的建议是进入Ehcache缓存的对象尤其是可能参与集群同步的对象统一设计成可序列化的POJO显式声明serialVersionUID。不要图方便直接缓存框架内部对象或者ORM实体。另外序列化器要全局统一。Ehcache支持JDK原生、Kryo、Jackson等多种序列化方式集群同步时两端必须用相同配置否则本地读正常远程读就会乱码或者报错。我建议对外传输统一走JSON或者KryoJDK原生序列化性能差、且强依赖类版本适合本地临时缓存但千万别用在集群通信上。2.3 失效广播风暴与网络抖动这个坑多见于采用Ehcache自带集群同步能力的团队。某些配置项一更新缓存里上百个Key同时失效广播消息一下子塞满网络。更糟糕的是如果实现里把“失效”做成“重新分发新值”消息体还可能携带全量数据网络和内存双双遭殃。广播风暴的根因是失效事件粒度过大。比如缓存了整个配置表配置表一更新所有节点收到全量失效通知每个节点再去数据库重新加载数据库压力也会瞬间上来。我曾经见过一个项目缓存里放了一个包含几万条记录的大Map更新一次配置集群里20个节点同时回源查询数据库连接池直接被打满。解决广播风暴要从粒度上想办法。第一尽量让缓存Key细粒度化不要用一个大Key覆盖全量数据。第二失效通知只发Key名不要发值。第三必要的时候做合并如果同一秒内有多次更新通知可以合并成一条。比如用Redis的Pub/Sub生产端发布消息不做合并消费端做去抖收到消息后在本地做延迟清理把窗口内的多次失效合为一次。这个方案我实测下来很稳既能保证一致性又能把消息量降一个数量级。网络抖动带来的问题是另一类。如果集群同步采用同步RPC方式某个节点网络抖动其他节点发送失效通知时就会阻塞进而拖慢业务线程。所以任何跨节点缓存同步都应该走异步消息绝对不能做成同步等待。这也是为什么我坚决不推荐用RMI同步调用做缓存广播跨节点的强依赖会让缓存变成可用性隐患。2.4 缓存穿透、击穿与雪崩集群把局部问题放大成全局故障单机下缓存穿透是一件小事最多就是数据库压力稍大。集群环境下如果多个节点对同一个不存在的Key同时miss每个节点都会回源数据库会被重复打穿。更常见的是缓存击穿热点Key过期瞬间集群节点同时回源数据库瞬时QPS翻倍。还有一类坑和TTL设计有关。很多人给所有Key配置相同的过期时间比如统一10分钟那么整点前后可能出现成批缓存同时失效形成类似“踩踏”的效果。节点越多踩踏越严重。解决办法是给TTL加随机扰动比如基础过期时间加上0到2分钟的随机偏移让失效时间错开。我觉得这里核心要明确一点本地缓存解决的是“重复读”的问题集群环境下如果读请求本身就打到了不同节点那么本地缓存对同一个Key的命中率实际上是下降的。假设集群有10个节点并且负载均衡做得均匀每个Key在每个节点上被访问的概率只有单机的十分之一本地缓存的命中率天然降低。此时如果还想保持很高的命中率要么扩大本地缓存容量要么引入中心化缓存。这套逻辑直接决定了我们后面要选什么方案。3. 集群环境下的几种实现方案与取舍3.1 本地Redis两级缓存最稳妥的通用方案我当前在项目里最推荐的是“本地Ehcache做L1Redis做L2”的两级缓存架构。本地缓存负责高并发下的快速读Redis负责统一数据版本与跨节点失效通知同时Redis本身也做一份热点数据的缓存承担一部分跨节点的访问。这个方案的核心思想是不要试图让Ehcache自己具备集群能力而是通过外部组件协调各节点的本地缓存。Redis在这里承担两个角色一是作为中心化缓存兜底所有节点都未命中本地缓存时的数据访问二是作为事件总线用Pub/Sub广播缓存失效消息。数据读取流程是这样的先查本地Ehcache命中直接返回未命中则查RedisRedis命中则回填本地并返回Redis也未命中则查数据库写回到Redis和本地。数据更新流程则反过来先更新数据库再删除Redis中的Key并通过Redis Pub/Sub广播一条“Key失效”消息所有节点收到后删除本地缓存。这套设计的好处是读写性能好本地命中时不需要网络交互跨节点一致性通过失效通知保证数据库始终是最终一致性的兜底。风险点在于如果Redis不可用本地缓存还能撑一阵子但失效通知收不到所以需要配合降级策略。Redis不可用时可以短时间依赖本地缓存缩短TTL把数据不一致窗口压到可接受范围。别小看这个降级很多线上事故都是因为组件依赖链太长一个缓存挂了把上游全部拖死。两级缓存的好处就是天然多了一层冗余。3.2 Ehcache自带的集群同步能力RMI/JMSEhcache官方曾经提供了几种集群同步方式包括RMI手动配置、RMI组播、JMS、JGroups等。这些方式确实能实现跨节点缓存失效广播但在现代容器化、虚拟化环境下组播基本不可用RMI的跨防火墙配置非常痛苦JMS又需要额外维护一套消息中间件。这些方案最大的问题是强耦合。节点间缓存同步依赖网络通信一旦网络抖动缓存同步就会失败而很多实现里同步失败并不会自动重试最终结果还是缓存不一致并且还很难排查因为同步是异步的你根本不知道哪条失效消息丢了。我见过有团队在Kubernetes环境里用RMI组播发现Pod重启后根本发现不了其他节点因为组播地址在容器网络里默认不通每次发布之后都要人工清一遍所有节点的缓存运维同学苦不堪言。RMI和JGroups这类方案属于上一个时代的产物在网络环境可控、节点数量固定的场景下或许能用但放到微服务和容器化的语境里维护成本远超收益。3.3 Terracotta BigMemory重量级但最彻底Terracotta是Ehcache背后的商业公司提供的分布式缓存解决方案它的思路是把多个JVM的Ehcache通过一个独立的Terracotta Server阵列组织成一个逻辑上统一的大缓存堆所有节点读写同一个分布式堆。由于数据存在堆外内存它可以支撑非常大的缓存量也不受JVM GC影响。这个方案的优点是使用体验最接近“单机缓存”。你不需要写任何同步逻辑节点之间自动一致API也是原生的Ehcache API业务代码几乎不需要改动。从功能角度看它确实很完美。但代价是架构复杂度非常高需要额外部署一组Terracotta Server还要处理Server的高可用、数据持久化、客户端连接管理、版本兼容等问题。对于一个业务系统来说相当于为了一个缓存专门引入一套分布式存储系统运维成本完全不亚于维护一套数据库。而且Terracotta的授权模式偏商业很多团队在选型时会有顾虑。我个人的建议是除非团队里有专门的中间件运维能力或者缓存量确实大到Redis撑不住、同时对延迟又极度敏感否则不要轻易上这套方案。3.4 只读缓存启动预热能绕就绕的朴素方案如果你的业务有一个“数据变化频率极低、几乎只在发布时变更”的场景比如系统配置、字典表、资源列表其实根本不需要集群同步。做法是应用启动时从数据库加载数据到Ehcache运行期间只读不写配置变更通过重新发布或者调用一个专门的清理接口完成。这个方案的优点是极其简单没有任何同步开销也不会出现不一致。缺点是数据更新滞后更新只能通过发布或主动触发。但很多场景恰恰能接受这一点比如活动配置在活动开始前才修改全量发布耗时也就几分钟。我也见过一个更聪明的做法把配置放在数据库中然后用一个watchdog任务定期比对配置表的版本号发现变化就刷新本地缓存。版本号变化时全节点轮询的延迟是秒级的但对大多数配置类数据来说完全够用。这本质上是用“轮询一致性”替代“事件一致性”好处是不依赖消息组件架构上更简单。这里我把几个方案的取舍整理成一张表方便对照选型方案一致性复杂度适用场景我的评价本地Redis两级缓存最终一致中绝大多数业务系统首推Ehcache自带RMI/JMS同步较弱中小规模物理机集群容器化环境不推荐Terracotta BigMemory强一致高超大数据量、超高延迟敏感重量级慎重选型只读缓存启动预热手动一致低低频更新的配置类数据简单可靠能绕就绕4. 实操落地一套本地Redis两级缓存4.1 整体架构与数据流设计我结合实际项目的代码把两级缓存拆成三个核心模块本地缓存管理器、Redis缓存客户端、失效消息订阅器。本地缓存用Ehcache负责快速读Redis负责存储统一数据Redis Pub/Sub负责失效广播。具体数据流我整理一下。读路径优先查本地EhcacheKey命中则直接返回本地未命中查RedisRedis命中则把数据写入本地缓存设置本地TTL返回Redis未命中查数据库回写Redis和本地缓存返回。写路径更新数据库删除Redis中的Key通过Redis Pub/Sub广播一条CacheInvalidate消息包含缓存区名称和Key各节点订阅到消息后删除本地Ehcache中的对应Key。这套设计有一个细节值得特别注意删除Redis中的Key而不是更新Redis中的值。原因是更新值需要处理并发写万一两个节点同时写会产生旧值覆盖新值的问题。而删除Key后下次读的时候从数据库拉最新值回填天然规避了并发写冲突。这是Cache Aside模式里被验证过无数遍的经验务必照做。4.2 Ehcache配置与Redis发布订阅实现先用Ehcache 3的XML配置举个例子。注意我们需要配置缓存别名、Key和Value类型、TTL和堆容量。容器环境下不建议开启磁盘持久化因为Pod重启后磁盘大概率不保留反而拖慢速度config xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xmlnshttp://www.ehcache.org/v3 xsi:schemaLocationhttp://www.ehcache.org/v3 https://www.ehcache.org/schema/ehcache-core-3.10.xsd cache aliasbizCache key-typejava.lang.String/key-type value-typejava.lang.Object/value-type expiry ttl unitseconds300/ttl /expiry heap unitentries10000/heap /cache /config再看核心的Redis Pub/Sub订阅代码。我一般用Spring Data Redis实现配置一个消息监听容器收到CacheInvalidate消息后从本地Ehcache删除对应KeyComponent public class CacheInvalidateListener implements MessageListener { private static final String CHANNEL cache:invalidate; Autowired private CacheManager cacheManager; Autowired private RedisTemplateString, Object redisTemplate; Override SneakyThrows public void onMessage(Message message, byte[] pattern) { String body new String(message.getBody(), StandardCharsets.UTF_8); CacheInvalidateMessage invalidateMsg JSON.parseObject(body, CacheInvalidateMessage.class); CacheObject, Object cache cacheManager.getCache(invalidateMsg.getCacheName()); if (cache ! null) { cache.remove(invalidateMsg.getKey()); log.info(本地缓存失效, cacheName{}, key{}, invalidateMsg.getCacheName(), invalidateMsg.getKey()); } } }生产端在写完数据库之后发布消息public void updateBizData(String key, BizData newData) { // 1. 更新数据库 bizDataMapper.update(key, newData); // 2. 删除Redis缓存 redisTemplate.delete(buildRedisKey(key)); // 3. 广播失效通知让所有节点清本地缓存 CacheInvalidateMessage msg new CacheInvalidateMessage(bizCache, key); redisTemplate.convertAndSend(CHANNEL, JSON.toJSONString(msg)); }这里有一个细节必须强调消息体里面只带cacheName和key绝对不要带value。我见过有人图省事直接在广播消息里放更新后的数据其他节点收到后直接替换本地缓存。结果在高并发下两个节点先后更新同一个Key消息到达顺序不可控后到的旧值把先到的新值覆盖了反而制造出新的数据不一致。只广播失效通知是最安全的其他节点收到后宁可先去数据库拉最新值也不要信任消息体里的值。另外一个容易被忽略的地方是消息消费端的幂等性同一个Key可能在一秒内收到多条失效通知删除缓存本身就是幂等操作所以天然安全不需要做额外去重。4.3 关键参数的计算与调优一级缓存本地Ehcache的容量和TTL怎么定很多团队拍脑袋其实可以算。我们需要预估两个数据QPS和单个Key的平均大小。假设单个Key的value平均2KB本地缓存堆内存预算500MB理论上可以放大约25万个Key。再看业务访问热度分布如果80%的请求集中在20%的热点Key上本地缓存只需要覆盖热点Key就能获得很高命中率。也可以根据日活用户数和每个用户的Key数估算。比如日活10万每用户5个Key总共50万个Key那500MB堆内存装不下就需要调整策略只缓存访问量最高的部分。TTL设置原则是“越接近数据真实变化周期越好”。如果数据每小时更新一次本地TTL设60秒就够了命中率不会受影响但一致性窗口小得多。要是数据一天才更新一次本地TTL设10分钟也显得太短可以设置30分钟甚至1小时。原则就是TTL取数据更新周期的一半左右同时加一点随机扰动避免雪崩。Redis连接池线程数也要算。假设Redis单实例能支撑5万QPS缓存服务需要支撑每秒2万次读其中80%本地命中实际打到Redis的只有4000 QPS预留2倍冗余就是8000远低于单实例上限那一个Redis实例就够。但如果本地命中率不高打到Redis的请求多就需要考虑Redis集群了。这些计算做完之后你对自己系统的容量心里就有数了不会等到线上打爆再去加机器。顺带说一句Redis的Pub/Sub本身不占用太多连接但消费端的线程池大小要按业务流量估否则消息积压会导致本地缓存清理不及时又退回数据不一致的老路。5. 常见问题与避坑清单5.1 高频问题速查表我把这几年被问得最多的几个问题整理成一张表方便直接对号入座。现象可能根因快速处理两个节点数据不一致本地缓存没有失效广播检查是否启用了Redis Pub/Sub订阅发布端是否在写库后发送消息缓存一清数据库被打爆失效消息广播了全量Key或大Key改为只广播Key名删除Redis中的缓存值不要广播新值反序列化报ClassNotFound节点包版本不一致或缓存了动态代理对象统一使用JSON序列化所有缓存对象改为POJO并声明serialVersionUIDRedis挂了服务不可用本地缓存和Redis强同步没有降级读路径加降级开关Redis不可用时走本地缓存直查数据库热点Key过期瞬间数据库压力大多个节点同时命中同一个失效Key给本地TTL加随机扰动热点Key手动延长过期时间节点重启后缓存是空的接口变慢本地缓存没有预热启动时对热点Key执行一次预热查询回填本地缓存这些场景我基本都亲自遇到过。前三个是功能性故障后三个是性能故障处理思路完全不同。功能故障要检查事件链路重点看发布端在数据库写完之后到底有没有把消息发出来、订阅端有没有成功消费、消费之后有没有正确删除本地缓存性能故障要重新设计TTL、容量和降级策略重点看回源次数、数据库连接池压力和本节点缓存命中率。这里有句实在话与其等故障出现再排查不如在压测阶段就把集群缓存场景纳入验证范围。很多团队测试环境只用单节点跑缓存一致性问题永远留到生产才暴露代价非常大。5.2 几条用真金白银换来的经验第一绝对不要把Ehcache当作分布式缓存用。分布式缓存的选型应该是Redis或类Redis组件Ehcache的定位就是进程内缓存。硬要让Ehcache做集群同步等于让一个原本只负责本地读写的东西去处理网络通信复杂度会成倍增长。定位清晰了很多方案纠结自然就消失了。第二缓存更新尽量走“删除”而不是“更新”。更新缓存值需要处理并发删除则简单安全得多这个观点在Cache Aside模式里已经被验证过无数次。实践中一定要忍住不去写那个“顺手把Redis值也更新了”的代码一旦并发出问题你连排查的抓手都没有。第三一切跨节点通信都做异步化。同步等待失效通知返回等于把缓存的可用性绑定在网络上。我见过一个项目把缓存失效做成了RPC同步调用结果缓存服务器抖动业务接口全部超时这是典型的连锁故障。异步消息加多级降级才是集群缓存的正确打开方式。第四监控不能只看命中率还要看“失效消息积压量”和“回源次数”。命中率只能反映读的优化效果回源次数才能反映数据库压力。如果回源次数飙升说明缓存策略出了问题这时候再去查TTL、查失效广播定位效率会高很多。监控指标一旦齐全很多问题根本轮不到用户投诉就会发现。第五开发环境、测试环境、生产环境的缓存配置最好保持一致。很多问题都是因为测试环境是单机不上集群导致集群相关的坑全部留到生产才炸。哪怕条件有限至少准备一个两节点的测试集群来演练缓存一致性场景。这套演练能帮你提前发现一堆隐蔽的问题远比上线后救火划算。我个人对Ehcache在集群环境里的核心判断很简单它适合做一级缓存不适合单打独斗需要一套外部机制来协调多个节点之间的同步。如果你正在纠结要不要在集群里上Ehcache我的建议是直接采用本地Redis两级缓存把失效广播和一致性交给Redis让Ehcache专注于它最擅长的进程内高速读取。这样既能享受本地缓存的性能又能避免自己实现分布式缓存带来的各种深坑。最后再说一个小技巧本地Ehcache的容量其实可以做成动态的根据命中率反馈自动调整。比如命中率低于某个阈值时自动增加容量超过配额就把最不常用的Key淘汰掉。或者更进一步给不同业务设置不同的TTL和容量策略让热点业务多占空间冷门业务少占空间。这个思路能让两级缓存在业务高峰和低谷都能处于比较合理的工作状态比拍脑袋配置一个固定值要靠谱得多。实现上不需要多复杂的框架Ehcache自带的淘汰策略加上一个定时任务就能做到。好了这篇就到这里。实际项目中我还有不少细节没展开比如Spring Cache注解如何与二级缓存对接、Redis集群下的Pub/Sub注意事项等等后续会单独写文章补充。也欢迎在评论区聊聊你们在集群环境里处理缓存不一致的独特方法。
返回列表