返回

Micronaut Caffeine expire-after-write 测试为何“罢工”?原因与对策

java

搞定 Micronaut Caffeine 缓存:为何 expire-after-write 时常“罢工”?

使用 Micronaut 配合 Caffeine 做本地缓存,是个挺常见的选择。Caffeine 本身性能优异,配置也灵活。但有时候,一些看似简单的配置,比如 expire-after-write,在测试或者特定场景下,偏偏就不按预期工作,数据到期了却还在缓存里赖着不走。这可就有点头疼了。

这不,就有朋友遇到了这么个事儿:

问题现象复现

application.yml 里,是这么配的:

micronaut:
  application:
    name: isac-card
  cache:
    redis:
      enabled: false # 先把 Redis 关了,专心搞 Caffeine
    caffeine:
      enabled: true
      caches:
        keyConfig:          # 针对名为 keyConfig 的缓存配置
          maximum-size: 1000
          expire-after-write: 2s # 设定写入2秒后过期

然后呢,服务代码大概长这样:

import io.micronaut.cache.annotation.Cacheable;
import io.micronaut.cache.annotation.CacheInvalidate;
import jakarta.inject.Singleton;

@Singleton
public class CacheService {

    // 模拟从数据库或者远程服务获取值
    private String fetchValueFromSource() {
        System.out.println("Fetching from AWS/DB..."); // 日志里用 "Fetching from AWS"
        return "expensive_value_" + System.currentTimeMillis();
    }

    @Cacheable(value = "keyConfig")
    public String getValue() {
        return fetchValueFromSource();
    }

    @CacheInvalidate("keyConfig")
    public void invalidateValue() {
        System.out.println("Invalidating cache...");
    }
}

测试代码,期望缓存能在 2 秒后过期:

import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

// @MicronautTest // 稍后我们会讨论为什么这个注解很重要
class CacheServiceTest {

    @Inject
    CacheService cacheService;

    @Test
    void testCacheExpiry() {
        // 为了确保测试环境的隔离性,每次测试前可以先清一下,如果需要的话
        // 不过对于这个特定问题,关键点不在这里
        // cacheService.invalidateValue(); 

        System.out.println("第一次调用: " + cacheService.getValue()); // 期望从数据源获取
        System.out.println("第二次调用: " + cacheService.getValue()); // 期望从缓存获取

        try {
            System.out.println("等待3秒,让缓存过期...");
            Thread.sleep(3000); // 等待超过2秒的过期时间
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt(); // 恢复中断状态
            throw new RuntimeException(e);
        }

        System.out.println("等待3秒后调用: " + cacheService.getValue()); // 期望因为过期,再次从数据源获取

        // 下面这行是为了演示 invalidate 功能,它工作正常
        // cacheService.invalidateValue();
        // System.out.println("手动失效后调用: " + cacheService.getValue());
    }
}

跑了一下测试,日志输出却有点不听话:

第一次调用: Fetching from AWS/DB...
expensive_value_1678886401000
第二次调用: expensive_value_1678886401000 (符合预期,从缓存来)
等待3秒,让缓存过期...
等待3秒后调用: expensive_value_1678886401000 (问题在这!还从缓存来,没过期!)

日志里还可能看到类似这样的信息:
Value found in cache [keyConfig] for invocation: String getValue()
即使是在 Thread.sleep(3000) 之后,值依然从缓存中获取,这显然不是我们想要的 expire-after-write: 2s 的效果。但如果手动调用 cacheService.invalidateValue(),缓存确实会被清掉,下次调用就会重新从数据源加载。

那么,问题出在哪儿呢?

一、过期策略为何“失效”?原因剖析

Caffeine 的缓存过期并不是一个有独立线程死死盯着每个缓存项的倒计时器。它的过期清理机制,更像是一种“惰性”混合“定期”的策略。

  1. 惰性驱逐 (Lazy Eviction):
    当你尝试读取一个缓存项时,Caffeine 会检查它是不是已经过期了。如果过期了,它不会返回这个旧值,而是会加载新值(如果配置了加载器),然后把旧的干掉。写操作也会触发类似的检查和清理。

  2. 后台维护 (Background Maintenance):
    Caffeine 内部有一个小的维护任务,会定期执行。这个任务会做一些清理工作,包括移除那些已经过期但一直没被访问到的“陈旧”数据。这个任务依赖于 ScheduledExecutorService

  3. Thread.sleep() 的局限性:
    在单元测试里用 Thread.sleep() 来等待缓存过期,有时候会遇到麻烦。为什么呢?

    • 测试上下文可能不完整: 一个普通的 JUnit @Test 方法,可能没有完全启动 Micronaut 应用的上下文,特别是那些依赖后台线程池的服务(比如 Caffeine 的定期清理)。
    • Ticker 的影响: Caffeine 使用 Ticker 接口来获取当前时间,默认是 Ticker.systemTicker() (基于 System.nanoTime())。在纯粹的 Thread.sleep() 期间,如果没有任何缓存访问操作,并且后台维护任务没机会运行或者压根没启动,那么缓存项即使“逻辑上”过期了,也可能没被真正从缓存中移除。Caffeine 不会因为你 sleep 了一下就主动唤醒一个线程去挨个检查过期。
  4. 测试方法与实际运行的差异:
    在真实的 Micronaut 应用跑起来的时候,通常会有持续的请求和操作,这些操作会间接触发 Caffeine 的读写检查,从而清理过期项。或者,应用的生命周期管理也会确保 Caffeine 的后台维护任务正常运行。测试环境往往安静得多。

所以,原生的 JUnit 测试配合 Thread.sleep 可能无法准确模拟 Caffeine 在完整应用环境下的过期行为。

二、对症下药:让缓存乖乖过期

知道了原因,我们就可以有针对性地调整测试方法和理解其行为了。

方案一:使用 @MicronautTest (推荐用于此类集成场景)

这是最直接也是最符合 Micronaut 测试哲学的方法。

  • 原理与作用:
    @MicronautTest 注解会启动一个完整的 Micronaut 应用上下文。这意味着所有在应用运行时会初始化的 Bean、监听器、后台任务等,都会在测试期间被正确加载和运行。Caffeine 缓存的后台维护任务(如果配置了调度器)就有机会正常工作了。

  • 操作步骤:

    1. 在测试类上添加 @MicronautTest 注解。
    2. 确保测试类和被测试的 Service 都在同一个包下,或者 @MicronautTest 能扫描到。
    3. 通过 @Inject 注入你的服务。
  • 代码示例:

    import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
    import jakarta.inject.Inject;
    import org.junit.jupiter.api.Assertions;
    import org.junit.jupiter.api.Test;
    
    @MicronautTest // 关键在于这个注解
    class CacheServiceMicronautTest {
    
        @Inject
        CacheService cacheService;
    
        @Test
        void testCacheExpiryWithMicronautTest() throws InterruptedException {
            // 为了结果可观察,我们让数据源每次返回不同的值
            // 如果你的 CacheService.fetchValueFromSource() 已经这么做了,这里可以简化
    
            String firstValue = cacheService.getValue();
            System.out.println("第一次调用 (MicronautTest): " + firstValue);
    
            String secondValue = cacheService.getValue();
            System.out.println("第二次调用 (MicronautTest): " + secondValue);
            Assertions.assertEquals(firstValue, secondValue, "第二次调用应该从缓存获取相同的值");
    
            System.out.println("等待3秒,让缓存过期 (MicronautTest)...");
            Thread.sleep(3000); // 等待时间要大于配置的 expire-after-write
    
            String thirdValue = cacheService.getValue();
            System.out.println("等待3秒后调用 (MicronautTest): " + thirdValue);
            Assertions.assertNotEquals(firstValue, thirdValue, "等待过期后,应该获取到新的值");
        }
    }
    

    当你使用 @MicronautTest 时,你会发现之前的 getValue() 方法中的 System.out.println("Fetching from AWS/DB...") 在第三次调用时会被打印,表明缓存确实失效并重新加载了。

  • 额外安全建议:
    @MicronautTest 会启动应用上下文,如果你的应用依赖外部服务(数据库、消息队列等),确保测试环境有这些依赖的模拟实现(如Testcontainers)或者测试实例,避免测试污染生产环境或因外部服务不可用导致测试失败。

  • 进阶使用技巧:
    @MicronautTest 可以配合 @Property 注解来覆盖 application.yml 中的配置,方便针对特定测试场景调整参数,比如动态修改缓存的过期时间进行测试。

    @MicronautTest(propertySources = "classpath:test-application.yml")
    // 或者
    @MicronautTest
    @Property(name = "micronaut.cache.caffeine.caches.keyConfig.expire-after-write", value = "1s")
    

方案二:手动触发 Caffeine 缓存清理 (适用于更精细的单元测试控制)

如果你不想启动完整的 Micronaut 上下文,或者想在普通单元测试中更精确地控制过期检查,可以尝试手动调用 Caffeine 的清理方法。

  • 原理与作用:
    Caffeine 的 Cache 接口有一个 cleanUp() 方法。调用此方法会强制执行一次缓存维护操作,包括清理那些已过期的条目。这对于在 Thread.sleep() 之后,立即验证过期状态非常有用。

  • 操作步骤:

    1. 你需要获取到原始的 Caffeine Cache 对象。可以通过 Micronaut 的 CacheManager 获得。
    2. Thread.sleep() 之后,调用 nativeCache().cleanUp()
  • 代码示例:

    import com.github.benmanes.caffeine.cache.Cache; // 引入 Caffeine 的 Cache
    import io.micronaut.cache.CacheManager;
    // import io.micronaut.test.extensions.junit5.annotation.MicronautTest; // 如果不用MicronautTest, 需要手动管理Bean
    import jakarta.inject.Inject;    // JSR-330 Inject
    import org.junit.jupiter.api.Assertions;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import io.micronaut.context.ApplicationContext; // 手动获取Bean时需要
    
    // 这个例子演示不用 @MicronautTest,手动获取bean并调用cleanup
    // 如果你的测试类已经用了 @MicronautTest,则可以直接 @Inject CacheManager
    class CacheServiceManualCleanupTest {
    
        // 如果不用 @MicronautTest,需要手动创建和管理 ApplicationContext
        // 但通常测试时,建议用 @MicronautTest
        // 这里为了演示 cleanUp 的独立性,暂时这么写。
        // 实际项目中,要么用 @MicronautTest, 要么你需要有机制注入 CacheManager
    
        CacheService cacheService;
        CacheManager cacheManager;
        ApplicationContext context;
    
        @BeforeEach
        void setUp() {
            // 注意:在实际项目中,如果你不用 @MicronautTest,就需要一个 ApplicationContext 实例
            // 如果是在一个 @MicronautTest 类里,可以直接 @Inject CacheManager 和 CacheService
            context = ApplicationContext.run(); // 简单启动一个上下文
            cacheService = context.getBean(CacheService.class);
            cacheManager = context.getBean(CacheManager.class);
        }
    
        //  @AfterEach
        //  void tearDown() {
        //      if (context != null) {
        //          context.close();
        //      }
        //  }
    
    
        @Test
        void testCacheExpiryWithManualCleanup() throws InterruptedException {
            String firstValue = cacheService.getValue();
            System.out.println("第一次调用 (Manual Cleanup): " + firstValue);
    
            String secondValue = cacheService.getValue();
            System.out.println("第二次调用 (Manual Cleanup): " + secondValue);
            Assertions.assertEquals(firstValue, secondValue);
    
            System.out.println("等待3秒 (Manual Cleanup)...");
            Thread.sleep(3000);
    
            // 获取名为 "keyConfig" 的原生 Caffeine Cache 对象
            io.micronaut.cache.SyncCache<?> genericCache = cacheManager.getCache("keyConfig");
            Object nativeCache = genericCache.nativeCache();
    
            if (nativeCache instanceof com.github.benmanes.caffeine.cache.Cache) {
                System.out.println("手动执行 Caffeine Cache cleanUp()...");
                ((com.github.benmanes.caffeine.cache.Cache<?, ?>) nativeCache).cleanUp();
            } else {
                System.err.println("无法获取到 Caffeine 的原生 Cache 对象进行 cleanup");
            }
    
            String thirdValue = cacheService.getValue();
            System.out.println("清理并等待后调用 (Manual Cleanup): " + thirdValue);
            Assertions.assertNotEquals(firstValue, thirdValue, "手动清理后,应该获取到新的值");
        }
    }
    

    注意: 上述 CacheServiceManualCleanupTest 中的 setUp 部分演示了如何在非 @MicronautTest 环境(或者需要更细粒度控制时)获取bean。如果你在一个 @MicronautTest 的类中,直接 @Inject CacheManager 即可。

  • 额外安全建议:
    cleanUp() 操作是同步的,并且可能会扫描整个缓存来查找过期项。对于非常大的缓存,频繁调用 cleanUp() 可能带来性能开销。在测试中用它来确保状态是可以的,但在生产代码中应谨慎使用,依赖 Caffeine自身的维护机制通常更好。

  • 进阶使用技巧:
    如果你想更深入地控制 Caffeine 的行为(比如在测试中替换时间源 Ticker),可以考虑不使用 Micronaut 的 @Cacheable 自动配置,而是手动创建和注册 Caffeine Cache Bean。这样你就能完全掌控 Caffeine.newBuilder() 的所有配置项。

    // 在你的 @Factory 或配置类中
    // import com.github.benmanes.caffeine.cache.Caffeine;
    // import com.github.benmanes.caffeine.cache.Ticker;
    // import io.micronaut.context.annotation.Bean;
    // import io.micronaut.context.annotation.Factory;
    // import jakarta.inject.Named;
    // import java.util.concurrent.TimeUnit;
    
    // @Factory
    // public class CacheConfiguration {
    //     @Bean
    //     @Named("keyConfigProgrammatic") // 给个不同的名字,避免和自动配置冲突
    //     public com.github.benmanes.caffeine.cache.Cache<Object, Object> keyConfigCache(Ticker ticker) { // Ticker可以注入一个自定义的
    //         return Caffeine.newBuilder()
    //                 .maximumSize(500)
    //                 .expireAfterWrite(5, TimeUnit.SECONDS)
    //                 .ticker(ticker) // 使用自定义的ticker
    //                 .build();
    //     }
    
    //     @Bean // 测试时可以提供一个可控制的Ticker
    //     public Ticker testTicker() {
    //         // return new TestTicker(); // TestTicker是你自己实现的可以前进时间的Ticker
    //         return Ticker.systemTicker(); // 或者先用系统默认的
    //     }
    // }
    

    这属于比较高级的玩法了,通常用于对缓存逻辑本身进行非常细致的单元测试。

方案三:调整期望,理解 Caffeine 的“最终一致性”

  • 原理与作用:
    如前所述,Caffeine 的过期不是硬实时的。它更像是一种“最终会过期”的保证。在没有读写操作,且后台维护任务还没运行时,即使过了过期时间点,数据在内存里可能还“躺着”。只有当访问它,或者维护任务运行时,它才会被正式清理。

  • 操作步骤:
    在测试时,如果仅仅 Thread.sleep() 后直接断言缓存不存在,可能会因为上述原因失败。可以尝试在 sleep 后,再进行一次缓存的读操作(即再次调用 cacheService.getValue())。这次读操作本身就可能触发惰性驱逐。

  • 代码示例:
    其实原始的测试代码 System.out.println("After 3s: " + cacheService.getValue()); 本身就是在 sleep 后进行读操作。如果这个操作依然从缓存读,那说明单靠这次读操作还不足以触发清理(或者是因为测试环境的问题,维护任务不活跃)。

    这种情况下,还是方案一或方案二更可靠,因为它们能更主动地模拟或强制真实环境的行为。

方案四:检查配置与依赖版本

虽然用户提供的问题场景里配置看起来没大毛病,但排查问题时,总要留一手给这些基础检查。

  • 原理与作用:
    配置错误、版本不兼容等都可能导致奇怪的行为。

  • 操作步骤:

    1. 核对缓存名称: 确保 @Cacheable("keyConfig") 中的 "keyConfig"application.ymlcaches.keyConfig 的名称完全一致,包括大小写。
    2. Micronaut 与 Caffeine 版本: 检查你使用的 Micronaut Cache 和 Caffeine 依赖版本是否兼容。通常 Micronaut 的 BOM (Bill of Materials) 会管理好这些传递依赖的版本。但如果手动指定了版本,就需要注意。
    3. 检查依赖范围: 确保 micronaut-cache-caffeine 依赖在正确的范围 (通常是 implementation)。
  • 代码示例 (pom.xml / build.gradle):
    Gradle:

    implementation("io.micronaut.cache:micronaut-cache-caffeine")
    // MicronautTest通常在testImplementation
    testImplementation("io.micronaut.test:micronaut-test-junit5")
    

    Maven:

    <dependency>
        <groupId>io.micronaut.cache</groupId>
        <artifactId>micronaut-cache-caffeine</artifactId>
        <scope>compile</scope> <!-- 或者 runtime -->
    </dependency>
    <dependency>
        <groupId>io.micronaut.test</groupId>
        <artifactId>micronaut-test-junit5</artifactId>
        <scope>test</scope>
    </dependency>
    

面对 Micronaut Caffeine 缓存 expire-after-write 在测试中不生效的问题,优先考虑使用 @MicronautTest 来创建更真实的测试环境。如果需要更细粒度的控制或是在纯单元测试场景,可以考虑手动调用 cache.cleanUp()。理解 Caffeine 自身的过期机制特性,对写出正确的测试和预估其在生产环境的行为也很有帮助。