ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JCache缓存过期策略:TTL与TTI的关键差异与实战解析

JCache缓存过期策略:TTL与TTI的关键差异与实战解析

1. 题面解析:先把标准答案钉死

最近又有人在群里贴出这道JCache基础篇的面试题:JCache(JSR-107)标准要求实现必须支持哪两种缓存过期策略?题目很短,答错率却很高。有人脱口而出“TTL和TTI”,有人愣一下说“TTL和TTI不是一回事吗”,还有人干脆把“LRU和LFU”搬出来。说实话,能把LRU当过期策略来答,说明对缓存两个核心机制——过期和淘汰——还没完全分清楚。今天就用实战视角把这道题彻底拆开,从规范要求、接口设计、代码验证到面试追问,一次说透。

1.1 标准答案:TTL 与 TTI

正确答案是:JCache(JSR-107)要求实现必须支持两种基于时间的过期策略,分别是TTL(Time-To-Live,存活时间)和TTI(Time-To-Idle,空闲时间)。用JCache术语再翻译一遍,就是“基于条目创建时间的过期策略”和“基于最后访问时间的过期策略”。

TTL的计时起点是缓存条目被创建或者被更新的时刻。比如设置TTL为5分钟,那么无论这5分钟内被读取多少次,只要到了时间点,条目就该被视为过期。TTI的计时起点是最后一次成功的访问操作,每次读取都会让计时器归零重新开始。所以一条数据如果持续被高频读取,TTI会被不断续期;只有连续空闲超过设定时间,它才会过期。

这两种策略解决的是完全不同的缓存场景,后面会详细展开。但先记住这个结论:规范里“必须支持”这四个字非常重要,它不是建议,而是所有合格实现的最低门槛。

1.2 很多人掉进去的坑:过期策略不等于淘汰策略

这道题最容易翻车的地方,不是答不出TTL和TTI,而是把过期策略和淘汰策略混为一谈。

过期策略关心的是“这个条目什么时候失效”,核心是时间维度。缓存框架会在读取时判断条目是否过期,或者通过后台任务定期清理,过期后再次get会得到null。淘汰策略关心的是“缓存空间满了,我该先扔掉谁”,核心是空间维度。LRU(Least Recently Used)淘汰最近最少使用的,LFU(Least Frequently Used)淘汰访问频率最低的,这些东西解决的是容量压力,不是时间失效。

一个典型场景:用户会话数据设置30分钟TTI,它不一定占满空间,但超过30分钟不访问就该失效,这是过期策略的活。如果缓存容量不够,我们可以用LRU把不常用的会话先挤掉,这是淘汰策略的活。两者可以共存,但不能互相替代。面试官问出这道题,就是想看你能不能清晰区分这两个维度。

1.3 面试官问这道题到底想考什么

表面上它是一道送分题,背下来就行。但面试官真正考察的是三个层次:

第一,你是否知道JCache是Java官方的缓存规范,它把缓存API标准化到了javax.cache这个包里,JSR-107是这个规范的编号。第二,你是否知道规范对实现方有强制性要求,而不是只给了一堆可选的API。第三,你是否能把两种策略对应到实际的业务场景,而不是停留在背概念。

所以答题时最好三步走:先给出标准答案,再解释两种策略的计时规则,最后各配一个典型使用场景。这样既展示了记忆准确,又体现了理解深度。下面我们就进入规范背后的设计逻辑,看看为什么偏偏是这两种策略被强制要求。

2. 规范背后的设计逻辑:为什么非要强制这两种

JSR-107在制定时并不是拍脑袋定下“必须支持TTL和TTI”,而是从大量缓存应用中抽象出来的最低共识。如果你看过Caffeine、Guava、Redis这些缓存方案的源代码或者配置项,会发现它们都摆脱不了这两个基本的失效维度。JCache只是把最核心的那个交集抽成了规范。

2.1 从业务场景反推:TTL 和 TTI 各自的价值

先看TTL。它的语义是“从写入开始计时,到期就失效”。典型场景包括验证码、临时token、优惠券快照、配置推送。比如一个活动的商品价格页,我们从上游接口拉取价格后缓存60秒,60秒后必须重新拉取,这就是TTL。这类数据的特征非常明显:数据从出生开始就有固定的保鲜期,不管用户看不看,到期就得作废。

再看TTI。它的语义是“从上一次被访问开始计时,空闲超过阈值就失效”。典型场景是用户登录会话、表单填写状态、需要高频访问的热点数据。比如一个用户在电商网站浏览商品,只要他不断点击,会话就不该过期;他离开半小时后再回来,会话应该已经失效。TTI对这种“有活动就续命,没活动就回收”的需求特别匹配。

如果规范只要求支持一种,实现方的自由度太大,业务方一旦需要另一种,就只能自己造轮子。如果要求支持太多,比如把进程内缓存常用的弱引用、软引用也纳入强制,那又会给分布式缓存实现造成巨大负担。所以JCache选择把TTL和TTI这两个普适性最强的时间策略定为硬性要求。

2.2 ExpiryPolicy:三个方法对应三种时机

在JCache的javax.cache.expiry包里,有一个核心接口ExpiryPolicy,它定义了三个方法:getExpiryForCreation、getExpiryForAccess、getExpiryForUpdate。

getExpiryForCreation决定了条目刚创建时从哪个时刻开始算过期,TTL主要落在这里。getExpiryForAccess决定了条目在每次读取命中后应该被赋予多长的续期,TTI主要落在这里。getExpiryForUpdate决定了条目在更新值之后重新计算过期时间,常见做法是把它当TTL重新开始计算。

理解这三个方法,比单纯背TTL和TTI更接近规范本意。TTL不是一个独立方法,TTI也不是一个独立方法,它们都是ExpiryPolicy在不同访问时机下返回Duration的具体体现。规范要求实现必须支持“基于创建时间的过期”和“基于最后访问时间的过期”,落到代码层面就是:默认提供的ExpiryPolicyBuilder必须能帮你构建出这两种策略,并且底层缓存必须能正确处理访问时间戳的更新。

2.3 Duration 的黑话:ZERO、ETERNAL 和负数

ExpiryPolicy的三个方法返回的类型是javax.cache.expiry.Duration,不是java.time.Duration,这一点很容易踩坑。JCache的Duration内部有两个常量:ZERO表示立即过期,ETERNAL表示永不过期。

ZERO的使用非常微妙。如果你在自定义ExpiryPolicy的getExpiryForAccess里返回Duration.ZERO,意味着条目在创建后第一次访问就会立即过期,这通常不是你想要的效果。ETERNAL则是默认行为,JCache默认情况下缓存条目是永不过期的,除非你显式配置了过期策略。

至于负数,规范明确不允许返回负数。如果你传入一个负的Duration,轻则被忽略,重则抛异常。所以生产环境里如果有外部配置的动态过期时间,一定先做钳制校验,别把负数传给ExpiryPolicyBuilder。这个细节在面试追问里很常见,也是实际开发中容易被忽略的坑。

3. 代码实操:亲手验证两种过期策略

讲原理总归有点虚,我们直接写代码验证。这里只使用JCache规范提供的接口,具体实现只需要选择一个实现了JSR-107的开源库放到classpath里。为了避免被具体厂商牵着走,我会尽量保持代码只依赖javax.cache的标准API。

3.1 环境准备:只依赖规范接口也能跑

首先引入JCache API。如果你的项目是Maven结构,依赖坐标一般是group为javax.cache、artifact为cache-api的jar,这是官方规范接口。还需要一个具体的JCache实现类库,每个实现的坐标不同,这里就不展开写死,你自己选一个主流的JSR-107实现即可。

然后我们需要一段最基础的初始化代码,获取CachingProvider和CacheManager:

import javax.cache.Cache; import javax.cache.CacheManager; import javax.cache.Caching; import javax.cache.configuration.MutableConfiguration; import javax.cache.expiry.Duration; import javax.cache.expiry.ExpiryPolicy; import javax.cache.expiry.ExpiryPolicyBuilder; import javax.cache.spi.CachingProvider; import java.time.temporal.ChronoUnit; import java.util.concurrent.TimeUnit; CachingProvider provider = Caching.getCachingProvider(); CacheManager cacheManager = provider.getCacheManager(); MutableConfiguration<String, String> config = new MutableConfiguration<>(); Cache<String, String> cache = cacheManager.createCache("demo-cache", config);

这里要提醒一点:同一个CacheManager里创建同名缓存会抛异常。如果你反复调试,要么换缓存名,要么在创建前先判断缓存是否存在。另外,CacheManager本身也是一个资源,用完最好调用close(),尤其在单元测试里频繁创建时,不关闭可能会把内存堆满。

3.2 配置 TTL:5秒后必然消失

下面创建一个TTL为5秒的缓存。我习惯用lambda给setExpiryPolicyFactory传参,代码短,意图也清楚:

MutableConfiguration<String, String> ttlConfig = new MutableConfiguration<>(); ttlConfig.setExpiryPolicyFactory(() -> ExpiryPolicyBuilder.timeToLiveExpiry(Duration.ofSeconds(5))); Cache<String, String> ttlCache = cacheManager.createCache("ttl-demo", ttlConfig); ttlCache.put("orderId", "20250514001"); System.out.println(ttlCache.get("orderId")); // 输出:20250514001 TimeUnit.SECONDS.sleep(6); System.out.println(ttlCache.get("orderId")); // 输出:null

运行之后你会发现第一个get正常返回,第二个get返回null。这就是TTL:从put那一刻开始倒计时5秒,期间不管你怎么get,计时都不会重置。

这里有一个值得注意的实现差异:有些JCache实现是惰性过期,也就是get时才检查时间戳;有些实现会有后台线程定期清理。所以你在第5秒整去get,结果可能是null,也可能还没被物理删除,但逻辑上一定不能返回旧值。规范要求的是“看起来过期”,不强制物理删除时机。

3.3 配置 TTI:访问之后计时器重新归零

TTI的验证稍微绕一点,需要模拟“访问后重置”:

MutableConfiguration<String, String> ttiConfig = new MutableConfiguration<>(); ttiConfig.setExpiryPolicyFactory(() -> ExpiryPolicyBuilder.timeToIdleExpiry(Duration.ofSeconds(5))); Cache<String, String> ttiCache = cacheManager.createCache("tti-demo", ttiConfig); ttiCache.put("sessionId", "abc123"); TimeUnit.SECONDS.sleep(3); System.out.println(ttiCache.get("sessionId")); // 输出:abc123,访问把过期时间续到了第8秒 TimeUnit.SECONDS.sleep(3); System.out.println(ttiCache.get("sessionId")); // 输出:abc123,距离最后一次访问只过去3秒,未过期 TimeUnit.SECONDS.sleep(6); System.out.println(ttiCache.get("sessionId")); // 输出:null,最后访问后又空闲了6秒,超过5秒阈值

这里的逻辑可以这样理解:第一次sleep 3秒时访问了一次,空闲计时器被重置,过期时间点从原来的第5秒推迟到了第8秒。第二次sleep 3秒后再次访问,又重置到第11秒。最后一次连续空闲6秒,超过5秒,于是过期。

TTI在分布式场景里有个经典坑:多个节点同时访问同一个key,每个节点都会更新自己的本地访问时间,但如果没有统一的访问时间存储,TTI就名存实亡。这一点面试官如果追问,你可以补充“TTL比TTI更容易在分布式环境下实现”。

3.4 组合策略和自定义防御式写法

如果业务要求“最长TTL是10分钟,但连续空闲3分钟就过期”,JCache内置的builder没法直接组合。规范只强制分别支持TTL和TTI,并不保证二者的组合语义。这时候你需要自己实现一个ExpiryPolicy:

public class CombinedExpiryPolicy implements ExpiryPolicy { private final Duration ttl; private final Duration tti; public CombinedExpiryPolicy(Duration ttl, Duration tti) { this.ttl = ttl; this.tti = tti; } @Override public Duration getExpiryForCreation() { return ttl; } @Override public Duration getExpiryForAccess() { return tti; } @Override public Duration getExpiryForUpdate() { return ttl; } }

别急着把这段代码直接上生产,这里有一个语义陷阱:创建时返回ttl,但第一次访问后getExpiryForAccess返回tti,会把原有的ttl覆盖掉。也就是说,如果创建后第5秒你访问了一次,后续实际过期时间就变成了“这次访问后的3分钟”,而不是原来剩余的ttl。

真正要同时保证“最长不超过10分钟”和“空闲超过3分钟过期”,必须在getExpiryForAccess里做计算,取剩余TTL和新的TTI中更早的那个。这个计算逻辑实现难度不大,但需要缓存框架能拿到“当前时间”和“已存活时间”,JCache规范并没有统一暴露这些内部时间戳。所以更务实的做法是在业务代码里做好定时续期或过期判断,不要过度依赖自定义组合策略。

4. 常见问题与排查技巧实录

这块内容来自我实际接手的几个项目问题,很多坑不是配置错了,而是大家对“过期”这个动作的理解有偏差。

4.1 过期条目为什么不会立刻物理删除?

这是刚开始用JCache时最常见的困惑:TTL设了5秒,sleep6秒后一查内存,缓存条目还在,只是get不到了。

原因很简单,JCache规范只要求“get的时候必须返回null”,并没有强制要求实现必须同步删除物理条目。有些实现采用惰性删除,只有访问过期条目时才真正移除;有些实现使用后台清理线程,每隔一段时间扫描删除。从用户视角看,条目逻辑上已经过期,物理残留与否不影响正确性,但会影响内存占用。

如果你的缓存容器长期存在大量过期条目,内存曲线会越来越难看的。这时可以去你的JCache实现里找有没有“定期清理”的配置项,把清理间隔适当调小,或者手动遍历缓存并触发get来加速失效。不过这类操作在分布式缓存上要小心,遍历大缓存可能拖垮线上性能。

4.2 为什么TTI缓存在高并发下看着像“永远不过期”?

这是TTI的天然特性:只要每个请求都来访问这个key,空闲计时器就一直在重置,理论上这个key永远不会因为TTI过期。如果你的访问量非常高,TTI缓存里的所谓“热点数据”可能会长时间不失效,导致数据新鲜度变差。

我之前遇到过一个问题:团队用TTI缓存一套价格规则,想着3分钟不访问就作废,结果这套规则是全站每个请求都会读的,TTI永远被续期,价格规则几乎一整天都没有重新加载。最后改成TTL才解决问题。

所以选型时要想清楚:如果你希望数据必须定期重新拉取,不管有没有人访问,请用TTL。如果你只希望清理那些长时间没人用的数据,用TTI。二者没有优劣,只有适用场景不同。

4.3 五大高频坑:不是配置错了,是理解错了

现象根因解决建议
设置了TTL但缓存一直有效用的可能是Caffeine的expireAfterWrite但JCache适配层没真正应用检查配置是否传到了底层Cache的expiryPolicyFactory
get之后TTI不重置实现可能只在写操作时更新时间戳查JCache实现文档,确认是否支持读续期
Duration传入负数JCache规范要求Duration非负配置来源处做Integer/Duration校验
自定义ExpiryPolicy返回null方法返回值不能随意给null默认返回Duration.ETERNAL或显式指定
同一CacheManager重复createCache同名缓存已存在会抛异常用cacheManager.getCache(name)判断或try-catch

这些坑在单个实现里不一定会全遇到,但它们都有共性:不要拿“用过某个缓存框架”就直接替代对规范的理解。JCache是API标准,底层实现各有各的实现策略,面试官其实很爱从这个角度问下去。

4.4 这种Error/Exception到底怎么排查

有一次线上缓存突然大量失效,日志里刷“expiry duration is not positive”一类的错误。排查后发现是我们一个外部配置项传了负数,配置中心改了数据但代码没做钳制。

后来我总结了一条排查路径:第一,复现时先看配置的Duration到底是多少;第二,在自定义ExpiryPolicy的三个方法入口加日志,打印每次返回的Duration;第三,确认实现是否启用后台清理线程,把清理日志打开;第四,区分缓存命中不到底是因为过期,还是因为淘汰。这四条走完,大部分问题都能定位。

5. 面试答题的节奏与我的实战感受

这道题虽然简单,但答得好不好很能拉开差距。下面给出一个我验证过比较好用的答题结构,以及一些面试之外的实际心得。

5.1 一个可以直接复用的答题模板

面试官问出“JCache标准要求实现必须支持哪两种缓存过期策略”时,你可以这样回答:

第一层,给结论:必须支持TTL和TTI,也就是基于创建时间的过期,和基于最后访问时间的过期。第二层,解释差异:TTL从写入开始计时,访问不会重置;TTI从上次访问开始计时,每次命中会续期。第三层,落场景:比如验证码用TTL,用户会话用TTI。第四层,补深度:JCache里的ExpiryPolicy接口有三个方法,分别对应创建、访问、更新三种时机,TTL和TTI的本质是这三个方法在不同时机返回不同Duration。

如果面试官追问“淘汰策略和过期策略的区别”,你还可以顺带说一句:LRU、LFU这类淘汰策略和TTL、TTI不冲突,前者管容量,后者管时效。这样整个回答就立体起来了。

5.2 我在项目里踩过的TTI续期坑

说一个我自己的真实经历。当时做一个活动页面,需要把用户最近浏览过的商品列表缓存起来,30分钟不活跃就清空。我当时很自然地选了TTI,觉得非常合适。结果上线后发现一个问题:只要用户在活动页挂着不动,每5分钟会有一个前端心跳请求打过来,把整个列表的TTI不断续期,导致用户其实早就没看了,但缓存里还是装着半小时前的浏览记录。

后来我把心跳请求排除在TTI的访问更新之外,或者用最后业务操作时间来判断,才算解决了。这个案例说明TTI不是一个纯粹的“最后一次读取”概念,在真实系统里,什么是“有效访问”需要你自定义。

5.3 学习建议:从API到规范原文

如果你正在准备Java面试,我建议不要只背JCache的API,可以找一下规范原文,重点读关于ExpiryPolicy和CacheEntry的章节。那部分内容写得很清楚,会告诉你默认策略是ETERNAL,会告诉你实现必须支持哪些Duration语义,也会告诉你访问操作如何影响过期时间。

读完规范再去看具体实现源码,你会发现很多“反直觉”的行为都有据可循。比如某些实现把TTL的检查放在get路径里,某些实现用一个环形时间轮批量删除过期条目。这些细节面试官未必会问,但你能聊出来,就会让整个回答从“背题”变成“懂行”。

我个人在实际排查缓存问题时最深的体会是:与其对着具体缓存框架的配置项猜来猜去,不如先把JCache这套最小语义吃透。TTL和TTI只是起点,真正难的是理解规范方法背后的时机模型,以及它在你当前技术栈里落地时的各种偏差。把这道题当成一次切入口,顺带把ExpiryPolicy的源码和规范原文过一遍,绝对不亏。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进