1. 这道面试题到底在考什么
1.1 为什么面试官偏偏选 JCache 来问缓存穿透
今天这道题是 2025 年 5 月 24 日的基础篇题目,主题落在了 JCache(JSR-107)上。问题拆开看是两个层次:先是让你解释什么是缓存穿透,再问 JCache 里的 CacheLoader 机制怎么帮助缓解。第一问大多数人能聊几句,但第二问能答到点子上的人真不多。很多人知道 CacheLoader 是缓存未命中时加载数据的组件,却说不清楚它和缓存穿透之间到底是什么关系,更不用说给出一个能落地的方案。
面试官选 JCache 来问缓存穿透,不是心血来潮。JCache 是 Java 平台的官方缓存规范,它能考察你有没有"标准化 API"的意识:你平时写缓存是不是只会在代码里 new 一个 HashMap 或者直接怼 Redis?你有没有了解过 javax.cache 这套统一接口?而 CacheLoader 恰恰是 JCache 里跟"读缓存"强相关的组件,把缓存穿透和 CacheLoader 放在一起问,既能考察基础概念的扎实程度,又能考察你是否在真实项目里思考过"缓存查不到数据怎么办",比单纯问八股文有价值得多。
1.2 缓存穿透的一句话定义和现场还原
缓存穿透可以先一句话定义:查询一个一定不存在的数据,缓存和数据库都无法命中,导致每次请求都直接穿过缓存打到数据库。注意关键词是"一定不存在",这意味着缓存永远不会有这个 key,所以缓存层形同虚设。
我见过很多人在解释时把它和"缓存未命中"混为一谈。普通未命中的 key,第一次查数据库后会把数据写回缓存,后续请求就走缓存了。但穿透的 key 是查什么都没用的,比如用户传了一个不存在的商品 ID -1,或者攻击者随机拼 UUID 去查订单,后端逻辑是先去缓存查,没有;再去数据库查,也没有;返回空。这个过程中数据库被真实地请求了一次,而且因为是恶意遍历,每次 key 都不一样,缓存完全帮不上忙。
用代码还原就是最常见的三段式:
java复制Object value = cache.get(key);
if (value == null) {
value = database.query(key); // 永远查不到
if (value != null) {
cache.put(key, value);
}
}
return value;
问题出在 database.query 这一步。正常情况下它只负责兜底加载,但在穿透场景里,它承担了所有流量,而且这些流量最终都查不到结果,纯属无效开销。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 穿透、击穿、雪崩:先把边界划清楚
2.1 三兄弟的差异对照表
缓存穿透、缓存击穿、缓存雪崩,是面试里最爱放在一起问的三个概念。很多人背了定义,但一遇到场景题就混。我建议用这张表把边界钉死:
| 对比维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 现象 | 数据本身不存在,缓存永远查不到 | 热点 key 过期瞬间大量请求 | 大量 key 同时失效或缓存节点故障 |
| 触发时机 | 每次请求都绕过缓存 | 单个热点 key 失效的瞬间 | 批量 key 过期时间相同或宕机 |
| 打到数据库的流量 | 持续、恒定、可被恶意放大 | 集中在过期后的极短时间内 | 大面积同时冲击 |
| 典型例子 | 查不存在的订单号、随机 ID 遍历 | 某爆款商品详情 key 过期 | 缓存统一设置 10 分钟过期,同一刻集体失效 |
| 常见防线 | 空值缓存、布隆过滤器、参数校验 | 互斥锁、逻辑过期、多级缓存 | 过期时间随机化、集群高可用、限流降级 |
穿透和击穿最容易搞混。核心区分点是:穿透是"这个数据根本没有",击穿是"数据有,只是缓存刚好过期了"。一个是 key 本身非法或不存在,一个是 key 热度极高导致的瞬时竞争。雪崩则是"面"上的问题,不是单个 key,而是整体缓存层失效。
2.2 为什么穿透最难防
击穿和雪崩本质上是缓存"临时失效"问题,防的思路比较统一:让失效的影响面变小,或者让失效瞬间的流量被挡回去。但穿透难在它是"永久性 miss",攻击者可以每秒钟构造成千上万个不同的、不存在的 key,每一个都能绕过缓存直达数据库。缓存在这里完全失去了存在感,因为缓存是 key-value 存储,对不存在的 key 是没有任何先验知识的。
更麻烦的是穿透的影响会自我放大。一次数据库空查询本身可能只要几十毫秒,但在高并发下,几千个不存在的 key 同时涌过来,数据库连接池被打满,连接等待时间直线上升,后续即使有正常请求也开始排队超时,最后整条链路雪崩。所以面试时如果能说出"穿透最难的不是怎么查,而是怎么用极低成本判断 key 是否可能存在",这句话本身就是加分项。
3. JCache(JSR-107)入门:先看懂 CacheLoader 再答题
3.1 JCache 的定位和核心概念
JSR-107,也就是 JCache,是 Java 官方的缓存 API 规范,包名是 javax.cache。它不是某个具体缓存产品,而是一套统一接口,类似 JDBC 之于数据库。你用同一套标准 API 写代码,底层可以切换 Ehcache、Hazelcast、Infinispan 等不同实现,业务代码不需要改。
核心接口就那么几个:CachingProvider 是缓存提供者的入口,负责创建 CacheManager;CacheManager 管理多个 Cache;Cache 是真正存数据的容器,类似 Map 但多了缓存特有的配置能力。围绕 Cache 还有几个重要组件:CacheLoader 负责缓存未命中时从外部数据源加载数据,CacheWriter 负责缓存数据回写到外部存储,ExpiryPolicy 控制缓存条目过期策略。今天我们重点啃 CacheLoader。
理解 JCache 的定位,需要先跳出"缓存就是 Redis"的思维惯性。Redis 是分布式缓存,JCache 定义的是 API 层面的交互规范,它既可以用在本地缓存场景,也可以由 Hazelcast 这类分布式实现支撑。面试里提到 JCache,重点考察的是组件间协作关系,而不是某个具体产品怎么配置。
3.2 CacheLoader 在 JCache 里的扮演角色
CacheLoader 是 JCache 里负责"数据加载"的组件。当应用程序调用 cache.get(key) 且缓存未命中时,如果缓存配置了 CacheLoader 并且开启了 read-through(读穿透)模式,JCache 会自动调用 CacheLoader.load(key) 去外部数据源加载数据。加载成功后,缓存实现会把结果回填到缓存里,然后把值返回给调用方。
这里有个很形象的类比:CacheLoader 就像自习室里的值班同学,有人来问不会的题,他去资料室把答案翻出来,同时会把答案抄在公共黑板上,下次同样的题再来问就不用再跑一趟了。这个"抄在公共黑板上"的动作就是回填缓存。
在 JCache 中启用 read-through 需要同时做两件事:配置 CacheLoaderFactory 和 setReadThrough(true)。缺一不可。很多新手只设置了 CacheLoader,忘了把 readThrough 打开,导致 get 永远不触发 load 逻辑,这是最典型的配置坑。
3.3 一个能跑的最小示例
先引入依赖。JCache 本身只提供 API,还需要一个实现,业界常用 Ehcache 或 Hazelcast。以 Ehcache 3 为例:
xml复制<dependency>
<groupId>javax.cache</groupId>
<artifactId>cache-api</artifactId>
<version>1.1.1</version>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>3.10.8</version>
</dependency>
然后写一个带 CacheLoader 的缓存:
java复制CachingProvider provider = Caching.getCachingProvider();
CacheManager cacheManager = provider.getCacheManager();
MutableConfiguration<String, Object> configuration = new MutableConfiguration<>();
configuration.setTypes(String.class, Object.class)
.setReadThrough(true)
.setCacheLoaderFactory(() -> new CacheLoader<String, Object>() {
@Override
public Object load(String key) {
System.out.println("load from database: " + key);
return queryDatabase(key);
}
});
Cache<String, Object> cache = cacheManager.createCache("userCache", configuration);
// 缓存未命中时,会自动触发 CacheLoader.load("user:10001")
Object user = cache.get("user:10001");
这里有几个关键点:setTypes 声明 key 和 value 的类型,setReadThrough(true) 开启读穿透,setCacheLoaderFactory 提供 CacheLoader 实例。调用 cache.get 时,如果 key 不存在,JCache 实现会自动走到 load 方法,把 queryDatabase 的结果回填缓存。
4. CacheLoader 如何真正帮助缓解缓存穿透
4.1 穿透问题的本质再剖析
要理解 CacheLoader 为什么能帮忙,得先看穿透的本质。缓存穿透发生的前提是"缓存未命中"和"数据库也不存在"同时出现。但实际代码里还有一个隐形问题:业务系统里可能有很多地方都在查同一个数据源,有的走了缓存,有的没走缓存,有的查到了空结果就直接返回,有的还会做异常兜底。这种散乱的逻辑本身就会放大穿透的冲击。
举个实际例子,一个订单服务里,查订单详情可能在订单 API 里走缓存,在退款流程里又直接查数据库,在定时任务里再查一次。如果这个订单 id 不存在,同一个无效 id 会在多个入口各打一次数据库,数据库承受的不是"一次无效查询",而是"几次无效查询"。恶意场景下,攻击者不断变换 id,这个放大效应就更明显了。
所以穿透不只是"缓存没挡住"的问题,还是"数据库入口太多,缺少统一管控"的问题。CacheLoader 的价值恰恰在于它把"未命中之后怎么办"这件事集中收口了。
4.2 CacheLoader 缓解穿透的三个层次
第一个层次是统一加载入口。用了 CacheLoader 之后,业务代码不再直接关心"缓存 miss 之后我去哪里查数据库",而是全权交给缓存层。查询路径从"每个业务自己查 DB"收敛为"缓存统一触发 load",代码里不会再出现到处漏查的情况。这是最基础的价值。
第二个层次是并发合并。虽然 JSR-107 规范没有强制要求同一个 key 的并发未命中只执行一次 load,但主流实现普遍会做类似处理:同一个 key 同时来了 10 个请求,第一个请求触发 load,其余请求等待同一个加载结果,而不是 10 个请求都去查数据库。这个特性对缓解穿透很有用,因为穿透虽然是"查不到",但它同样是缓存 miss,同样会触发并发重复查询。
第三个层次才是真正"预防"穿透的空值保护。CacheLoader.load 方法返回 null 时,JCache 规范规定缓存不存储 null,get 直接返回 null。这意味着如果数据库查不到就返回 null,那么缓存依然留不住任何东西,穿透依然存在。正确的做法是让 load 方法返回一个"空值标记对象",让缓存认为这个 key 有值,从而把空结果也缓存起来。
4.3 实操:在 CacheLoader 里加入空值保护
我们用一个具体例子演示。假设用户表里不存在 id 为 10001 的用户,我们希望在第一次查询后,30 秒内同样请求不再打到数据库:
java复制private static final Object NULL_MARK = new Object();
Object load(String key) {
Object data = queryDatabase(key);
if (data == null) {
return NULL_MARK;
}
return data;
}
这里返回的不是 null,而是 NULL_MARK 对象。这样缓存会认为这个 key 有值,后续 30 秒内再来查,缓存直接返回 NULL_MARK,数据库不会再被请求。但要注意,业务拿到的也是 NULL_MARK,所以读取方要做判断:
java复制Object value = cache.get("user:10001");
if (value == null || value == NULL_MARK) {
return null;
}
return value;
更精细一点的方案是配合 ExpiryPolicy,让空值条目的过期时间远短于正常数据。ExpiryPolicy 的 getExpiryForCreation 方法能看到即将创建的 value,所以可以根据 value 是否等于 NULL_MARK 返回不同的 Duration:
java复制configuration.setExpiryPolicyFactory(() -> new ExpiryPolicy() {
@Override
public Duration getExpiryForCreation(String key, Object value) {
if (value == NULL_MARK) {
return Duration.ofSeconds(30);
}
return Duration.ofMinutes(10);
}
@Override
public Duration getExpiryForAccess(String key, Object value) {
return null;
}
@Override
public Duration getExpiryForUpdate(String key, Object value) {
return null;
}
});
这样做的好处是:正常的用户数据缓存 10 分钟,空值标记只缓存 30 秒。30 秒内拦截掉重复的无效请求,30 秒后如果攻击者换了新 key,最多也就是重新触发一次空查询,不会形成持久性穿透压力。
面试时把这一层讲清楚,已经比绝大多数候选人强了。但如果你能补一句"空值标记只能缓解,不能根治,因为攻击者可以不断生成新 key,所以还需要布隆过滤器这类存在性判断方案",那就是标准的高分答案。
5. 面试加分:缓存穿透的完整防线
5.1 空值缓存:最简单也最容易犯错
空值缓存是缓解穿透最朴素的手段,本质上就是在缓存里存一个"查不到"的结果。很多团队不用 JCache 也会自己做:查数据库没查到,就往缓存里写一个空字符串或特定占位符,设置 30 到 60 秒过期。
这里面有几个特别容易犯错的地方。第一个是空值的过期时间不能太长,否则数据后来真的创建了,缓存还是返回空,出现数据不一致。第二个是不要把"确定不存在"和"查询异常"混在一起缓存。数据库超时、服务熔断导致的没查到,不代表数据不存在,如果这种结果也写进缓存,等于把故障固化下来。第三个是空值本身也是缓存条目,海量恶意 key 会让缓存空间被无效条目占满,所以在写空值时最好设置一个相对短的 TTL,防止内存被垃圾 key 塞爆。
5.2 布隆过滤器:从存在性上挡第一刀
布隆过滤器是面试里绕不开的方案,也是根治穿透的好办法。它的核心思想很直观:用一个 bit 数组和多个哈希函数,在极低的内存开销下判断某个 key 是否可能存在。把所有合法 id 预先放入布隆过滤器,每次查询前先问过滤器"这个 key 存在吗"。如果过滤器说不存在,那肯定不存在,直接拦截;如果过滤器说存在,再去查缓存和数据库。
布隆过滤器的一个关键特性是:它有一定的误判率,但它只会把"不存在的误判为存在",绝不会把"存在的误判为不存在"。也就是说,它能挡住确定不存在的 key,但可能放进来少量假阳性请求,不会伤害正常数据。代价是有误判率和删除困难两个问题,想支持删除要用 Counting Bloom Filter,但成本会上去。
实际落地时,布隆过滤器一般放在缓存层前面。请求进来,先走布隆过滤器判断 key 是否存在,不存在直接返回,不碰缓存和数据库。这一刀挡住了绝大多数穿透流量。适合数据总量可控、id 集合相对稳定的场景,比如商品 id、用户 id 这种有固定生成规则的数据。
5.3 几种方案怎么组合
缓存穿透的防线从来不是单点,而是层层设防。我一般建议这样组合:
- 第一层:参数校验。id 格式、长度、取值范围等不合法直接拒掉,这能挡住大量简单攻击。
- 第二层:布隆过滤器。对合法范围内但可能不存在的 id 做存在性判断,从源头拦截穿透。
- 第三层:缓存查询。正常的缓存命中在这一层直接返回,这是性能核心。
- 第四层:CacheLoader 或空值缓存。缓存未命中时,如果没有被布隆过滤器拦截,先查数据库,查不到就写一个短 TTL 空值标记。
- 第五层:限流与熔断。最坏情况下数据库还是被打到了,不能让数据库被拖垮,要有 QPS 限制和快速失败机制。
每层都有各自的成本和适用边界。布隆过滤器有内存开销和误判率,空值缓存有过期一致性问题,限流会牺牲一点可用性。面试时能说出"没有银弹,要根据场景组合",比只背方案名高级得多。
6. 实战踩坑与排查技巧
6.1 我在 CacheLoader 上踩过的坑
先说第一个坑:load 方法里抛异常。如果数据库查询抛了 RuntimeException,有些缓存实现会把异常直接抛给调用方,同时不缓存任何东西。这意味着同一个 key 每次请求都会重新执行 load,如果数据库持续异常,那就是把异常当成穿透来打了。在我的项目里,load 方法内部一定会做 try-catch,区分"业务上确实不存在"和"临时查询失败",临时失败直接抛出或者返回 null,让上层走降级逻辑,而不是把异常当空结果。
第二个坑就是前面提到的返回 null 的问题。我最初以为 load 返回 null 后,缓存会把这个"空"存下来,后来看监控发现缓存命中率始终是 0,才知道规范里根本没这么设计。javax.cache.CacheLoader 的方法签名虽然允许返回 null,但返回 null 意味着"这次没有加载到数据,缓存不保存结果",所以空值保护必须靠返回标记对象来做。
第三个坑是 readThrough 没开。有同事写了完整的 CacheLoaderFactory,结果 get 还是不走 load,排查半天才发现 MutableConfiguration 里少了 setReadThrough(true)。这个属性名很容易被忽略,因为默认值是 false。面试时可以提一句,这会显得你确实踩过配置坑。
第四个坑是 ExpiryPolicy 的粒度。如果你用的是前面那种自定义 ExpiryPolicy,判断 value 是不是 NULL_MARK 来决定过期时间,要注意不同缓存实现对这些方法的调用频率和语义可能有细节差异,写代码前一定要查对应版本的文档,不能默认所有实现行为完全一致。
6.2 线上怎么快速定位到底是不是穿透
很多系统被穿透打挂之后,研发第一反应是"数据库慢",但真正原因很难一眼看出来。我总结一套快速定位方法。
第一步看缓存命中率。正常情况下缓存命中率应该稳定在一个高位,如果一段时间内命中率突然从 90% 掉到 30%,同时 DB 的 QPS 上升,优先怀疑穿透或击穿。第二步看 DB 的慢查询日志。穿透的典型特征是一条简单的按主键查询,却出现大量重复执行记录,而且 key 都不存在或者看起来像随机字符串。第三步在业务日志里统计 cache.get(key) 的 key 分布。如果大量 key 具有相同的非法特征,比如负数 id、超长字符串、UUID 拼接,那基本可以确定是遍历攻击。
如果已经定位到是穿透,可以临时用一个简单的办法止血:在入口处记录"查不到数据"的 key,维护一个最近 N 分钟不存在的 key 集合,在这个时间段内相同 key 直接返回空。这本质上是进程内的小型空值缓存,虽然粗糙,但能在几分钟内把数据库流量降下来,给布隆过滤器和空值缓存方案争取落地时间。
6.3 这道题的标准答案和隐藏加分点
最后给一个可以直接背下来的答题框架,适合面试时 3 到 5 分钟的口述:
- 一句话定义:缓存穿透是查询一个一定不存在的数据,缓存都无法命中,请求直接打到数据库,造成数据库无效压力。
- 用例子还原:比如查询一个不存在的用户 id,缓存查不到,数据库也查不到,每次都要查数据库。
- 点出三种典型场景:恶意攻击、随机 key 遍历、业务逻辑漏判。
- 引出 CacheLoader:JCache 中 CacheLoader 是 read-through 模式的核心组件,当 cache.get 未命中时会自动调用 load 从数据源加载。
- 说明 CacheLoader 如何缓解:一是统一未命中后的加载入口,二是在并发场景下合并相同 key 的加载请求,三是我们可以让 load 方法对查不到的数据返回空值标记对象,配合短 TTL 的 ExpiryPolicy,把空结果也缓存起来。
- 主动补一句局限:CacheLoader 只能缓解不能根治,因为攻击者可以不断生成新 key,所以真实场景还需要布隆过滤器、参数校验、限流等手段组合。
这套话术的巧妙之处在于,它不是把 CacheLoader 吹成万能药,而是把它放回到"缓存链路中的一环"来回答。面试官听到你能自己说出"只能缓解不能根治",就知道你不是死记硬背,而是真的理解了问题本质。这一步才是从"会背八股"到"有架构意识"的分水岭。
在我自己面试候选人时,能主动说出 CacheLoader 返回 null 不会缓存空值这个细节的,基本都会给过。因为这种细节不翻文档不踩坑,很难知道。反过来,能把这几层防线说得头头是道、却说不出一个自己在生产环境里用过的配置参数的,多半是题库型选手。面试题本身是死的,但它背后考察的"你有没有在真实系统里把缓存链路打通"这件事,才是永远躲不开的隐藏考点。
