Spring Cache 只留注解:MetaLite 如何二开出首创式缓存运行时
Spring Cache 最吸引人的地方,是业务方法只写 @Cacheable、@CachePut 和 @CacheEvict,不需要关心底层缓存。但注解统一只解决了调用形式,并没有自动统一每个缓存的 TTL、本地与远程后端选择、空值策略、多实例失效和无缓存环境降级。
如果直接接受默认 CacheManager,团队得到的是一套通用 SPI;如果绕过 Spring Cache 自己发明注解,又会失去成熟的 AOP、SpEL 和开发者使用习惯。真正困难的选择,是怎样保留 Spring 原生编程模型,同时重写项目需要的运行时语义。
MetaLite 没有废弃 Spring Cache,而是沿着 CacheManager、Cache 和 AbstractValueAdaptingCache 三个扩展点二开:用 L2CacheManager 路由后端,用自定义 Caffeine/Redis Manager 管理不同实例,再在 CaffeineCache 与 RedisCache 中定义加载、写入、删除和失效行为。
| 设计动作 | MetaLite 的选择 | 为什么这样取舍 |
|---|---|---|
| 保留 | 注解、SpEL、AOP、Cache SPI | 不迫使业务学习第二套缓存语言 |
| 接管 | 后端路由、命名实例、TTL、空值与失效 | 把项目级不变量放进运行时 |
| 拒绝伪装 | 不把当前实现描述成 L1→L2 自动读穿 | 类名和愿景不能替代执行事实 |
这就是全文最重要的设计哲学:成熟框架已经稳定的部分继续复用,企业工程必须负责的部分才二开;扩展点是边界,不是炫技入口。
源码结论: 051 已经深挖 Caffeine 跨实例删除,055 已经证明当前不是自动 L1→L2 读穿。本文不重复两篇实现细节,而是回答一个更高层问题:为什么保留 Spring 注解、替换缓存运行时,是一种比"全用原生"或"全部自研"更稳的首创式工程选择。
"首创式"描述作者对这套组合和方法论的原创主张,不构成全球唯一性的外部证明。
一、Spring Cache 原生抽象解决了什么,又故意没有解决什么
Spring Cache 负责把业务方法与缓存操作解耦:
java
@Cacheable(cacheNames = "user", key = "#userId", sync = true)
public UserDto getUser(String userId) {
return loadUser(userId);
}
框架可以解析缓存名、Key、条件和同步加载标志,再调用一个 Cache 实现。但它不会替项目决定:
user应该使用 Caffeine 还是 Redis;- 每个缓存是否拥有不同容量和 TTL;
- 未启用任何缓存时接口是否还能运行;
- Caffeine 单机副本怎样通知其他实例;
- Redis
clear()应怎样避免全量扫描或误删; - 本地与远程同名时是读穿还是二选一;
- 日志怎样区分缓存命中、加载和删除。
这些问题不是 Spring Cache 的缺陷,而是通用框架必须留给应用定义的策略空间。
二、二开的第一原则:保留业务侧稳定接口
MetaLite 继续使用 Spring 原生注解和 SpEL,因此业务 Service 不需要学习另一套缓存语法:
text
业务层保持:@Cacheable / @CachePut / @CacheEvict
基础设施替换:CacheManager / Cache / 失效通知
这项选择降低了两个方向的耦合,也落实了首屏"保留原生编程模型"的决策。
对上,业务只依赖缓存意图,不依赖 Caffeine 或 Redis API;对下,底座可以改变缓存实例、路由规则和集群失效方式,而不必批量改写业务方法。
顶级工程思维不一定表现为创造新的注解。能识别 Spring 已经稳定的部分,并只重做项目必须拥有的部分,往往更难也更重要。
三、L2CacheManager 为什么是运行时路由器
L2CacheConfiguration 把 L2CacheManager 注册为 @Primary CacheManager。Spring 注解最终只面对这一个入口:
java
@Primary
@Bean
public CacheManager cacheManager(
CaffeineCacheManager caffeine,
RedisCacheManager redis) {
return new L2CacheManager(caffeine, redis);
}
getCache(name) 的真实顺序是:
text
命中 Caffeine 名称 → 返回 CaffeineCache
否则命中 Redis 名称 → 返回 RedisCache
都没有 → 返回 NoOpCache
因此当前实现是"按缓存名选择运行后端",不是先查本地、未命中再查 Redis 的二级读穿。L2CacheManager 这个类名和部分注释容易让人产生另一种理解,生产文档必须以执行代码为准。
四、为什么每个后端还要拥有自己的 CacheManager
直接使用一个全局 Caffeine 或 Redis 配置,很难表达不同数据的生命周期。
CaffeineCacheManager 从 CaffeineProperties.instanceList 创建多个命名实例,每个 CaffeineInstance 可以提供自己的 Caffeine spec。RedisCacheManager 同样从 RedisProperties.instanceList 创建多个 RedisCache,每个实例拥有独立过期时间。
| 层级 | 负责什么 | 不负责什么 |
|---|---|---|
L2CacheManager |
根据名称选择后端 | 不执行 L1→L2 读穿 |
CaffeineCacheManager |
创建本地命名缓存 | 不提供强一致集群协议 |
RedisCacheManager |
创建远程命名缓存 | 不自动治理热点和容量 |
CaffeineCache / RedisCache |
执行 Spring Cache 语义 | 不定义业务一致性等级 |
这种分层让"使用哪个后端"和"该后端怎样操作"成为两个独立决策。
五、为什么要继承 AbstractValueAdaptingCache
MetaLite 的两种 Cache 都继承 AbstractValueAdaptingCache,继续复用 Spring 对空值包装和 ValueWrapper 的抽象。
它们只实现后端差异:
text
CaffeineCache.lookup → cache.getIfPresent(key)
RedisCache.lookup → redisClient.vGet(key)
allowNullValues 则继续由 Spring 基类统一处理。这样既能缓存业务 null 以缓解穿透,又不会让 Caffeine 和 Redis 各写一套不同空值协议。
二开的关键不是"所有代码自己写",而是准确选择继承点:稳定的空值适配继续复用,后端访问与治理规则由项目接管。
六、CaffeineCache 二开增加了哪些原生之外的工程语义
自定义 CaffeineCache 使用每个实例的 spec 创建 native cache,并针对 Spring Cache 方法记录统一调用信息。
当 @Cacheable(sync=true) 触发 get(key, valueLoader) 时,底层使用 Caffeine 的原子 cache.get(key, function),让同一 JVM 中同一 Key 的加载合并。
当 evict(key) 或 evictIfPresent(key) 执行时,它先删除本地值,再交给 CaffeineHelper 通知同服务其他实例:
text
当前实例 invalidate
→ CaffeineHelper 记录调用
→ InternalServiceClient.callAllInstance
→ 其他实例 /api/cache/caffeine/evict
→ 远端直接 invalidate native cache
远端不再次调用普通 evict,否则会形成重复广播。这是 Spring Cache 单机抽象之外,MetaLite 增加的集群运行时语义。
七、RedisCache 为什么不直接使用默认 RedisCacheManager
自定义 RedisCache 复用前一篇所述的 RedisClient 契约,因此 Spring Cache 的写入也自动获得统一 Key、FastJson2 对象处理和 TTL 随机偏移入口。
java
public void put(Object key, Object value) {
if (expireTimeMillis <= 0) {
redisClient.vSet(key.toString(), toStoreValue(value));
} else {
redisClient.vSet(
key.toString(),
toStoreValue(value),
expireTimeMillis,
TimeUnit.MILLISECONDS
);
}
}
这样缓存注解与直接 Redis 数据访问不会形成两套 Key 和序列化规范。
但 RedisCache.get(key, valueLoader) 当前使用实例方法级 synchronized。它只在单 JVM 生效,而且同一个 Cache 实例的不同 Key 也会竞争同一把锁;这既不是分布式单飞,也不是细粒度 Key 锁。
八、NoOpCache 为什么是一项有争议的降级设计
当 Caffeine 和 Redis 都没有提供指定缓存名时,L2CacheManager 返回 NoOpCache。注解调用仍能继续,真实方法会被执行,只是不保存结果。
优点是服务可以在未启用缓存的环境运行,缓存从强依赖变成可选能力。
风险是配置拼错或缓存意外关闭时,应用不会立即失败,而可能把流量全部压到数据库或下游 RPC。可用性提高了,错误也更安静。
因此 NoOp 降级必须配合:
- 启动时输出实际缓存名与后端映射;
- 对意外 NoOp 产生告警;
- 监控命中率和源站负载;
- 对强依赖缓存的服务提供 fail-fast 配置选项。
当前源码提供 NoOp 回退,但尚未发现针对意外 NoOp 的专用告警闭环。
九、当前二开最重要的五个真实边界
1. 它不是自动二级读穿
同一个缓存名只返回一个 Cache。选择 Caffeine 后不会继续读取或回填 Redis。
2. Redis 全量 clear 尚未实现
RedisCache.clear() 当前为空。@CacheEvict(allEntries=true) 不能据此宣称已经删除远程缓存全部 Key。
3. Caffeine 全量清理不广播
CaffeineCache.clear() 和 invalidate() 只执行本地 invalidateAll(),跨实例通知只覆盖单 Key 删除。
4. 跨实例通知是异步尽力而为
CaffeineHelper 使用没有显式 Executor 的 CompletableFuture.runAsync,通知失败只记录警告,没有持久化重试、版本对账或 MQ 补偿。
5. 忽略自身只比较 IP
同一主机不同端口部署多个同名实例时,其他实例可能一起被过滤。实例身份更稳妥的比较应包含 IP、端口或注册实例 ID。
这些边界说明当前系统提供的是轻量后端路由与最终一致失效,不是完整分布式缓存平台。
十、为什么这不是"重复造 Spring Cache"
MetaLite 保留了 Spring Cache 最稳定的部分:
- 注解与 SpEL;
- AOP 调用拦截;
CacheManagerSPI;Cache接口;AbstractValueAdaptingCache空值适配。
它二开的部分,恰好是通用框架无法替项目决定的策略:
- 每个缓存的后端与配置;
- 未启用缓存时怎样降级;
- 本地缓存如何通知集群副本;
- Redis 调用如何遵守统一项目契约;
- 缓存动作如何进入 MetaLite 观测体系。
这叫沿扩展点二开,而不是把 Spring 源码复制一份改名字。
十一、怎样验收自定义缓存运行时
至少应建立以下测试矩阵:
| 测试场景 | 关键断言 |
|---|---|
| 仅启用 Caffeine | 指定名称返回 CaffeineCache |
| 仅启用 Redis | 指定名称返回 RedisCache |
| 两端同名 | 当前明确优先 Caffeine,不发生读穿 |
| 名称不存在 | 返回 NoOp,并产生可观测提示 |
sync=true 本地并发 |
同一 JVM、同一 Key 只加载一次 |
| Redis 多实例并发 | 不误判为分布式单飞 |
| Caffeine 单 Key 删除 | 本地先删,其他实例收到一次纯本地删除 |
| 通知失败 | 更新不回滚,旧值由 TTL 等机制兜底 |
allEntries=true |
测试暴露 Redis clear 与跨实例 clear 缺口 |
| 两实例同 IP 不同端口 | 测试暴露当前忽略自身判断问题 |
这些断言比"注解能命中缓存"更接近生产语义,因为它们验证的是路由、并发、降级和一致性。
十二、首创式缓存运行时的设计哲学
这套二开的核心可以压缩成一句话:
保留 Spring 稳定的开发者协议,把项目特有的运行时语义放进标准扩展点。
它同时避免两个极端:直接使用默认实现而把项目差异留给配置和人工记忆;完全自研注解体系而失去 Spring 生态与迁移能力。
作者的工程思维体现在"哪里不改"与"哪里必须改"同样清楚。注解、SpEL 和 SPI 已经成熟,所以继续使用;缓存实例、后端路由、集群失效和项目 Redis 契约属于真实缺口,所以沿扩展点接管。
当前命名、Redis clear、异步通知和真正二级读穿仍有演进空间。首创式设计不是宣称一步到位,而是先建立正确扩展轴,让后续能力可以集中演进,业务代码仍保持稳定。
框架简介
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