SpringBoot 集成 layering-cache 实现两级缓存调研与实践
做后端开发这几年,缓存永远是个说不完的话题。尤其在高并发场景下,Redis 扛不住的时候,本地缓存就成了保命稻草。但本地缓存怎么和 Redis 配合,又不能让数据一致性成为新的坑,这里头的门道不少。我前阵子在做一个服务端的重构项目(某模拟项目的核心服务),缓存这块从单一 Redis 升级成了“本地缓存 + Redis”的两级缓存方案,用的就是 layering-cache 这个开源框架。这篇文章把这次调研和落地的完整过程写出来,从架构设计到配置细节,再到踩过的坑,希望能给正在做缓存选型的朋友一个参考。
先说结论:layering-cache 并不是缓存框架界的新面孔,但在 SpringBoot 体系下,它做两级缓存的思路确实干净——把 Caffeine 当一级缓存,Redis 当二级缓存,通过异步刷新 + 消息通知的机制来解决一致性难题。和手动写两层缓存逻辑相比,它能做到对业务代码几乎零侵入,只需注解声明,就能让服务同时吃上一级缓存的速度和二级缓存的容量。对于需要快速响应的读多写少场景,这套方案确实省心。
1. 整体架构与设计思路拆解
1.1 为什么是两级缓存,而不是单层 Redis 或纯本地缓存
先把需求摆出来。我们的核心服务是典型的读多写少场景,接口响应要求尽可能快,同时峰值流量会瞬间把数据库打满。早期只依赖 Redis 的时候,一次请求要经过网络 IO 往返,单次耗时在 1-3ms 左右,看起来不慢,但在每秒数万次请求的冲击下,Redis 的连接数和吞吐就成了瓶颈,数据库的查询压力虽然被挡住了,但 Redis 本身的 CPU 和网络开销开始飙升。
如果只用本地缓存,速度确实快得离谱,但问题也很明显:缓存数据散落在每个实例的内存里,一致性完全没法保证。某个实例更新了缓存,其他实例还是旧数据,用户请求一旦被负载均衡打到了不同节点,拿到的结果可能都不一样。这在金融、库存这类对一致性敏感的系统里是不能接受的。
两级缓存刚好互补:Caffeine 在应用内存里提供微秒级访问,扛住大部分热点读请求;Redis 作为共享缓存层,保存全量热点数据,同时承担 Caffeine 未命中的回源职责。最关键的是,更新缓存时可以通过 Redis 的消息通道通知各实例清除本地缓存,从而保证多实例环境下的一致性。这套思路不是 layering-cache 首创,但它把这个模式做成了直接可用的 SpringBoot 组件。
1.2 layering-cache 的核心机制
layering-cache 基于 Spring Cache 抽象,所以对已经使用 Spring Cache 注解(@Cacheable、@CacheEvict 等)的项目来说,迁移成本非常低。它内置了 Caffeine 作为一级缓存,Redis 作为二级缓存,同时提供了两个核心能力:
第一是异步刷新。一级缓存的 key 设置了较短的过期时间,当缓存过期且并发请求同时进来时,只会有一个请求去 Redis 回源并刷新本地缓存(即缓存击穿保护),其他请求直接返回旧值,这个机制在某些方案里叫 “Cache-aside” 的变体,但实现细节要优雅很多。
第二是缓存同步。通过 Redis 的 Pub/Sub 机制,当某个节点更新或删除缓存时,会广播一个清除本地缓存的通知,其他节点收到消息后删除对应的 Caffeine 缓存。这样既保证了一级缓存的命中率,又不会出现多实例间的脏读。
对比一下我之前用过的其他方案:
| 方案 | 一致性保障 | 实现复杂度 | 对业务侵入性 |
|---|---|---|---|
| 手动 Caffeine + Redis | 自己写双重清理逻辑 | 高 | 高 |
| Redisson 本地缓存 + RMap | 通过分布式锁部分保障 | 中 | 中 |
| layering-cache | 内置 Pub/Sub 通知 + 异步刷新 | 低 | 低 |
综合下来,layering-cache 在一致性保障和代码洁净度上的平衡是做得比较好的,这也是我们最终确定用它来做改造的原因。
1.3 适用场景和限制
两层缓存不是万能药,它适合的是“读多写少、热点集中、可容忍极短时间的不一致”的业务。比如商品详情、配置信息、用户会话摘要这类数据。但对于强一致的场景(比如账户余额),任何本地缓存方案都不合适,哪怕是极短暂的数据滞后也可能造成资损。这类数据还是要直接走数据库加锁或者用分布式事务方案。
另外要注意,一级缓存实际上是“用内存换速度”,每个实例都得为 Caffeine 分配堆外或堆内内存,实例多了以后,这部分内存的总量是相当可观的。部署 10 个实例,每个实例缓存 1GB 数据,就等于凭空吃掉 10GB 内存。所以一级缓存通常只用来存高热点中的高热点,全量热点还是该交给 Redis 去扛。
2. 环境准备与基础依赖集成
2.1 搭建 SpringBoot 基础工程
我们是在一个已有的 SpringBoot 2.7 项目上做的集成,JDK 用的 1.8。虽然 layering-cache 也支持更高版本,但在 2.7 上跑得很稳,没必要为了用这个组件去升级 SpringBoot。
基础工程这里不展开多说,直接看依赖。项目原本就有 Spring Web、MyBatis、Redis 这些基础模块,我们只是在 pom.xml 中额外引入了 layering-cache 相关的包:
<!-- layering-cache 核心包 --> <dependency> <groupId>com.github.xiaolyuh</groupId> <artifactId>layering-cache</artifactId> <version>2.1.2</version> </dependency> <!-- layering-cache 对 spring boot 的自动装配支持 --> <dependency> <groupId>com.github.xiaolyuh</groupId> <artifactId>layering-cache-spring-boot-starter</artifactId> <version>2.1.2</version> </dependency> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>2.9.3</version> </dependency>注意一点,layering-cache 的 starter 是直接依赖 Caffeine 的,但我还是建议显式声明 Caffeine 依赖,避免依赖传递时拿到不兼容的版本。这个项目早期没显式声明,结果 Caffeine 被传递到了 2.8.1,而这个版本在 JDK 1.8 下有个并发初始化的小 bug,后面排查了很久才定位到。
2.2 配置缓存管理器
依赖加完只是第一步,还得告诉 Spring 容器用 layering-cache 的缓存管理器替换掉默认的。这一步非常关键,不会配置正确就无法激活两级缓存。
我们需要创建一个配置类,声明缓存管理器。参考典型做法,在项目里新建 cache 包,写一个配置类:
@Configuration public class CacheConfig { @Bean public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { // 声明一个 Redis 的 key 序列化器,key 统一用 String 类型 RedisSerializer<String> keySerializer = new StringRedisSerializer(); // value 使用 JSON 序列化,方便在 Redis 里直接用客户端查看内容 GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); RedisCacheManager.Builder builder = RedisCacheManager.builder(connectionFactory) .cacheDefaults(redisCacheConfiguration(keySerializer, valueSerializer)); return LayeringCacheManager.builder() .redisCacheManager(builder.build()) // 一级缓存默认配置 .caffeineConfig(buildCaffeineConfig()) // 一级缓存过期时间与刷新策略 .build(); } private CaffeineSpec buildCaffeineConfig() { return CaffeineSpec.parse( "maximumSize=10000,expireAfterWrite=30s,recordStats" ); } private RedisCacheConfiguration redisCacheConfiguration( RedisSerializer<?> keySerializer, RedisSerializer<?> valueSerializer) { return RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(keySerializer)) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(valueSerializer)) .disableCachingNullValues() .entryTtl(Duration.ofMinutes(30)); } }这里的三个配置参数值得关注:
maximumSize=10000:一级缓存最多容纳 10000 个 key,超过了会按 LFU 算法淘汰最不常用的。expireAfterWrite=30s:一级缓存写入 30 秒后过期,这个时间主要靠经验调,太短会频繁回源 Redis,太长一致性窗口变大。recordStats:开启统计信息,后面做监控和排查命中率会用到。
Redis 二级缓存我这里设置的 TTL 是 30 分钟,因为底层数据变更不频繁,二级缓存承担的是回源数据库前的最后一道拦截。
2.3 关键配置项速查表
在调优过程中,我给几个最关键的配置项做了一个速查表,方便后期维护时快速确认参数含义:
| 配置项 | 作用 | 建议值 |
|---|---|---|
| maximumSize | 一级缓存最大 key 数 | 根据热点量估算,一般为业务 key 数的 10% |
| expireAfterWrite | 一级缓存写入后过期时间 | 30s - 2min,视数据一致性要求 |
| redisCache TTL | 二级缓存过期时间 | 10min - 2h,视数据更新频率 |
| cacheNullValue | 是否缓存空值,防穿透 | 建议开启,但注意设置短过期 |
| enableCacheSync | 是否启用缓存同步通知 | 必开,否则多实例一致性问题 |
需要注意的是,这些配置里的 TTL 值不是“越小越安全”,而是存在一个收益成本拐点。我见过有团队把一级缓存 TTL 设成 5 秒,这样命中率断崖式下降,本地缓存基本退化成摆设,Redis 的压力根本没降下来。具体多少合适,得结合你接口的实际调用频率来算。比如某个 key 每秒被访问 100 次,TTL 设 30 秒,等于这 30 秒内 3000 次请求全部命中本地缓存,Redis 只承受 1 次请求。这个账算清楚,你就能定出合理的 TTL。
3. 核心实现与业务代码接入
3.1 在 Service 层加缓存注解
配置配好了,业务接入比想象中简单。最关键的是,原有代码里如果有@Cacheable、@CachePut、@CacheEvict这些注解,可以直接复用。
以我们项目里的商品详情查询为例,改造前代码是直接查库加手动查 Redis:
public GoodsVO getGoodsById(Long goodsId) { // 手动查 Redis String cacheKey = "goods:" + goodsId; Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return (GoodsVO) cached; } // 查数据库 Goods goods = goodsMapper.selectById(goodsId); GoodsVO vo = convertToVO(goods); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(vo), 30, TimeUnit.MINUTES); return vo; }这段代码的问题很明显:业务逻辑里混了缓存细节,每个方法都要写一遍 Redis 操作,如果后续要加两级缓存,还得再套一层 Caffeine 逻辑。
改造后,业务代码被精简到只关注查询本身:
@Cacheable(cacheNames = "goodsCache", key = "#goodsId", unless = "#result == null") public GoodsVO getGoodsById(Long goodsId) { return convertToVO(goodsMapper.selectById(goodsId)); }不用在方法内部写任何缓存代码。layering-cache 拦截这个注解后,会先查 Caffeine,没命中再查 Redis,还没有就落到方法执行。整个过程从几十行缩到一句话,代码可读性和维护性提升非常明显。
值得注意的是unless = "#result == null"这个配置,我把查询结果为 null 的情况排除在缓存外。但后来实际压测时发现,如果在高并发下某个 key 对应的数据正好不存在,所有请求都会穿透到数据库,这个风险很大。后面我改成了cacheNullValue=true的设置,只在缓存空值上单独配置了很短的 TTL,确保不缓存“查不到”这个状态太久。这个经验后面会详细说。
3.2 缓存更新与淘汰策略的落地
有查询就得有更新和淘汰。在更新商品的服务里,我用@CachePut保证缓存跟着数据库一起变:
@CachePut(cacheNames = "goodsCache", key = "#goods.id") public GoodsVO updateGoods(Goods goods) { goodsMapper.updateById(goods); return convertToVO(goods); }但如果项目里更新操作返回的 VO 和查询返回的 VO 是同一个结构,这么用没问题。遇到更新方法返回的是影响行数而不是完整对象时,@CachePut就会把错误的数据写进缓存,这种情况建议直接改用@CacheEvict,宁可让缓存失效重新回源,也不要塞错误数据。
对于删除操作,用@CacheEvict:
@CacheEvict(cacheNames = "goodsCache", key = "#goodsId") public void deleteGoods(Long goodsId) { goodsMapper.deleteById(goodsId); }这里重点说明一下 layering-cache 和普通 Spring Cache 不同的地方:执行@CacheEvict时,它不只清除本地实例的 Caffeine 缓存,还会通过 Redis 消息通道把淘汰通知广播给所有实例。其他实例收消息后,各自删除本地缓存里对应的 key。这就解决了多实例缓存一致性的核心问题。
不过我在实测这个功能时发现,消息通知是有网络开销的,而一个节点收到消息到真正执行删除之间有一小段时间差。如果一个请求在这段时间差内命中了本地旧缓存,就会读到脏数据。这个窗口很短,一般是几毫秒到几十毫秒,对大多数场景可以接受。但如果业务不允许,就得在应用层面加一层版本号校验,或者干脆把一级缓存的 TTL 压到几秒。
3.3 手动缓存操作与空值处理
前面提到缓存空值防穿透,但 layering-cache 默认是不缓存 null 值的。这时候要么在注解上配置,要么在 Service 层返回一个空对象占位。
实际项目中我是这样处理的:在配置层把序列化方式调整好,同时在业务代码里对“空结果”做一次包装。我们项目的规范是,查询不到数据时抛一个业务异常,由全局异常处理器统一返回,因此实际上不会出现 null 被缓存的情况。但如果你的 Service 允许返回 null,且热点很高,那还是建议打开空值缓存:
@Cacheable(cacheNames = "goodsCache", key = "#goodsId", cacheNullValue = true, nullValueTTL = Duration.ofSeconds(10)) public GoodsVO getGoodsById(Long goodsId) { return goodsMapper.selectById(goodsId); }这几个参数里,nullValueTTL尤其关键。空值缓存本身是一种取舍,防的是缓存穿透,副作用是如果有数据写入数据库了,但还没更新缓存,那这期间查询会把“查不到”的状态返回给用户。所以空值 TTL 必须短。我用的是 10 秒,既能减缓突发穿透压力,又不会把“查不到”的状态保持太久。
3.4 全量缓存预热
项目上线后还碰到另一个问题:数据库热点数据很多,但 Caffeine 是惰性加载的,只有被请求到才会进缓存。刚启动完或发布完新版本,第一批流量会把 Redis 和数据库打得很痛。
为了解决这个问题,我写了一个缓存预热监听器,在服务启动完成后,把 Top N 热点商品 ID 从配置中心拉出来,先手动加载到 Redis,然后请求这些 key 触发一层缓存预热:
@Component public class CacheWarmupListener implements ApplicationRunner { private final CacheManager cacheManager; private final GoodsMapper goodsMapper; @Override public void run(ApplicationArguments args) { // 从配置中心读取热点商品ID集合 List<Long> hotIds = fetchHotIdsFromConfigCenter(); for (Long id : hotIds) { // 主动触发缓存加载 getGoodsById(id); } log.info("缓存预热完成,共加载 {} 个热点数据", hotIds.size()); } }预热完成后,一上线就有现成的缓存,不会有冷启动的“缓存雪崩”窗口期。但要注意预热时如果有大量 key 同时过期,会带来一波回源压力,所以预热逻辑里最好带点随机延迟,比如每个请求加 1-3 秒的随机等待间隔,这样数据库压力是均匀的,不会瞬间爆炸。
4. 实操过程与重点场景分析
4.1 多实例部署下的缓存同步实测
我们项目的服务是多个实例部署的,配置好了 layering-cache 后,做的第一件实测就是验证缓存同步到底靠不靠谱。
模拟场景是这样的:实例 A 和实例 B 同时放着商品 ID=10086 的本地缓存,我通过一个管理接口,让实例 A 执行商品更新(触发@CacheEvict),然后立刻在实例 B 上查询这个商品。预期结果是:实例 B 的本地缓存被主动清掉,下一次查询会回源到 Redis 拿最新值。
实测结果第一次是不符合预期的:实例 B 的查询结果依然是旧数据。排查过程在这里不细讲,后面问题排查部分会展开,但根因是我的 Redis 连接配置里把消息监听容器初始化顺序搞错了,导致 Pub/Sub 消息没法正常收到。调整完消息监听器的初始化时机后,重新测试,实例 A 的更新操作执行完大概 20ms 内,实例 B 的本地缓存就被清掉了。
有个细节值得提一下:layering-cache 的同步通知只清理本地缓存,并不会直接更新本地缓存。也就是说,实例 B 的 Caffeine 里没有了这个 key,但下一次请求回源时,它会先从 Redis 拿数据,再放回本地缓存。这就意味着,第一次读取会比之前多一次 Redis 往返,响应时间会有一个小的波动,但第二次之后就恢复了。这个现象属于正常行为,不算 bug,调整预期就好。
4.2 一级缓存统计与监控面板
既然用了 Caffeine,就不能不了解它的命中率。layering-cache 底层把 Caffeine 的统计能力暴露出来了,我在项目里加了一个简单的监控端点,输出一级缓存的命中率、淘汰数、加载耗时等指标。
用 Spring Boot Actuator 暴露一个自定义端点,直接在运行时查看:
@RestController public class CacheMetricsController { private final CacheManager cacheManager; @GetMapping("/internal/cache/metrics") public Map<String, Object> metrics() { LayeringCacheManager layeringCacheManager = (LayeringCacheManager) cacheManager; Map<String, Object> result = new HashMap<>(); layeringCacheManager.getCacheNames().forEach(name -> { Cache cache = layeringCacheManager.getCache(name); // 通过 Cache 对象获取 Caffeine 内部统计 com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache = (com.github.benmanes.caffeine.cache.Cache<Object, Object>) cache.getNativeCache(); CacheStats stats = caffeineCache.stats(); Map<String, Object> statMap = new HashMap<>(); statMap.put("requestCount", stats.requestCount()); statMap.put("hitCount", stats.hitCount()); statMap.put("hitRate", stats.hitRate()); statMap.put("evictionCount", stats.evictionCount()); result.put(name, statMap); }); return result; } }这个监控端点上线后帮了大忙。有一段时间我们开启了大促活动模拟,活动页的接口 TTL 设得太短,导致一级缓存命中率明显下降。通过这个端点定位到是某个活动查询的 key 失效过于频繁,调整了 TTL 之后命中率恢复到了 92% 以上。
4.3 性能对比实测数据
为了评估改造效果,我挑选了三个典型的接口做了一次有对比的实测。基准环境是模拟环境:单机部署,内存 4GB,数据库连接池上限 50,压测工具用 wrk,并发 200,持续 60 秒。
| 接口 | 纯 Redis 缓存方案平均响应 | 两级缓存方案平均响应 | 一级缓存命中率 |
|---|---|---|---|
| 商品详情查询 | 3.2ms | 0.35ms | 96.2% |
| 用户购物车聚合查询 | 8.7ms | 2.1ms | 77.4% |
| 活动页配置查询 | 2.4ms | 0.28ms | 98.1% |
商品详情这类热点高度集中的接口,收益最大,响应时间直接降了一个数量级。购物车聚合查询因为 key 比较分散,一级缓存命中率偏低,但整体响应依然提升了 4 倍。这个测试结果验证了一级缓存的价值,但也要看数据本身的访问特征,热点越集中收益越明显。
4.4 Redis 序列化方式选型分析
layering-cache 默认是用 JDK 序列化把对象写到 Redis。这有两个问题:第一,JDK 序列化出来的字节流体积大,压缩率差,存储和网络开销高;第二,跨语言读取困难,排查问题的时候拿 Redis 客户端去读,看到的是一堆二进制乱码。
我改成GenericJackson2JsonRedisSerializer之后,Redis 里存的就是 JSON 字符串,可以用命令行工具直接查看和调试。但改成 JSON 序列化也有代价:序列化和反序列化的 CPU 开销比 JDK 原生序列化要高一些。对于高频访问的接口,这个额外开销是可以接受的,因为 Redis 读一次也只是偶尔发生,一级缓存直接存的是 Java 对象,不需要序列化。
还有一个常见的坑,就是GenericJackson2JsonRedisSerializer在反序列化时会带出@class信息,如果后端对象的包名和类名改了,老数据就无法反序列化。所以在改造过程中,我特意在缓存对象的类上加了@JsonTypeInfo标识,并且把包名结构保持稳定,避免类名变化导致缓存数据失效。
5. 常见问题排查与避坑实录
5.1 缓存同步不生效,本地缓存各玩各的
这是多实例改造落地后最容易碰到的问题。表象是实例 A 更新了数据,但实例 B 的本地缓存里还是旧数据。排查顺序很有讲究:
第一步,确认enableCacheSync配置有没有打开。layering-cache 的 starter 里有个参数控制同步开关,如果没显式打开,默认可能是不开启的,那多实例之间就只有靠 TTL 过期来兜底。
第二步,确认 Redis 的 Pub/Sub 通道在多个实例间能正常互通。建议直接用 Redis 命令行开启一个客户端订阅通道,然后在另一个客户端 publish 一条消息,看看能不能收到。排除 Redis 配置本身的问题。
第三步,也是这个项目里卡最久的点:RedisMessageListenerContainer的初始化时机。由于 layering-cache 的缓存管理器在容器启动阶段就要注册监听器,而我们把 Redis 连接工厂也做了自定义扩展,导致监听容器在连接工厂完全装配完之前就初始化了,广播消息发到 Redis 后被立即丢弃,接收方当然什么也收不到。解决办法是给监听容器加一个@DependsOn声明,强制它在 RedisConnectionFactory 之后完成初始化。
5.2 缓存击穿与重复回源
即使有一级缓存,也有可能发生击穿。原因很简单:Caffeine 的 key 过期了,同一时刻有几十个请求同时进入方法体,虽然 layering-cache 内置了锁机制,但如果你的业务方法比较慢(比如查数据库耗时数百毫秒),在锁等待期间大量线程堆积,反而拖垮了服务线程池。
针对这种情况,我的做法是加两层防护:第一层,在一级缓存层面使用 Caffeine 的expireAfterWrite加一个随机值,防止大批量 key 同时过期(这个可以通过配置多个 cacheNames 解决,不同 cache 设置不同 TTL);第二层,在数据库查询外层加一个分布式锁,只允许一个线程真正去查库,其他线程阻塞等待。
public GoodsVO getGoodsByIdWithLock(Long goodsId) { String lockKey = "lock:goods:" + goodsId; RLock lock = redissonClient.getLock(lockKey); try { // 尝试拿锁,最多等 500ms if (lock.tryLock(500, TimeUnit.MILLISECONDS)) { // 加锁成功,重新先查一次缓存 GoodsVO cached = getFromCache(goodsId); if (cached != null) { return cached; } // 查库回源 return loadAndCache(goodsId); } else { // 没拿到锁,退化为直接查缓存或返回旧值 return getFromCache(goodsId); } } finally { lock.unlock(); } }这个方案的思路是:绝大多数情况下,一级缓存能直接命中,不需要走到锁这一步。只有在缓存过期的那一瞬,锁机制会拦截并发请求,保证只有一个线程回源。实测下来,200 并发下回源只有一个线程真正打到数据库,其余全部阻塞或走旧值。
5.3 class 属性变化导致 Redis 反序列化失败
这个坑在缓存长期运行后很容易踩到。某天我们给商品详情这个返回对象加了一个字段,去掉了一个废弃字段,然后发布上线。上线后日志立刻报反序列化异常,一堆请求全部失败。
原因是 Redis 里存的 JSON 字符串里带了@class信息,记录的是旧版本的类结构。新版本的类定义变了,Jackson 在反序列化时发现字段对不上,直接抛异常。
处理这个问题有两个方向:
一是在兼容期内保留废弃字段,在类上加@JsonIgnoreProperties(ignoreUnknown = true)忽略多余字段,新增字段给默认值,等缓存自然过期。二是如果缓存量大,不能等,就主动清理一批缓存让它们重新加载。我在项目里选了第二个方案,通过一个运维脚本,用 Redis 的 SCAN 命令按前缀批量删除 goodsCache 下的数据,人为触发缓存重建,配合错峰预热,问题很快解决了。
5.4 Caffeine 内存占用过高
一级缓存快是快,但内存消耗也不能忽视。早期配置里我把maximumSize设成了 100000,数据放太多,直接导致应用堆内存吃紧,频繁触发 Full GC。
排查过程很典型:先看监控,发现老年代空间一直打满,GC 时间很长。然后看了 Caffeine 的统计,发现缓存里塞了将近 9 万个 key,每个 key 对应的 value 平均 5KB,光这一项就吃掉了约 450MB。对一个小实例来说,这个开销不可接受。
调优建议是两条腿走路:控制maximumSize到合理的值,比如 20000,同时给 value 对象瘦身,只缓存接口出参,不缓存内部的实体大对象。调整后,内存占用降到 100MB 以内,GC 恢复正常。
另外,Caffeine 支持强弱引用配置,如果 JVM 堆紧张,可以配softValues(),让 GC 压力大时自动回收掉部分缓存。但要注意的是,开启软引用后 Caffeine 的淘汰逻辑会更激进,命中率会有一定下降,这个取舍要结合业务来权衡。
5.5 常见问题速查表
| 问题 | 表现 | 排查思路 |
|---|---|---|
| 缓存同步不生效 | 更新后其他实例仍读到旧数据 | 检查 enableCacheSync、监听容器初始化顺序、Pub/Sub 连通性 |
| Redis 反序列化失败 | 上线后请求异常,堆栈指向 JSON 相关类 | 检查 @class 字段、字段兼容性、清理旧缓存 |
| 一级缓存命中率低 | 监控显示很多请求落到 Redis | 调大 TTL、检查 key 设计、热点是否集中 |
| 内存占用过高 | 频繁 Full GC | 调小 maximumSize、精简缓存对象、开启 recordStats 观察 |
| 缓存穿透 | 热点 null 请求打到数据库 | 开启缓存空值,设置 nullValueTTL |
| 数据不一致 | 短暂读到旧数据 | 接受窗口期或缩短一级缓存 TTL,必要时关闭本地缓存 |
6. 核心配置与优化建议总结
6.1 基于业务的缓存分层策略
集成完成以后,我最大的体会是:layering-cache 只是提供了一个框架,真正的调优重心要放在“哪些数据值得放一级缓存”上。并不是所有读接口都适合加@Cacheable,特别是那种每次返回结果都可能不同、且访问频率不高的接口,加上一级缓存反而浪费内存。
我给项目的缓存使用定了几个原则:
- 热点数据(访问频率 top 20%)优先配置一级缓存,冷数据只走 Redis。
- 更新频繁的数据(比如订单状态)不设一级缓存,或者 TTL 压到 10 秒以内。
- 大对象(比如几十 KB 的详情流)不放进 Caffeine,内存成本太高,留 Redis 就好。
- 所有缓存 key 统一前缀和命名规范,方便排查和管理。
6.2 从单一 Redis 切换到两级缓存的注意事项
如果你们现在用的是纯 Redis 缓存方案,切到 layering-cache 的时候,有几个小细节需要注意:
第一,缓存 key 的序列化方式要保持一致。之前的 Redis 缓存的 key 可能是RedisTemplate序列化后的二进制字符串,而 layering-cache 默认用的是 String 序列化。如果不处理,会出现同一个业务 key 在 Redis 里存了两份的情况。所以改造前要把老的 key 清理掉,或者统一序列化方案。
第二,老代码里如果有手动操作 Redis 缓存的地方,要和注解方案协调好。比如某个接口一边用@Cacheable,另一边 Controller 里又手动写了个 Redis 删除操作,就会破坏 layering-cache 内部的缓存管理逻辑。最好是把所有缓存操作都收敛到注解这一层。
第三,应用重启阶段,一级缓存被清空,全部的缓存请求会先压到 Redis。如果 Redis 性能不够,建议启动时自动预热热点数据,避免冷启动期间数据库被打穿。
6.3 基于压测反馈的推荐配置模板
最后给出一份我们调整后的核心配置模板,可以作为一个还不错的起点。但记住,最好的参数一定是基于你的实际业务压测出来的,套模板之前先把业务请求特征摸清:
layering-cache: enable: true cache-sync: true caffeine: maximum-size: 20000 expire-after-write: 30s record-stats: true redis: expire-after-write: 30m cache-null-value: true null-value-ttl: 10s实际项目里配置可能散落在代码里,但逻辑一致。重点参数就是那几个:一级缓存 TTL 决定了响应速度和一致性的平衡点;maximumSize决定了内存占用上限;cache-sync决定了多实例间的一致性保障强度。
7. 个人经验总结与扩展思路
这次的调研和实践做下来,最直观的感受是:缓存方案的选型和业务特征强绑定,脱离场景谈架构都是纸上谈兵。layering-cache 的好处是把两级缓存这套通用方案做成了标准组件,让开发人员省去了很多重复造轮子的工作,但真正决定上线效果差距的,还是你对底层组件的理解深度。
如果你正打算在项目里引入两级缓存,我的建议是先从单条核心链路的改造开始,比如挑一个热点高度集中的查询接口,做完对比测试和数据观测之后,再决定要不要推广到其他接口。不要一口气把所有接口都加上一级缓存,那样只会让内存爆炸和一致性风险同时放大。
之前踩过不少坑,现在回顾起来,特别想强调一个点:缓存是系统的一等公民,它不只是 Redis 的事,更不只是加个注解的事。把缓存当做一个完整子系统来设计,监控、预热、修复、降级这些能力一个都不能缺。
后续如果大家有兴趣,还可以往这几个方向继续扩展:第一,把 Caffeine 的统计指标接入 Prometheus,做到分钟级缓存监控;第二,尝试在更新频繁的业务上用 Caffeine 的refreshAfterWrite实现后台自动刷新,进一步降低写操作带来的缓存不一致;第三,缓存埋点上做全链路追踪,排查问题时能看到每个请求在各级缓存之间的流转路径。这三个方向做好了,两级缓存方案就能从“能用”进化到“好用”,希望这篇文章能给你带来一些有用的参考。