
前几天在技术群里看到有人转这道题题目本身不长就一句话如何通过 JCache API 实现一个简单的缓存预热逻辑。底下跟着一长串讨论有人说 JCache 是什么能不用 Redis 吗有人说预热不就是启动时遍历数据库往缓存里塞数据吗还有人问 loadAll 是不是异步的怎么等它执行完。说实话这问题看着基础但能答完整的人真不多。我做 Java 开发这些年本地缓存这块踩过不少坑JCacheJSR-107也在一套供应链系统里实打实落地过。当时做缓存预热就踩过“启动后第一批请求又慢又卡”的坑后来把 JCache 的 CacheLoader、loadAll、定时刷新这套玩熟了才彻底解决。这篇就以这道面试题为引子把 JCache 缓存预热的原理、代码、坑一次性说完。不管你是准备高级 Java 岗位面试还是正在给老系统做性能优化这篇都值得你花十分钟读完。1. 先搞清楚 JCache 是干什么的再谈预热1.1 JSR-107 规范的由来与边界JCache 是 Java 官方的缓存标准准确说是 JSR-107 专家组制定的规范2014 年发布了 1.0 版本目前 API 主要落在javax.cache包里。 JCache 的定位和 JDBC 之于数据库类似JDBC 统一了 Java 操作数据库的接口JCache 则统一了 Java 应用操作缓存的方式。这套规范定义了一组核心接口CachingProvider负责获取缓存管理器CacheManager负责创建和管理缓存Cache就是实际的 KV 容器可以理解为一个线程安全的 Map。除此之外还有CacheLoader负责“缓存未命中时从哪里加载数据”CacheWriter负责“缓存数据变更时写到哪个外部存储”ExpiryPolicy负责过期策略CacheEntryListener负责监听缓存事件。有一点必须先说清楚JCache 规范本身不限定缓存是本地还是分布式它只定义接口实现方可以是单机内存、也可以是分布式集群。常见实现包括 Ehcache 3、Hazelcast、Infinispan、Oracle Coherence 等。所以你写代码时面对的是接口底层换成哪个实现业务代码基本不用动。这道题如果出现在高级 Java 面试里考官往往不是要你背 API而是想听你讲明白“你怎么在真实系统里把缓存预热这件事做对”。1.2 这道面试题考的是规范还是实现很多候选人看到“JCacheJSR-107”就开始紧张觉得这玩意只在八股文里见过实际项目没几个人用。但面试官问这道题至少有三层意图第一层是考 API 熟练度。你能不能脱口而出Caching.getCachingProvider()、getCacheManager()、createCache()这套调用链能不能说出cache.put、cache.get、cache.loadAll这几个方法的区别第二层是考工程经验。预热逻辑放哪里是 Spring 启动完成后通过ApplicationRunner执行还是在静态代码块里做或者用PostConstruct启动时一次性加载多少数据才不会把数据库打垮这些只有真正做过的人才能答出细节。第三层是考原理理解。比如 JCache 里loadAll是异步的很多新手以为调用完缓存里就有数据了实际并非如此。再比如CacheLoader的load方法在缓存未命中时会被自动调用这个机制如果利用好了预热逻辑会写得非常优雅。这道题冠以“基础篇”不代表它简单而是提醒你越是基础的东西越能考察一个工程师的功底。1.3 预热这件事为什么值得单拎出来问缓存预热这个词通俗讲就是在系统冷启动或大规模发布的时候提前把热点数据从数据库或其他数据源加载到缓存里避免用户请求一进来就打到数据库导致响应时间飙升。我见过不少团队Redis 用得很熟但项目里一旦引入本地缓存就只会在请求进来时cache.get(key)查不到再查库再回填。这种懒加载模式对低频冷数据没问题对高并发热点数据就是一场灾难。比如秒杀活动零点刚过成千上万个请求同时查一个还没进缓存的热点商品缓存击穿就在一瞬间数据库直接被打到报警。预热的核心价值就是把这些热点数据在流量到达之前塞进缓存让缓存永远“备好弹药”。同一道题在不同的工程阶段有不同的答法后面几个部分我会把代码和思路完整铺开。2. 缓存预热的设计思路从需求到方案2.1 冷启动的痛不预热到底会发生什么先讲一个我真实遇到过的场景。那套供应链系统里有个门店库存查询接口每次查询需要关联商品、门店、库存流水三张表数据库算一次要 30 到 80 毫秒。上线初期用的懒加载缓存第一个用户查某个门店的时候缓存里没有数据就得等数据库把三张表 join 完之后再塞缓存。结果每次发版重启后的一两分钟内请求延迟从平时的 5 毫秒飙升到 200 毫秒以上用户端表现为页面一直在转圈。更麻烦的是并发场景。多个线程同时发现同一个 key 不在缓存里就会同时去查库形成一个放大效应。缓存里每缺一个热点 key数据库就要多扛几十倍的查询压力。这就是缓存穿透和击穿的雏形。预热就是为了解决这类问题当然它不能完全替代布隆过滤和互斥回填等措施但能把绝大部分冷启动问题消解掉。从技术选型角度看预热方案有三种常见实现路径第一种是用 JCache 规范里的CacheLoaderloadAll第二种是应用启动后手动遍历数据源一条条cache.put进去第三种是用定时任务做周期性刷新。这道题问的是“通过 JCache API 实现”所以核心重点在前两种但定时刷新也必须懂因为预热数据不会永远“热”下去。2.2 方案一用 CacheLoader 实现自动加载与批量预热JCache 规范设计了一个非常巧妙的东西叫CacheLoader它有两个核心方法load(K key)负责根据单个 key 加载数据loadAll(Iterable? extends K keys)负责批量加载。这里有个容易被忽略的联动机制当你给一个缓存配置了CacheLoader之后调用cache.get(key)时如果缓存未命中JCache 规范规定实现方会自动调用CacheLoader.load(key)去外部数据源取数据取到后回填到缓存再返回给调用方。也就是说缓存查不到时会自动去问数据源不需要你在业务代码里手写“查不到就去 DB 查再 put”这种样板代码。而loadAll就是我们做缓存预热的核心入口。你可以把需要预热的热点 key 集合传进去CacheLoader会按 key 去数据源加载数据并写入缓存。这个机制的巧妙之处在于预热的加载逻辑和运行期的未命中加载逻辑共用同一套CacheLoader不存在两套数据加载规则不一致的问题。2.3 方案二启动时手动批量写入如果你不想引入CacheLoader也可以在缓存创建好之后直接调用cache.putAll(map)把数据库里的数据批量写进缓存。这种方式的优点是非常直观代码量少一看就懂。缺点是“加载数据”的逻辑和“缓存写入”的逻辑耦合在业务代码里不便于复用尤其当你有多个缓存需要预热时得写大量重复代码。我见过一些老项目就是这种风格写一个XXXCacheWarmUp类里面先查数据库再循环cache.put每加一个缓存就复制一份类似代码。 这种方案在小场景下没有大问题但一旦你的系统里缓存数量变多、数据源变复杂就会发现每个缓存的预热逻辑散落各处没人统一管理。后来我把这些预热逻辑收拢到缓存配置层配合CacheLoader重写维护成本才降下来。2.4 两个方案怎么选这里直接给出一张对比表方便你根据项目情况做决策维度CacheLoader loadAll启动时手动 putAll代码耦合度加载逻辑统一在 Loader业务代码只关心 key预热逻辑散落在启动类或工具类未命中兜底自动调用 load无需额外代码需要自己写查库回填逻辑批量加载支持 loadAll符合规范支持 putAll但缺少加载语义学习成本需要理解 JSR-107 的异步加载机制低和操作普通 Map 类似定时刷新结合可以复用 Loader 做定时 loadAll需要再起逻辑重复代码较多推荐场景多个缓存、热点 key 明确、生命周期受控单缓存、一次性启动加载我的个人建议是新项目优先用CacheLoader方案它和 JCache 的设计思路一致后期想要加定时刷新、加监控、加分布式协调都更容易。老项目如果是临时优化例外直接用putAll也完全没问题。3. 手把手实现JCache 缓存预热完整代码3.1 工程准备Maven 依赖和 Provider 选择先用 Maven 引入 JCache API 和一个具体实现。我比较常用 Ehcache 3 作为本地缓存 Provider因为它对 JSR-107 的支持比较完整配置也灵活。先加cache-api再加ehcache依赖dependency groupIdjavax.cache/groupId artifactIdcache-api/artifactId version1.1.1/version /dependency dependency groupIdorg.ehcache/groupId artifactIdehcache/artifactId version3.10.8/version /dependency这里注意一点ehcache这个 dependency 会把 JCache 相关的类也带进来所以很多项目只加ehcache不加cache-api也能编译。但为了代码层面不依赖具体实现我们还是建议显式声明cache-api。另外如果你用的是 Hazelcast方案类似但依赖包名不同Provider 是通过Caching.getCachingProvider()的 SPI 机制自动发现的不需要手动指定类名。3.2 自定义 CacheLoader把数据库查询写进去假设我们有个用户信息表需要预热用户 ID 1 到 10000 的基础信息。先定义一个用户服务模拟数据库查询public class UserService { public User getUserById(Long userId) { // 模拟数据库查询实际场景替换成 MyBatis / JPA 等 return new User(userId, user- userId); } }然后定义UserCacheLoader实现javax.cache.integration.CacheLoaderLong, Userpublic class UserCacheLoader implements CacheLoaderLong, User { private final UserService userService new UserService(); Override public User load(Long key) { return userService.getUserById(key); } Override public MapLong, User loadAll(Iterable? extends Long keys) { MapLong, User result new HashMap(); for (Long key : keys) { // 实际项目建议批量查询不要循环单查 result.put(key, load(key)); } return result; } }这里的重点是loadAll的实现。很多人在面试时只写了load没写loadAll这是不完整的。因为预热走的是loadAll如果你不重写它默认行为是什么取决于具体实现部分 Provider 会退化成逐个调用load性能没法保证。我在实际项目中会把loadAll改成按批次的批量 DB 查询比如每 500 个 key 一批用WHERE id IN (...)去查。3.3 创建缓存并执行启动预热注意 loadAll 是异步的先创建一个带CacheLoader配置的缓存public class CacheWarmUpDemo { public static void main(String[] args) throws Exception { // 1. 获取 JCache 的 CachingProvider CachingProvider provider Caching.getCachingProvider(); CacheManager cacheManager provider.getCacheManager(); // 2. 定义缓存配置绑定 CacheLoader MutableConfigurationLong, User configuration new MutableConfiguration(); configuration.setTypes(Long.class, User.class); configuration.setCacheLoaderFactory(() - new UserCacheLoader()); // 3. 创建名为 userCache 的缓存 CacheLong, User userCache cacheManager.createCache(userCache, configuration); // 4. 准备预热 key 集合 SetLong hotKeys new HashSet(); for (long id 1; id 10000; id) { hotKeys.add(id); } // 5. 执行预热 CountDownLatch latch new CountDownLatch(1); userCache.loadAll(hotKeys, true, new CompletionListener() { Override public void onCompletion() { latch.countDown(); } Override public void onException(Exception e) { e.printStackTrace(); latch.countDown(); } }); // 6. 等待预热完成超时时间视数据量而定 boolean finished latch.await(30, TimeUnit.SECONDS); if (finished) { System.out.println(预热完成缓存中数据量 userCache.size()); } else { System.out.println(预热超时请检查数据源性能); } } }这里最关键的一点是loadAll方法签名void loadAll(Set? extends K keys, boolean replaceExistingValues, CompletionListener completionListener)第二个参数replaceExistingValues表示是否覆盖缓存中已有的值。预热场景建议传true否则缓存里已经有脏数据时不会刷新。第三个参数是完成监听器可以为 null但只要你希望知道预热什么时候结束就最好传一个CompletionListener。规范里loadAll是异步的实现方会在内部线程池里执行批量加载执行完成后回调onCompletion。这里我用了CountDownLatch把异步转同步预热完成后才对外提供服务。这一步非常关键很多生产事故就是“缓存还没热完流量就放进来了”。3.4 定时刷新让预热过的数据不“凉”下去启动时预热只解决冷启动问题但热点数据会随业务变化而转移。上午热门的商品下午可能就不是了。所以除了启动预热我还用ScheduledExecutorService做周期刷新。逻辑很简单每隔一段时间调用一次loadAll把新一代热点 key 刷进缓存同时淘汰掉已经不需要的 key。ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1, r - { Thread t new Thread(r, user-cache-warmup); t.setDaemon(true); return t; }); scheduler.scheduleAtFixedRate(() - { SetLong latestHotKeys queryLatestHotUserIds(); userCache.loadAll(latestHotKeys, true, null); }, 1, 10, TimeUnit.MINUTES);实际项目里热 key 列表往往由运营后台配置、或者通过大数据平台统计最近 N 分钟的访问量得到。我这里用queryLatestHotUserIds()代替你只需要把它替换成真实的动态查询即可。定时刷新有几个细节需要注意线程池一定要给线程起名字否则排查问题时发现一堆无名线程会很难受后台刷新如果失败不能影响主流程刷新频率要避开业务高峰期比如凌晨 3 点到 5 点之间做全量重刷就是比较常见的选择。3.5 单条数据的动态预热与兜底除了批量预热还有一种场景是“按需预热”。比如用户第一次登录我们要缓存他的昵称和头像。这时候走cache.get如果配置了CacheLoaderJCache 会自动调到load方法从数据库查出数据然后回填缓存。业务代码不需要关心缓存里有没有只管拿结果就行User user userCache.get(userId); if (user ! null) { return user; } // 返回 null 说明缓存里没有且 Loader 也没能加载到数据这是我看好 JCache 的一个原因它对业务代码的侵入非常低。不过这里有个坑如果数据源里真的没有这个 userIdload会返回 null那么缓存里会不会缓存 null不同实现行为不同有些实现会把空值也存进去有些不会。为了业务安全我的习惯是缓存里只存“能确认存在的对象”查不到就抛异常或者返回Optional.empty()不要让 null 在缓存层面传播。另外还有一种动态预热场景数据库里的某条记录变更了需要主动刷新缓存里对应 key。直接用userCache.put(userId, newUser)即可但要注意put 操作会不会触发CacheWriter。如果你的缓存配置了CacheWriterput 会同时写入外部存储这可能不是你想要的行为。只更新缓存不清数据源时可以考虑putIfAbsent、replace等方法仔细读一下 JCache API 的注释再动手。4. 实战中的坑与排查技巧4.1 以为 loadAll 是同步的结果请求来了缓存还是空的这是我们项目里真实发生过的一次事故。开发同学写了userCache.loadAll(keys, true, null);之后直接在主线程打印了userCache.size()结果打出来是 0。他以为是代码写错了排查了半天才发现loadAll根本没等加载完成就返回了。这不是实现 bugJSR-107 规范里明确写了loadAll是异步的。如果你调用时不传CompletionListener那加载后台默默执行业务线程拿不到任何通知。所以核心代码里我用CountDownLatch做了阻塞等待并且设置了超时时间。建议你在面试中也把这一点作为亮点主动说出来面试官一般都会追问“那你怎么知道异步加载完成了”此时能答出CompletionListener和CountDownLatch配合使用会明显加分。4.2 预热的 Key 跟业务 Key 串了台有位读者私信问过我一个问题为什么预热完成了但接口里cache.get还是查到不到后来 debug 发现他预热时用的 key 是Long类型的用户 ID但接口里从请求参数拿到 ID 后做了一次String.valueOf(userId)再拿String类型当 key 去查。CacheLong, User里根本不可能命中String类型的 key于是每次都走CacheLoader去查库。这个坑的本质是 key 类型不统一。 JCache 在创建缓存时会通过setTypes(Long.class, User.class)声明 key 和 value 的类型运行时如果 key 类型不对会直接抛ClassCastException。但如果你同时有多个缓存且定义得比较随意就不一定能及时发现。建议在工程上做一个统一的 CacheKey 封装或者至少保证所有调用方都通过同一个人口类操作同一个缓存。4.3 并发环境下重复预热打爆数据源假设你的服务有 10 个节点每个节点启动时都执行一次全量预热。如果热点 key 集是 10 万个那么启动瞬间就会有 100 万次数据库查询请求进来。数据源再扛不住就直接影响正在运行的业务。解决思路有几个第一预热数据源最好走只读从库第二控制批量加载的大小比如loadAll内部按 500 个 key 一批查询第三分布式环境可以用分布式锁比如基于 Redis 或数据库实现的锁保证同一时刻只有一个节点在执行全量预热其他节点等锁释放后只做增量拉取第四如果允许短暂的冷启动可以分批预热先热最核心的 1000 个 key后续再扩展。4.4 换了缓存 Provider 之后行为变了JCache 号称统一缓存 API但不同 Provider 对等规范的实现细节略有差异。比如同样是createCache如果缓存名已经存在Ehcache 3 会抛CacheException有些实现可能只是打印警告。再比如Cache.get未命中时自动调用CacheLoader的能力不同 Provider 对“null value”的处理策略也不完全一样。所以我的建议是代码里尽量依赖 JCache 标准 API不要调用任何 Provider 特有的类测试环境用 Ehcache生产环境用 Hazelcast 的场景一定要在预发环境做一轮完整的缓存专项验证。否则等到上线那天才发现行为不一致排障会很被动。4.5 数据一致性预热时数据源刚好在更新预热本质上是把数据库里的数据做一次快照复制到缓存。但如果预热过程中数据源发生了更新缓存里放进去的可能是旧数据。比如预热读到用户手机号是旧号码预热进行到一半时用户改了手机号那缓存里就会一直是旧号码直到过期或下次刷新。解决方式之一是预热完成之后再结合业务事件做一次增量刷新。比如数据变更时通过消息队列发送一个事件消费者收到事件后调用cache.put更新。另一个思路是让预热的写操作用putIfAbsent而正常业务变更用replace从 API 层面区分初始化写入和更新写入。当然这些都要结合具体业务场景来选不要为了用而用。5. 面试延伸这一题怎么答才加分5.1 答题结构建议如果面试现场遇到这道题个人建议按“是什么、为什么、怎么做、有哪些坑”四层来答。先一句话点明 JCache 是 JSR-107 定义的缓存标准接口再讲为什么要缓存预热然后展开代码层面的实现从CacheLoader到loadAll重点强调异步回调最后主动说出几个坑比如异步等待、并发预热、key 类型一致性等。整个回答控制在 3 到 5 分钟比较合适。不要一上来就贴大段代码面试官想听的是思路。代码可以放到说完思路之后作为补充。主动提及CountDownLatch等待异步完成以及定时刷新热点 key 这两个点往往能让面试官觉得你确实在真实场景中写过而不是只看过八股文。5.2 几个容易答错的点第一CacheLoader和CacheWriter别混淆。CacheLoader是数据加载解决缓存 miss 后从哪来的问题CacheWriter是数据写入解决缓存 put 后同步到哪的问题。两者方向相反。第二loadAll的replaceExistingValues参数很多人不知道理解成“是否允许加载”甚至“是否异步”完全偏了。它的语义是当缓存里已有某个 key 的值时要不要用加载的新值覆盖。第三JCache 缓存默认过期策略是什么不同的 Provider 默认值可能不同千万不要假设MutableConfiguration不设置 ExpiryPolicy 就等于永不过期。在 Ehcache 3 里如果不配置 Duration缓存对象是永久有效的但如果底层存储淘汰策略触发了数据还是可能被提前清掉。所以生产环境始终要明确设定过期策略。5.3 再把目光拉回这道题本身“高级 Java 每日一道面试题 2025 年 6 月 16 日基础篇”虽然是“基础篇”但背后串联起来的其实是 Java 缓存领域最核心的一条知识线标准 API、加载器、异步、批量预热、定时刷新、容量管理。真正吃透这条线你在实际项目中面对任何缓存框架无论是 JCache 还是 Spring Cache 还是自己封装的内存缓存都会多一层掌控力。如果让我说一个在这道题里最有价值的实操细节我会选“异步加载的等待机制”。工作中很多和缓存相关的事故都不是因为缓存技术本身复杂而是因为开发同学默认某个操作是同步的结果它实际是异步的或者反过来。JCache 的loadAll恰好是个典型。你在代码里加一个CountDownLatch只需要几行但能在启动瞬间挡住一大批潜在问题。根据我的经验凡是上线前做过预热验证、并留出等待时间的系统冷启动阶段的错误率都低得多。希望你下次写缓存预热时也能保留这个习惯至少让它成为一个默认动作。