看到“SpringBoot+Caffeine+Redis实现多级缓存”这个题目,我猜很多人的第一反应是“又是个整合Demo”。确实,网上随便一搜就是一堆把Caffeine当一级缓存、Redis当二级缓存然后塞进CacheManager的入门案例。但真正在线上跑过一段时间之后,你会发现这玩意儿远不只是“两级缓存套壳”那么简单,里面藏着大量关于命中率、一致性、穿透防护和内存水位的问题,处理不好,轻则缓存形同虚设,重则直接拖垮整个应用。
这篇文章我不打算走“从零创建项目”的教程路线,那些脚手架代码翻文档就有。我想从一个实际落地的角度,把我自己做多级缓存时的核心思路、关键实现、踩坑经验一次说清楚,尤其是那些常规教程里不会写、但线上真正决定成败的细节。如果你正在设计或者重构缓存方案,希望这篇能帮你少走点弯路。
1. 先想明白:为什么需要Caffeine+Redis两级缓存,而不是只用其中一种
很多项目一开始是只用Redis的,因为架构简单、看起来也够用。但随着并发量上来,Redis这块的瓶颈会越来越明显。最直观的问题就是网络IO,每次查询就算Redis再快,也要经过一次网络往返,Redis的QPS虽然高,但应用实例多了之后,单机Redis的连接数、带宽和延迟都会成为压在头上的天花板。这时候在应用进程内加一层本地缓存,把热点数据直接放在内存里,能直接消掉大量的网络开销,命中率上去了,Redis压力骤降。
但本地缓存也有它天然的短板:数据分散在每个实例上,集群环境下A实例缓存了某个值,B实例可能没有,再加上缓存一旦更新,必须想办法通知所有实例,一致性处理起来比Redis麻烦得多。所以两边一对比,架构就呼之欲出了:Caffeine管高频热点数据的快速访问,Redis管多实例共享的中间层,数据库兜底。这正好能扬长避短。
再从数据访问的特征来看,互联网应用的读请求大多遵循“二八定律”,20%的热点数据占了80%的访问量。本地缓存就是奔着这20%去的,而Redis则是拦截掉剩下那些本地没命中、但数量依然可观的请求。这种组合模式下,数据库真正承受的压力会小到让你惊讶。我见过一个业务,Redis的读QPS原本在4万左右,加了Caffeine之后直接掉到了8000上下,数据库几乎看不到压力了。
还有一个很多人忽略的点是成本。Redis不管是用自建还是云服务,内存价格都不便宜。Caffeine把最高频的那部分数据“下沉”到应用机器内存里,腾出了Redis的空间,等于变相降低了基础设施成本。所以从成本收益的角度看,多级缓存不是一个“玩具架构”,而是实打实能省钱的方案。
2. 架构设计不是简单叠两层,先算清这五个关键点
很多人做两级缓存,脑子里就是“Caffeine查不到就去查Redis,Redis再查不到就去查数据库”。这个流程对,但远不够。真正要落地,你需要先想清楚下面这几个问题。
2.1 数据一致性级别:你能容忍多少秒的延迟
这是整个方案的第一颗纽扣。Caffeine本地缓存天然会有跨节点不一致的问题。比如你在A节点写入了新数据,B节点在失效时间之前读到的还是旧值。所以设计之初就得明确:业务能不能接受这种短暂的不一致?能接受多久?
我自己的经验是,绝大多数读多写少的场景,比如商品详情、配置信息、用户基础资料,几十秒的最终一致性完全没问题。但对于库存、价格这类强一致数据,就必须走主动失效机制,也就是写操作发生后,要么主动删掉相关缓存键,要么通过消息广播告诉所有实例删掉本地缓存。主动失效机制的复杂度会明显高一些,但这是强一致场景绕不开的代价。
2.2 缓存粒度:别再一套CacheManager走天下了
很多人用Spring Cache的@Cacheable,一个CacheManager配置全局的过期时间,所有业务共用。这在小型项目里没毛病,但业务一复杂就是灾难。比如验证码可能要缓存5分钟,商品库存可能只要30秒,配置信息又要1小时。全局一套参数根本没法差异化,要么有的数据过期太早打穿缓存,要么有的数据过期太晚长期脏读。
我习惯的做法是围绕CacheManager构建多套缓存配置,在注册Caffeine的CacheLoader时按业务维度预设好不同的Cache实例,每个实例有自己的expireAfterWrite、maximumSize。然后在业务代码里用@Cacheable(cacheNames = "product:detail", cacheManager = "shortTimeCacheManager")这样的方式精确指定。看着只是配置上的小调整,实际线上命中率和数据准确性的提升是天壤之别。
2.3 容量控制:本地缓存不是无限内存池
JVM堆内存是有限的,Caffeine虽然可以设置maximumSize,但如果你拍脑袋写个maximumSize=10000,很可能在流量高峰时发现老年代疯涨、GC时间飙升。正确的姿势是做推算。
以单条缓存平均占用1KB计算,一台4G堆内存的应用,留给缓存的空间撑死也就1G,按1KB一条来估算就是100万条左右。但实际业务数据大小差异很大,JSON序列化后的对象动辄几KB到几十KB,本地缓存塞几十万条就可能把堆撑爆。所以我建议你用maximumWeight而不是maximumSize来控制,同时实现Weigher接口按字节数算权重。更稳妥的方式是设置maximumSize在几万到十几万级别,再配合expireAfterWrite控制生命周期,宁可牺牲一点命中率,也要保证应用稳定。
2.4 缓存穿透、击穿和雪崩,每一层都要有对策
多级缓存不是“防穿透神器”,反而因为它层级多,穿透的放大效应更明显。本地没命中、Redis没命中、数据库没命中,一次穿透等于打了三次查询。所以必须在架构设计时就做好防护。
穿透防护我常用的手段是布隆过滤器加缓存空值,布隆过滤器放在Redis前面挡掉不存在的ID,万一漏过去了,数据库返回空之后也要在Redis和本地各缓存一份短时效的空值,比如30到60秒。击穿防护靠的就是Caffeine的CacheLoader、refreshAfterWrite以及互斥锁机制,同一时刻只有一个线程去回源,其他线程阻塞等待。雪崩防护则靠过期时间加随机偏移量,不要让大量key在同一秒集体失效。
2.5 回源策略:同步阻塞还是异步刷新
这是最容易被忽略的一个点。默认情况下,Caffeine在CacheLoader.load()里回源数据库,命中率不高的场景下,一旦缓存过期,所有请求都会串行等待数据库返回。如果数据库慢,那调用方的RT会呈线性上升。我用的是“同步加载加异步刷新”的混合策略:用refreshAfterWrite设置一个较短的刷新阈值,同时配合expireAfterWrite做兜底,刷新时让旧值继续对外服务,后台线程去数据库拉新值。这样能最大程度降低回源造成的延迟波动。
3. 核心实现:把骨架搭起来,这些雷我先帮你避开
到了动手环节,我直接给出一个基于Spring Boot的实现骨架,并标注清楚每个配置的作用。把这些代码消化透,远比去搜索零散片段更有价值。
3.1 引入依赖,版本对齐是关键
<dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>3.1.8</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>版本上,Caffeine 3.x要求JDK 11以上,如果你的项目还在用JDK 8,就去用2.x分支。Spring Boot 2.7以上默认的缓存抽象对Caffeine的支持已经很完善,不需要额外引入第三方适配器。
3.2 多CacheManager注册,按业务区分缓存行为
@Configuration public class CacheConfig { public static final String DEFAULT_CACHE_MANAGER = "defaultCacheManager"; public static final String SHORT_CACHE_MANAGER = "shortCacheManager"; public static final String LONG_CACHE_MANAGER = "longCacheManager"; @Bean(DEFAULT_CACHE_MANAGER) public CacheManager defaultCacheManager() { Caffeine<Object, Object> caffeine = Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(Duration.ofMinutes(30)) .recordStats(); return new CaffeineCacheManager(); } @Bean(SHORT_CACHE_MANAGER) public CacheManager shortCacheManager() { Caffeine<Object, Object> caffeine = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofSeconds(30)) .recordStats(); return new CaffeineCacheManager(); } @Bean(LONG_CACHE_MANAGER) public CacheManager longCacheManager() { Caffeine<Object, Object> caffeine = Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(Duration.ofHours(2)) .refreshAfterWrite(Duration.ofHours(1)) .recordStats(); return new CaffeineCacheManager(); } }这里注意一个坑:Spring的CaffeineCacheManager默认对于@Cacheable(cacheNames = "xxx")会为每个cacheName创建一个全新的缓存实例,而不是复用你Builder里设定好的参数。解决办法是要么在CaffeineCacheManager构造时通过setCacheNames显式声明管理哪些缓存,要么在每个缓存名前缀上区分。否则你会发现明明在CacheManager里配了30秒过期,业务的缓存却是默认180秒,线上排查时容易一头雾水。
3.3 两级缓存读写封装:原子性就在这里体现
Spring Cache抽象本身不支持“先查本地、没命中再查Redis”这种跨存储的编排,需要我们自己封装一个CacheService。我直接用Caffeine的Cache接口和RedisTemplate写一个简单的例子:
@Component public class MultiLevelCacheService<T> { private final Cache<String, T> localCache; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; public MultiLevelCacheService(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { this.redisTemplate = redisTemplate; this.objectMapper = objectMapper; this.localCache = Caffeine.newBuilder() .maximumSize(20_000) .expireAfterWrite(Duration.ofMinutes(15)) .build(); } public T get(String key, Class<T> clazz, Supplier<T> dbLoader) { // 1. 先读本地缓存 T value = localCache.getIfPresent(key); if (value != null) { return value; } // 2. 本地没命中,读Redis String json = redisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { try { T t = objectMapper.readValue(json, clazz); localCache.put(key, t); return t; } catch (IOException e) { // 反序列化失败时不要吞异常,记录告警并继续走下一条链路 log.error("缓存反序列化失败, key={}", key, e); } } // 3. 数据库回源,双写一层和二层缓存 T loaded = dbLoader.get(); if (loaded != null) { localCache.put(key, loaded); redisTemplate.opsForValue().set(key, writeJson(loaded), Duration.ofMinutes(30)); } return loaded; } private String writeJson(T value) { try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException e) { throw new RuntimeException(e); } } }这段代码的核心逻辑是“层级递进回源”。但这里我必须提醒几个藏着的坑。
第一,反序列化失败不能被当作数据不存在。因为一旦你把异常吞了,下次请求还会重新走一次完整的回源链路,等于每次都在制造一次无谓的数据库查询。正确的做法是记录WARN日志并继续回源,同时做好Redis键的主动删除,避免脏数据一直盘旋。
第二,dbLoader.get()返回null时,一定要走空值缓存策略。不然那些不存在的ID每次请求都会穿透到数据库,布隆过滤器都懒得查你。
第三,整个get方法并不是线程安全的。两个线程同时本地未命中,可能同时查Redis、同时回源数据库。如果回源代价很高,就需要在回源那段加锁,保证同一时刻只有一个请求回源。这也是后面要专门讲互斥锁的原因。
3.4 空值缓存与布隆过滤器:穿透防护的组合拳
我在前面提到布隆过滤器,可能有人觉得冗余。但真正极端的攻击场景下,布隆过滤器确实是把好刀。设计上,我用Google的BloomFilter在Redis查询之前拦截不存在的key。
@Component public class BloomFilterService { private final BloomFilter<String> bloomFilter; public BloomFilterService() { // 预期数据量100万,误判率设置1% this.bloomFilter = BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 1_000_000, 0.01); // 实际项目中,初始化时应当从数据库把现有主键导入,或用定时任务增量维护 } public boolean mightContain(String key) { return bloomFilter.mightContain(key); } public void put(String key) { bloomFilter.put(key); } }布隆过滤器最大的问题是删除困难,你没法把一个键从过滤器里去掉。所以它更适合“只有新增、几乎没有删除”的业务形态,比如根据用户ID查用户信息。订单号、用户ID这类递增型数据都很合适。如果你的业务键大量动态变化,比如商品ID有频繁的上下架,那布隆过滤器会不断累积无效键,误判率上升,后面的空值缓存反而变成了更靠谱的手段。
空值缓存的实现也需要注意:空值的过期时间一定不能和正常数据一样长。正常数据可能缓存30分钟,空值30秒就够了。否则恶意请求构造大量随机不存在的ID,Redis里会堆积一堆无用键,占内存不说,还会让本应被缓存的热点数据被挤掉。
3.5 Redis序列化策略:避免默认序列化器的大坑
很多项目的RedisTemplate还是走默认的JdkSerializationRedisSerializer,后果就是Redis里存的不是人能看懂的JSON,而是带一堆类路径的二进制串。一旦你升级了实体类字段、修改了包名,反序列化就可能直接崩。我习惯直接把RedisTemplate的序列化器换成GenericJackson2JsonRedisSerializer,同时在实体类上加上@JsonSerialize注解控制日期格式。
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); // key 用 String,避免序列化后不可读 template.setKeySerializer(RedisSerializer.string()); template.setHashKeySerializer(RedisSerializer.string()); // value 用 JSON template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; }这里有个细节:GenericJackson2JsonRedisSerializer序列化时会带一个@class字段,反序列化的时候需要依赖类路径。所以如果你是多模块项目,不同服务共用同一个Redis,反序列化就会因为找不到类而报错。遇到这种场景,老老实实自己封装一个基于ObjectMapper的序列化器,别图省事用Generic的。
4. 缓存一致性:从“最终一致”到“强一致”的落地方案
多级缓存很容易出现“Redis更新了,Caffeine还在用旧数据”这种尴尬情况。要解决,需要分场景讨论。
4.1 基于过期时间的最终一致性方案
如果你的业务能接受几十秒的最终一致性,那最简单的方式就是靠Caffeine和Redis各自的过期时间错开。让Caffeine的过期时间明显短于Redis,比如Caffeine设30秒,Redis设5分钟。这样最坏情况下,本地数据最多脏30秒,30秒后强制从Redis重新拉取,Redis数据又在5分钟内从数据库刷新一次。
这个方案零运维成本,也不需要引入额外组件,是最稳妥、最常用的兜底方案。我一般在业务容忍度内把Caffeine的过期时间调短一点,这样即使出问题,恢复速度也快。
4.2 主动失效:写操作后立刻广播清除
如果需要更严格的一致性,就得在写路径上做“删缓存”的动作。这里有个经典问题:到底是更新完数据库就删Redis,还是等事务提交后再删?
我踩过一次坑:先更新数据库、再删Redis,结果删缓存那一步刚好失败,事务又已经提交,没法回滚,脏数据一直留着,直到过期才被纠正。后来我改成“先删缓存,再更新数据库”。这样可以保证如果第二次读发生在更新之前,它会在Redis里查不到旧值,直接去数据库拿最新的,天然避开了中间态的脏读。代价是删缓存和更新数据库之间有个短暂的空窗期,你会在那一下多打一次数据库,但我认为这是可以接受的。
更进一步,可以考虑用事务消息或者订阅数据库的binlog来异步删缓存。Spring里如果用@TransactionalEventListener配合@Transactional,可以拿到事务提交后的回调,在那个回调里执行删缓存操作,保证删缓存一定发生在数据稳定之后。
@Component public class CacheEvictListener { @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onEvict(CacheEvictEvent event) { // 在这里执行 Redis 和 Caffeine 的双删 caffeineCache.invalidate(event.getKey()); redisTemplate.delete(event.getKey()); } }这种方案在单机环境下很好用,但到了多实例部署时,本地缓存的主动失效仍然是个麻烦。本地缓存分布在每个JVM里,A节点删了自己的,B节点如果没有消息通知,依然存着旧值。这时候就需要引入Redis的Pub/Sub,或者用Redisson的分布式锁加广播机制。
4.3 使用Redis Pub/Sub广播本地缓存失效
我比较推荐用Redis的Pub/Sub来广播缓存失效事件。每个服务实例订阅同一个频道,收到消息后就清除本地缓存。实现很轻量,也不需要额外引入MQ。如果你已经有了Kafka或者RocketMQ,用MQ当然更稳,毕竟Redis的Pub/Sub有消息丢失的风险,进程重启期间发来的消息是收不到的。
@Component public class RedisCacheSubscriber { private final Cache<String, Object> localCache; public RedisCacheSubscriber(Cache<String, Object> localCache) { this.localCache = localCache; } @Bean public MessageListenerContainer messageListenerContainer(RedisConnectionFactory connectionFactory, RedisCacheSubscriber subscriber) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); container.addMessageListener((message, pattern) -> { String key = new String(message.getBody(), StandardCharsets.UTF_8); subscriber.localCache.invalidate(key); }, new PatternTopic("cache:evict")); return container; } }这里需要接受的现实是:Redis的Pub/Sub不保证消息不丢,消息也没持久化。如果一个节点刚好在发布消息时宕机重启,这条失效消息就永远消失了。所以“广播删本地缓存”只能作为加快一致性的手段,最终的兜底还是过期时间。两头一起抓,才能既保证性能,又不至于出大事故。
4.4 本地缓存和Redis结合使用时的“原子性”陷阱
注意,本地缓存和Redis的结构天然不是原子的。一个请求读本地缓存未命中,去读Redis时,可能另一个请求刚好把这个key写进了Redis,你读到的是新值;也可能你读到一半,对方把key删了,你拿到的是null。要避免这种问题,就要让“读Redis并写本地缓存”这个动作带锁。我常用的是synchronized包一层本地锁,或者直接用Caffeine自带的cache.get(key, k -> loadFromRedis(k))方法。Caffeine的get会保证同一个本地JVM里,同一个key只有一个线程执行回源逻辑,其他线程等着拿结果,这个特性天然避免了对同一个key的重复回源。
5. 缓存命中率与性能调优:数据不会说谎,先看再说
很多人上线了缓存,却从来没有看过命中率。我建议在配置类里一定要开启recordStats(),这一步会让你后面的调优事半功倍。
5.1 监控命中率:你应该关心的三个指标
Caffeine 3.x在recordStats()开启后,可以通过cache.stats()拿到hitCount、missCount、loadSuccessCount和loadExceptionCount。用Spring Boot Actuator的话,还可以通过CaffeineCacheMetrics暴露给监控平台。我一般会把命中率低于80%的缓存列为重点观察对象。
命中率 = hitCount / (hitCount + missCount)本地命中率如果低于70%,就要回头检查是不是过期时间太短、或者缓存容量被频繁淘汰。Redis命中率则通过INFO stats或云Redis控制台看,如果Redis的keyspace_hits占比也很低,说明本地缓存没有做好热点筛选,或者业务数据分布太散。
5.2 调优参数:不是越大越好
Caffeine的maximumSize不是越大越好。缓存越大,本地GC的代价越高,而且可能产生大量“冷数据把热点数据挤出”的问题。我建议根据业务接口的QPS分布去动态调整。
一个简单的方法:统计每个接口的调用量和单次回源耗时。如果某个接口QPS 1000,数据更新频率是每分钟一次,那么maximumSize设个5000已经非常宽裕,不需要塞几十万条。这里的核心思想是“缓存容量要匹配热点规模,而不是匹配全量数据规模”。
5.3refreshAfterWrite为什么能救命的延迟
我强烈推荐把refreshAfterWrite和expireAfterWrite配合起来用。refreshAfterWrite不是重新加载数据,而是允许旧值在后台异步刷新过程中继续对外服务。而expireAfterWrite则是硬性过期,过期后访问会触发回源。如果你只配expireAfterWrite,一旦缓存过期又恰逢高峰期,大批请求会同时打到数据库上,数据库扛不住就雪崩。配了refreshAfterWrite之后,更常见的情况是旧值还在服务,后台线程正在拉新值,用户的RT基本没有波动。
Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .refreshAfterWrite(Duration.ofMinutes(5)) .build(key -> loadFromDb(key));注意refreshAfterWrite只有在Caffeine的cache.get(key)调用时才会触发刷新,而且要配合CacheLoader使用。如果你用getIfPresent来读缓存,它是不会触发刷新的。
6. 生产环境避坑清单:这些错误我也犯过
最后这部分,我把这些年实战中踩过的坑整理成了清单。作为从业者之间的交流,我希望大家不要每个坑都亲自踩一遍。
6.1 缓存穿透导致的数据库压力不降反升
这是最容易出现的问题之一。你以为加了缓存数据库压力就小,结果业务上线后数据库CPU反而飙升。原因大多是空值没有缓存,或者缓存键设计不合理导致大量“间歇性热点”。比如订单表中,新用户的ID是随机散列的,老用户的ID是集中的,如果你按用户维度做本地缓存,那么新用户的请求每次都会穿透到底层。解决办法就是把空值缓存做好,同时布隆过滤器定期重建。
6.2 序列化类型变更后老缓存全部报废
我遇到过实体类里把int改成了long,结果Redis里存了一堆老版本的反序列化数据,一次性全部反序列化失败。这个问题的根源是没给缓存的版本号留后路。建议缓存的value里带一个version字段,变更结构时扫一遍Redis删掉旧版本,或者用更彻底的方案:发布时直接换个Redis库。
6.3 大key删除导致的Redis阻塞
如果你把一个大对象,比如一个几MB的商品聚合数据放到Redis里,删除的时候会阻塞Redis单线程。建议大对象拆成多个小key,或者用unlink代替delete,后者是异步删除,不会阻塞主线程。
6.4 多实例下本地缓存一致性问题
如果你上了两级缓存,本地缓存实例之间彼此是独立的,A节点无论如何删自己的缓存,B节点只能是“过期后才重建”。所以我的建议是:凡是需要强一致的场景,不要用本地缓存,直接走Redis。宁可多付出一次网络开销,也别为了省那几毫秒把数据搞乱。
6.5 缓存预热不是必选项,但流量洪峰前一定是
在一些已知的流量洪峰场景,比如秒杀、大促,如果没有预热,刚启动的实例缓存是空的,那么第一波请求全部会穿透到数据库,搞垮了实例还好说,数据库扛不住就直接全站崩溃。预热的方式可以是从Redis批量拉取热点key写入Caffeine,或者提前在数据库中跑一个统计SQL,选出top N热点数据塞进去。
6.6 一定要加上降级开关
最后一条铁律:任何时候,缓存方案都应该能随时被“一键关闭”,同时不影响核心业务。我在每个缓存读取路径上都加了配置开关,比如@ConditionalOnProperty或者一个简单的SwitchConfig,线上出问题时,动态关掉本地缓存或Redis层,让请求直接回源数据库,先保命再排查问题。
从第一次把Redis“裸奔”上线的青涩,到后来把Caffeine、Redis、布隆过滤器、Pub/Sub、异步刷新组合在一起形成一套体系,这个过程中最大的感受是:缓存方案没有银弹,不存在一套配置能适配所有业务。每次设计和调优,都要回到业务数据本身去研究访问规律、一致性要求、更新频率和回源代价。希望这篇文章能帮你把框架搭对,把坑填平,让你在做技术决策的时候,有更清晰的尺子可以拿。