Skip to content

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 组合:

java
// 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 里有这么一个用例:

java
@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
// 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

这是一个循环条件问题:

  1. 本配置注入 Spring Boot 的 CacheProperties
  2. CacheProperties 由 Boot 的 CacheAutoConfiguration 通过 @EnableConfigurationProperties 注册
  3. 而 Boot 的 CacheAutoConfiguration 带 @ConditionalOnMissingBean(CacheManager.class)——本配置注册了 CacheManager,于是它退让
  4. 它一退让,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.jsonIDE 里没有配置提示。顺带一提,根 pom 的 annotationProcessorPaths 里也没有 spring-boot-configuration-processor,所以就算加了 @ConfigurationProperties 也不会生成元数据

三、正确的设计 ​

多级缓存必须实现在 Cache 这一层。Spring Cache 的抽象是两个接口:

java
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 ​

java
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 广播失效。

java
@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:

yaml
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 前缀:

java
@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 自动配置 ​

java
@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 可观测性 ​

缓存是最容易「以为在生效其实没生效」的组件。指标必须内置,而不是让使用者自己加:

java
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 放进标签——那是无界基数,会把时序数据库打爆。

有了这三个计数器就能算出真正有用的两个比率:

txt
# 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,历史上出过反序列化漏洞,而且类改名/移包后老数据全部读不出来。

建议:

java
// 每个缓存绑定明确的值类型,用类型安全的序列化器
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 类型,没断言行为。新的测试要覆盖行为,而且要能证伪。

java
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 仍然工作
    }
}

第三个和第四个用例是现有实现完全没有覆盖、但生产上一定会遇到的场景。

五、改造清单 ​

  1. ✅ 修掉测试文件的语法错误,让构建恢复(见 2.4)
  2. ✅ 新增 DdkCacheProperties,前缀 ddk.cache,停止复用 spring.cache.*
  3. ✅ 实现 TwoLevelCache extends AbstractValueAdaptingCache
  4. ✅ 实现 DdkCacheManager,按缓存名装配 L1/L2
  5. ✅ 实现 RedisCacheInvalidationPublisher / CacheInvalidationListener,Redis Pub/Sub 广播 L1 失效
  6. ✅ 重写 DdkCacheAutoConfiguration,加上 before / after 声明,去掉 @Primary
  7. ✅ 加 CacheMetrics,接 Micrometer
  8. ✅ 配置元数据由 spring-boot-configuration-processor 从 javadoc 生成,不再手写 json
  9. ✅ 用 Testcontainers 重写测试,覆盖回填、双写、广播失效、Redis 故障降级
  10. ✅ 重写 README

六、落地时的调整 ​

草案落地原因
Redis 不可用时注册一个单独的 CaffeineOnlyCacheManager只有一个 DdkCacheManager,没有 L2 时 TwoLevelCache 强制启用 L1「容器里没有连接工厂」和「Redis 运行时宕机」本质是同一件事,放在同一个类里处理,行为一致
Redis 宕机的降级靠外部 CacheErrorHandlerTwoLevelCache 内部把 L2 异常当作未命中,并计入 ddk.cache.remote.errors降级是这个缓存的语义,不该依赖使用方另外配置
L1 用原始对象做 keyL1 与 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。

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