我开发的keCache:面向 Spring Boot 3 的 L1 + L2 两级缓存框架
- 约 11 分钟读完 - 全文 4057 字在高并发 Java 业务里,缓存几乎是标配。但“用缓存”这件事从来不是只有“加 Redis”一种答案:本地缓存快却解决不了多机一致,Redis 能共享却多了网络与序列化开销。我最近实现一个把两者结合起来、并针对集群一致性做了不少细节处理的小而美框架 —— keCache,现在做一次系统梳理。
一、keCache 是什么
keCache 是一个基于 Java 17 + Spring Boot 3.x + Caffeine + Redisson 的本地缓存框架,采用 L1(Caffeine 本地缓存)+ L2(Redis 分布式缓存) 两级架构,同时提供:
- 声明式注解(
@LocalCacheable、@LocalCacheEvict、@LocalCacheClear) - 编程式 API(
CacheTemplate) - 集群失效广播(Redisson Pub/Sub)
- 空值保护(防止缓存穿透)
- 数据一致性校准 SPI
它的定位不是“又一个 Redis 客户端”,而是“让本地缓存能在集群环境下安全使用”的胶水层。
1.1 整体架构
graph TD
App[业务 Service] -->|注解或 API| Facade["CacheTemplate / AOP 切面"]
Facade --> L1{L1 Caffeine}
L1 -->|命中| Return[返回数据]
L1 -->|未命中| L2{L2 Redis}
L2 -->|命中| Deserialize[Jackson 反序列化]
Deserialize --> Backfill[回填 L1]
Backfill --> Return
L2 -->|未命中| Load["执行方法 / 加载数据"]
Load --> Write["写入 L1 & L2"]
Write --> Return
PubSub["Redisson Pub/Sub"] -->|失效事件| L1
Calibration[CalibrationProvider SPI] -->|定时校准| L2
二、开源地址
三、为什么需要 keCache?与纯 Redis / 纯 Caffeine 的对比
很多团队在选择缓存方案时,常常在“纯 Redis”和“纯 Caffeine”之间摇摆。keCache 试图在二者之间取得平衡。
| 维度 | 纯 Caffeine | 纯 Redis | keCache(L1 + L2) |
|---|---|---|---|
| 读取延迟 | 极低(内存内) | 较高(网络 + 序列化) | 热数据近似 Caffeine,冷数据走 Redis |
| 单机内存压力 | 大(全部数据在本 JVM) | 小(Redis 承担) | 小(只有热数据在本地) |
| 集群一致性 | 无,各节点独立加载 | 强一致 | 最终一致,写操作广播失效 |
| 缓存穿透保护 | 需自行实现 | 需自行实现 | 内置 NullValue 短 TTL 保护 |
| 多机共享 | 不支持 | 支持 | 支持(L2 Redis) |
| 适用场景 | 只读/准静态数据、单节点 | 写多读少、需要强一致 | 读多写少、可接受最终一致 |
3.1 相比纯 Caffeine 的好处
Caffeine 是 JVM 内缓存的佼佼者,但它天然只服务于当前进程。在集群部署时:
- 每台机器都要独立加载数据,导致数据库在启动/预热时被打 N 次。
- 数据更新后其他节点无法感知,容易出现 A 节点已更新、B 节点仍读旧值的“ stale data ”问题。
- 内存占用随节点数线性增长,同样一份热数据在每个 JVM 都存一份。
keCache 用 L2 Redis 作为共享底座:首次加载从 Redis 取,后续读请求命中本地 Caffeine;写操作时通过 Redis Pub/Sub 通知其他节点清除本地缓存。这样既保留了 Caffeine 的本地速度,又解决了多机一致性难题。
3.2 相比纯 Redis 的好处
Redis 虽然性能好,但毕竟跨进程:
- 网络 RTT 不可忽视:高频读取时,哪怕 1ms 也会累积成可观的延迟。
- 序列化/反序列化开销:Java 对象要先转成 JSON/二进制,再还原,CPU 和 GC 都有压力。
- 热点 Key 风险:大促期间某些热 Key 会被反复打到同一个 Redis 节点。
keCache 把最热的数据放在本地 JVM 里,只有本地未命中或更新时才访问 Redis,显著降低网络和序列化开销,也分散了 Redis 热点压力。
3.3 方案选型速查
flowchart TD
Start([开始选缓存方案]) --> Q1{是否需要多机共享?}
Q1 -->|否| Q2{数据是否只读 /<br/>准静态?}
Q2 -->|是| Caffeine[纯 Caffeine<br/>简单高效]
Q2 -->|否| Q3{能否接受最终一致?}
Q3 -->|能| KeCache[keCache<br/>L1 + L2 加速]
Q3 -->|否| RedisOnly[纯 Redis /<br/>DB 强一致]
Q1 -->|是| Q4{写多读少 /<br/>强一致?}
Q4 -->|是| RedisOnly
Q4 -->|否| KeCache
3.4 一句话总结
keCache 适合“读多写少、能接受最终一致、希望兼顾本地速度与集群共享”的业务场景;如果你的业务对实时一致性要求极高(如库存扣减、金融记账),建议直接走 Redis 或数据库方案。
四、核心能力速览
| 能力 | 说明 |
|---|---|
| 声明式缓存 | @LocalCacheable / @LocalCacheEvict / @LocalCacheClear,支持 SpEL |
| 多键映射 | @LocalCacheable.Multi 一次方法返回构建多个索引 key |
| 编程式 API | CacheTemplate.get/put/evict/clear,类型安全 |
| 二级缓存 | L1 Caffeine + L2 Redis,读时先 L1 再 L2,未命中回调加载 |
| 空值保护 | DB 不存在的 key 缓存短 TTL 的 NullValue,防止缓存穿透 |
| 集群一致性 | Redisson Pub/Sub 广播失效事件,其他 JVM 清除本地 L1 |
| 自失效屏蔽 | 事件携带 sourceId,本机忽略自己发出的事件,避免误清本地缓存 |
| 广播风暴防护 | 远程失效事件只清 L1,不再次广播 |
| 数据校准 | CalibrationProvider SPI,支持时间戳扫描或 CDC 扩展 |
4.1 读取流程
flowchart TD
Start([读请求]) --> L1[查 L1 Caffeine]
L1 -->|命中| A[直接返回]
L1 -->|未命中| L2[查 L2 Redis]
L2 -->|命中| Deserialize[Jackson 反序列化]
Deserialize --> Backfill[回填 L1]
Backfill --> Return[返回数据]
L2 -->|未命中| Method[执行真实方法]
Method --> NullCheck{结果是否为 null?}
NullCheck -->|是| NullVal[写入 NullValue 到 L1/L2<br/>TTL: null-value-ttl]
NullCheck -->|否| RealVal[写入真实值到 L1/L2]
NullVal --> ReturnNull[返回 null]
RealVal --> Return
4.2 写入与集群失效广播流程
sequenceDiagram
participant A as 节点 A(写请求)
participant DB as 数据库
participant R as Redis(L2)
participant P as Redis Pub/Sub
participant B as 节点 B
participant C as 节点 C
A->>DB: 更新数据
A->>R: 删除/覆盖 L2 Key
A->>P: 发布 EvictEvent(携带 sourceId)
A->>A: 清除本机 L1
P->>B: 接收 EvictEvent
B->>B: sourceId ≠ 本机?
B->>B: 清除本地 L1
Note over B: 只清 L1,不再次广播
P->>C: 接收 EvictEvent
C->>C: sourceId ≠ 本机?
C->>C: 清除本地 L1
五、快速开始
5.1 引入 Starter
<dependency>
<groupId>com.keke</groupId>
<artifactId>kecache-spring-boot-starter</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>5.2 启用缓存
import com.keke.kecache.starter.annotation.EnableKeCache;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@EnableKeCache
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}5.3 基础配置
local-cache:
enabled: true
namespace: kecache.sample
default-ttl: 30m
null-value-ttl: 5m
caffeine:
default:
maximum-size: 1000
expire-after-write: 10m
record-stats: true
redis:
address: redis://127.0.0.1:6379
broadcast-channel: kecache:evict
default-ttl: 30m
calibration:
enabled: false5.4 声明式使用示例
@Service
public class AccountService {
@LocalCacheable(
cacheNames = "tve_account_info",
key = "'id_' + #accountId",
unless = "#result == null"
)
public AccountInfo getById(long accountId) {
return accountDao.loadById(accountId);
}
// 一次查询构建多个索引 key
@LocalCacheable.Multi({
@LocalCacheable(cacheNames = "tve_account_info", key = "'id_' + #accountId"),
@LocalCacheable(cacheNames = "tve_account_info", key = "'nm_' + #result.accountName", unless = "#result == null")
})
public AccountInfo getByIdMultiIndex(long accountId) {
return accountDao.loadById(accountId);
}
@LocalCacheEvict(cacheNames = "tve_account_info", key = "'id_' + #accountId")
public void updateById(long accountId, AccountInfo info) {
accountDao.update(accountId, info);
}
@LocalCacheClear(cacheNames = "tve_account_info")
public void clearAll() {
accountDao.clear();
}
}5.5 编程式使用
@Service
public class AccountService {
@Autowired
private CacheTemplate cacheTemplate;
public AccountInfo getById(long accountId) {
return cacheTemplate.get("tve_account_info", "id_" + accountId, key -> {
return accountDao.loadById(accountId);
});
}
}六、模块结构
graph TD
Parent[kecache-parent] --> API[kecache-api<br/>注解 / SPI / 配置模型 / 事件]
Parent --> Core[kecache-core<br/>统一门面 / Key策略 / 空值保护]
Parent --> Caffeine[kecache-provider-caffeine<br/>L1 Caffeine 实现]
Parent --> Redisson[kecache-provider-redisson<br/>L2 Redis + 失效广播]
Parent --> Calibration[kecache-calibration<br/>数据一致性校准]
Parent --> Starter[kecache-spring-boot-starter<br/>自动配置 + AOP]
Parent --> Sample[kecache-spring-boot-sample<br/>示例应用 + 测试]
Starter --> API
Starter --> Core
Core --> Caffeine
Core --> Redisson
Core --> Calibration
七、关键约定(写代码前必读)
- L1 key:
cacheName#dataKey - L2 key:
{namespace|cacheName}:dataKey(使用 hash tag 避免集群模式下 slot 分散) - 默认读写策略:Cache-Aside
- 失效广播频道:默认
kecache:evict - 序列化:Jackson 启用 default typing,保留类型信息
八、使用注意点与避坑指南
8.1 Redis 是硬性依赖
L2 缓存和失效广播都依赖 Redis,测试与运行前请确保 Redis 可达。运行全部测试需要本地启动 Redis:
mvn clean test8.2 集群一致性是“最终一致”,不是实时一致
本地缓存存在一致窗口:A 节点写入并广播,B 节点收到消息前仍可能读到旧值。对于库存、余额、订单状态等强一致场景,建议绕过本地缓存,直接走 Redis 或数据库。
8.3 空值保护是把双刃剑
默认对 null 结果缓存 5 分钟,可防止缓存穿透。但如果业务刚刚从 DB 写入了一条新记录,而之前查询时正好命中了 NullValue,就会出现“写入后 5 分钟内查不到”的延迟。需要时可:
- 缩短
null-value-ttl - 在写入后主动
@LocalCacheEvict - 对新增数据敏感的 cacheName 关闭空值保护
8.4 序列化类型变更要兼容
Redis 层使用 Jackson 并保留类型信息。如果缓存对象发生不兼容的类结构变更(如字段类型改变、删除字段、包名调整),旧数据反序列化可能失败。建议:
- 对缓存 DTO 做版本控制
- 重大变更时清空 Redis 对应 key 或切换
cacheName - 避免把接口、匿名类、动态代理对象放入缓存
8.5 注意 Key 的命名空间隔离
L2 key 格式为 {namespace|cacheName}:dataKey。如果多个业务共用同一个 Redis 实例,务必设置不同的 namespace,否则可能出现 Key 冲突。
8.6 @LocalCacheable.Multi 的 result 引用要判空
多索引注解里如果通过 #result.xxx 取字段,必须配合 unless = "#result == null",否则方法返回 null 时会触发 SpEL 计算异常。
8.7 AOP 自调用不会生效
和 Spring Cache 一样,keCache 的注解基于 AOP。同类方法内部调用(this.method())不会走代理,缓存注解不会生效。需要时注入自身代理或改用编程式 CacheTemplate。
8.8 谨慎使用 @LocalCacheClear
@LocalCacheClear 会清空整个 cacheName 的本地缓存并广播。在高并发场景下,频繁全量清除会:
- 导致本地缓存命中率骤降
- 引发集体回源,DB 压力陡增
- 产生大量广播消息
建议优先使用 @LocalCacheEvict 精确驱逐。
8.9 校准任务不是开箱即用的
CalibrationProvider 目前只是 SPI 骨架,需要业务方自己实现 fetchChanges(Instant start, Instant end) 逻辑(如查变更表、CDC 日志)。开启 local-cache.calibration.enabled=true 后才会按 Cron 触发。
8.10 当前 Redis 部署模式限制
自动配置里的 RedissonClient 目前只实现了 single 单节点模式。如果生产环境使用 Redis Cluster、Sentinel 或主从,需要自行扩展配置。
8.11 关注 Caffeine 内存与统计
默认开启 record-stats: true,本地缓存命中率、加载次数等都会被记录。建议配合 actuator / Micrometer(后续可扩展)监控:
maximum-size是否设置合理- 本地缓存命中率
- 加载耗时与异常率
九、许可证
keCache 采用 MIT 协议开源。
十、总结
keCache 是一个目标聚焦、实现克制的两级缓存框架:
- 如果你想要 Caffeine 的速度 + Redis 的共享能力,它值得尝试。
- 如果你希望 声明式注解即可解决集群本地缓存一致性问题,它提供了失效广播与自失效屏蔽。
- 如果你的业务对 实时一致性零容忍,请把它当作“加速层”而非“唯一数据源”。
有兴趣可以直接到 Gitee 查看源码与示例:https://gitee.com/soulnew/ke-cache。