这道题我在面试候选人的时候经常拿来开篇,也在我自己复盘线上缓存体系的时候反复推演过。Caffeine、Redis、MySQL三级缓存,十个字不到,却能把JVM内存管理、分布式缓存、数据库存储、并发控制、缓存一致性这些知识点全部串起来。很多同学拿到题目,第一反应是“先查Caffeine,没有再查Redis,再没有查MySQL”,说完这句就卡住了。要么就是上来背八股,结果在面试官追问“第二层怎么回填”“本地缓存怎么失效”“Redis挂了怎么办”的时候,被问得哑口无言。
这篇文章我按自己平时拆解问题的套路来讲:先帮你把“十万用户并发”这个数字背后的真实压力算清楚,再讲三级缓存每一层该承担什么角色、核心参数怎么定,然后给出一套可以直接落地到项目里的代码骨架,最后把面试官最常追问的连环问题串一遍。适合正在准备Java后端面试的工程师,也适合那些线上缓存经常出幺蛾子、想重构缓存体系的开发者。
1. 先拆题:十万用户并发到底在考什么
1.1 并发和QPS不是一回事
先把这个最容易踩的坑说清楚。很多候选人一听到“十万用户并发”,上来就说“十万QPS扛不住”。这两个根本不是同一个概念。
用户并发通常指的是同时在线操作、同时占用连接的用户数,而QPS是每秒钟系统处理的请求数量。一个用户的一次页面操作,可能产生1个请求,也可能产生5个、10个请求,比如打开一个商品详情页,可能要同时拉商品信息、库存、评论、推荐位。假设十万用户同时刷页面,每个用户产生3个请求,那落到系统上的真实QPS可能就是三十万。
反过来,如果十万用户只是登录挂着,偶尔点一下,那实际QPS可能只有几千。所以拿到这种题目,第一步不是讨论方案,而是要反问清楚:这十万并发是IM在线连接数、是Web请求并发,还是数据库连接并发?场景不同,方案复杂程度完全不一样。
面试官抛出这个数字,真正想考察的是你能不能把“并发用户数”转换成“系统要扛的QPS”再去设计架构。直接跳到Caffeine、Redis怎么配,反而会让面试官觉得你思维模型太简单。
1.2 这个场景的真实压力在哪
如果按一个电商读多写少场景来估算,假设十万用户在高峰期同时浏览商品,平均每人每秒产生1.5个请求,那总QPS就是十五万。再假设整个服务集群有10个节点,每个节点要承担一万五千QPS。
先看MySQL能不能扛。常规MySQL单实例,在简单查询、走索引、连接池配置合理的情况下,支撑三千到五千QPS已经算不错了。优化到一万QPS需要非常精细的调优,而且往往意味着要牺牲很多其他能力。一万五千QPS的读请求如果全打到MySQL,基本上活不过一分钟,连接数会先被打满,然后线程栈堆积,紧接着整个服务跟着雪崩。所以MySQL之上必须有一层分布式缓存来挡流量,Redis就是干这个的。
再看Redis能不能直接扛十五万QPS。Redis单实例读性能很猛,十万以上的QPS是能做到的,但要付出不小的代价。到了这个量级,Redis的内存占用会很大,网络带宽会吃紧,请求耗时也会因为网络IO而上涨。更重要的是,如果所有请求都穿透到Redis,Redis万一抖动,MySQL会立刻被打穿。所以Redis之上还需要一层更快的本地缓存,也就是Caffeine,把高频热点请求拦在应用进程内部。
这就是三级缓存架构存在的根本原因:不是闲着没事堆缓存,而是每一层都在拦截上一层的压力,让流量逐级衰减。
1.3 这道题真正考察的能力模型
进阶一点说,多级缓存这个话题,表面考察的是技术选型,深层次考察的是你在分布式系统里的权衡能力。
本地缓存快,但数据不能跨节点共享;Redis能共享,但每次都有网络IO;MySQL最可靠,但吞吐有限。你要做的不是选一个“最好”的组件,而是给每一层分配合理的职责边界,接受每一层的缺陷,再用其他手段弥补。这种“用复杂换性能”的思路在面试官眼里是很值钱的——因为生产环境里,出了问题你得能退化、能降级,不能只盯着理想状态的性能数字。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构:三级缓存各司其职
2.1 为什么要设计成三级而不是两级
有不少人问过:有Redis就够了,为什么还要加一层Caffeine?我用一组直观的数据来回答。
假设一个商品详情页的Redis缓存查询平均耗时是2毫秒,MySQL查询平均耗时是20毫秒。当QPS只有一千的时候,2毫秒和20毫秒的差距体感不明显,反正都挺快。但QPS到十万的时候,2毫秒单次请求意味着系统要凭空处理巨大的并发开销,而且Redis的带宽和连接数都会成为瓶颈。Caffeine这种进程内缓存,查询耗时是微秒甚至纳秒级别的,因为它直接在JVM堆内存里操作对象,不经过网络、不经过序列化。这三者在耗时上完全不是一个量级。
另外还有一个更现实的问题:成本。Redis再快也是需要独立部署的,内存成本很高。Caffeine跑在应用进程里,几乎零额外成本。在多级缓存体系里,Caffeine挡住第一波访问,Redis只处理本地缓存未命中的请求,Redis的压力会大幅下降,集群规模也能缩下来。十几台应用节点 + 一个小规模Redis集群就能扛住原本需要很大Redis集群才能扛住的流量,这笔账很划算。
多级缓存的本质,就是用一层比一层便宜、但速度更快的存储,把昂贵的请求逐级拦截在最前端。
2.2 三级缓存各自的职责边界
我习惯用角色来区分这三层:
Caffeine是“哨兵”,只负责拦截最热的热点请求。它容量有限,所以只放被访问次数最多的那部分数据。它的特点就是极端快,但数据只存在于当前进程里,其他应用节点查不到。
Redis是“主力”,负责承载绝大部分的缓存流量。所有节点共享同一份Redis数据,回填MySQL数据后先写到Redis,再异步补充到本地缓存。Redis是系统对MySQL保护的关键屏障,它的命中率直接决定了MySQL的压力。
MySQL是“底牌”,管持久化和数据一致性。它必须保证在任何缓存失效的情况下都能兜住请求,虽然它慢,但它不丢数据。缓存体系设计得再花哨,MySQL这一层的稳定性也不能妥协。
这里有一个很容易被忽略的细节:MySQL不光是读数据的兜底,也是写操作的唯一入口。所有更新操作先落到MySQL,成功后才能通知缓存删除或更新。这个顺序一旦颠倒,后面会衍生出一堆一致性问题。
2.3 算一笔账:10万并发下每层要扛多少流量
我们做一个保守的推算。假设100个商品里有20个是热点商品,热点请求占比80%,单机Caffeine命中率设为80%,Redis命中率设为90%,总QPS为15万,部署10台应用节点。
每台节点分担1.5万QPS,Caffeine拦截掉80%,剩下20%打到Redis,也就是每台3000 QPS,Redis集群总计3万QPS。Redis再拦截90%,剩下10%打到MySQL,也就是每台300 QPS,MySQL总计3000 QPS。注意,这3000 QPS是对MySQL的真实请求量,已经落在MySQL能力范围内了,再加上读写分离、分库分表,完全扛得住。
如果去掉Caffeine这一层,15万QPS直接打到Redis,Redis本身能扛,但MySQL要承受1.5万QPS的穿透,这是致命的。所以“多”的这层Caffeine不是性能表演,它切切实实把MySQL的QPS降了一个数量级。
3. 核心设计细节与代码实现
3.1 Caffeine本地缓存的关键参数怎么定
本地缓存设计最怕两件事:一是容量设置太大导致JVM频繁GC,二是容量设置太小导致命中率太低,形同虚设。
先估算单条缓存对象的大小。以一个商品对象为例,包含名称、价格、描述、图片地址等字段,序列化前大概2KB。假设Caffeine的maximumSize设置为1万条,那堆内存占用就是20MB左右,加上对象头、引用、缓存框架内部结构,放大到50MB。在一个2G堆的应用里,50MB完全可以接受。如果设置成10万条,那就是500MB起步,GC压力立刻显现。所以我的建议是:先以1万条为起点压测,观察命中率和GC情况,再逐步调整。线上环境配合recordStats()开启统计,实时观察hitRate,如果命中率低于70%,要么增大容量,要么检查key设计是否合理。
过期策略也值得掰扯一下。expireAfterWrite和expireAfterAccess的区别在于写访问和读写访问。在高并发场景下,我建议用expireAfterWrite固定过期时间,因为expireAfterAccess会不断刷新热点key的过期时间,导致某些key长期不失效,数据新鲜度完全不可控。配置示例:
java复制Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
这里的5分钟是一个折中值。太短会导致大量请求穿透到Redis,太长会导致数据更新后本地缓存长时间不刷新。线上实际项目里,我会把热数据的本地缓存时间控制在1到5分钟之间,具体看业务对数据新鲜度的容忍度。
3.2 Redis缓存设计与序列化方案
Redis这一层的设计,key命名规则要统一,比如business:entity:id这样的格式,商品就是product:info:123。命名混乱的后果是排查问题时定位不到数据,而且写代码的同事也容易弄混。
序列化方案我踩过大坑。早期有项目用JdkSerializationRedisSerializer,存进Redis的是一坨二进制,占用空间大不说,其他语言的服务根本读不了。后来统一改成JSON序列化,比如GenericJackson2JsonRedisSerializer,直观可读,排查问题时看一眼value就知道里头是啥。代价是JSON序列化有额外CPU开销,但这个开销在Redis那几千上万QPS面前完全可以接受。
Redis连接池参数也需要留意。以Lettuce为例,maxTotal一般设置300到500,maxIdle设置100到200。不要无脑调很大,连接池太大反而会增加Redis服务端的线程开销。
java复制@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
Redis的过期时间设置要和Caffeine错开。比如Caffeine本地缓存5分钟过期,Redis缓存就可以设置30到60分钟。这样即使本地缓存全部失效,Redis还在,不会打穿到MySQL。更稳妥的做法是给过期时间加一个随机偏移量,避免所有key同时过期形成雪崩。
3.3 完整读写流程代码骨架
把三级缓存的读写流程串起来,大概是这个逻辑。读请求先查Caffeine,命中直接返回;没命中查Redis,命中则回填Caffeine并返回;Redis也没命中,才查MySQL,回填Redis和Caffeine后返回。如果MySQL也没有这个数据,还需要把空值也缓存一下,防止反复穿透。
java复制public ProductVO getProductById(Long id) {
String cacheKey = "product:info:" + id;
// 1. 本地缓存
ProductVO local = localCache.getIfPresent(cacheKey);
if (local != null) {
return local;
}
// 2. Redis缓存
String json = redisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNotBlank(json)) {
ProductVO vo = JSON.parseObject(json, ProductVO.class);
localCache.put(cacheKey, vo);
return vo;
}
// 3. 查MySQL(带互斥锁防击穿,后面会讲)
ProductVO vo = queryProductFromDbWithLock(id);
if (vo != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(vo),
30 + ThreadLocalRandom.current().nextInt(60), TimeUnit.SECONDS);
localCache.put(cacheKey, vo);
} else {
// 空值缓存,防止缓存穿透
redisTemplate.opsForValue().set(cacheKey, "",
5, TimeUnit.MINUTES);
}
return vo;
}
这段代码看起来简单,但包含了两层细节。第一,本地缓存只存正数据,空值缓存放在Redis层就够了,没必要污染本地缓存。第二,回填Redis的过期时间加了随机数,30秒到90秒之间波动,这就是防雪崩的具体手段。后面第4节会展开讲。
3.4 缓存更新与一致性:Cache Aside模式
写操作的顺序很讲究。标准的做法是Cache Aside模式:先更新MySQL,再删除缓存,而不是先删缓存再更新数据库。
如果先删缓存,后更新数据库,中间有一个极小的时间窗口,一个读请求会把旧数据回填到缓存,等数据库更新完,缓存里还是旧值,而且这个旧值可能很久都不会被清理,这就是脏数据。反过来先更新数据库,再删除缓存,即使删除缓存失败,最多就是Redis里还是旧数据,但下一次读请求会因为缓存不命中或过期而拿到新数据,一致性窗口要小得多。
删Redis缓存还好办,本地缓存Caffeine怎么失效?本地缓存分散在各应用节点,Redis删除事件这几个节点感知不到。行业里有几种解法,我按优先级列一下:
订阅Redis的key过期或删除事件(keyspace notifications),让各节点监听并清掉本地缓存。用Redis Pub/Sub广播一条“删除某个key”的消息,所有应用节点收到后删除本地缓存。或者本地缓存设置很短的过期时间,比如1分钟,允许一个短暂的不一致窗口。大部分业务场景下,第三种方案最简单也最实用,因为绝大多数场景都能容忍几十秒的数据延迟。
延迟双删是很多博客会提的方案,先删缓存、更新库、隔几百毫秒再删一次缓存。我不推荐在面试里把它当主要方案,因为引入了一些无关逻辑,而且那个延迟时间很难拍准,网上争论也很多。你可以知道它存在,但设计回答的时候以Cache Aside为主,配合短过期时间或者消息广播来清本地缓存,已经足够了。
4. 穿透、击穿、雪崩、热key:高并发缓存四大拦路虎
4.1 缓存穿透:布隆过滤器与空值缓存
先讲第一种威胁。缓存穿透指的是查询一个压根不存在的key,缓存里没有,数据库里也没有,每个请求都会打到MySQL。如果有攻击者恶意构造大量不存在的商品ID发起请求,MySQL直接被高强度查询打挂。
布隆过滤器是常用的解法。它先把所有存在的ID加载进过滤器,判断一个ID是否存在时只用做几次位运算,不存在的情况直接拦截。它最大的优点是省内存,十万个ID只占几十KB。缺点是存在误判率,会把“可能存在但实际不存在”的ID放进来,这个误判率可以通过设置大小来控制。
空值缓存是更简单务实的方案。数据库查询不到数据时,也往Redis里写一个空标识,并设置较短的过期时间,比如5分钟。这样同一个不存在的ID短时间内不会反复打到MySQL。两者可以配合使用,布隆过滤器拦大面,空值缓存兜细节。
4.2 缓存击穿:互斥锁与逻辑过期
缓存击穿和穿透只有一字之差,但含义完全不同。它指的是一个热点key在过期的瞬间,大量并发请求同时到达,发现缓存不存在,同一时刻全部打到MySQL,数据库连接瞬间被打满。
解决思路是加锁。只有一个线程允许去查MySQL并回填缓存,其他线程等待或降级。我用Redisson的RLock来实现,给每个key一把锁,抢到锁的线程查库、回填缓存,没抢到的线程短暂自旋后重新查缓存。
java复制private ProductVO queryProductFromDbWithLock(Long id) {
String lockKey = "lock:product:" + id;
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
locked = lock.tryLock(500, 3000, TimeUnit.MILLISECONDS);
if (!locked) {
// 抢锁超时,返回兜底数据或直接快速失败
return fallbackData(id);
}
// 双重检查:拿到锁后再查一次缓存
String json = redisTemplate.opsForValue().get("product:info:" + id);
if (StringUtils.isNotBlank(json)) {
return JSON.parseObject(json, ProductVO.class);
}
ProductVO vo = productMapper.selectById(id);
return vo;
} finally {
if (locked) {
lock.unlock();
}
}
}
这个锁一定要设置合理的等待时间和持有时间。等待时间过短,请求容易快速失败;持有时间过长,又可能拖慢后续请求。实际项目中我会动态计算持有时间,比“查询数据库预估耗时”稍微长一点,避免业务还没执行完锁就释放了。
还有一个逻辑过期的方案:缓存不设物理过期时间,而是存一个虚拟过期时间字段。读取时发现逻辑过期,异步线程去刷新缓存,旧值先返回给用户。这种方案性能最好,但实现复杂度更高,一般用在热点极强、业务能容忍短暂旧数据的场景。
4.3 缓存雪崩:过期时间打散
缓存雪崩指的是大量key在同一时间过期,所有请求越过缓存直达数据库。主要的诱因有两个:一个是设置了相同的过期时间,另一个是Redis实例宕机。
针对第一类诱因,过期时间加随机偏移就能解决。前面代码里我写的30 + ThreadLocalRandom.current().nextInt(60)就是干这个的。确保同一业务维度的key,过期时间呈现出30秒到90秒之间的离散分布,不会出现整点集体过期的情况。
针对Redis宕机的场景,多级缓存架构的优势就体现出来了。Redis不可用的时候,Caffeine本地缓存仍然是存活的,它依然能挡住一部分读流量。同时需要开启降级开关,如果Redis不可达,直接只走本地缓存和数据库,或者让非核心请求快速失败,绝不拖垮整个应用。
4.4 热点key识别与本地化
热点key问题在多级缓存架构里很典型。某些资源被极端高频地访问,比如某明星微博下的评论、某个秒杀活动的商品详情,这些key在Redis上的访问量甚至能压垮单分片。
解决思路是识别热点key并提升到Caffeine本地缓存。热点key的识别手段有很多,可以在Redis上加一层代理统计各key的访问频次,也可以客户端侧统计访问次数。对于单机的应用节点,也可以用一个定时线程每隔几秒扫描本地缓存的访问计数,发现访问频率超过阈值的key,就将它的过期时间人为延长。
这个策略本质上就是“动态缓存”:普通key按正常的过期时间走,热点key被识别出来后,进入本地缓存,并且在热点窗口内不容易过期。等到热点过去,访问频率下来了,再慢慢移除。这块我的经验是,一定要给本地缓存设置最大容量上限,防止大量热点key长期驻留挤占堆内存并引发GC问题。
5. 面试追问与实战避坑实录
5.1 面试官连招追问怎么接
多级缓存设计是面试题,也是面试官“连环炮”的重灾区。我整理了三个最常被追问的问题,回答思路也一并附上。
“Redis挂了怎么办?”这个问题的核心是考察降级方案。回答的思路是:Redis不可用时,本地缓存继续服务,DB不走缓存直接查,同时开启熔断开关,把写操作和一致性要求高的操作限流,优先保证核心读链路可用。如果DB也扛不住,就启动服务降级,返回降级页面或历史快照。
“本地缓存和Redis数据不一致怎么办?”核心是考察一致性认知。回答思路是:本地缓存本身就不追求强一致,最终一致性就可以。通过设置较短过期时间、订阅Redis删除事件、或者容忍秒级延迟的数据旧读,控制不一致窗口。不要试图用分布式事务去解决缓存一致性问题,太贵也太重。
“缓存预热怎么做?”核心是考察对冷启动问题的处理。回答思路是:项目启动时加载配置的热点key列表到本地缓存,定时任务定期把低频但重要的数据预热到Redis,大促前用脚本扫描数据库并把热点数据提前灌进缓存。预热的关键是数据量要和实际流量分布吻合,否则白白占用内存。
5.2 线上踩坑实录
这里整理一些小众但真实的问题。第一个是Caffeine的maximumSize设置过大导致频繁Major GC。线上有项目把它设置成了几十万条,结果老年代很快被缓存占满,每次GC停顿好几秒。后来开了recordStats()发现命中率也就六成,降到1万条后命中率不变,GC问题消失。命中率和数据条数不是完全线性的关系,加多了不一定有用。
第二个是Redis的value序列化方式。早期项目用JDK默认序列化,一个对象存进Redis膨胀了将近三倍,而且Redis Desktop Manager里看value全是乱码,排查问题全靠猜。后来统一改成JSON序列化,可读性强,占用的内存也小了一大截。
第三个是连接池参数不合理。某个服务把Redis的maxTotal调到了2000,结果Redis服务端线程被连接请求拖垮,P99延迟飙到几十毫秒。连接池不是越大越好,长连接维持本身就有开销,具体数值要根据QPS、业务耗时、Redis实例规格来压测。
第四个是Lettuce在多线程下的坑。非线程安全的连接被多个线程共享可能会导致响应乱序,解决方式是使用LettuceConnectionFactory自带的连接池。这些小问题都排查过,写出来希望你能少走弯路。
5.3 压测验证:怎么证明设计可行
面试时聊完设计,如果能顺手提一下“我上线前用压测验证过”,效果会好很多。压测工具用JMeter或者开源的wrk都可以,我拿JMeter举例。
准备一台压测机,模拟1万线程并发访问商品详情接口,每个线程循环请求100次,这样总请求量是100万。关注四个指标:接口QPS、P99响应时间、Redis QPS、MySQL QPS。压测前先在监控面板里确认四项指标都会正常上报。
理想结果是接口QPS能到两万以上,P99在50毫秒以内,Redis QPS在三万到五万之间,MySQL QPS稳定在三千以下。如果MySQL QPS明显偏高,说明缓存命中率没有达标,排查方向集中在key设计、过期时间、热点key识别几个环节。压测的同时也可以故意停掉Redis,观察系统是否触发降级,降级后接口是否还能返回兜底数据,这个演练过程在面试中讲出来会非常加分。
我个人在实际项目中反复验证过一个观点:多级缓存架构里,最容易被忽视的往往不是缓存本身,而是缓存层之间相互影响带来的连锁问题。比如本地缓存命中率上来了,Redis压力小了,但本地缓存和Redis的数据一致性窗口又会长一点;Redis删key消息广播延迟高一点,各节点的本地缓存就会出现参差不齐的旧数据。所以在设计时我会刻意把“允许哪些数据短暂不一致”“不一致窗口最坏有多长”写清楚,先确定业务底线,再去选技术方案,而不是反过来。
最后再分享一个小技巧:线上排查缓存问题时,先看缓存命中率,再看GC日志,最后看Redis慢日志。命中率异常说明key设计或过期策略出了问题,GC日志异常说明缓存容量或者对象膨胀出了问题,Redis慢日志异常说明有一个大的热点key或者大对象读写存在性能隐患。把这四样东西拉出来看一眼,大部分缓存疑难杂症都能定位出个大概方向。
