
最近帮几个朋友做面试复盘发现只要简历里写了Java后端开发十个面试官至少有八个会问到Caffeine。Spring Boot 2.0之后它取代了Guava Cache成为默认本地缓存而且背后藏着一套名为W-TinyLFU的淘汰策略八股考点密度极高命中率几乎是百分百。我自己也整理过一份Caffeine面经解答版这篇就把它完整展开不仅是把答案背出来而是把每个考点背后的设计动机讲透这样面试时才能从背八股变成讲设计。1. 为什么面试官盯上Caffeine先看清它在缓存体系里的位置1.1 Caffeine到底解决了什么问题Caffeine是一个基于Java 8的高性能进程内缓存库常被拿来和Guava Cache对比。它在Spring Boot 2.0后成为spring-boot-starter-cache的默认本地缓存实现性能上比Guava Cache有量级上的提升。很多人只把它当成把数据放内存里的小工具但面试官真正想听的是你能不能分清楚本地缓存和分布式缓存的边界。一条典型的后端访问链路是浏览器-Nginx-应用服务-数据库。数据库是最终一致性源头Redis是分布式缓存层而Caffeine属于JVM进程内的本地缓存层。Redis每次get都要走网络IO一般耗时在0.1到1毫秒之间Caffeine是纯内存操作命中后耗时在微秒甚至纳秒级别差一到两个数量级。所以单实例内部的高频读、能容忍短暂不一致的数据用Caffeine需要多实例共享、需要跨节点一致性的数据用Redis。这个分层的回答本身就是面试加分项。1.2 面试官真正想考察的三个能力Caffeine相关的追问通常指向三个能力第一有没有缓存分层意识还是只会把数据一股脑塞进缓存。第二对淘汰算法是否有底层理解。很多人张口就是LRU但Caffeine不用纯LRU而是W-TinyLFU为什么替换、替换后解决了什么问题这是核心考点。第三有没有踩过真实项目的坑比如缓存穿透、缓存雪崩、缓存与数据库的一致性怎么处理。这三个能力对应面经里的三个板块算法设计、配置使用、工程实践。接下来的内容会按这个逻辑逐个拆解从最硬的W-TinyLFU开始。2. W-TinyLFU淘汰算法本地缓存面试最硬的考点2.1 经典LRU和LFU为什么都有明显短板LRU的全称是Least Recently Used淘汰最久未访问的条目。它的实现简单用一个双向链表加哈希表就能做到O(1)操作在各种缓存框架里都有应用。但LRU有个致命问题一次突发的冷数据扫描就能把真正的热点全部挤出缓存。比如一个电商服务运营跑了个批量任务把一万个冷门商品ID全部查询了一遍这一万个冷门数据会占据缓存位置把真正高频访问的热门商品挤出去。等热门商品下一次被访问时缓存里没有它只能重新查DB命中率瞬间暴跌。这个现象叫缓存污染。LFULeast Frequently Used按访问频率淘汰似乎能抵抗突发流量。但LFU有两个麻烦一是需要精确记录每个key的访问次数一个long类型的计数器就是8字节几十万个key就是几MB的额外开销对本地缓存来说太重了二是历史频率高的老古董很难被新热点替代比如一个上周爆火的商品频率很高这周的真正热点反而挤不进来这叫热点漂移能力弱。纯LFU的适应性并不好。2.2 TinyLFU的频率估计Count-Min Sketch怎么省内存Caffeine没有用精确计数而是采用一种叫Count-Min Sketch的近似计数结构。它的思路和布隆过滤器很像准备d个计数器数组每个数组配一个哈希函数。写入key时用d个哈希函数算出d个位置各自把计数器加1查询频率时取d个位置的最小值作为估计值。因为哈希冲突会导致计数被高估但不会低估所以取最小值是相对保守的估计能有效控制误差。Caffeine在实现中还做了进一步的内存压缩。计数器不是用long或int而是用4bit的小计数器最大值只能记到15。单个key访问超过15次计数器就封顶不再增长。加上哈希数组本身按比例分配内存整体额外开销被压到很低。每个key平均只占几十字节而不是精确计数下的8字节乘以多路。光有Count-Min Sketch还不够因为计数只增不减的话旧数据永远压着新数据。Caffeine设计了一个周期性衰减机制维护一个采样窗口当访问量达到阈值时把所有计数器整体右移一位相当于所有key的历史访问次数减半。这样新热点才有机会在频率上超过旧热点。面试时能说出4bit近似计数定期衰减这两个点基本就能证明你确实读过相关机制。2.3 Window区与Protected区两段式的独创设计如果只用TinyLFU新数据初始频率很低可能永远进不了缓存这叫新生数据被饿死。Caffeine的解法是增加一个Window区即窗口缓存。新数据一律先进入Window区Window区默认占整个缓存预算的1%。Window区满了之后候选数据才进入主缓存区Main区。Main区又分成两部分Probation区试用区和Protected区保护区。默认分配是80%给Protected区20%给Probation区。Probation区里的数据如果再次被访问会晋升到Protected区Protected区满了以后尾部的数据会降级回Probation区。当Probation区也要满、需要放入新候选者时会拿新候选者与当前Probation区里最差的那个受害者做频率比较频率高的一方留下。这套机制的设计意图非常明确。第一Window区专门用来吸收突发流量相当于一个隔离缓冲批量扫描的冷数据只在Window区短暂停留不会污染整个主缓存。第二Protected区保护真正的长期热点频繁访问的key很难被淘汰。从这个角度回答为什么Caffeine能同时应付突发流量和热点迁移比单纯抛名词有说服力得多。2.4 一个场景串起完整流转用一个具体场景把这些机制串起来。假设maximumSize配置为10000那么Window区大约有100个位置。某短视频平台出现一个爆款视频大量请求打过来这个key先进Window区然后随着访问频率上升进入Probation区同批数据触发晋升条件后进入Protected区。与此同时运营在后台跑批量任务把一批老视频的key全部访问了一遍。这批老视频虽然也被加载进缓存但它们在Window区里还没站稳很快就被更高频率的实时热点挤出对主缓存几乎没有影响。反过来如果昨天的爆款今天没人看了它在Protected区的频率优势会被定期衰减机制逐渐削弱最终让位给今天的新热点。这个新数据有窗口缓冲、热点有保护区、历史影响会衰减的组合就是W-TinyLFU的核心设计哲学。3. Caffeine的加载与失效四种API背后的设计取舍3.1 Cache、LoadingCache、AsyncCache怎么选Caffeine提供三类主要接口Cache、LoadingCache、AsyncCache。基础Cache接口是手动方式get的时候传一个lambda例如cache.get(key, k - loadFromDB(k))。LoadingCache在构建时传入CacheLoader之后的get、getAll、refresh都基于这个loader自动完成。AsyncCache把加载过程异步化返回CompletableFuture适合DB查询耗时较长的场景。面试里有个高频题cache.get(key, k - load(k))和先get再判断null、然后手动put有什么区别区别在于原子性。Caffeine的Cache.get(key, mappingFunction)对同一个key的并发调用是合并的多个线程同时请求同一个缺失key时只有一个线程真正执行loader加载其余线程等待结果。如果手动先get、发现null、再load、再put同一时间多个线程可能各自加载一遍导致重复查询数据库。这在面试里叫缓存击穿问题的一种解法几乎是必问点。3.2 expireAfterWrite与expireAfterAccess的差异expireAfterWrite是写入后固定时长过期到期后数据失效任何读操作都会触发重新加载。适合数据变化相对固定、追求强时效性的场景。expireAfterAccess是最后一次访问后计时每次访问都把过期时间往后推适合读多写少、希望热点数据常驻的场景。面试深挖点是expireAfterWrite的惊群效应。如果一批key在同一个时间窗口内写入它们也会在同一个时间点附近到期。到期后若大量不同key同时被访问每个key的缺失都会并发触发DB查询瞬时DB压力很大。Caffeine官方建议用refreshAfterWrite配合expireAfterWrite来规避而不是简单把过期时间调大。3.3 refreshAfterWrite的正确打开方式refreshAfterWrite的语义是过期后刷新它解决的是过期瞬间的并发冲击问题。通常和expireAfterWrite搭配比如expireAfterWrite10分钟refreshAfterWrite5分钟。前5分钟内缓存直接返回5分钟到10分钟之间某个线程首次访问该key时触发刷新操作同时其它线程直接拿到旧值不阻塞这种模式叫stale-while-revalidate。它能有效避免缓存雪崩时的惊群效应代价是短时间内可能读到旧数据。如果数据一致性要求非常高就别用refresh直接用expireAfterWrite。还有一个实现细节同步刷新时第一个触发线程会阻塞在loader执行中异步刷新使用AsyncCacheLoader则完全不阻塞调用线程。生产环境我倾向于异步刷新把加载任务交给独立线程池避免业务线程卡在可能耗时几百毫秒的DB查询上。这部分如果面试官继续追问你可以说refreshAfterWrite在官方文档里明确是可以配合异步loader使用的异步刷新模式基本能镇住大部分追问。4. 参数配置的面试必答题数值怎么定、配置怎么聊4.1 核心参数逐个拆解Caffeine的参数很多但面试常聊的就那几类。我把它们整理成一个速查表参数作用备注initialCapacity预分配哈希表桶数量减少后续扩容开销maximumSize缓存条目数上限超过后触发淘汰maximumWeight权重上限配Weigher实现适合条目大小不均的场景expireAfterWrite写入后固定时长过期所有key统一过期expireAfterAccess最后一次访问后计时过期热点自动续期refreshAfterWrite写入后固定时长触发刷新返回旧值异步加载新值recordStats开启命中率等统计线上调优依赖它weakKeys / softValues键弱引用、值软引用会改变相等性语义慎用4.2 从QPS倒推参数的完整思路面试官问到参数配置时最怕听到我随便配的或我设的10000。正确的回答方式是把参数和业务数字绑定。举个例子一个商品详情接口QPS峰值2000单次DB查询耗时50毫秒。如果不加缓存DB要扛2000 QPS加了缓存假设命中率90%DB只承担200 QPS压力低一个量级。缓存大小怎么定看业务数据规模。假设活跃SKU数量是1万那maximumSize给2万就够既能完整容纳活跃数据又留出一定缓冲。过期时间怎么定商品价格不是秒级变动的5分钟内的缓存对用户体验几乎无感那expireAfterWrite就给5分钟同时希望避免到期时惊群refreshAfterWrite给1分钟或者2分钟让缓存提前刷新。这样一套推导下来面试官会认为你真的在做系统设计而不是在背参数。还需要算内存。一个商品对象按500字节估算2万条大约10MB加上JVM对象头、哈希表开销摊到几百MB堆里完全可接受。如果换成大对象缓存就得用maximumWeight按权重控制而不是只看条数。能把对象大小和堆内存纳入考虑回答的完整度会更高。4.3 弱键、软值、统计开关这些容易忽略的点weakKeys和softValues在面试里偶尔被问到。weakKeys使用弱引用持有key允许GC回收key适合key是业务对象、且不想因为缓存持有强引用导致内存泄漏的场景。但要注意弱键模式下key的相等性从equals变成同一个字符串字面量都可能是两个不同key非常容易踩坑。softValues使用软引用持有valueJVM内存不足时才回收适合缓存大对象但软引用的回收是不确定性的会引入GC行为波动。recordStats()建议每个生产环境都开启。开启后通过cache.stats()可以拿到CacheStats对象包含hitCount、missCount、hitRate、evictionCount、loadSuccessCount、loadFailureCount、totalLoadTime等指标。这些指标可以通过Micrometer暴露成Metrics接进Prometheus或者日志。我每次上线新缓存配置都会先观察命中率和淘汰量曲线命中率低就说明maximumSize或过期时间设计有问题。有数据支撑的调优才算真正的工程实践。5. 与Spring Boot集成项目经验是面经的最后一块拼图5.1 两种接入方式与实际选择Spring Boot集成Caffeine主要有两种方式。一种是通过Spring Cache注解抽象配置非常简单spring.cache.typecaffeine spring.cache.cache-namesproductCache,userCache spring.cache.caffeine.specmaximumSize10000,expireAfterWrite5m然后在启动类加EnableCaching业务方法上用Cacheable注解即可。另一种是手动构建Caffeine实例直接注入到ServiceBean public CacheString, Product productCache() { return Caffeine.newBuilder() .maximumSize(20_000) .expireAfterWrite(Duration.ofMinutes(5)) .refreshAfterWrite(Duration.ofMinutes(1)) .recordStats() .build(); }两种方式没有绝对优劣。注解方案代码侵入小但缓存逻辑分散在各方法上想统一监控比较麻烦手动实例方案直观、可控性强做缓存一致性改造时更好操作。面试时我通常回答核心交易链路用手动Cache实例读多写少的展示型接口用Cacheable两种方式在项目里是并存的。这个回答能体现你有实际架构判断而不只是会写配置。5.2 Cacheable注解使用中的经典翻车点第一坑是key设计。如果直接拿userId当key同名的cacheNames下多个业务方法可能冲突。应该用SpEL表达式带上前缀比如key #userId :profile或者自定义KeyGenerator统一生成带业务标识的key。第二坑是null值不缓存。Cacheable在方法返回null时不会写入缓存下一次请求依然穿透到DB。如果DB里确实没有这条记录每次查询都会打一次DB这就是缓存穿透。解决办法是返回一个特殊占位对象或者空集合而不是返回null。第三坑是Caffeine本身不允许value为null。Cache.put(key, null)会直接抛NullPointerException。所以在手动Cache加载函数里如果查询结果为空要么不写缓存要么用占位对象标识空数据并给这个占位对象设置较短的过期时间。这个细节面试官如果追问能答出来会非常加分。5.3 实际项目里的组合配置方案我在项目里习惯按业务域拆多个Cache实例例如商品缓存、用户会话缓存、验证码缓存每种业务有独立的maximumSize和过期策略互不影响。通过一个CacheConfig配置类统一管理所有实例代码里按需注入。监控方面给每个Cache注册Micrometer的Gauge指标把hitRate、evictionCount、estimatedSize暴露到Prometheus再配个简单的Grafana面板。线上接口数据不健康时看曲线基本能定位是缓存太小还是过期太短。再看一个参数组合商品数据expireAfterWrite10分钟、refreshAfterWrite2分钟用户Token用expireAfterAccess30分钟验证码用expireAfterWrite5分钟且不允许refresh。不同业务用不同策略而不是一套配置走天下这才符合生产要求。6. 兜底问题与现场发挥被追问时的回答策略6.1 Caffeine和Redis怎么分工一个标准回答框架Caffeine管单机热点Redis管集群共享两者是L1加L2的层级关系。请求先查Caffeine未命中查Redis再未命中查DB然后逐级回填。为什么Redis前面还要加Caffeine因为Redis访问依然是网络IO在CPU密集的服务内部一次本地缓存命中可以省掉序列化、网络往返、反序列化整个过程。但如果服务是多实例部署且每个实例都加了一层大容量本地缓存容易造成不同实例缓存不一致这个副作用必须靠过期时间兜底。面试官如果问到这里你可以主动说本地缓存适合读多写少、能容忍短暂不一致的业务对一致性要求严格的场景宁可多查一次Redis。6.2 缓存一致性怎么保证最常见的方案是Cache Aside模式。读路径先查缓存未命中则查DB并回填写路径先更新DB再删除缓存。为什么先更新DB再删缓存因为如果先删缓存再更新DB在并发场景下另一个线程可能在DB更新完成之前把旧数据读回缓存导致缓存长期保存脏数据。而先更新DB再删缓存即使删除动作失败也只需要在后续读取时通过较短的过期时间自然失效兜底成本低。如果要进一步保证删除成功可以用延迟双删或者订阅数据库binlog异步清理缓存。这部分能答出一个方案加一个兜底就已经超过大部分候选人。6.3 现场手写一个带统计的Caffeine示例面试官偶尔会让现场写一个带统计的Caffeine使用示例能写出下面几行就够LoadingCacheString, Product cache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .refreshAfterWrite(Duration.ofMinutes(1)) .recordStats() .build(key - productService.loadFromDb(key)); Product p cache.get(sku_123); CacheStats stats cache.stats();注意build方法里的loader如果抛异常Caffeine会传播异常stats里的loadFailureCount会增加。能主动说明这一点说明你不是第一次写这个代码。6.4 遇到没准备过的追问怎么办Caffeine的源码细节很多面试官可能会追问Count-Min Sketch的哈希函数数量、4bit计数器的溢出处理、甚至AdmissionPolicy的实现类。遇到没准备过的追问不要硬编。可以说我目前了解到的实现是窗口加主缓存两段式、基于近似计数和衰减机制更底层的源码细节我还没有细读但我知道Caffeine参考了Ristretto与W-TinyLFU两篇论文这块回去我会补。诚实且有方向比乱答强得多。面试官其实很在意候选人对自己能力边界的认知。我自己准备Caffeine这类八股的经验是背结论永远不够必须看懂每个设计背后的为什么。W-TinyLFU为什么加Window区、为什么用近似计数、为什么需要周期性衰减这些设计动机才是面经的精华。面试时把这篇内容当成索引提到任何一个考点都能往设计动机上引两三分钟基本就不会冷场。如果后面有时间我再把Caffeine源码的阅读路径单独整理一篇从那几个核心类入手会比纯背八股有用得多。