Micronaut Caffeine expire-after-write 测试为何“罢工”?原因与对策
2025-05-07 19:43:56
搞定 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 的缓存过期并不是一个有独立线程死死盯着每个缓存项的倒计时器。它的过期清理机制,更像是一种“惰性”混合“定期”的策略。
-
惰性驱逐 (Lazy Eviction):
当你尝试读取一个缓存项时,Caffeine 会检查它是不是已经过期了。如果过期了,它不会返回这个旧值,而是会加载新值(如果配置了加载器),然后把旧的干掉。写操作也会触发类似的检查和清理。 -
后台维护 (Background Maintenance):
Caffeine 内部有一个小的维护任务,会定期执行。这个任务会做一些清理工作,包括移除那些已经过期但一直没被访问到的“陈旧”数据。这个任务依赖于ScheduledExecutorService
。 -
Thread.sleep()
的局限性:
在单元测试里用Thread.sleep()
来等待缓存过期,有时候会遇到麻烦。为什么呢?- 测试上下文可能不完整: 一个普通的 JUnit
@Test
方法,可能没有完全启动 Micronaut 应用的上下文,特别是那些依赖后台线程池的服务(比如 Caffeine 的定期清理)。 Ticker
的影响: Caffeine 使用Ticker
接口来获取当前时间,默认是Ticker.systemTicker()
(基于System.nanoTime()
)。在纯粹的Thread.sleep()
期间,如果没有任何缓存访问操作,并且后台维护任务没机会运行或者压根没启动,那么缓存项即使“逻辑上”过期了,也可能没被真正从缓存中移除。Caffeine 不会因为你sleep
了一下就主动唤醒一个线程去挨个检查过期。
- 测试上下文可能不完整: 一个普通的 JUnit
-
测试方法与实际运行的差异:
在真实的 Micronaut 应用跑起来的时候,通常会有持续的请求和操作,这些操作会间接触发 Caffeine 的读写检查,从而清理过期项。或者,应用的生命周期管理也会确保 Caffeine 的后台维护任务正常运行。测试环境往往安静得多。
所以,原生的 JUnit 测试配合 Thread.sleep
可能无法准确模拟 Caffeine 在完整应用环境下的过期行为。
二、对症下药:让缓存乖乖过期
知道了原因,我们就可以有针对性地调整测试方法和理解其行为了。
方案一:使用 @MicronautTest
(推荐用于此类集成场景)
这是最直接也是最符合 Micronaut 测试哲学的方法。
-
原理与作用:
@MicronautTest
注解会启动一个完整的 Micronaut 应用上下文。这意味着所有在应用运行时会初始化的 Bean、监听器、后台任务等,都会在测试期间被正确加载和运行。Caffeine 缓存的后台维护任务(如果配置了调度器)就有机会正常工作了。 -
操作步骤:
- 在测试类上添加
@MicronautTest
注解。 - 确保测试类和被测试的 Service 都在同一个包下,或者
@MicronautTest
能扫描到。 - 通过
@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()
之后,立即验证过期状态非常有用。 -
操作步骤:
- 你需要获取到原始的 Caffeine
Cache
对象。可以通过 Micronaut 的CacheManager
获得。 - 在
Thread.sleep()
之后,调用nativeCache().cleanUp()
。
- 你需要获取到原始的 Caffeine
-
代码示例:
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
自动配置,而是手动创建和注册 CaffeineCache
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
后进行读操作。如果这个操作依然从缓存读,那说明单靠这次读操作还不足以触发清理(或者是因为测试环境的问题,维护任务不活跃)。这种情况下,还是方案一或方案二更可靠,因为它们能更主动地模拟或强制真实环境的行为。
方案四:检查配置与依赖版本
虽然用户提供的问题场景里配置看起来没大毛病,但排查问题时,总要留一手给这些基础检查。
-
原理与作用:
配置错误、版本不兼容等都可能导致奇怪的行为。 -
操作步骤:
- 核对缓存名称: 确保
@Cacheable("keyConfig")
中的"keyConfig"
和application.yml
中caches.keyConfig
的名称完全一致,包括大小写。 - Micronaut 与 Caffeine 版本: 检查你使用的 Micronaut Cache 和 Caffeine 依赖版本是否兼容。通常 Micronaut 的 BOM (Bill of Materials) 会管理好这些传递依赖的版本。但如果手动指定了版本,就需要注意。
- 检查依赖范围: 确保
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 自身的过期机制特性,对写出正确的测试和预估其在生产环境的行为也很有帮助。