ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JCache容量驱逐策略详解:从JSR-107规范到LRU/LFU配置实战

JCache容量驱逐策略详解:从JSR-107规范到LRU/LFU配置实战 1. 面试题背后的考点为什么JCache的驱逐策略没有“标准答案”1.1 JSR-107只画了框没填内容JCacheJSR-107是Java官方的缓存API标准2014年发布最终版本目标是给Java生态提供一套统一的缓存编程模型。这套规范定义了CachingProvider、CacheManager、Cache、CacheConfiguration、CacheLoader、CacheWriter等核心接口让业务代码可以不绑定任何具体缓存中间件像用JDBC一样统一操作缓存。但很多人没有注意到一个关键设计JSR-107规范在驱逐策略Eviction Policy这里刻意只画了一个框没有填内容。javax.cache.configuration.EvictionPolicy本质上是一个空的标记接口MutableConfiguration里确实提供了setEvictionPolicy方法但传进去的只是一个占位对象具体如何驱逐、什么时候驱逐、容量阈值怎么定义全部交给Provider自行实现。我当年第一次看JCache源码时也很意外以为标准API后面会有一套默认实现结果翻遍javax.cache包只有接口契约没有驱逐算法。这背后其实有合理考量本地堆缓存、堆外内存缓存、分布式缓存不同实现的驱逐机制和成本差异太大如果规范强行统一算法反而会让实现方无法做深度优化。1.2 面试官问的是“配置容量驱逐”而不是“什么是驱逐”面试题里加上“容量”这个词考察的层次就变了。不是简单问“驱逐策略有哪几种”而是问“在一个最多只能放N条数据的缓存里满了之后按照什么规则淘汰旧数据”。这需要你同时掌握三个层面的能力知道JCache的配置入口在哪里CacheConfiguration、MutableConfiguration、CacheManager.createCache之间的关系是什么清楚标准API的能力边界明白“驱逐策略是非标准化的、由Provider决定的”这是很多人栽跟头的地方能落出一段真实可运行的配置代码至少熟悉一种常见JCache实现比如Hazelcast或Ehcache。所以这道题答得好不好跟背不背题关系不大取决于你实际动过几次手。下面先补一段JCache标准配置的基础代码把整个缓存创建的链路串起来再进入具体的容量驱逐配置。CachingProvider provider Caching.getCachingProvider(); CacheManager cacheManager provider.getCacheManager(); MutableConfigurationString, Product config new MutableConfiguration(); config.setTypes(String.class, Product.class); config.setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(Duration.ONE_HOUR)); CacheString, Product cache cacheManager.createCache(productCache, config);这段代码是纯标准API在任何实现了JSR-107的Provider上都能跑。但如果你想在创建缓存时直接指定“最多放5万条、满了淘汰最久未使用的数据”标准API就帮不上忙了必须借助Provider的扩展配置。1.3 LRU、LFU、FIFO容量驱逐里的三种主流算法在讲具体配置之前有必要先把驱逐算法的口径对齐因为面试官很可能在代码之后追问一句“为什么选LRU而不是LFU”。LRULeast Recently Used最近最少使用根据条目的最后访问时间排序优先淘汰最长时间没有被访问的条目。它适合数据热度随时间衰减的场景比如用户会话、热点商品详情。实现时需要维护一个按访问时间排序的结构高并发下锁竞争会比较明显所以很多Provider做的是近似LRU不是教科书式精确LRU。LFULeast Frequently Used最不经常使用根据条目的访问频率排序优先淘汰访问次数最少的条目。它适合访问频率有明显长尾分布的业务比如“少部分热点key扛起大部分请求”的场景。缺点是需要维护访问计数器内存开销更高而且一个历史上非常热的key可能长期占着容量不退出现“缓存污染”。FIFOFirst In First Out先进先出按插入顺序淘汰先进入缓存的条目先被淘汰完全不考虑访问热度。实现最简单适合几乎不存在访问热点差异的场景但对大多数业务来说太粗暴。从实现难度和业务适配度上看实际生产中LRU用得最多LFU在特定热点场景下更优FIFO一般只出现在后台批处理类缓存中。1.4 容量驱逐和过期Expiry是两件事这个点必须放在最前面讲清楚因为面试里至少有一半的人会在这里翻车。过期是时间维度的主动失效条目的生命周期由ExpiryPolicy决定到了时间就被标记为过期容量驱逐是空间维度的被动淘汰只有在缓存容量达到上限、需要写入新数据时才会触发。JCache标准API对ExpiryPolicy有非常完整的定义你可以配置TTL、访问后刷新时间等但对驱逐策略规范只提供了占位接口。这意味着什么意味着“设置了10分钟过期”和“配置了容量驱逐”完全是两个独立维度不能互相替代。一个容量设置过小的缓存即使每个条目都设置了60秒过期仍然可能在几十秒内被大量新key把热点数据挤出去。后面我会用一个真实踩坑案例来说明这一点现在先记住结论生产环境中两者必须同时配置各自承担各自的职责。2. 动手配置容量驱逐以Hazelcast和Ehcache两种实现为例2.1 用Hazelcast的CacheConfig配置LRU驱逐Hazelcast是JSR-107 TCKTechnology Compatibility Kit过得很彻底的主流实现也是很多中大型项目在分布式场景下直接选用的Provider。它的JCache扩展配置类叫com.hazelcast.config.CacheConfig这个类实现了JCache标准接口CompleteConfiguration所以可以直接传给CacheManager.createCache不需要绕道。下面是一段完整的配置代码目标很明确创建一个名为userSessionCache的缓存最多存放10万条会话数据满了之后按LRU策略驱逐最久未使用的条目。// 1. 获取标准JCache入口 CachingProvider provider Caching.getCachingProvider(); CacheManager cacheManager provider.getCacheManager(); // 2. 使用Hazelcast扩展的CacheConfig CacheConfigString, UserSession cacheConfig new CacheConfig(); cacheConfig.setName(userSessionCache); cacheConfig.setTypes(String.class, UserSession.class); // 3. 配置容量驱逐最多10万条LRU策略 EvictionConfig evictionConfig cacheConfig.getEvictionConfig(); evictionConfig.setEvictionPolicy(EvictionPolicy.LRU); evictionConfig.setMaxSizePolicy(MaxSizePolicy.ENTRY_COUNT); evictionConfig.setSize(100_000); cacheConfig.setEvictionConfig(evictionConfig); // 4. 创建缓存 CacheString, UserSession cache cacheManager.createCache(userSessionCache, cacheConfig);三个核心参数的含义拆开说setSize(100_000)是容量上限值但注意它要跟MaxSizePolicy配合才有效。这里用的是ENTRY_COUNT表示按条目数限制。Hazelcast的MaxSizePolicy还支持按堆内存占用比例USED_HEAP_MEMORY_PERCENTAGE、按堆外内存大小USED_NATIVE_MEMORY_SIZE等策略适合不同存储形态。setEvictionPolicy(EvictionPolicy.LRU)是驱逐算法选择。Hazelcast的枚举里还有LFU、RANDOM、NONE其中NONE表示容量满了之后不驱逐直接拒绝新条目写入。生产环境中这个选项要慎用一旦选错缓存满后写入会直接失败。getEvictionConfig()返回的是已初始化的对象直接改属性即可不需要自己new。但如果你是手动new一个EvictionConfig再塞回去效果也一样代码语义上更明确。这里有个容易被忽略的细节同一个CacheManager下对同一个缓存名重复调用createCache会抛异常。生产环境里通常先尝试cacheManager.getCache(name)拿不到再创建或者用try-catch在CacheException里兜底。2.2 用Ehcache 3的资源池配置堆内容量Ehcache从3.x开始完整支持JSR-107它的集成思路跟Hazelcast不太一样不强制你用扩展API而是让你先用Ehcache底层的CacheConfigurationBuilder构建一个资源池配置再通过Eh107Configuration包装成JCache标准配置然后走CacheManager.createCache入口创建。看代码// 1. 构建Ehcache底层的CacheConfiguration org.ehcache.config.CacheConfigurationString, UserSession ehcacheConfig CacheConfigurationBuilder.newCacheConfigurationBuilder( String.class, UserSession.class, ResourcePoolsBuilder.newResourcePoolsBuilder() .heap(50_000, EntryUnit.ENTRIES) // 堆上最多5万条 ) .withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofMinutes(30))) .build(); // 2. 包装成JCache Configuration javax.cache.configuration.ConfigurationString, UserSession configuration Eh107Configuration.fromEhcacheCacheConfiguration(ehcacheConfig); // 3. 走标准入口创建缓存 CachingProvider provider Caching.getCachingProvider(); CacheManager cacheManager provider.getCacheManager(); CacheString, UserSession cache cacheManager.createCache(userSessionCache, configuration);.heap(50_000, EntryUnit.ENTRIES)表示堆内最多放5万条条目。这里有两个关键认知第一Ehcache的默认驱逐策略不是LRU而是近似LFU。如果你在面试中能把“Ehcache默认用的是近似LFU不是LRU”这个点说出来面试官基本能确认你真正翻过实现而不只是看了个配置样例。第二ResourcePoolsBuilder可以同时配置多层资源池比如.heap(5000).offheap(200, MemoryUnit.MB).disk(1, MemoryUnit.GB)意思分别是堆内最多5000条、堆外最多200MB、磁盘最多1GB。各层之间的换入换出由Ehcache内部管理容量驱逐策略在不同层之间的联动语义又不一样这是扩展话题面试的时候可以只点一句“如果配了多级资源池驱逐会发生在层与层之间的换入路径上”给面试官留个追问钩子。除了编程式配置Ehcache还支持XML方式cache aliasuserSessionCache resources heap unitentries50000/heap /resources /cache然后在代码里通过Eh107Configuration.fromXmlResource(ehcache.xml)加载整个配置文件再创建缓存。这种方式方便运维调整容量不需要重新编译发版。2.3 为什么不建议在纯标准API里塞驱逐配置网上很多博客会教一个“技巧”直接用MutableConfiguration调用setEvictionPolicy(new CustomEvictionPolicy())以为传一个自定义的EvictionPolicy实现就能配置驱逐。实际上这个EvictionPolicy是个标记接口里面没有任何需要实现的方法Provider不消费它。你塞进去一个空对象缓存该怎么样还是怎么样。所以这道题的“正确姿势”是先确定你的CachingProvider是哪一个然后去读这个Provider针对JCache的扩展文档。Hazelcast有EvictionConfigEhcache有ResourcePools其他Provider也各有各的入口。把这一点讲清楚比背一堆API签名更能体现对规范边界的理解。3. 面试追问环节容量驱逐的触发逻辑与易混淆点3.1 容量“满了”到底由谁判定JCache规范没有定义“容量满”的唯一标准这给面试官留了非常大的追问空间。同一个“10万条上限”在Hazelcast里由MaxSizePolicy.ENTRY_COUNT判定在Ehcache里由heap(100000, EntryUnit.ENTRIES)判定。但如果换一种限制方式比如按内存占用比例判定逻辑就完全不同了一个缓存里全是大对象可能还没写到5万条内存占用先爆了。还有一个隐藏更深的点即使配置了10万条上限在并发写入的瞬间实际条目数也可能短暂超过10万。因为驱逐是在写入路径上“检查容量、执行淘汰”的动作做不到严格意义上的“写入前先驱逐”。在分布式实现里更是如此分区分布在多个节点上全局条目数的统计有延迟容量上限只能保证“最终趋向于不超过”不是实时精确值。3.2 驱逐一次淘汰一条还是一次一批JCache规范同样没有规定单次驱逐多少条。如果你在面试时主动提这一点会显得思考层次更高在一次高并发put触发的容量检查中如果只淘汰一条下次put又要触发一次驱逐写路径的抖动会很严重而且这种抖动会传染给所有并发线程。所以主流实现一般会一次性淘汰一批条目比如按批次驱逐直到容量降到阈值以下或者采用异步批量清理。这在Hazelcast和Ehcache的源码里都有体现但实现细节不同。面试时不需要准确说出“Hazelcast每次驱逐多少条”而是表述为“规范没规定生产级实现一般会做批量驱逐来降低写路径抖动”这就够了。3.3 驱逐会不会触发CacheEntryListener事件JCache标准API提供了CacheEntryListener其中CacheEntryRemovedListener监听的onRemoved、CacheEntryExpiredListener监听的onExpired大家比较熟。但驱逐算不算“移除”规范里写得很模糊结论是可能触发也可能不触发取决于实现。我在Hazelcast和Ehcache的不同版本上都观察过不一致的行为。Hazelcast的某些版本里驱逐和CacheEntryExpiredListener有联动但换个版本可能行为又变了Ehcache的堆内驱逐走的是一条内部淘汰链路不一定会作为标准JCache的REMOVED事件抛给CacheEntryRemovedListener。所以生产代码里千万不要依赖“监听器一定会收到驱逐通知”来清理外部资源比如用监听器去删除关联的数据库记录或发MQ消息。你要是依赖了版本升级分分钟给你来个“静默事故”。3.4 EntryProcessor执行期间会被驱逐吗这个问题如果面试官问出来说明他已经在考察并发安全了。EntryProcessor是JCache定义的一种在缓存条目上执行原子操作的机制规范要求执行期间必须保证原子性。但驱逐是Provider内部行为规范没有明确规定“驱逐不能被EntryProcessor打断”。实际实现中成熟的Provider会感知当前条目的锁状态。写入路径触发驱逐时如果某个key正被EntryProcessor锁住通常会被跳过等锁释放后再补一次驱逐检查。你可以把这一点作为加分项说出来然后补一句“但这属于实现细节标准API不保证所以如果你的业务同时依赖EntryProcessor和极端容量配置一定要在高并发压测下验证”。4. 生产环境里容量驱逐的真实瓶颈与避坑建议4.1 容量估算是第一步别拍脑袋写个10万面试答完代码真正的生产考验才开始。我把所有调过的JCache容量问题整理了一下发现最根本的坑是很多人配置容量时根本没有估算随手写一个“看起来很大”的数字。我建议用窗口模型粗算再用压测校正所需容量 ≈ 峰值QPS × 数据在缓存中的平均存活时长举例一个订单查询接口峰值QPS是5000业务允许订单数据在缓存中最多保留30秒也就是说DB更新后30秒内可见延迟可以接受那么容量下限就是5000 × 30 150000条。如果每条约2KB堆内存占用约300MB。这时候你就要判断这个体量适不适合放堆内缓存JVM堆够不够要不要换成堆外注意这只是理论下限。现实中的请求分布不可能完全均匀热点集中时容量会被快速消耗所以我一般会在这个基础上再加20%到30%的缓冲。如果是大促期间的新业务我更建议第一版直接按理论值翻倍配置再根据监控逐步下调。4.2 命中率必须监控否则驱逐策略就是盲人摸象配置容量驱逐不是一劳永逸的事。JCache标准里定义了CacheStatisticsMXBean通过JMX可以拿到缓存命中次数、未命中次数、驱逐次数等关键指标前提是你的Provider启用了统计功能。Hazelcast的CacheConfig有isStatisticsEnabled()Ehcache也提供对应的统计开关。这些都是JCache标准之外、由Provider实现的JMX暴露方式建议压测和上线前就打开。命中率的计算公式很简单命中率 命中次数 / (命中次数 未命中次数)。当命中率低于95%的时候缓存的价值就已经存疑了你需要立刻去看驱逐计数。如果驱逐次数在一段时间内异常暴涨说明容量窗口不够或者出现了大量临时key把热点数据挤出去了。我的经验是上线容量驱逐后的前两周每天都要看命中率曲线。调容量时不要一次调太大每次涨30%观察两三天再决定下一轮。这个节奏可以避免一次调太大导致内存超预算。4.3 一个真实踩坑案例把容量驱逐当成“过期”用我接过一个线上问题现象是用户经常登录状态丢失排查半天最后落到缓存上团队把用户Session数据放进JCache只配置了10万条容量和LRU驱逐完全没配ExpiryPolicy。表面上看LRU会淘汰最久未使用的Session似乎“够用”但问题恰恰出在这里。压测工具产生的海量临时key把真实用户的Session从LRU队列里挤了出去。LRU只保证“容量不够时淘汰最久未使用”不保证“Session必须存活N秒”。会话类数据必须靠ExpiryPolicy做时间维度的兜底容量驱逐只能作为空间维度的安全绳。这个案例在面试时也很有说服力能体现你真正理解驱逐和过期的边界。4.4 堆内还是堆外决定驱逐的成本和上限如果你的容量估算结果超过JVM堆的15%到20%就不建议继续用堆内LRU硬扛了。原因有两个第一大堆的GC停顿不可接受Full GC会把请求延迟拉上去好几个量级。第二LRU和LFU自身也要代价——维护访问时间戳或访问计数器需要额外内存在超大规模缓存里这部分元数据的开销很可观。这类场景通常有两条路一是交给堆外内存Hazelcast支持MaxSizePolicy.USED_NATIVE_MEMORY_SIZE配合InMemoryFormat.NATIVEEhcache也提供了OffHeapResourcePoolBuilder二是干脆不要用JCache做一级大缓存把它做成分布式缓存的一层短TTL前置缓存容量控制在可承受范围内。但堆外缓存不是免费的午餐读写都需要序列化和反序列化驱逐时无法直接拿到对象引用做高效比较命中率敏感型业务需要仔细压测。4.5 多级缓存和驱逐联动别让下游读到旧数据如果JCache只是你整个缓存链路中的一级前面有本地热数据、后面有Redis或数据库那么驱逐就不只是缓存内部的事了。当JCache因为容量上限驱逐了一条数据如果下游没有收到任何“这条数据失效了”的通知下一次请求还是先从JCache里读不到然后去后面的二级缓存或数据库查再把结果写回JCache看起来没什么问题。但如果你在下游也放了一份同样的数据并且下游那份数据更新更慢就可能出现“JCache里已经被驱逐、二级缓存里还是旧值”的窗口。应对方案是在驱逐发生后主动发一个轻量级失效事件比如把被驱逐的key打进一个消息队列让下游做无害化处理。前提是你确认Provider真的能稳定触发驱逐监听器否则就要在get时额外验一次时间戳或者设置一个比业务容忍上限更短的过期时间来兜底。我在4.3的案例之后给那个团队定的规矩就是“过期保证业务语义驱逐保证运行安全两者缺一不可”。5. 这道面试题的高分回答结构与话术5.1 一个可以直接用的“三段式”回答模板面试不是笔试不能只对着白板写代码要结构化表达。我自己面试别人时最反感两种答案一种是背了一堆配置项名称但完全没有层次另一种是上来就写代码写完整页白板也说不出为什么这么配。一个好答案应该按“边界、实现、设计”三层往下递进。第一层先讲边界10秒内点出关键“JSR-107规范把驱逐策略定义为Provider相关的非标准能力标准API只提供了EvictionPolicy标记接口和CacheConfiguration入口没有规定具体算法和容量参数。”面试官听到这里就知道你对规范的定位是清楚的。第二层落到实现讲一个你项目里实际用过的Provider。比如“我们团队用的是Hazelcast的JCache实现配置方式是创建一个com.hazelcast.config.CacheConfig通过setEvictionConfig指定EvictionPolicy.LRU、MaxSizePolicy.ENTRY_COUNT和size然后通过CacheManager.createCache把配置传进去。”如果你用的Ehcache就把说法换成ResourcePoolsBuilder.heap和Eh107Configuration.fromEhcacheCacheConfiguration。第三层升级到设计30到60秒。补一个容量估算公式、提一句“驱逐不等于过期两者要配合使用”、再说一下通过JMX统计命中率和驱逐次数来验证配置合理性。到这一层你已经和只会背API的候选人拉开了差距。5.2 面试官可能追问的三个变体“Ehcache和Hazelcast的配置方式有什么区别”如果你主用Hazelcast不要硬编一个Ehcache的答案可以说“我用Hazelcast比较多Ehcache我知道它是通过ResourcePoolsBuilder配置堆内条目数再用Eh107Configuration包装成JCache配置但具体到某个版本的API差异我需要翻一下文档确认。”面试官要看的往往是你面对不确定知识时的处理方式诚实加检索路径是好答案。“容量满了但所有key刚被访问过会驱逐谁”这个追问考察的是对近似算法的理解。你可以说“教科书式的LRU在这种情况下没法选出淘汰对象因为所有key的时间戳都很新。实际实现大多是近似LRU或采样LRU不是全局精确的不同Provider会通过分片、采样窗口等方式降低维护成本。”能说出“近似”两个字面试官就知道你理解工程实现和理论算法的差距。“分布式场景下容量驱逐由谁触发”这个问题比较大可以挑一个点切入“Hazelcast的容量配置默认是按照节点本地生效的集群里的总数等于节点容量乘以节点数不是全局一份。分布式环境下驱逐会涉及分区归属和备份同步复杂度比单机高很多。”如果你们项目其实把一级缓存放在本地堆、分布式缓存交给独立中间件也可以直说“我们当时主动避开了JCache的分布式复杂度本地JCache只做一级短期缓存最终以外部缓存为准。”这同样是合理的设计取舍。5.3 别掉进的两个“面试雷区”雷区一把驱逐策略和过期策略混为一谈回答成“设置10分钟过期就是驱逐”。这个我之前提过一旦被追问“缓存没满但也过了10分钟算不算驱逐”“缓存满了但没到10分钟会淘汰谁”就露馅了。同样地把Redis的allkeys-lru当成JCache配置也是不对的虽然思路相通但配置入口和参数体系完全不同不能直接画等号。雷区二只回答“LRU淘汰最久未使用”但说不清LRU怎么衡量“最久未使用”。面试官接着问“读操作会刷新访问时间吗遍历操作呢EntryProcessor执行时呢”就会卡住。这种细节不一定要求你全部回答精确但至少要有“具体语义看Provider实现”的意识。最后再分享一个我印象很深的案例。有一次线上告警缓存命中率从99%掉到82%排查了半天没发现问题最后是看到JMX里驱逐次数在一小时内暴涨了几十万次。复盘发现是运营批量导数据每个请求都生成一个带唯一ID的临时key把热点商品数据全顶出去了。那次之后我们定了一个规矩JCache缓存必须同时配置ExpiryPolicy和容量驱逐容量估算是发版流程的必填项临时性数据要么走很短的TTL要么单独放一个容量很小的缓存实例。后来再没有出现过同类事故。希望你下次配容量驱逐的时候也能想起这句话驱逐是兜底不是业务语义。
返回列表