ARTICLE DETAIL

资讯详情

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

JCache过期策略深度解析:从ExpiryPolicy到AccessedExpiryPolicy实战

JCache过期策略深度解析:从ExpiryPolicy到AccessedExpiryPolicy实战 “你的缓存Key访问一次就永不过期了吗”“你配置的AccessedExpiryPolicy真的在按你的预期工作吗”这两个问题我前两年面试高级Java岗时都被问到过也是在项目中真正踩过坑的地方。JCacheJSR-107作为Java官方的缓存规范面试题里出现的频率一年比一年高尤其是过期策略Expiry Policy这一块属于那种看着简单、往深里问立刻见高下的点。今天就把2025年5月25日这道“基础篇”题目完整拆一遍把AccessedExpiryPolicy从接口定义、源码逻辑、配置方式到工程选型全部串起来讲清楚。这篇内容不是给纯小白背八股文的而是给已经有缓存使用经验、准备冲高级工程师或者正在做缓存治理的Java开发者看的。我会先解释JSR-107在整个Java生态里的定位再把ExpiryPolicy这个接口的三个方法掰开揉碎之后专门针对AccessedExpiryPolicy做源码级剖析和代码实操最后补充我在真实项目中总结的踩坑记录和面试回答思路。1. 先从JSR-107规范的定位说起1.1 JCache到底解决了什么问题JCacheJSR-107是Java社区为统一缓存API而制定的标准规范2014年随Java EE 7正式发布之后一直作为javax.cache包存在于Java生态中。它的核心价值在于不管底层用的是Ehcache、Hazelcast、Infinispan还是Apache Geode上层业务代码都只需要面向javax.cache.Cache这同一套接口编程切换缓存实现时不需要改业务代码。这个思路跟JDBC非常像。你写JDBC的时候不会关心底层是MySQL还是PostgreSQL你只面对Connection、Statement、ResultSet。JCache就是缓存领域的JDBC它定义了CacheManager、Cache、Cache.Entry、ExpiryPolicy、CacheLoader、CacheWriter这些核心抽象。在实际项目中引入JCache的价值不只是“换实现方便”更重要的是它把缓存的基本行为标准化了尤其是过期策略这类最容易出问题的语义规范里给了清晰的接口约束。很多公司自研缓存工具类的时候最喜欢自己写一个带TTL的Map然后封装成工具类到处用。这种做法在小项目里能跑但一旦涉及分布式缓存、多级缓存、缓存一致性自定义的过期逻辑很快就会暴露出各种边界问题。JCache把过期策略抽象成ExpiryPolicy接口配合CacheConfiguration里的ExpiryPolicyFactory从规范层面强制你思考“什么时候开始计时”“什么操作会重置计时”“什么操作不会”这本身就是对设计能力的训练。1.2 为什么面试官偏偏挑过期策略来问缓存面试题里大家最熟的可能是淘汰策略Eviction Policy比如LRU、LFU但淘汰策略和过期策略是两回事。淘汰策略解决的是“缓存满了先踢谁出去”的问题过期策略解决的是“数据什么时候算失效不能再读”的问题。这两者经常被混为一谈能分清楚的人本身就少所以面试官特别喜欢在这里设卡。另外一个原因是过期策略直接关系到正确性。缓存里最容易出的线上事故就是两种情况一是数据明明改了缓存却还是旧值这是更新策略的问题二是数据已经过期了但因为续期逻辑写错导致缓存里永远留着脏数据这是过期策略的问题。AccessedExpiryPolicy这种“每次访问都续期”的策略用得好是性能利器用不好就是脏数据的温床。面试官问这道题考察的其实是三个层次第一层是知不知道ExpiryPolicy接口的存在和基本方法第二层是能不能准确说出四种内置策略的语义差异第三层是能不能结合业务场景说明为什么选AccessedExpiryPolicy而不是CreatedExpiryPolicy以及这个选择背后有什么代价。大多数人卡在第二层到第三层之间。2. ExpiryPolicy接口过期策略的骨架2.1 三个方法各自负责的“时刻”在javax.cache.expiry包下ExpiryPolicy是一个只包含三个方法的接口看起来非常简单但每个方法的语义都值得仔细琢磨。先看接口定义package javax.cache.expiry; import javax.cache.Cache; public interface ExpiryPolicy { Duration getExpiryForCreation(); Duration getExpiryForAccess(); Duration getExpiryForUpdate(); }这三个方法分别定义了缓存条目在三种不同事件发生时的过期规则第一个是getExpiryForCreation()缓存条目被创建时调用也就是第一次往缓存里put一个Key的时候用这个方法的返回值来决定这条数据多久后过期。第二个是getExpiryForAccess()缓存条目被访问时调用这里的“访问”主要指get操作命中缓存在某些实现里也可能包含containsKey这类读操作。第三个是getExpiryForUpdate()缓存条目被更新时调用也就是对已存在的Key执行put或replace操作。这里最关键的细节是返回值类型Duration而且这三个方法都可能返回null。在JCache规范里返回null意味着“保持当前过期时间不变”返回Duration.ETERNAL意味着“永不过期”返回Duration.ZERO意味着“立即过期”。这三个语义必须区分清楚不然写出来的策略在特定场景下会跟预期完全相反。2.2 Duration对象过期时间不是简单longDuration是javax.cache.expiry包下的不可变类内部由两个字段组成一个TimeUnit枚举和一个long类型的durationAmount。创建一个五分钟过期的Duration可以这样写Duration fiveMinutes new Duration(TimeUnit.MINUTES, 5);JCache还内置了两个特殊常量Duration.ETERNAL表示永不过期Duration.ZERO表示立即过期。ETERNAL的内部实现是durationAmount为Long.MAX_VALUEZERO的durationAmount为0。这两个常量在实现自定义ExpiryPolicy时非常常用因为不是每个事件都需要重新计算过期时间。用long直接表示过期时间的问题在于单位不明确。有人喜欢用秒有人喜欢用毫秒一旦在代码里写死1000没人知道这是1秒还是1000秒。Duration把时间单位和数值封装在一起至少从API层面杜绝了一部分单位混乱问题。不过在实际开发中我发现很多人习惯直接用Duration.ofSeconds这种快捷方式但JCache规范的Duration其实没有提供ofSeconds这类静态工厂方法只有new Duration(TimeUnit, long)和两个常量。这一点很多人记错面试时提出来反而是加分项。2.3 四种内置策略的语义对比JCache规范内置了四种ExpiryPolicy实现全部在javax.cache.expiry包下分别是CreatedExpiryPolicy、ModifiedExpiryPolicy、AccessedExpiryPolicy、TouchedExpiryPolicy。它们的区别可以用一张表说清楚策略类getExpiryForCreationgetExpiryForAccessgetExpiryForUpdate语义CreatedExpiryPolicy返回设定时长返回ETERNAL返回ETERNAL只有创建时开始计时后续任何操作都不影响过期时间ModifiedExpiryPolicy返回设定时长返回ETERNAL返回设定时长创建和更新都会重新计时但访问不会续期AccessedExpiryPolicy返回设定时长返回设定时长返回ETERNAL创建和访问都会重新计时但更新不会续期TouchedExpiryPolicy返回设定时长返回设定时长返回设定时长创建、访问、更新都会重新计时我第一次看到这张表的时候最困惑的就是AccessedExpiryPolicy和TouchedExpiryPolicy的区别。Access只处理“读”事件Update事件不续期Touched则是读和写都续期。规范对AccessedExpiryPolicy的定位是“基于最后一次访问时间来决定过期”所以更新操作不归它管。但在某些缓存实现里比如Ehcache的JCache封装新旧版本的实现细节并不完全一致这个在后面的实操部分我会专门提醒。3. AccessedExpiryPolicy深入拆解3.1 访问即续期的真实语义AccessedExpiryPolicy是javax.cache.expiry包下一个具体类它没有公开的构造函数而是通过静态方法factoryOf(Duration)返回一个ExpiryPolicyFactory。这也是JCache配置过期策略时特别容易踩坑的地方你配置的是ExpiryPolicyFactory不是ExpiryPolicy实例。ExpiryPolicyFactory factory AccessedExpiryPolicy.factoryOf(Duration.ONE_MINUTE);从语义上讲AccessedExpiryPolicy的逻辑非常贴合“会话类”业务场景用户只要还在持续访问这个Key缓存就一直有效一旦超过设定时间没有访问缓存自动失效。典型的例子是登录Token或临时权限数据用户操作频繁时Token自然续期用户长时间不操作后Token自动过期这比固定时间过期的体验好得多。实现源码层面其实不复杂它内部保存了一个Duration字段getExpiryForCreation返回这个DurationgetExpiryForAccess也返回这个DurationgetExpiryForUpdate返回ETERNAL。也就是说这个策略一旦配置所有新建的缓存条目都有固定的初始过期时间之后每次读取命中都会把过期时间重置为初始值。更新操作不会重置过期计时这一点容易被人忽略。3.2 与CreatedExpiryPolicy的核心差异CreatedExpiryPolicy是很多人的默认选择它只在创建时计算一次过期时间。比如配置了五分钟过期无论你在这五分钟里读多少次到时间它就过期下次get返回null。这种策略适合“数据最终会变但五分钟内允许读到旧值”的场景比如配置信息、字典数据。AccessedExpiryPolicy则完全不同。它把过期时间从“绝对时刻”变成了“相对窗口”。同样是五分钟过期只要Key被持续访问它可能永远不过期。这就带来一个数据正确性风险如果后台数据源已经更新但某个高频访问的Key始终被续期缓存里存的数据就会无限期地是旧值。我在实际项目中遇到过类似的线上问题。当时一个商品详情接口用了AccessedExpiryPolicy过期时间设置成10分钟结果运营后台改了商品价格前台用户看到的却一直是旧价格持续了好几个小时。原因就是商品详情页访问量太大每个请求都在给缓存续期旧数据根本不会到期。这个教训让我后来养成了一个习惯凡是数据内容可能被外部修改的场景绝不用AccessedExpiryPolicy要么用CreatedExpiryPolicy要么引入主动失效机制。3.3 实现层面的更新竞争问题还有一个细节值得展开访问续期在实现层面会触发缓存条目元数据的更新。也就是说每次get操作不仅是在读数据还在写这个Key的过期时间戳。这带来两个后果。第一是性能损耗。读操作变成了“读写”在高并发场景下同一个Key的访问续期会产生激烈的竞争。分布式缓存实现里这种竞争可能需要通过锁或原子操作来保证一致性吞吐量会明显下降。如果你的缓存本身就是热点Key再叠加访问续期性能问题会被放大。第二是时间戳的原子性问题。JCache规范没有强制规定访问续期时过期时间戳的更新必须是原子的但不同实现为了数据一致性通常会用CAS或者锁来实现。比如在Hazelcast的JCache实现里访问续期会更新entry的metadata这涉及跨节点的通信成本。所以从工程角度看AccessedExpiryPolicy不适合超高吞吐且对性能极其敏感的场景这一点在架构设计时就要想清楚。4. 实操项目里怎么配置AccessedExpiryPolicy4.1 基于Ehcache 3的JCache配置实战目前市面上对JSR-107支持最成熟的开源实现之一是Ehcache 3它在JCache兼容性测试上做得相当完整。下面我用一个用户信息的短时缓存来演示完整的配置过程。首先引入依赖Maven坐标如下dependency groupIdorg.ehcache/groupId artifactIdehcache/artifactId version3.10.8/version /dependency dependency groupIdjavax.cache/groupId artifactIdcache-api/artifactId version1.1.1/version /dependency然后构建一个带AccessedExpiryPolicy的缓存。注意在javax.cache.configuration包下构造配置时要传入类型信息否则缓存无法做类型校验import javax.cache.Cache; import javax.cache.CacheManager; import javax.cache.Caching; import javax.cache.configuration.MutableConfiguration; import javax.cache.expiry.AccessedExpiryPolicy; import javax.cache.expiry.Duration; import java.util.concurrent.TimeUnit; public class JCacheDemo { public static void main(String[] args) { MutableConfigurationString, User config new MutableConfiguration(); config.setTypes(String.class, User.class); // 关键配置访问后5分钟过期只要被读到就续期 config.setExpiryPolicyFactory( AccessedExpiryPolicy.factoryOf(new Duration(TimeUnit.MINUTES, 5)) ); CacheManager cacheManager Caching.getCachingProvider().getCacheManager(); CacheString, User userCache cacheManager.createCache(userCache, config); userCache.put(u1001, new User(u1001, 张三, 28)); // 模拟第一次读取命中后缓存续期5分钟 User user userCache.get(u1001); System.out.println(user.getName()); } static class User { private final String id; private final String name; private final int age; User(String id, String name, int age) { this.id id; this.name name; this.age age; } public String getName() { return name; } } }这段配置里最关键的就是那一行setExpiryPolicyFactory。如果不理解ExpiryPolicyFactory和ExpiryPolicy的关系很容易写错成setExpiryPolicy(new AccessedExpiryPolicy(...))但MutableConfiguration里根本没有setExpiryPolicy方法编译直接报错。我在代码评审里见过不少这种失误。4.2 自己实现一个自定义ExpiryPolicy有时候内置策略满足不了业务需求比如我想实现“创建后5分钟过期访问续期2分钟更新后重新计时3分钟”这时候就要自己实现ExpiryPolicy接口。做法如下import javax.cache.expiry.Duration; import javax.cache.expiry.ExpiryPolicy; import java.util.concurrent.TimeUnit; public class CustomExpiryPolicy implements ExpiryPolicy { private static final Duration CREATE_DURATION new Duration(TimeUnit.MINUTES, 5); private static final Duration ACCESS_DURATION new Duration(TimeUnit.MINUTES, 2); private static final Duration UPDATE_DURATION new Duration(TimeUnit.MINUTES, 3); Override public Duration getExpiryForCreation() { return CREATE_DURATION; } Override public Duration getExpiryForAccess() { return ACCESS_DURATION; } Override public Duration getExpiryForUpdate() { return UPDATE_DURATION; } }然后通过一个简单的工厂类把它接入配置import javax.cache.configuration.Factory; import javax.cache.expiry.ExpiryPolicy; public class CustomExpiryPolicyFactory implements FactoryExpiryPolicy { Override public CustomExpiryPolicy create() { return new CustomExpiryPolicy(); } }配置时调用config.setExpiryPolicyFactory(new CustomExpiryPolicyFactory())。这里有个不太起眼但很实用的设计ExpiryPolicyFactory的create方法每次都返回一个新的策略实例。为什么要这样设计因为缓存配置可能在多个CacheManager、多个Cache之间复用而ExpiryPolicy内部如果有可变状态共享实例就会出问题。虽然规范内置策略都是无状态的但自定义实现最好也保持无状态把Duration定义成final常量避免踩到并发安全的地雷。4.3 写单元测试验证过期行为过期策略这种逻辑不写测试验证很容易藏着猫腻。我用最朴素的Thread.sleep方式来写一个可读性强的测试用例验证AccessedExpiryPolicy确实会续期import org.junit.jupiter.api.Test; import javax.cache.Cache; import javax.cache.CacheManager; import javax.cache.Caching; import javax.cache.configuration.MutableConfiguration; import javax.cache.expiry.AccessedExpiryPolicy; import javax.cache.expiry.Duration; import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; class AccessedExpiryPolicyTest { Test void testAccessRenewsExpiry() throws InterruptedException { MutableConfigurationString, String config new MutableConfiguration(); config.setTypes(String.class, String.class); config.setExpiryPolicyFactory( AccessedExpiryPolicy.factoryOf(new Duration(TimeUnit.SECONDS, 1)) ); CacheManager cacheManager Caching.getCachingProvider().getCacheManager(); CacheString, String cache cacheManager.createCache(renewTest, config); cache.put(k, v); // 0.7秒时读取一次续期 Thread.sleep(700); assertNotNull(cache.get(k)); // 从续期时刻起再等0.7秒仍然没到1秒应该还在 Thread.sleep(700); assertNotNull(cache.get(k)); // 这次不再访问等超过1秒后应该过期 Thread.sleep(1100); assertNull(cache.get(k)); } }这个测试用例重点验证“访问后过期时间被重置”这个核心行为。如果把策略换成CreatedExpiryPolicy第二次assertNotNull就会失败因为创建后1秒无论如何都会过期跟访问无关。用这种对比例子来理解两种策略比死记硬背定义有效得多。5. 面试官视角这题想考察什么怎么答才加分5.1 常见的追问点与高频变体这道题在高级Java面试里不会只问一句“解释一下AccessedExpiryPolicy”就完事。面试官通常会在你回答完之后连续追问目的就是看你有没有真正理解缓存过期的本质。我遇到过的追问至少有这些第一个追问是“getExpiryForAccess返回null和返回ETERNAL有什么区别”。这个问题的坑在于很多菜鸟会把null当成“不过期”其实null是“保持现状”ETERNAL才是“永不过期”。在访问事件里返回null意味着这个Key当前的到期时间不变返回ETERNAL则是把它的到期时间改成永久。这两个语义在实现一个“首次访问后永不过期”的策略时天差地别。第二个追问是“AccessedExpiryPolicy和TouchedExpiryPolicy的区别是什么”。前面已经说过一个是更新不续期、一个是访问和更新都续期。这里容易翻车的是很多人记反或者根本不知道有TouchedExpiryPolicy这个类。把这个区别说清楚基本就能证明你认真读过JCache源码或规范文档。第三个追问是“Duration.ZERO在实际会有什么表现”。Duration.ZERO表示条目立即过期在这样的策略下数据创建后立刻失效下一次get基本拿不到值等同“写入即失效”。这种策略看起来没用但在某些“写缓存只是为了触发副作用”的场景下有人会这么用面试时能答出来会很加分。5.2 容易踩的坑工厂与策略的混淆还有一个高频易错点是ExpiryPolicyFactory和ExpiryPolicy的关系。配置项setExpiryPolicyFactory接收的是Factory 类型的对象而不是ExpiryPolicy本身。这个设计源于Java Cache规范的通用配置框架配置缓存、监听器、加载器都是通过Factory来延迟创建实例的。为什么搞这么绕核心原因是缓存配置可能被序列化后在集群节点间传播。比如Hazelcast作为分布式缓存一个CacheManager配置可能要发给多台机器Factory模式允许配置对象在本地构建具体实例在每个节点上按需创建。如果你直接传ExpiryPolicy实例序列化和反序列化后实例状态就容易出错。所以回答面试题时能主动提一句“配置的是ExpiryPolicyFactory不是ExpiryPolicy实例这是为了支持分布式环境下的延迟实例化”会明显比干巴巴背三个方法加分。5.3 面试答题框架从定义到场景再到权衡如果你在面试中被问到这类题我建议按下面这个框架来组织回答控制在两到三分钟先讲规范背景。JCache是JSR-107标准定义了ExpiryPolicy接口包含getExpiryForCreation、getExpiryForAccess、getExpiryForUpdate三个方法分别对应创建、访问、更新三个事件。再讲策略家族。内置的CreatedExpiryPolicy、ModifiedExpiryPolicy、AccessedExpiryPolicy、TouchedExpiryPolicy分别对应“只按创建时间”“只按更新时间”“按访问时间”“按访问或更新时间”四种过期语义并且指出AccessedExpiryPolicy对更新事件返回ETERNAL不参与续期。最后讲工程选型。抛出实际业务场景说明选择过期策略不能只看API还要想清楚数据被外部修改的风险和高频访问带来的续期放大效应。这时候如果能把“访问续期可能让脏数据长期存活”“热点Key读操作变写操作导致性能损耗”这两个权衡点讲出来面试官基本就知道你是有真实项目经验的。6. 实际工程里的经验过期策略选型建议6.1 访问过期适合什么业务从我接触过的项目来看AccessedExpiryPolicy适合那些“用户活跃期间必须保持有效用户离开后自动失效”的数据。最典型的是Session会话信息、临时授权令牌、API限流状态。比如接口限流你希望用户在持续调用期间计数一直有效但用户停了几分钟之后计数自动归零。用AccessedExpiryPolicy就很自然每次请求都刷新计数Key的过期时间用户一旦停止请求Key到点消失限流状态随之重置。再比如分布式锁的自动续期场景虽然实现比这个复杂但核心思想也是访问续期。这类场景有一个共同点数据的“新鲜度”与用户活跃度强相关而不是与时间绝对值强相关。使用访问续期本质上是在用“用户回来过”这个信号来判断数据是否还有价值这种动态判断比固定时间窗口更贴近业务语义。6.2 创建过期适合什么业务CreatedExpiryPolicy则适合“数据源有更新机制允许短暂延迟”的场景。比如数据库查询结果的短时缓存、远程调用结果的缓存、字典配置项。这类数据的特点是底层数据一变上游系统会主动通知或主动清理缓存缓存里的数据不需要靠访问续期来保鲜。我现在的团队在做配置中心对接时就统一用CreatedExpiryPolicy加主动刷新机制。配置发布时通过消息队列通知各服务清理对应缓存即使通知丢失配置项五分钟强过期兜底最多延迟五分钟生效。这个设计里强过期时间充当的是“保险丝”角色核心正确性靠主动通知保证而不是靠缓存自身的过期策略。如果你在这种场景里用了AccessedExpiryPolicy就会陷入我之前说的困境高频访问的配置Key永远不过期主动通知又恰好没覆盖数据就变成“永久过期”的脏数据了。所以我的经验法则是凡是有独立数据源且数据可被外部修改的场景优先用CreatedExpiryPolicy只有纯粹以用户活跃度为中心的临时状态数据才考虑AccessedExpiryPolicy。6.3 混合策略与性能考量实际项目往往不是只用一种策略。一个缓存可能同时存在“代码配置项”和“临时限流状态”两类数据在同一个CacheManager里创建多个Cache每个Cache用不同的ExpiryPolicy是一种很常见的做法。JCache本身也支持这种设计因为过期策略是挂在Cache级别的配置上不是全局统一的。另外一个必须提醒的性能问题是访问续期带来的“读放大”。表面上看缓存命中比查数据库快得多但如果每次命中都要更新过期时间戳分布式缓存的内部就要做一次远程元数据写入。对于Redis这种单线程模型访问续期通常通过TTL刷新实现高并发下本身就是一次写操作对于Hazelcast这类基于内存数据网格的实现则涉及分布式锁或者原子更新协议。所以“用访问续期包装高频读接口”并不是稳赚不赔的优化可能在系统层面引入比预期大得多的开销。如果非要使用访问续期又担心性能可以考虑两个替代方案一是把“续期窗口”放宽减少续期频率二是先在本地缓存层做访问续期只有本地淘汰时才回源到分布式缓存两层配合起来既能保持会话有效又能避免分布式层被高频刷新。6.4 配置失效策略时的三条检查清单最后分享一个我每次配置缓存过期策略都会过一遍的检查清单用来避免低级错误第一确认配置的是ExpiryPolicyFactory。如果是通过Spring或者Spring Boot集成一定要检查配置类里传的是factoryOf方法的结果而不是new出来的策略实例。第二想清楚更新操作的影响。如果你用的是AccessedExpiryPolicy更新操作不会续期这个行为有没有违背业务预期如果你希望更新后续期应该用TouchedExpiryPolicy这是最容易被忽略的点。第三画一条时间线验证。把Key的创建、第一次访问、第二次访问、更新、再次访问都排成一条时间轴手工推算一下每次事件之后的新过期时刻是不是你想要的。别嫌这个做法土很多线上问题就是少了这张时间线图。个人在实际操作中还有一个习惯所有缓存相关的过期策略配置在代码评审里必须写明选型原因。比如“这里使用CreatedExpiryPolicy是因为数据源有主动刷新机制”或者“这里使用AccessedExpiryPolicy是为了保证用户活跃期间会话不掉线”。把选型理由写清楚一方面逼着自己想明白为什么这么选另一方面也给后来维护的人留下一份准确的上下文信息。缓存过期这种看似微小的地方往往才是系统在流量高峰时能不能站稳的关键。
返回列表