
4月27号下午我在某家公司面完一个Java后端岗面试官在后半程突然把话题从Redis引到了Caffeine上。当时我愣了一下因为大部分面经都是把Redis单独拎出来背很少会把本地缓存和分布式缓存放在同一轮里问。回家之后我把整轮问答重新过了一遍把这个“Redis八股 Caffeine面经”的组合整理成了一份笔记今天分享出来。这份笔记适合两类人一类是正在准备面试、想系统梳理缓存知识点的同学另一类是已经在项目里用了Redis和Caffeine但没想清楚两者边界、淘汰策略、一致性问题的人。我不是在给你标准答案而是把那天面试官真正想听到的回答逻辑、以及后来我在项目里验证过的东西一起写出来。1. 为什么Redis面经里会混着Caffeine一起问1.1 开场问题缓存系统是怎么分层设计的大部分面试官不会一上来就让你背数据结构而是先抛一个很宽的场景题一个接口的QPS已经到几千甚至上万数据库只能扛几百你会怎么让系统不挂这道题的标准回答里一定会有缓存但只说“用Redis”是不够的。面试官后面那句话往往是那你觉得Redis这一层缓存够用吗这时候如果你能主动提到“Redis前面还要加一层Caffeine本地缓存”整个回答的层次就不一样了。Caffeine是JVM进程内的缓存Redis是跨进程的分布式缓存两者不是替代关系而是上下级关系。我那天回答时画了一条链路请求先进CaffeineCaffeine没命中再查RedisRedis还没命中才打数据库。这个链路也符合实际系统里“多级缓存”的典型架构。面试官接着问了一个关键问题既然Redis已经能抗住大部分请求了为什么还要在前面加Caffeine我给的答案是因为Redis再快也有一次网络RTT和序列化开销Caffeine是纯内存对象访问毫秒和微秒的差距在高QPS场景下会被放大而且Redis集群的带宽和连接数也不是无限的。1.2 本地缓存与分布式缓存的定位差异Caffeine和Redis之间的选择本质上是“速度”和“一致性”之间的取舍。我在准备面经时整理过一张对比表面试时直接按这张表的思路往外讲逻辑非常清晰。维度Caffeine本地缓存Redis分布式缓存访问延迟微秒级纯内存对象访问毫秒级包含网络RTT存储位置JVM堆内独立进程可能跨机器容量上限受单台实例堆内存限制可横向扩展容量更大数据一致性多实例各自为政容易不一致集中式存储天然更一致适用场景热点数据、高频访问、允许短暂不一致共享数据、跨服务数据、持久化缓存故障影响进程重启缓存即失有持久化与副本机制可恢复这张表里最值得展开的是“一致性”。本地缓存最大的问题就是每个服务实例各存一份同一个key在不同机器上的值可能不一样。那天面试官追问如果你们服务部署了10个实例一条数据在数据库改了Caffeine里还是旧值怎么办这个问题我放在后面的缓存一致性章节详细说。但先记住一个原则本地缓存适合存“变化不那么频繁、即使短暂不一致也不致命”的数据比如配置项、商品的基础信息、或者经过聚合计算的热点结果。像用户余额、订单状态这类强一致数据一定要以Redis或数据库为准。1.3 面试官问Caffeine的真实意图把Caffeine塞进Redis面经面试官大概率不是真的想考你某个API怎么用而是想确认三件事。第一你有没有真正理解缓存的分层价值。只背Redis八股的人通常会把Redis当成唯一的缓存工具。但实际高并发系统里本地缓存几乎一定会出现在Redis前面如果你从来没考虑过这一层说明你做的项目可能并发量不够或者你只负责写了CRUD。第二你知不知道本地缓存是怎么做淘汰的。Caffeine的核心是W-TinyLFU算法面试官会顺着这个问LRU为什么不够好、LFU又有什么问题最后引出Caffeine的优化思路。这部分不是死记硬背如果你能讲清楚算法原理基本就能证明你阅读过源码或者深入研究过。第三你能不能处理缓存一致性问题。架构里多了一层缓存就多了一层数据不一致的风险。面试官想听到的答案不是“我们项目用了Cache Aside模式”这种一句话答案而是你能说清楚为什么先删缓存再更新数据库、延迟双删为什么不能根治、以及项目里最终怎么妥协的。所以这一章节的定位不是孤立地考Caffeine而是在考察一个后端工程师对“数据访问链路”的整体理解。2. Redis高频八股底层结构、过期与淘汰2.1 五种基础数据类型为什么String能扛住大多数场景Redis八股里最基础的就是五种数据类型String、List、Hash、Set、ZSet。面试官一般会从“你们项目里用了哪些类型”开始然后顺势让你讲底层结构。我的建议是不要只背“String是SDS、List是quicklist、ZSet是跳表哈希表”而是要把每种类型的“为什么”讲出来。先看String。String底层有int、embstr、raw三种编码方式长度小于某个阈值用embstr超过阈值转成raw。关键是SDS简单动态字符串的设计它记录了used长度所以获取字符串长度的时间复杂度是O(1)而且在拼接字符串时可以根据空闲空间决定是否需要扩容避免频繁内存分配。List底层不是单一结构在元素数量少时用压缩列表新版本是listpack元素多了之后转成quicklist。quicklist本质上是一个双向链表每个节点又是一个压缩列表。这个设计的目的是在“内存紧凑”和“增删性能”之间做平衡。Hash和ZSet底层都涉及哈希表和跳表。ZSet为什么用跳表而不是平衡树面试官很喜欢问这个。跳表实现起来比红黑树简单而且区间查询在跳表上很容易实现只要找到起点然后顺序遍历后继节点就行。配合一个哈希表用来存member到score的映射就能同时支持O(1)查score和O(logN)范围查询。回答的时候用一个案例串起来会更有说服力。比如我说过我们项目里用ZSet做排行榜用Hash存用户的基本信息用String做接口级缓存用Set做UV去重。面试官接着就会问为什么UV去重不用HyperLogLog这又是一个可以展开的点但注意别扯太远。2.2 过期删除策略惰性定期而不是定时Redis的过期删除策略是我认为八股里最值得深挖的点之一因为它直接体现了“性能与内存的博弈”。Redis的key过期后并不会立刻从内存里消失。它采用的是惰性删除 定期删除的组合策略。惰性删除的意思是每次访问一个key时先检查它是否已经过期如果过期了就删除并返回nil。这种方式的好处是对CPU友好只有被访问的key才会触发检查不会浪费CPU主动扫描。坏处也很明显如果过期的key一直不被访问它会一直占着内存。定期删除就是为了解决这个问题的。Redis每隔一段时间会随机抽取一部分设置了过期时间的key检查它们是否过期过期就删除。注意是“随机抽取”不是全量扫描。这是为了让删除操作的控制成本可控不会因为一次性扫描所有key导致主线程卡顿。那为什么不用定时删除呢每个key设一个定时器到点立刻删除这在内存上最及时但每个定时器都需要额外的CPU和内存开销在高并发场景下完全不可接受。所以Redis选择了“懒 抽检”的组合。这里面试官还有一个高频追问如果内存被大量过期key占满了怎么办答案就是expire不是实时的依赖惰性删除和定期删除可能来不及所以还要有内存淘汰策略兜底。我当时补了一句如果一个key设置了过期时间但它已经通过内存淘汰被移除那就不会再执行过期删除了。面试官点头说明他知道我理解了两条机制是独立的。2.3 内存淘汰策略与maxmemory-policyRedis的内存淘汰策略是八股里的必考项。核心要背的是8种策略但更重要的是理解“如何选”。可以把它们分成三类第一类是不处理也就是noeviction。内存满了之后写入命令直接返回错误读请求还能正常服务。这个策略适合当成保护开关但生产环境很少单独用它。第二类是只针对设置了过期时间的key进行淘汰包括volatile-lru、volatile-lfu、volatile-random、volatile-ttl。这个适用于“缓存数据都有过期时间且可以安全丢弃”的场景。第三类是对所有key进行淘汰包括allkeys-lru、allkeys-lfu、allkeys-random。这个适用于“Redis里存的都是缓存数据没有必须长期保留的数据”的场景。面试时最好能说清楚LRU和LFU的区别。LRU按“最近访问时间”淘汰问题是一个key曾经被访问过之后很长时间不被访问它仍然可能留在缓存里。LFU按“访问频率”淘汰问题是一旦某个key的历史频率过高即使它现在已经不热了也很难被淘汰。Redis实现的LRU是近似LRU不是严格的LRU因为它不会对所有key维护一个访问时间链表而是采样后淘汰。LFU则用了Morris计数器的思路频率计数会随时间衰减避免“老的热点永远占着位置”。我通常会这样回答如果业务数据有明显的热频差异选allkeys-lfu如果只是通用缓存、希望实现简单选allkeys-lru。不要用volatile类策略除非你能保证所有key都设置了过期时间否则没设过期时间的key永远不会被淘汰风险很大。3. Caffeine面经本地缓存的实现细节3.1 Caffeine的加载方式Cache、LoadingCache、AsyncLoadingCacheCaffeine面试题不像Redis那么密集但一旦开始问问得都很细。首先要分清三种缓存接口。Cache是最基础的同步缓存获取一个key时如果未命中可以直接计算并手动写入。LoadingCache内置了load方法当key不存在时会自动调用加载逻辑省去手动判断的代码。AsyncLoadingCache则是异步加载适合加载逻辑比较耗时、不想阻塞请求线程的场景。我建议在面试里给一个简单的配置例子证明你真的用过而不是光背概念。比如CacheString, UserInfo cache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .recordStats() .build();这个配置看起来简单但里边有几个容易被追问的细节maximumSize和maximumWeight有什么区别expireAfterWrite和expireAfterAccess有什么区别recordStats是干嘛的maximumSize按条数限制maximumWeight按权重限制如果每个缓存key的“内存占用”差异很大用weight更合理。expireAfterWrite是写入后固定过期expireAfterAccess是最后一次访问后过期后者更适合读多写少的数据。recordStats会记录命中率、加载耗时等指标生产环境一定要开否则你根本不知道缓存到底有没有发挥作用。Caffeine还支持refreshAfterWrite也就是写入后多久自动刷新。这个和expireAfterWrite容易混淆。refresh是在后台异步刷新key的值刷新期间请求还能拿到旧值所以不会因为一个key过期而阻塞大量线程。但如果执行刷新时数据源挂了它会把异常抛出来还是继续用旧值默认配置下会因为加载失败返回异常所以一般要配合自定义逻辑做降级。我当时在项目里就是直接用LoadingCache refreshAfterWrite把热点配置放在本地每30秒刷新一次效果很好。面试官对这个方案很感兴趣因为不是所有人都会想到用refresh来替代expire把“锁等待”变成“异步更新”。3.2 W-TinyLFU淘汰算法为什么比LRU更聪明Caffeine面试题里最容易卡住人的是W-TinyLFU。如果你不了解它的原理很难解释Caffeine凭什么比Guava Cache更优秀。先回忆LRU的问题它只考虑“最近是否被访问”不考虑“历史访问频率”。比如一个key在过去一分钟被访问了一万次另一key刚刚被访问了一次如果新key正好触发了淘汰LRU可能会把高频key淘汰掉但明明高频key更值得保留。W-TinyLFU的设计思路是把缓存分成两个区域窗口区和主区。窗口区很小用于容纳新进入的数据避免新数据因为频率低而直接被淘汰。主区存放长期存活的数据使用LFU策略进行淘汰。它用了一个叫做Count-Min Sketch的计数结构用多个哈希函数映射到一个固定大小的频率表里用空间换时间近似地统计访问频率。因为不需要精确计数所以内存占用很低。窗口区还有一个准入机制一个新来的key要进入主区必须它的估算频率高于主区候选淘汰key的频率否则就只留在窗口区。这就避免了“一次性热点”占据主区位置。面试时不要背名词用一句话概括它W-TinyLFU通过窗口期给新数据机会又通过频率计数保护真正的热数据同时用Count-Min Sketch把计数开销降到可控。这句话足够让面试官知道你真的理解了这个算法。如果面试官继续深挖你还可以说Caffeine默认不对缓存内容做全局锁它通过多个内部队列和缓冲来降低并发竞争这也是它性能高的原因之一。3.3 缓存一致性先更新数据库还是先删缓存做缓存设计逃不开一致性问题。面试里最经典的问法是Cache Aside模式下更新数据时应该“先删缓存再更新数据库”还是“先更新数据库再删缓存”先说结论大多数场景采用“先更新数据库再删缓存”也就是Cache Aside的推荐顺序。为什么因为先删缓存再更新数据库在并发读请求下会出现一个窗口期缓存删了但数据库还没更新另一个线程查到旧数据写回缓存后续读到的还是旧值。反过来先更新数据库再删缓存虽然也存在窗口期但窗口期非常短只有“数据库更新完成到缓存删除完成”这段时间。而且如果删除缓存失败旧数据会继续留在缓存里这个问题需要通过重试或者订阅binlog等机制来解决。这里就会引出延迟双删。延迟双删是在先删缓存、再更新数据库之后等几百毫秒再删一次缓存目的是清掉之前并发读可能写回的旧值。但延迟双删并不能根治问题因为延迟时间很难精确控制而且多了一次额外操作。那么本地缓存和Redis并存的场景下怎么做一致性我的实践是在更新数据库成功后先删Redis再通过异步通知或者版本号让各个服务实例的Caffeine失效。Caffeine没有像Redis那样的删除订阅机制但你可以把缓存key设计成带版本号的本地缓存只认最新版本或者每次刷新时对比数据版本。我还在项目里用过一个更简单粗暴的方案对强一致要求高的数据不放在Caffeine里只放在Redis或者只放在数据库不引入第二层缓存。这个方案看起来不够炫但能够从根上避免多级缓存的一致性问题。我在面试里给了这个答案面试官反而很认可因为说明我知道什么时候该放弃“高级方案”。4. 一套可以直接用的缓存设计方案4.1 从QPS和数据特征决定用几级缓存面试聊到缓存最后一定会让你落到系统设计。我给过一套回答框架现在拆开讲。第一步先算QPS和数据库的瓶颈。假设单库能抗500 QPS接口的峰值QPS是10000那你至少要降低99%的数据库请求。如果Redis能抗住混合读写理论上只需要Redis一层也能行。但Redis的瓶颈通常在带宽和连接数如果key又大又热门单实例Redis也可能被打爆。所以需要按数据特征拆。第二步判断数据特征。热点非常集中的数据比如商品详情、首页推荐列表非常适合放到Caffeine。因为10000次请求里可能有9000次都在读同一个key。放进本地缓存后这9000次根本不会打到RedisRedis压力瞬间降下来。第三步确定每层的容量和过期时间。Caffeine的maximumSize可以根据JVM堆内存设置一般几万到几十万条足够。Redis的容量看预算和机器规格但也要给每条key评估大小。过期时间要错开避免所有key在同一秒过期。这套思路最大的价值是它不是靠“感觉”决定用Redis还是Caffeine而是根据热点比例和QPS来做推理。面试官听完会认为你有容量规划意识。4.2 穿透、击穿、雪崩防护三板斧缓存三兄弟是面试八股的高频题但如果只是背定义面试官一下就能听出来。我把这三个问题放在一个上下文里讲。缓存穿透请求查询一个根本不存在的数据每次都会穿过缓存打到数据库。这时候哪怕缓存有十万个key也没用因为查的key不在里面。解决方案有两种一是对空结果也做短暂缓存比如缓存一个空对象并设置较短的过期时间二是在缓存前面加布隆过滤器把不存在的key直接拦截掉。缓存击穿某个热点key恰好过期大量并发请求同时打到数据库。和穿透的区别是击穿是“单个key过期”穿透是“查询不存在的key”。解决思路是互斥锁在缓存重建时加锁只允许一个线程去加载数据库其他线程等待锁释放后直接读取缓存。或者用Caffeine的refreshAfterWrite异步刷新避免热点key过期瞬间阻塞所有请求。缓存雪崩大量key在同一时间过期或者Redis实例本身挂了导致所有请求打到数据库。预防措施是过期时间加一个随机值避免key齐刷刷过期同时Redis要做高可用比如主从加哨兵或者集群模式不能单点。我面试时回答完这三个点都会补一句这些方案不是互斥的穿透和击穿可以同时存在生产系统里通常要组合使用。这句话会让你的答案显得更有实战感。4.3 监控命中率、延迟、淘汰数缓存设计再好看上线后不加监控等于白做。面试时最后如果问落地我一定会讲监控。Redis本身的INFO stats里有个keyspace_hits和keyspace_misses通过这两个值可以算出缓存命中率。命中率如果低于80%说明缓存的价值没有发挥出来要么key设计不合理要么过期时间太短。Caffeine是通过recordStats()来收集统计信息的。你可以定期输出CacheStats看hitCount、missCount、evictionCount和loadSuccessCount。evictionCount突然变大说明缓存容量不够或者数据写入过于频繁需要调整最大容量或过期策略。除了命中率还有一个容易忽略的指标是平均缓存访问延迟。Caffeine本身访问就是微秒级但如果加载逻辑很慢loadSuccessCount会暴露出加载耗时过长的问题。Redis这边可以用SLOWLOG查看慢查询尤其要注意大key操作和复杂命令。监控的意义不仅在于排查问题它也是面试时展示工程素养的机会。你可以说我会把命中率、淘汰数、加载耗时打到监控系统里按小时观察趋势如果命中率突然跳水就会排查是否有批量key过期或数据热点迁移。5. 面经避坑与实战问题排查实录5.1 容易在八股里翻车的细节有些细节不翻一翻源码真的很容易答错我整理几个我实际被问过、或者听过别人翻车的点。第一个坑Redis的String类型能存多大答案是512MB但这个数字本身不是重点重点是你要知道String不适合存大对象一大对象会带来网络传输和内存拷贝的浪费还会增大阻塞风险。第二个坑Redis事务是原子的吗面试官指望你回答“MULTI/EXEC不是真正的原子性它只是按顺序执行中间某个命令失败不会回滚前面的命令”。如果你只说“Redis事务是原子的”基本就被判死刑了。第三个坑Caffeine的maximumSize设置过小会有什么问题它不是报错而是频繁淘汰和重新加载导致命中率下降、Load次数飙升。实际表现是接口耗时不稳定监控上看loadSuccessCount非常大。第四个坑把expireAfterWrite和refreshAfterWrite搞混。我之前在一次面试里回答“refreshAfterWrite会在过期后自动删除key”面试官当场纠正说refresh是覆盖旧值不是删除。这个点非常值得记住。第五个坑Redis Cluster下不能执行跨slot的MGET、MSET、Pipeline多key操作。很多人背多级缓存方案时会顺手说“把多个key一起查出来”在Cluster模式下这会出现CROSSSLOT错误只能用Hash Tag解决或者写循环单key查询。5.2 实战中踩过的一些缓存坑每个做后端的人应该都被缓存坑过这里写几个我自己踩过的真实问题。第一个是缓存穿透打爆数据库。早期项目里有一个查询接口用户传入的ID是连续的整数但很多ID没有对应数据。当时没有做空缓存也没有布隆过滤器结果有人写了个脚本批量爬每个不存在的ID都穿透到数据库数据库连接瞬间被打满。后来加了空值缓存Map虽然简单但非常有效。第二个是key同时过期导致的抖动。我们曾经给一批用户维度的缓存统一设置了2小时过期时间结果每天固定时间点接口耗时从50ms飙到500ms。排查后发现就是大量key在同一秒过期导致缓存雪崩。后来把过期时间改成“基础过期时间 随机0到300秒”耗时曲线立刻平了。第三个是Caffeine缓存和Redis缓存不一致。本地缓存里的配置数据更新频率很低但有一次运营后台改动配置后线上服务要等10分钟才能感知到。我们一开始把expireAfterWrite设成了10分钟后来改成refreshAfterWrite每分钟刷新再配合一个版本号字段问题才算解决。这几个坑告诉我缓存设计不是配一个参数就完事它需要你持续观察监控及时调整策略。面试时讲这些真实经历比背任何八股都更能打动面试官。5.3 除了背八股还能怎么准备如果你还有时间准备我会建议不要只刷面经。我自己准备“Redis八股 Caffeine面经”时做了一件很有价值的事写了一个Demo项目模拟多级缓存场景。具体来说就是用一个本地接口第一次请求查数据库第二次请求走Caffeine然后把数据同步写到Redis。再用压测工具并发打这个接口观察加Caffeine前后的TPS和P99延迟变化。这个过程会让你真正理解本地缓存的价值。然后故意制造几个故障场景手动删除Redis里的缓存观察击穿把Caffeine的maximumSize调小观察淘汰率把数据库查不出的数据请求打进来观察穿透。这些实验做完你对缓存的理解会远超背题。面试时当面试官问“你项目里遇到过缓存问题吗”你可以顺手把实验过程中的现象、排查思路和改进方案讲一遍这比回答“遇到过后来加了缓存”要有说服力得多。八股是基础但真正的分水岭是你有没有在实践中验证过这些知识点。我在准备阶段还养成了一个习惯每道面试题都写一个“为什么”的注释。比如Redis为什么快不只是“内存操作”还因为IO多路复用、单线程避免上下文切换、高效的数据结构。Caffeine为什么比Guava Cache快不只是一个W-TinyLFU还因为它对并发读多写少的场景做了大量无锁优化。把每个结论还原成原理面试官才愿意相信你是真的懂。