DDK Cache Starter
业务代码只写
@Cacheable,得到「Caffeine 本地缓存(L1)+ Redis 共享缓存(L2)」两级结构:回填、双写、跨实例失效广播、Redis 故障降级、命中率指标都内置。设计推导见缓存设计推演。
能做什么
text
@Cacheable 读
L1 命中 ─────────────────────────────► 返回
L1 未命中 → L2 命中 → 回填 L1 ─────────► 返回
L1 未命中 → L2 未命中 → 调用方法 → 写 L2 → 写 L1 → 返回
@CachePut / @CacheEvict
写/删 L2 → 写/删 L1 → 广播「清掉这个 key」给其他实例的 L1- 注册
DdkCacheManager:按缓存名创建TwoLevelCache,每个缓存的 TTL、是否启用 L1 分别可配。自动配置类上已标@EnableCaching。 TwoLevelCache:分层查找发生在单个Cache内部。CompositeCacheManager做不到这件事,它在CacheManager层按缓存名路由,第一个认识这个名字的 manager 接下全部请求。- L2 同步写入:Spring Data Redis 4 在 Lettuce 下默认异步写缓存,
evict返回时 Redis 里的旧值可能还在,其他实例收到失效广播后会把旧值读回 L1。这里显式开启immediateWrites()。 - L2 值序列化复用 Redis Starter 的序列化器,包括它的反序列化类型白名单(
ddk.redis.trusted-packages)。 - 失效广播:开启了 L1 的缓存,写入和删除后通过 Redis Pub/Sub 通知其他实例清理各自的 L1;收到消息的实例只清本地,不再转发,也不会因此创建自己没用过的缓存。
- 防穿透 / 防雪崩 / 防击穿:
- null 结果默认缓存,两级都用独立的短 TTL;
- L2 的 TTL 在配置值基础上随机延长(只延长不缩短);
@Cacheable(sync = true)时同一 key 在一个实例内只回源一次,拿到锁后先再查一次 L2。
- 指标:classpath 有 Micrometer 时输出
ddk.cache.access{cache, result=l1_hit|l2_hit|miss}与ddk.cache.remote.errors{cache, operation}。key 不进标签。
引入方式
xml
<dependency>
<groupId>com.ddk</groupId>
<artifactId>ddk-cache-starter</artifactId>
<version>${ddk.version}</version>
</dependency>传递引入 ddk-redis-starter(含 spring-boot-starter-data-redis)、spring-boot-starter-cache、caffeine;micrometer-core 是可选依赖。import 了 ddk-dependencies BOM 之后 <version> 可以省略。
yaml
ddk:
cache:
key-prefix: "order-service:cache:"
caches:
dictionary: # 字典数据,读多写少,开 L1
local-enabled: true
local-ttl: 5m
user-profile: # 一致性要求高,只用 Redis
ttl: 5m配置项
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
ddk.cache.enabled | boolean | true | 关闭后由 Spring Boot 自己的缓存自动配置接管 |
ddk.cache.default-ttl | Duration | 30m | L2 过期时间,0 表示不过期 |
ddk.cache.ttl-jitter | double | 0.1 | L2 过期时间随机延长的比例,取值 [0, 1] |
ddk.cache.cache-null-values | boolean | true | 是否缓存 null 返回值 |
ddk.cache.null-value-ttl | Duration | 1m | null 值的过期时间,两级都生效 |
ddk.cache.key-prefix | String | ddk:cache: | Redis key 为 <prefix><cache>::<key>;广播频道为 <prefix>invalidation。多个应用共用 Redis 时必须区分 |
ddk.cache.local.enabled | boolean | false | 所有缓存默认是否启用 L1 |
ddk.cache.local.ttl | Duration | 30s | L1 过期时间,也是跨实例不一致窗口的上限 |
ddk.cache.local.maximum-size | long | 10000 | 每个缓存的 L1 最大条目数 |
ddk.cache.local.broadcast-evict | boolean | true | 是否广播 L1 失效 |
ddk.cache.caches.<name>.ttl | Duration | 继承 | 该缓存的 L2 过期时间 |
ddk.cache.caches.<name>.local-enabled | Boolean | 继承 | 该缓存是否启用 L1 |
ddk.cache.caches.<name>.local-ttl | Duration | 继承 | 该缓存的 L1 过期时间 |
ddk.cache.caches.<name>.local-maximum-size | Long | 继承 | 该缓存的 L1 最大条目数 |
配置刻意独立于 spring.cache.*:那组配置由 Boot 的 CacheAutoConfiguration 注册,而它会因为本 starter 注册了 CacheManager 而退让。
装配条件与降级行为
- 自动配置类:
com.ddk.cache.starter.config.DdkCacheAutoConfigurationafter = {RedisAutoConfiguration, DdkRedisAutoConfiguration}:要用到连接工厂和值序列化器;before = CacheAutoConfiguration:Boot 的缓存配置在没有CacheManager时才生效,由声明顺序而不是@Primary决定谁生效。
- 条件:
@ConditionalOnProperty(ddk.cache.enabled, matchIfMissing = true);cacheManager:@ConditionalOnMissingBean(CacheManager.class),应用自定义CacheManager时整体退让;- 失效广播的发布器与
RedisMessageListenerContainer:需要容器里有RedisConnectionFactory,broadcast-evict为true,且至少一个缓存启用了 L1——都不用 L1 时不占用订阅连接。
- 降级:
- 容器里没有
RedisConnectionFactory→ 所有缓存只用 L1,应用照常启动,启动时打一条 WARN。 - Redis 服务不可达 → L2 的异常按未命中处理,计入
ddk.cache.remote.errors,不抛给业务。开了 L1 的缓存继续由 L1 服务;没开 L1 的缓存每次回源。 - 广播发布失败 → 只记日志,不影响本次写操作。
- 容器里没有
不适用的场景 / 已知问题
- 开启 L1 就接受了跨实例不一致。广播是尽力而为:Pub/Sub 不补发断连期间的消息,此时其他实例的 L1 要等
local.ttl到期。强一致要求的数据不要开 L1。 - 类名写在 L2 的数据里。类改名或换包后老缓存读不回来(读取失败按未命中处理,会回源重建);自定义值类型所在包需要在应用根包下或加入
ddk.redis.trusted-packages。 - 没开 L1 且 Redis 故障时没有进程内单飞保护之外的限流:每次调用都会回源,数据库压力由业务自己兜。
@Cacheable(sync = true)只在单实例内单飞,跨实例的并发回源没有分布式锁。- key 统一转成字符串。两个
toString()相同的不同 key 会被视为同一个条目;自定义 key 类型要保证字符串表示唯一。 - 不支持按缓存名单独配置 null 值 TTL 与序列化方式,这两项是全局的。