几年前的面试大家喜欢问“缓存穿透怎么解决”“Redis雪崩怎么处理”这两年风向明显变了越来越多的团队开始抠规范本身比如今天要聊的这道题JCacheJSR-107中 loadAll 和 load 的用途是什么它们如何与 getAll 配合这道题问的是基础篇但真能答透的人不多。大多数人用过Caffeine、Redis却不一定看过 JCache 的CacheLoader接口更少有人会把getAll和loadAll的联动关系说清楚。这道题好在哪里它不考你背 API而是考你对“缓存回源”这件事有没有体系化的理解。load是单键回源loadAll是批量回源getAll则是批量读取的缓存入口。三者一串联就是一个完整的 read-through 模式。无论你是在准备高级 Java 面试还是要为团队设计一套缓存加载层把 JCache 这套机制吃透都能直接迁移到自己的项目中。这篇文章我会从 JSR-107 的设计逻辑讲起把load/loadAll的签名、行为差异、边界条件全部拆开再给出和getAll配合的完整代码示例最后整理一些我实际踩过的坑。内容偏实践适合正在准备面试的 Java 工程师也适合正打算引入 JCache 做缓存抽象层的架构师。1. JCache规范与CacheLoader的设计逻辑1.1 JSR-107到底统一了什么JSR-107 是 Java 官方的缓存规范定义了一套标准的缓存 API也就是我们常说的 JCache。它不是什么具体的缓存产品而是一层抽象业务代码通过javax.cache.Cache、javax.cache.CacheManager、javax.cache.configuration.Configuration这些标准接口操作缓存底层可以用本地内存、分布式内存甚至任何你喜欢的数据结构来存储。这套抽象最大的价值是让“缓存”成为可替换的基础设施。以前项目里用ConcurrentHashMap做缓存后来换了Caffeine就发现 API 不完全兼容再换又要改一遍业务代码。而 JCache 统一了接口之后缓存产品之间切换的成本只存在于初始化配置业务调用方几乎不用动。当然它也不是没有缺点为了兼容各种实现规范留了很多可选项导致某些方法在不同实现下的行为存在差异。CacheLoader就是典型的例子很多实现都支持但细节处理各有不同。JCache 定义了几个核心扩展点CacheLoader是其中最重要的一个。它负责解决一个关键问题当缓存里没有你要的数据时数据从哪来这个问题的答案就是 read-through 模式——缓存自己去找底层数据源而不是业务代码手动回源。1.2 CacheLoader在缓存模式中的角色read-through的落地点很多开发者第一次理解 read-through靠的是Caffeine的CacheLoader参数。其实 Caffeine 的这套设计思路就是借鉴了 JCache 规范的精神。在 read-through 模式下业务代码调cache.get(key)如果缓存命中就直接返回缓存值如果未命中缓存引擎会调用用户提供的加载函数去数据库、或者远程服务把数据查出来放进缓存再返回给调用方。整个过程对业务代码透明业务方只感觉“这个缓存好像什么都查得到”不需要在业务代码里写“先查缓存、没查到再查库、查到后写回缓存”这三行样板代码。这个样板逻辑被收敛到了CacheLoader实现里也就是我们今天要分析的核心对象。JCache 中的CacheLoader接口定义如下public interface CacheLoaderK, V { V load(K key) throws CacheLoaderException; MapK, V loadAll(Iterable? extends K keys) throws CacheLoaderException; }人们常困惑的地方就在这里既然已经有了get为什么还要getAll既然已经有了load为什么还要loadAll这其实对应了两种完全不同的使用场景单个键读取和批量键读取。缓存接口要满足这两种场景加载器也得提供对应的两个维度。下面我就把这两个方法的细节逐一拆开。2. 拆解CacheLoader的load与loadAll2.1 load(K key)单键回源的设计细节load(K key)是最简单的回源入口方法接收一个键返回对应的值。它解决的是单键未命中时的加载问题。比如在用户详情页请求某个用户 ID 对应的信息就可以用这个方法。这个方法有一个容易被忽略的细节返回值允许是null。规范里对null的处理比较微妙——返回null并不意味着加载失败而是表示“该键不存在”。举个例子如果你的用户表里没有 ID 为 99999 的用户load(99999)返回null就代表“查过了没有这个人”。此时 JCache 实现通常不会把这个null缓存起来下一次请求同样的键还是会再次触发load这也是一些面试官喜欢追问的“缓存穿透”问题的源头之一。还有一个常见的误解load只会在缓存未命中时被调用吗严格来说这取决于客户端调用的哪个 API。当你调用cache.get(key)且缓存不包含该键时JCache 实现会触发CacheLoader.load(key)来补漏。但如果你主动调用cache.load(key, ...)无论缓存里已有这个键还是永远不会读都可以强制触发加载。这一点在后续章节里我会展开。关于异常如果load内部抛了异常JCache 会把未受检异常包装成CacheLoaderException抛给调用方。实际开发中要注意数据库短暂不可用这类异常不应该被静默吞掉否则缓存层会把下游故障伪装成“键不存在”导致业务逻辑做出错误判断。2.2 loadAll(Iterable? extends K keys)批量回源的差异点loadAll(Iterable? extends K keys)接收一个键集合返回一个MapK, V。它的设计意图非常明确一次性为多个键加载数据减少与底层数据源之间的交互次数。这个方法和load的一个关键差异藏在其返回值里。loadAll返回的Map不需要完整覆盖传入的 keys。也就是说你传入 100 个键但底层数据源只存在 90 个你的实现完全可以只返回 90 条记录剩下 10 个“不存在”的键不放在返回 Map 里即可。JCache 规范允许这种部分返回实现方也不会因为缺了某个键而报错。正是因为这一点loadAll的实现可以做得非常高效对数据库执行一条SELECT ... WHERE id IN (...)查询把结果按 ID 组装成 Map缺失的 ID 自然就不在 Map 中省去了逐键判断“是否存在”的多余代码。还有一个细节值得注意loadAll的触发时机通常不会是一个单独的loadAll调用而是由getAll方法在检测到部分键未命中时触发的。这里就引出了最核心的衔接问题我会在下一章节详述。2.3 方法签名与返回值的关键约定把两个方法放在一起对比会更清晰对比维度load(K key)loadAll(Iterable? extends K keys)输入粒度单个键键集合返回类型V单个值或 nullMapK, V可部分返回典型触发场景cache.get(key) 未命中cache.getAll(keys) 部分未命中底层数据源交互1 次单键查询1 次批量查询null 值语义表示键不存在不包含在返回 Map 中即视为不存在异常处理抛 CacheLoaderException抛 CacheLoaderException从表格可以总结出实现时的两个铁律第一load返回 null 是合法的调用方要有处理“查无此键”的准备第二loadAll返回的 Map 不必包含全部请求键值缺失键天然代表不存在二者在语义上完全一致。我在项目里见过不少人把loadAll写成“遍历 keys 逐个调load再组装成 Map”功能上没错但完全没有发挥批量加载的性能优势。正确的姿势是在数据源层面尽量把多个键的查询合并成一条批量语句比如数据库里的IN查询、Redis 里的MGET、HTTP 服务里的批量接口。这也是loadAll存在的终极意义——性能。3. getAll与loadAll的联动机制3.1 getAll的语义先查缓存再补缺失在看 JCache 的getAll之前我们先看看它和Map的区别。JCache 接口里getAll(Set? extends K keys)返回的是MapK, V但它的返回内容并不保证包含所有请求键。只有缓存中“确实存在”的键才会出现在返回结果里不存在或者尚未加载的键不会被塞进返回值。这个语义和普通的Map.get完全不同。普通 Map 里你查一个键如果没有就是 null你无从分辨这个 null 是“没这个键”还是“存了 null”。JCache 的getAll则明确表示返回的 Map 里只包含命中的键值对。所以你可以从返回 Map 的keySet()大小推断出有多少键没有命中缓存。比如传入 100 个键缓存里只有 30 个getAll直接返回 30 条记录剩余 70 个键去哪了这就看实现是否配置了CacheLoader。配置了就会触发loadAll没配置缺失的 70 条就直接不返回由业务层自行决定怎么补。这个“先查缓存、再按需回源”的行为是理解本题目最核心的底层逻辑。3.2 getAll是如何触发loadAll的JCache 规范对getAll和loadAll的耦合方式说起来其实很直白getAll内部实现第一步先遍历传入的 keys从缓存存储中查出命中的键值对第二步把没有命中的键收集成一个缺失键集合第三步如果配置了CacheLoader就把这个缺失键集合交给loadAll去批量加载第四步把loadAll返回的数据写回缓存第五步一次性地将“原本命中的数据”和“新加载的数据”合并成一个完整的 Map 返回给调用方。关键点在于第三步loadAll接收到的不是调用getAll时的完整 keys而是去重之后、真正未命中的那部分键。这样的设计非常聪明它避免了重复加载已经缓存的数据也保证了对底层数据源的最小访问。举个例子缓存里已经有 A、B 两个键你调getAll([A, B, C, D])实现只会带着[C, D]去调loadAll而不是[A, B, C, D]。这种“按缺失键回源”的机制和Caffeine里的getAllPresent加loadAll组合非常相似如果你熟悉 Caffeine理解 JCache 的这套联动就毫无障碍。3.3 为什么不建议getAll逐个调load有些同学可能会想既然已经有load(K key)那getAll拿到缺失键列表后循环调load不就行了为什么还要单独设计一个loadAll原因只有一个性能以及封装性。性能层面的道理很直观假设缺失 50 个键逐个调load就意味着 50 次数据库往返。而使用loadAll合并成一条IN查询通常一次往返就能搞定。在分布式缓存场景下这种批量加载的优势更明显网络 RTT 的次数会从 N 次降为 1 次。封装性则体现在接口设计上。CacheLoader的实现者只关心“给我一批键我还你一批值”而不必关心缓存内部何时、为何需要加载。如果让getAll循环调load虽然也能完成任务但这个循环逻辑就会固化在缓存实现里无法让上层针对“批量查询”做专门优化。JCache 把loadAll单独抽出来本质上就是给实现者留出批量优化的空间让“批量”这件事从 API 层面得到表达。4. 从零实现一个CacheLoader并接入JCache4.1 选型怎么选JCache实现由于 JCache 只是规范实际使用前必须选一个实现。近年主流的选择包括完全实现 JCache 规范的开源缓存库以及部分支持该规范的内存缓存框架。我个人的建议是如果你的项目已经依赖了某个成熟的缓存框架优先看它是否支持javax.cache的CachingProvider如果是从零选型直接选择 API 完整、文档丰富、社区活跃的实现避免后续踩到兼容性深坑。初始化代码一般在Maven中增加依赖后用Caching.getCachingProvider()获取 Provider再通过CacheManager创建Cache。为了避免无谓的争论本文以最普通的 JCache 实现作为示例所有代码在标准 JSR-107 API 层面都是通用的。你在自己项目里替换成任何标准实现代码几乎不用改。4.2 完整代码示例模拟项目X的CacheLoader下面用一个虚拟的场景“模拟项目X”来演示。这个项目的业务是从一张订单表里读取订单详情我们希望在缓存未命中时自动按订单号批量回源数据库。先定义订单对象public class Order { private String orderId; private BigDecimal amount; private String status; // 构造器、getter、setter 省略 }实现CacheLoaderpublic class OrderCacheLoader implements CacheLoaderString, Order { private final OrderRepository orderRepository; public OrderCacheLoader(OrderRepository orderRepository) { this.orderRepository orderRepository; } Override public Order load(String orderId) { // 单键回源直接查一次数据库 return orderRepository.findById(orderId); } Override public MapString, Order loadAll(Iterable? extends String orderIds) { SetString ids new HashSet(); for (String id : orderIds) { ids.add(id); } // 批量回源一条 IN 查询搞定 ListOrder orders orderRepository.findByIds(ids); MapString, Order map new HashMap(); for (Order order : orders) { map.put(order.getOrderId(), order); } // 不存在的订单不会出现在 map 中 return map; } }然后初始化 JCache 缓存并配置CacheLoaderpublic class CacheInitializer { public CacheString, Order createOrderCache(OrderCacheLoader loader) { CachingProvider provider Caching.getCachingProvider(); CacheManager cacheManager provider.getCacheManager(); MutableConfigurationString, Order config new MutableConfiguration(); config.setTypes(String.class, Order.class); // 核心关键行让 CacheLoader 与 Cache 绑定 config.setCacheLoaderFactory(() - loader); // 是否开启 read-through若为 false则不会自动调 Loader config.setReadThrough(true); // 是否需要写入缓存如果业务需要可以直接打开 config.setWriteThrough(false); return cacheManager.createCache(orderCache, config); } }注意这里的两个开关setCacheLoaderFactory负责提供CacheLoader实例setReadThrough(true)则决定缓存未命中时是否自动调用该 Loader。很多人配了 Loader 却忘了打开 read-through导致代码写得很完整缓存却从不回源。4.3 配置与缓存初始化细节初始化完成后业务代码就可以用get和getAll来读数据了CacheString, Order orderCache initializer.createOrderCache(new OrderCacheLoader(repository)); // 单键读取未命中时自动调 load(1001) Order order orderCache.get(1001); // 批量读取未命中键自动调 loadAll(缺失键集合) SetString keys new HashSet(Arrays.asList(1001, 1002, 1003)); MapString, Order orders orderCache.getAll(keys);需要特别留意的是getAll返回的 Map 可能小于请求键集合。比如 1003 在数据库里根本不存在那么 orders 里只会有 1001 和 1002 两条记录。业务侧在偏乐观的场景下可能意识不到这一点因此拿到结果后最好比较一次orders.size()与keys.size()对缺失键单独做提示或兜底。另外JCache 的Cache接口里还有两个主动加载的方法load(K key, boolean replaceExistingValue, ...)和loadAll(SetK keys, boolean replaceExistingValue, ...)。它们和get、getAll不同属于“主动调用加载器”并不关心当前缓存里有没有数据。replaceExistingValue参数决定已经存在的缓存值是否要被覆盖。实际项目中我常用loadAll做缓存预热应用启动后直接把热点数据批量灌入缓存避免第一波请求全部穿透到数据库。5. 常见问题与踩坑实录5.1 loadAll返回空Map导致的问题有次在模拟项目X 的压测环境中我发现一个诡异现象getAll返回的数据经常少了一部分但数据库里明明有记录。排查到最后发现我在loadAll里写了一个 bug——把findByIds返回结果组装成 Map 时键用错了字段。更典型的坑是返回空 Map。如果你的loadAll在业务异常时直接返回Collections.emptyMap()那getAll会认为所有请求键在数据源里都不存在缺失键会被安静地丢弃。高层业务根本不知道底层发生了什么只会看到一个不完整的结果集。这种静默失败是缓存层最隐蔽的问题。我的建议是CacheLoader内部对异常要透明处理。数据库超时就要抛出异常让上层感知到加载失败只有“业务上确实查不到”时才返回空集合或 null。这两者绝不能混为一谈。5.2 键集合过大导致的批量回源压力getAll传入的 keys 数量是没有上限的如果调用方不小心传了几万个 IDloadAll就会带着几万个 ID 去做一次巨大的IN查询底层数据库大概率会被打爆。经验做法是给批量加载设置一个规模上限比如在getAll外层按 500 个键一组分批调用public MapString, Order getOrdersSafe(SetString keys) { MapString, Order result new HashMap(); ListString keyList new ArrayList(keys); for (int i 0; i keyList.size(); i 500) { int end Math.min(i 500, keyList.size()); SetString batch new HashSet(keyList.subList(i, end)); result.putAll(orderCache.getAll(batch)); } return result; }这里分批的粒度取决于实际数据库和数据源的能力并没有绝对标准。我个人习惯先设 500 做压测再根据平均响应时间调整。5.3 并发场景下getAll与loadAll的线程安全JCache 的Cache接口是线程安全的多个线程同时调用getAll不会破坏内部数据结构。但线程安全问题会出现在你自己的CacheLoader实现里。如果你的loadAll内部使用了状态变量或者底层数据源连接不是线程安全的就可能在并发触发时出问题。更隐蔽的是重复加载十个线程同时getAll缺失键理论上你希望只有一个线程去数据库加载其余线程等待结果但 JCache 规范并没有强制实现“单一回源”功能也就是防击穿机制各实现的行为差异很大。针对这一点我的建议是如果你的业务对缓存击穿特别敏感不要完全依赖 JCache 规范层面的保证可以在CacheLoader里自己做请求合并或者选择一个在实现上支持回源合并的缓存框架。我总是把规范当作“约定底线”而不是“性能保证”。下面把这个章节踩过的坑汇总一下方便大家速查现象可能原因解决思路getAll 返回结果缺失loadAll 返回的 Map 键对不上检查 loadAll 组装 Map 时使用的键数据源异常但上层无感知loadAll 内部吞掉异常并返回空 Map异常统一抛出只在“查无数据”时返回空数据库负载突然飙升getAll 传入超大 keys 集合调用方分批调用 getAll控制单批规模并发下数据库回源次数爆炸多个线程同时未命中同一批键在 CacheLoader 内做请求合并或选择支持合并的实现6. 从面试题到实战我的几点体会这道题看起来在考 JCache 的 API实际上考的是你能不能把“缓存未命中”这件事想清楚。我见过太多候选人能背出CacheLoader接口的方法名却说不清getAll内部触发loadAll时只传缺失键、以及loadAll允许部分返回这两条关键语义。这两个细节恰恰是 read-through 模式能否做到“高性能回源”的关键。拿我实际的经历来说最初做缓存封装时我也犯过循环调load的错误后来看了 JCache 规范才意识到loadAll的定位。当我把所有单键回源改成批量回源接口耗时直接从原本的 800 毫秒降到 120 毫秒原因很简单数据库的IN查询比 20 次单查快太多了。这个性能差距在分布式环境下会被进一步放大。面试答这道题你可以这样收尾“load和loadAll是 CacheLoader 提供给缓存的回源能力前者负责单键精确加载后者负责批量键的高效加载getAll会先查缓存再把缺失键聚合成集合交给loadAll最后合并结果返回。这个设计本质上是把 read-through 模式标准化让业务方无感命中和回源。” 如果能再补一句“loadAll返回的 Map 可以小于请求键集合表示那些键不存在”面试官通常会觉得你是真看过规范的。最后再分享一个小技巧如果你想看某个 JCache 实现具体怎么把getAll和loadAll串起来直接去翻它的源码搜索getAll和loadAll的调用链比读文档直观得多。缓存规范的设计哲学往往就藏在实现类的这几行代码里。