Spring Cache 多缓存后端怎么选:Caffeine、Redis 与 NoOp 的路由边界
Spring Cache 同时配置 Caffeine、Redis 时,首先要回答的不是“有几级缓存”,而是一个缓存名称最终由哪个Cache实现承接。这个路由规则如果不清楚,开发者很容易误判读取顺序、故障降级和清理语义。
很多项目看到L2CacheManager、Caffeine 和 Redis 同时存在,就默认系统会先查本地缓存、未命中再查 Redis。MetaLite 当前源码并不是这种读穿链路:它按缓存名称选择一个后端,找不到配置时再降级到NoOpCache。
本文聚焦 Spring Cache 的多后端路由契约,逐项验证 Caffeine、Redis 与 NoOp 的选择顺序、同步加载和清理边界。至于 Caffeine 本地副本怎样跨实例失效,已经由文章 051 独立讲解,这里不再重复展开。
一、L2CacheManager 实际返回哪一个 Cache
MetaLite 将L2CacheManager注册为主CacheManager。它收到缓存名称后按顺序判断:
if(caffeineCacheManager!=null){Cachecache=caffeineCacheManager.getCache(name);if(cache!=null)returncache;}if(redisCacheManager!=null){Cachecache=redisCacheManager.getCache(name);if(cache!=null)returncache;}returnnewNoOpCache(name);这段逻辑的关键是:返回 Caffeine 以后,请求不会继续访问 Redis。
所以当前L2CacheManager更准确的定位是“缓存后端选择器”:某个名称优先使用 Caffeine;Caffeine 没有该名称时才尝试 Redis;两边都没有则返回 NoOpCache。
它不是一个把 L1 和 L2 串起来的读穿 Cache。
二、按名称选择有什么价值
这种模式仍然有实际价值。团队可以把缓存按数据性质分开:
- 极高频、允许短暂不一致的数据放 Caffeine;
- 多实例必须共享的数据放 Redis;
- 不需要缓存的环境关闭组件,让注解自动退化。
业务代码继续使用 Spring Cache 注解,不需要到处判断当前环境启用了哪种缓存。
但配置同名实例时,Caffeine 会优先,Redis 中即使存在同名配置也不会参与读取。这个优先级必须写进配置规范,否则维护者容易误以为两层都生效。
三、NoOpCache 为什么比返回 null 更安全
当两个缓存都未启用,L2CacheManager为缓存名称创建NoOpCache。它不保存数据,读取永远未命中,但能让 Spring Cache 调用链继续执行真实方法。
这种降级避免了开发、测试或轻量服务因为没有 Redis 而启动失败。
边界同样明确:缓存被误关闭时,系统不会立即报错,而是把全部流量打到真实方法或下游服务。可选能力提高了可用性,也要求监控缓存命中率和下游负载。
四、Caffeine 的 sync=true 解决什么问题
CaffeineCache.get(key, valueLoader)使用 Caffeine 原子加载,同一 JVM 中同一 key 可以只让一个线程执行加载逻辑。这正是@Cacheable(sync = true)想解决的缓存击穿问题。
但它只约束当前进程。十个服务实例同时未命中时,仍可能各有一个请求访问数据库或 RPC。
跨实例单飞需要分布式锁、请求合并或其他协调机制,不能由本地sync=true自动得到。
五、RedisCache 的 synchronized 不是分布式锁
RedisCache.get(key, valueLoader)使用实例方法上的synchronized:
publicsynchronized<T>Tget(Objectkey,Callable<T>valueLoader)它只能串行化当前 JVM、当前 Cache 对象上的加载,而且锁粒度覆盖所有 key。多个应用实例之间仍然会同时加载,单实例内不同 key 也可能互相等待。
如果目标是分布式防击穿,这个关键字不够;如果目标是细粒度本地合并,它的锁粒度又偏大。
六、Redis clear 当前并没有实现
RedisCache.clear()目前是空方法。调用@CacheEvict(allEntries = true)时,不能据此假设 Redis 中该缓存的所有键已经清除。
这也是为什么缓存抽象必须逐个验证方法语义:实现了 SpringCache接口,不代表每个操作都具有完整的分布式语义。
批量清理还涉及 key 前缀、SCAN、阻塞风险和误删边界,需要独立设计。
七、为什么“按名称路由”不能叫二级缓存
文章 051 处理的是多个 Caffeine 本地副本如何同步删除 key;055 讨论的是一个缓存名称最终选择哪个后端。前者属于失效通知,后者属于 CacheManager 路由,两者不能合并成同一个“缓存一致性”主题。
两者经常被混在一起:
- 跨实例失效属于一致性通知;
- L1→L2 读穿属于多层读取策略;
L2CacheManager当前实现的是后端选择。
概念分清以后,才能准确判断系统已经保证了什么。
八、如果要做真正的两级读穿需要什么
一个完整的组合 Cache 至少要定义:
- L1 未命中是否读取 L2;
- L2 命中后是否回填 L1;
- put、evict、clear 以什么顺序写两层;
- L2 故障时放行、失败还是只用 L1;
- 空值、TTL 和序列化规则怎样对齐;
- 并发加载如何避免多实例击穿。
这些语义没有实现之前,名称里出现 “L2” 也不应被宣传成完整的二级缓存链。
九、缓存设计首先要把语义说准确
MetaLite 当前实现提供了三种可用路径:Caffeine、Redis 和 NoOp,并通过统一CacheManager接入 Spring 注解体系。这已经减少了业务侧配置分叉。
下一步无论继续保持“按名称选后端”,还是演进为真正的两级缓存,都应该让命名、文档、监控和代码行为一致。缓存最危险的问题往往不是没有命中,而是团队以为系统具有一种实际上并不存在的保证。
十、四个断言可以证明它不是自动二级缓存
不要只看类名L2CacheManager。可以用四个断言验证真实语义:
- 同一个缓存名最终只返回一个
Cache实例,而不是先查 Caffeine、未命中再查 Redis; - 选择 Redis 后,本地 Caffeine 不会自动保存同一份值;
RedisCache.clear()当前没有提供完整全量清除语义;- 本地缓存跨实例失效解决的是 Caffeine 一致性,不会自动组成读穿式 L1/L2。
这四个验证结果决定了配置和故障预期。把“可以按名称选择本地或远程缓存”写成“已经具备二级缓存”,会让开发者错误推断命中顺序、回填和失效行为。
框架简介
MetaLite 是面向企业生产环境的新一代 Java 微服务技术底座。系列文章重点分享代码背后的设计思路、技术取舍与工程实践。
源码基线
JDK 21、Spring Boot 3.2.9、Spring Cloud 2023.0.1、Spring Cloud Alibaba 2023.0.1.3,具体组件版本以项目backend-bom为准。
作者简介
15 年 Spring 体系企业级开发经验,专注于 Java 微服务架构、工程治理与生产实践。
持续更新
MetaLite 系列内容将持续更新,围绕核心设计、源码链路、技术取舍与生产实践展开。欢迎关注作者,及时获取后续内容。
在线演示
演示地址: https://admin.metalite.top/
演示账号: guess
演示密码: admin@2026