Skip to content

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.enabledbooleantrue关闭后由 Spring Boot 自己的缓存自动配置接管
ddk.cache.default-ttlDuration30mL2 过期时间,0 表示不过期
ddk.cache.ttl-jitterdouble0.1L2 过期时间随机延长的比例,取值 [0, 1]
ddk.cache.cache-null-valuesbooleantrue是否缓存 null 返回值
ddk.cache.null-value-ttlDuration1mnull 值的过期时间,两级都生效
ddk.cache.key-prefixStringddk:cache:Redis key 为 <prefix><cache>::<key>;广播频道为 <prefix>invalidation。多个应用共用 Redis 时必须区分
ddk.cache.local.enabledbooleanfalse所有缓存默认是否启用 L1
ddk.cache.local.ttlDuration30sL1 过期时间,也是跨实例不一致窗口的上限
ddk.cache.local.maximum-sizelong10000每个缓存的 L1 最大条目数
ddk.cache.local.broadcast-evictbooleantrue是否广播 L1 失效
ddk.cache.caches.<name>.ttlDuration继承该缓存的 L2 过期时间
ddk.cache.caches.<name>.local-enabledBoolean继承该缓存是否启用 L1
ddk.cache.caches.<name>.local-ttlDuration继承该缓存的 L1 过期时间
ddk.cache.caches.<name>.local-maximum-sizeLong继承该缓存的 L1 最大条目数

配置刻意独立于 spring.cache.*:那组配置由 Boot 的 CacheAutoConfiguration 注册,而它会因为本 starter 注册了 CacheManager 而退让。

装配条件与降级行为 ​

  • 自动配置类:com.ddk.cache.starter.config.DdkCacheAutoConfiguration
    • after = {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 的缓存每次回源。
    • 广播发布失败 → 只记日志,不影响本次写操作。

不适用的场景 / 已知问题 ​

  1. 开启 L1 就接受了跨实例不一致。广播是尽力而为:Pub/Sub 不补发断连期间的消息,此时其他实例的 L1 要等 local.ttl 到期。强一致要求的数据不要开 L1。
  2. 类名写在 L2 的数据里。类改名或换包后老缓存读不回来(读取失败按未命中处理,会回源重建);自定义值类型所在包需要在应用根包下或加入 ddk.redis.trusted-packages。
  3. 没开 L1 且 Redis 故障时没有进程内单飞保护之外的限流:每次调用都会回源,数据库压力由业务自己兜。
  4. @Cacheable(sync = true) 只在单实例内单飞,跨实例的并发回源没有分布式锁。
  5. key 统一转成字符串。两个 toString() 相同的不同 key 会被视为同一个条目;自定义 key 类型要保证字符串表示唯一。
  6. 不支持按缓存名单独配置 null 值 TTL 与序列化方式,这两项是全局的。

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权