DDK Cache Starter 设计推演:从一个错误的实现说起
这篇是
ddk-cache-starter的设计文档。它从旧实现的一个根本性错误讲起——CompositeCacheManager并不能实现多级缓存——然后推导出正确的设计。状态:已按本文重写并落地,当前行为见 DDK Cache Starter。实现与本文草案的出入见第六节。下文第二节描述的是重写前的代码。
一、需求
ddk-cache-starter 想解决的问题很明确:让业务代码只写 @Cacheable,就自动获得「本地缓存 + 分布式缓存」两级结构。
- L1(Caffeine):进程内,纳秒级,容量小,各实例独立
- L2(Redis):跨进程共享,毫秒级,容量大
期望的读路径是:L1 命中直接返回 → L1 未命中查 L2 → L2 命中则回填 L1 并返回 → 都未命中则执行方法,结果写入 L2 和 L1。
二、现有实现为什么不成立
当前 CacheAutoConfiguration 的做法是造两个 CacheManager,再用 CompositeCacheManager 组合:
// ddk-starters/ddk-cache-starter/.../CacheAutoConfiguration.java
@Bean
@Primary
public CacheManager cacheManager(
@Qualifier("caffeineCacheManager") ObjectProvider<CacheManager> caffeineProvider,
@Qualifier("redisCacheManager") ObjectProvider<CacheManager> redisProvider,
CacheProperties cacheProperties) {
List<CacheManager> managers = new ArrayList<>();
// ... 把两个 manager 收集起来
return new CompositeCacheManager(managers.toArray(new CacheManager[0]));
}这段代码在整理本文时已做过一轮最小修复,让上下文至少能启动,但组合方式的根本问题没有改变。修复内容见第 2.4 节。
README 里描述的行为是「L1 未命中查 L2,L2 命中回填 L1」。但 CompositeCacheManager 做的完全是另一件事。
2.1 CompositeCacheManager 的真实语义
CompositeCacheManager 解决的是**「缓存名字分散在多个 CacheManager 里」**的问题。它的 getCache(name) 按顺序问每个委托的 manager「你有叫这个名字的缓存吗」,返回第一个非 null 的结果,然后就结束了。
所以对于 @Cacheable("user"):
CompositeCacheManager.getCache("user")
→ caffeineCacheManager.getCache("user") → 返回一个 Caffeine Cache 实例 ✅
→ 直接返回,redisCacheManager 根本不会被问到而 CaffeineCacheManager 默认允许动态创建缓存(setCacheNames 未调用时),意味着任何名字它都答得上来。结果是 Redis 永远拿不到请求,L2 形同不存在。
关键区别在于:多级缓存需要的是在单个 Cache 实例内部做分层查找,而 CompositeCacheManager 是在 CacheManager 层面做名字路由。这两者不在一个抽象层次上,前者不可能由后者实现。
2.2 现有测试为什么没发现
CacheAutoConfigurationTest 里有这么一个用例:
@Test
void whenBothConfigured_thenCompositeCacheManagerIsPrimary() {
// ...
assertThat(manager).isInstanceOf(CompositeCacheManager.class);
// ...
assertThat(service.getValue("3")).isEqualTo("value-3");
assertThat(service.getValue("3")).isEqualTo("value-3");
assertThat(service.getCallCount()).isEqualTo(1); // 「L1 生效了」
}这个断言会通过——但通过的原因是 Caffeine 独自完成了全部缓存,不是因为两级结构在工作。测试断言的是「结果被缓存了」,而没有断言「Redis 里也写入了」。它给了一个错误的安全感。
而且这个用例从来没有真正跑过——测试文件里有一行 Kotlin 式的 import 别名,是无效 Java:
// Java 没有 import 别名语法
import org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration as SpringCacheAutoConfiguration;它让整个 Maven reactor 在 ddk-cache-starter 处中断,后面 8 个模块全部 SKIPPED。由于项目当时没有 CI,这个状态维持了很久没人发现。
2.3 修好编译之后暴露的真正问题:上下文起不来
把 import 改成全限定名之后,4 个用例全部失败,而且失败原因不是断言,是 UnsatisfiedDependencyException——Spring 上下文根本无法启动:
Error creating bean with name 'cacheManager' defined in
com.ddk.cache.starter.config.CacheAutoConfiguration:
Unsatisfied dependency expressed through method 'cacheManager' parameter 2:
No qualifying bean of type
'org.springframework.boot.autoconfigure.cache.CacheProperties' available这是一个循环条件问题:
- 本配置注入 Spring Boot 的
CacheProperties CacheProperties由 Boot 的CacheAutoConfiguration通过@EnableConfigurationProperties注册- 而 Boot 的
CacheAutoConfiguration带@ConditionalOnMissingBean(CacheManager.class)——本配置注册了CacheManager,于是它退让 - 它一退让,
CacheProperties就没人注册了
依赖一个 Bean,同时又把提供这个 Bean 的自动配置顶掉了。 这正是「不要复用 spring.cache.* 和 Boot 的 CacheProperties」这条建议的实证依据。
2.4 已做的最小修复
为了让构建恢复,先做了三处不改变架构的修复:
| 修复 | 说明 |
|---|---|
加 @EnableConfigurationProperties(CacheProperties.class) | 自己注册 CacheProperties,上下文能启动 |
@Autowired(required=false) → ObjectProvider | 参数级 @Autowired 在 @Bean 方法上不是可靠的可选注入,缺 Bean 时直接抛异常 |
测试断言 hasSingleBean → 断言 @Primary 的 cacheManager | 本配置实际注册了 3 个 CacheManager,hasSingleBean 永远不可能成立 |
修复后 4 个用例通过。但要清楚:通过不等于正确——whenBothConfigured 依然是 Caffeine 独自完成缓存,Redis 依然没被访问。下面的重新设计才是解法。
2.5 顺带的几个问题
| 位置 | 问题 |
|---|---|
@ConditionalOnClass({..., Caffeine.class, RedisConnectionFactory.class}) | 要求 Caffeine 和 Redis 同时在 classpath,README 里宣传的「只用 Caffeine」模式实际不成立 |
复用 spring.cache.* 与 Boot 的 CacheProperties | 语义重叠,且靠 @Primary 硬压而没有声明 @AutoConfiguration(before/after),加载顺序不确定 |
caffeineCacheManager 方法内的默认 spec 分支 | 方法上有 @ConditionalOnProperty(spring.cache.caffeine.spec),所以「spec 为空时给默认值」的分支永远进不去,是死代码 |
if (!redisProperties.isUseKeyPrefix()) | 分支体是空的,只有一段注释说「这个有点 tricky」。配了 use-key-prefix=false 没有任何效果 |
无自己的 @ConfigurationProperties,无 additional-spring-configuration-metadata.json | IDE 里没有配置提示。顺带一提,根 pom 的 annotationProcessorPaths 里也没有 spring-boot-configuration-processor,所以就算加了 @ConfigurationProperties 也不会生成元数据 |
三、正确的设计
多级缓存必须实现在 Cache 这一层。Spring Cache 的抽象是两个接口:
public interface CacheManager {
Cache getCache(String name);
Collection<String> getCacheNames();
}
public interface Cache {
ValueWrapper get(Object key);
<T> T get(Object key, Callable<T> valueLoader);
void put(Object key, Object value);
void evict(Object key);
void clear();
}所以要写的是一个 TwoLevelCache implements Cache,内部持有 L1 和 L2 两个 Cache。
3.1 核心:TwoLevelCache
public class TwoLevelCache extends AbstractValueAdaptingCache {
private final String name;
private final Cache local; // Caffeine
private final Cache remote; // Redis
private final CacheMetrics metrics;
private final CacheEventPublisher publisher; // 用于跨实例失效广播
protected TwoLevelCache(String name, Cache local, Cache remote,
boolean allowNullValues,
CacheMetrics metrics, CacheEventPublisher publisher) {
super(allowNullValues);
this.name = name;
this.local = local;
this.remote = remote;
this.metrics = metrics;
this.publisher = publisher;
}
@Override
protected Object lookup(Object key) {
ValueWrapper l1 = local.get(key);
if (l1 != null) {
metrics.hitL1(name);
return l1.get();
}
ValueWrapper l2 = remote.get(key);
if (l2 != null) {
metrics.hitL2(name);
// 关键一步:回填 L1,下次就走本地
local.put(key, l2.get());
return l2.get();
}
metrics.miss(name);
return null;
}
@Override
public <T> T get(Object key, Callable<T> valueLoader) {
ValueWrapper existing = get(key);
if (existing != null) {
return (T) existing.get();
}
// 单飞:同一个 key 只让一个线程回源,避免缓存击穿
// Caffeine 的 get(key, mappingFunction) 本身对同 key 是串行的,
// 借它实现进程内单飞;跨进程的击穿由 L2 兜住
try {
T value = ((CaffeineCache) local).getNativeCache()
.get(key, k -> loadAndFill(k, valueLoader));
return value;
} catch (CompletionException e) {
throw new ValueRetrievalException(key, valueLoader, e.getCause());
}
}
private <T> T loadAndFill(Object key, Callable<T> loader) {
try {
T value = loader.call();
// 写序:先写 L2 再写 L1。反过来的话,L2 写失败会留下一个
// 只有本实例可见的值,其他实例读不到,行为不一致
remote.put(key, value);
return value;
} catch (Exception e) {
throw new CompletionException(e);
}
}
@Override
public void put(Object key, Object value) {
remote.put(key, value);
local.put(key, value);
// 通知其他实例失效它们的 L1(它们的 L1 里可能是旧值)
publisher.publishEvict(name, key);
}
@Override
public void evict(Object key) {
remote.evict(key);
local.evict(key);
publisher.publishEvict(name, key);
}
@Override
public void clear() {
remote.clear();
local.clear();
publisher.publishClear(name);
}
/** 收到其他实例的广播时调用,只清本地,不再转发 */
void evictLocalOnly(Object key) {
local.evict(key);
}
void clearLocalOnly() {
local.clear();
}
}3.2 最难的问题:L1 的跨实例一致性
这是多级缓存和单级缓存的本质区别,也是整个设计里最容易出事的地方。
L2 是共享的,一个实例改了所有实例都看得到。但 L1 是各自独立的:实例 A 更新了数据,实例 B 的 L1 里还是旧值,而且没有任何机制会让它失效,只能等 Caffeine 的 TTL 到期。
也就是说,引入 L1 的代价是引入了一个最长等于 L1 TTL 的不一致窗口。这个代价必须显式承认,然后决定怎么处理。
三种处理方式:
方式一:接受它,把 L1 TTL 设得很短。
最简单,也是我推荐作为默认值的做法。L1 TTL 设 30 秒到 1 分钟,配置层面强制一个上限。适用于「短时间内不一致可接受」的数据,实际上大多数缓存数据都属于这类。
方式二:Redis Pub/Sub 广播失效。
@Component
public class CacheEventPublisher {
private static final String CHANNEL = "ddk:cache:evict";
private final StringRedisTemplate redis;
private final String instanceId = UUID.randomUUID().toString();
public void publishEvict(String cacheName, Object key) {
redis.convertAndSend(CHANNEL, new EvictEvent(instanceId, cacheName, String.valueOf(key)));
}
}
@Component
public class CacheEventListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
EvictEvent event = deserialize(message.getBody());
// 跳过自己发的,避免重复清理
if (instanceId.equals(event.instanceId())) {
return;
}
TwoLevelCache cache = registry.find(event.cacheName());
if (cache != null) {
cache.evictLocalOnly(event.key()); // 只清 L1,不要再广播
}
}
}必须写清它的局限:Redis Pub/Sub 不保证投递。实例在断连期间发布的消息收不到,重连后不会补发。所以广播是「尽力而为的加速失效」,不能替代 TTL。TTL 依然是最终兜底——这一点和普通缓存一致性方案里的结论是一样的。
方式三:只在明确标记的缓存上启用 L1。
默认单级(只用 Redis),需要极致读性能的缓存显式开启 L1:
ddk:
cache:
default-ttl: 30m
local:
enabled: false # 默认不开 L1,避免一致性意外
caches:
dictionary: # 字典数据,几乎不变,开 L1
local-enabled: true
local-ttl: 10m
user-profile: # 用户资料,一致性要求高,不开 L1
ttl: 5m我倾向于方式三 + 方式二的组合:默认关闭 L1(安全默认值),开启 L1 的缓存自动带上广播失效。这样 L1 的一致性代价只出现在使用者明确选择的地方。
3.3 配置模型
不要复用 spring.cache.*,那会和 Spring Boot 自己的缓存自动配置打架。用独立的 ddk.cache 前缀:
@ConfigurationProperties(prefix = "ddk.cache")
public class DdkCacheProperties {
/** 总开关 */
private boolean enabled = true;
/** 默认 TTL,作用于 L2 */
private Duration defaultTtl = Duration.ofMinutes(30);
/** TTL 随机抖动比例,防止批量同时过期打穿数据库 */
private double ttlJitter = 0.1;
/** 是否缓存 null 值,防穿透 */
private boolean cacheNullValues = true;
/** null 值的 TTL,应显著短于正常值 */
private Duration nullValueTtl = Duration.ofMinutes(1);
/** key 前缀,多应用共用一个 Redis 时必须区分 */
private String keyPrefix = "ddk:cache:";
private final Local local = new Local();
/** 按缓存名的个性化配置,覆盖上面的默认值 */
private Map<String, CacheSpec> caches = new HashMap<>();
public static class Local {
/** 是否默认启用 L1。默认 false —— 安全优先 */
private boolean enabled = false;
private Duration ttl = Duration.ofSeconds(30);
private long maximumSize = 10_000;
/** L1 启用时是否广播失效 */
private boolean broadcastEvict = true;
}
public static class CacheSpec {
private Duration ttl;
private Boolean localEnabled;
private Duration localTtl;
private Long localMaximumSize;
}
}几个默认值的理由:
local.enabled = false:L1 带来一致性代价,不应该默认开启。使用者选择开启时,他就知道自己在换什么。local.ttl = 30s:远短于 L2 的 30 分钟。L1 的价值是挡住热点 key 的高频读,30 秒足够,同时把不一致窗口压到可接受范围。cacheNullValues = true+ 独立的短nullValueTtl:缓存 null 是防穿透的标准手段,但 null 的 TTL 必须比正常值短很多,否则数据真的写进去了还要等很久才能读到。ttlJitter = 0.1:批量预热的数据会在同一时刻过期,抖动是必需的。
3.4 自动配置
@AutoConfiguration(
// 必须声明顺序:要在 Boot 自己的缓存配置之前,否则 @ConditionalOnMissingBean 判定会反过来
before = org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration.class,
after = RedisAutoConfiguration.class
)
@ConditionalOnClass(CacheManager.class)
@ConditionalOnProperty(prefix = "ddk.cache", name = "enabled", matchIfMissing = true)
@EnableConfigurationProperties(DdkCacheProperties.class)
@EnableCaching
public class DdkCacheAutoConfiguration {
@Bean
@ConditionalOnMissingBean(CacheManager.class)
@ConditionalOnBean(RedisConnectionFactory.class)
public CacheManager ddkCacheManager(RedisConnectionFactory connectionFactory,
DdkCacheProperties props,
ObjectProvider<CacheEventPublisher> publisher,
ObjectProvider<MeterRegistry> meterRegistry) {
return new DdkCacheManager(connectionFactory, props,
publisher.getIfAvailable(CacheEventPublisher::noop),
new CacheMetrics(meterRegistry.getIfAvailable()));
}
// Redis 不可用时的降级:只用本地缓存,不要让应用起不来
@Bean
@ConditionalOnMissingBean(CacheManager.class)
public CacheManager localOnlyCacheManager(DdkCacheProperties props) {
log.warn("未检测到 RedisConnectionFactory,DDK Cache 降级为纯本地缓存");
return new CaffeineOnlyCacheManager(props);
}
}两个细节:
@AutoConfiguration(before = ...)不能省。Spring Boot 自己的CacheAutoConfiguration带@ConditionalOnMissingBean(CacheManager.class),谁先加载决定了谁生效。现有实现靠@Primary硬压,是绕过问题而不是解决问题。- Redis 不可用时要降级而不是启动失败。缓存是优化手段,不应该成为可用性的单点。
3.5 可观测性
缓存是最容易「以为在生效其实没生效」的组件。指标必须内置,而不是让使用者自己加:
public class CacheMetrics {
public void hitL1(String cache) {
counter("ddk.cache.access", cache, "l1_hit").increment();
}
public void hitL2(String cache) {
counter("ddk.cache.access", cache, "l2_hit").increment();
}
public void miss(String cache) {
counter("ddk.cache.access", cache, "miss").increment();
}
}注意标签只放 cache(缓存名,有限枚举)和 result,绝对不能把 key 放进标签——那是无界基数,会把时序数据库打爆。
有了这三个计数器就能算出真正有用的两个比率:
# L1 命中率:判断 L1 值不值得开
sum(rate(ddk_cache_access_total{result="l1_hit"}[5m])) by (cache)
/ sum(rate(ddk_cache_access_total[5m])) by (cache)
# 整体命中率:低于 50% 说明这个缓存可能没有意义
sum(rate(ddk_cache_access_total{result=~"l1_hit|l2_hit"}[5m])) by (cache)
/ sum(rate(ddk_cache_access_total[5m])) by (cache)3.6 序列化
L2 存 Redis 就要序列化,这里有个必须避开的坑:不要用 Jackson2JsonRedisSerializer 配 activateDefaultTyping。它会把类型信息写进 JSON,历史上出过反序列化漏洞,而且类改名/移包后老数据全部读不出来。
建议:
// 每个缓存绑定明确的值类型,用类型安全的序列化器
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer(objectMapper())))
.entryTtl(ttlWithJitter(props.getDefaultTtl(), props.getTtlJitter()));
private ObjectMapper objectMapper() {
return JsonMapper.builder()
.addModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
// 反序列化时忽略未知字段:让缓存能容忍字段新增,不用清空重建
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
}FAIL_ON_UNKNOWN_PROPERTIES 关掉这条很实际:给 DTO 加一个字段就要清空整个缓存,是运维上不可接受的。
四、测试策略
现有测试的问题是只断言了 Bean 类型,没断言行为。新的测试要覆盖行为,而且要能证伪。
class TwoLevelCacheTest {
@Container
static final GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Test
void L2命中时应回填L1() {
// 直接往 Redis 写,绕过 L1
remoteCache.put("k", "v");
// 第一次读:L1 未命中、L2 命中
assertThat(cache.get("k").get()).isEqualTo("v");
assertThat(metrics.count("l2_hit")).isEqualTo(1);
// 第二次读:应该走 L1,不再碰 Redis
assertThat(cache.get("k").get()).isEqualTo("v");
assertThat(metrics.count("l1_hit")).isEqualTo(1);
assertThat(metrics.count("l2_hit")).isEqualTo(1); // 没有增加
}
@Test
void 写入必须同时落到L1和L2() {
cache.put("k", "v");
// 断言 Redis 里真的有 —— 这正是现有测试缺失的
assertThat(redisTemplate.hasKey("ddk:cache:test::k")).isTrue();
assertThat(localCache.get("k")).isNotNull();
}
@Test
void 一个实例失效后另一个实例的L1应被广播清理() throws Exception {
var a = newCacheInstance("A");
var b = newCacheInstance("B");
a.put("k", "v1");
b.get("k"); // B 的 L1 里现在有 v1
assertThat(b.localOnlyGet("k")).isNotNull();
a.evict("k"); // A 失效并广播
await().atMost(2, SECONDS).untilAsserted(() ->
assertThat(b.localOnlyGet("k")).isNull());
}
@Test
void Redis不可用时应降级为本地缓存且不抛异常() {
redis.stop();
// 缓存是优化手段,挂了不能影响业务
assertThatNoException().isThrownBy(() -> cache.put("k", "v"));
assertThat(cache.get("k")).isNotNull(); // L1 仍然工作
}
}第三个和第四个用例是现有实现完全没有覆盖、但生产上一定会遇到的场景。
五、改造清单
- ✅ 修掉测试文件的语法错误,让构建恢复(见 2.4)
- ✅ 新增
DdkCacheProperties,前缀ddk.cache,停止复用spring.cache.* - ✅ 实现
TwoLevelCache extends AbstractValueAdaptingCache - ✅ 实现
DdkCacheManager,按缓存名装配 L1/L2 - ✅ 实现
RedisCacheInvalidationPublisher/CacheInvalidationListener,Redis Pub/Sub 广播 L1 失效 - ✅ 重写
DdkCacheAutoConfiguration,加上before/after声明,去掉@Primary - ✅ 加
CacheMetrics,接 Micrometer - ✅ 配置元数据由
spring-boot-configuration-processor从 javadoc 生成,不再手写 json - ✅ 用 Testcontainers 重写测试,覆盖回填、双写、广播失效、Redis 故障降级
- ✅ 重写 README
六、落地时的调整
| 草案 | 落地 | 原因 |
|---|---|---|
Redis 不可用时注册一个单独的 CaffeineOnlyCacheManager | 只有一个 DdkCacheManager,没有 L2 时 TwoLevelCache 强制启用 L1 | 「容器里没有连接工厂」和「Redis 运行时宕机」本质是同一件事,放在同一个类里处理,行为一致 |
Redis 宕机的降级靠外部 CacheErrorHandler | TwoLevelCache 内部把 L2 异常当作未命中,并计入 ddk.cache.remote.errors | 降级是这个缓存的语义,不该依赖使用方另外配置 |
| L1 用原始对象做 key | L1 与 L2 统一用转换后的字符串 key | 广播只能传字符串。L1 用原始对象时,收到 "42" 清不掉以 42L 存入的条目 |
| 3.6 节:不要用带类型信息的 JSON,按缓存名绑定值类型 | 复用 Redis Starter 的序列化器:带类型信息,但反序列化只放行白名单里的包 | @Cacheable 的返回值类型在缓存层拿不到,按缓存名绑定类型需要使用方逐个声明。类型白名单解决了安全问题,换包导致的读失败按未命中处理,会自动回源重建 |
| 没开 L1 时的单飞未定义 | 缓存级 ReentrantLock,拿到锁后先再查一次 L2 | 与 Spring 自带 RedisCache 的 sync 语义一致 |
| 总是订阅失效频道 | 至少一个缓存开启 L1 时才订阅 | 默认 L1 关闭,不用为用不到的广播占一条 Redis 连接 |
一个值得记下的细节:广播消息最初是一个带 isClear() 方法的 record。Jackson 把 isClear() 当成属性,序列化出多余的 clear 字段,接收端反序列化时因未知属性失败,每一条失效消息都被静默丢弃。单元测试用的是内存里的假 L2,碰不到这条路径;是 Testcontainers 用例「一个实例失效后另一个实例的 L1 被清理」超时才暴露出来。这再一次印证第四节的结论:断言行为,而且要在真实依赖上断言。
小结
这个 starter 的问题不在代码质量,而在抽象层次选错了:多级缓存要在 Cache 层实现,CompositeCacheManager 工作在 CacheManager 层做名字路由,用它做分层是不可能成立的。而 README 又把预期行为写得非常具体,测试还「通过」了,三者叠加形成了一个很难被发现的错误。
设计上真正需要想清楚的只有一件事:L1 引入了跨实例不一致,你打算怎么处理。默认关闭 L1、开启时自动广播失效、TTL 兜底——这是我给出的答案。
一个附带的教训:测试断言 Bean 的类型,几乎没有价值。 assertThat(manager).isInstanceOf(CompositeCacheManager.class) 通过了,但它保护的是实现细节,不是行为。断言「Redis 里有没有这个 key」才能发现这个 bug。