Testcontainers + Redis 实战——缓存逻辑集成测试完整指南
Testcontainers + Redis 实战——缓存逻辑集成测试完整指南
适读人群:Java 后端开发者、对缓存一致性有痛点的工程师 | 阅读时长:约 15 分钟 | 核心价值:用真实 Redis 测试缓存逻辑,彻底消灭缓存穿透和一致性 Bug
Redis 相关的 Bug,往往是最难复现的一类 Bug。
我们团队有一年做了一个"热点数据缓存"的优化,逻辑很简单:查数据先查 Redis,没有就查 MySQL 然后写入 Redis,设置 TTL 30 分钟。测试用的是 @MockBean RedisTemplate,Mock 掉了所有 Redis 操作,测试全绿,上线。
上线第一天没问题。第三天凌晨,缓存集中过期(因为所有 key 的 TTL 是固定的,同一批数据同时写入缓存,TTL 同时到期),大量请求打穿缓存直接怼到 MySQL,数据库连接池耗尽,服务雪崩。
事后复盘,问题很清晰:缓存的 TTL 应该加随机抖动,比如 30分钟 + Random(0, 5分钟)。这个逻辑在 Mock Redis 的情况下完全测不出来,因为 Mock 不会"真的等 TTL 到期",也不会模拟大量并发读同一个 key 时的行为。
从那之后,凡是涉及缓存策略、TTL、缓存穿透防护的代码,我们一律用真实 Redis 容器跑集成测试。
今天这篇,把我们 Redis 集成测试的完整方案写出来。
一、依赖配置
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<!-- Redis 没有专门模块,用 GenericContainer -->
<!-- Spring Boot 的 @ServiceConnection 支持 RedisContainer -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>二、基础配置:启动 Redis 容器
方式一:GenericContainer(通用,兼容所有版本)
@SpringBootTest
@Testcontainers
class RedisBasicTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7.2-alpine")
.withExposedPorts(6379)
.withCommand("redis-server", "--requirepass", "testpass")
.waitingFor(Wait.forLogMessage(".*Ready to accept connections.*", 1));
@DynamicPropertySource
static void configureRedis(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
registry.add("spring.data.redis.password", () -> "testpass");
}
@Autowired
private StringRedisTemplate redisTemplate;
@Test
void Redis连接_基础读写_正常() {
redisTemplate.opsForValue().set("test:key", "hello");
String value = redisTemplate.opsForValue().get("test:key");
assertThat(value).isEqualTo("hello");
}
}方式二:@ServiceConnection(Spring Boot 3.1+,推荐)
@SpringBootTest
@Testcontainers
class RedisServiceConnectionTest {
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer("redis:7.2-alpine");
// 无需 @DynamicPropertySource,Spring Boot 自动配置
@Autowired
private RedisTemplate<String, Object> redisTemplate;
}三、核心业务场景:缓存服务集成测试
以商品详情缓存为例,这是最典型的 Cache-Aside 模式:
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductCacheService {
private final ProductRepository productRepository;
private final RedisTemplate<String, Product> redisTemplate;
private static final String CACHE_KEY_PREFIX = "product:";
private static final long BASE_TTL_MINUTES = 30;
private static final long JITTER_MINUTES = 5;
public Optional<Product> getProduct(Long productId) {
String key = CACHE_KEY_PREFIX + productId;
// 先查缓存
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
log.debug("缓存命中: {}", key);
return Optional.of(cached);
}
// 缓存未命中,查数据库
Optional<Product> product = productRepository.findById(productId);
product.ifPresent(p -> {
// 写入缓存,TTL 加随机抖动
long ttl = BASE_TTL_MINUTES + ThreadLocalRandom.current().nextLong(JITTER_MINUTES);
redisTemplate.opsForValue().set(key, p, Duration.ofMinutes(ttl));
log.debug("写入缓存: {}, TTL: {}分钟", key, ttl);
});
return product;
}
public void invalidateProduct(Long productId) {
redisTemplate.delete(CACHE_KEY_PREFIX + productId);
}
}对应的集成测试:
@SpringBootTest
@Testcontainers
class ProductCacheServiceIntegrationTest {
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer("redis:7.2-alpine");
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36");
@Autowired
private ProductCacheService productCacheService;
@Autowired
private ProductRepository productRepository;
@Autowired
private RedisTemplate<String, Product> redisTemplate;
@BeforeEach
void setUp() {
// 清理 Redis
Set<String> keys = redisTemplate.keys("product:*");
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
// 清理数据库
productRepository.deleteAll();
}
@Test
void 获取商品_缓存未命中_查库并写入缓存() {
// given
Product product = productRepository.save(new Product(null, "iPhone 15", 6999.0));
// when - 第一次查询,缓存未命中
Optional<Product> result = productCacheService.getProduct(product.getId());
// then - 能查到数据
assertThat(result).isPresent();
assertThat(result.get().getName()).isEqualTo("iPhone 15");
// then - 数据已写入 Redis
Product cached = redisTemplate.opsForValue().get("product:" + product.getId());
assertThat(cached).isNotNull();
assertThat(cached.getName()).isEqualTo("iPhone 15");
}
@Test
void 获取商品_缓存命中_不查数据库() {
// given - 直接往 Redis 写数据(绕过数据库)
Product fakeProduct = new Product(999L, "缓存中的商品", 100.0);
redisTemplate.opsForValue().set("product:999", fakeProduct, Duration.ofMinutes(30));
// when
Optional<Product> result = productCacheService.getProduct(999L);
// then - 返回的是 Redis 里的数据,而不是数据库里的(数据库里没有 id=999 的商品)
assertThat(result).isPresent();
assertThat(result.get().getName()).isEqualTo("缓存中的商品");
}
@Test
void TTL随机抖动_多次写入_过期时间不完全相同() {
// given
List<Product> products = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
products.add(productRepository.save(new Product(null, "商品" + i, i * 100.0)));
}
// when - 触发缓存写入
products.forEach(p -> productCacheService.getProduct(p.getId()));
// then - 验证 TTL 存在随机抖动(不全相同)
Set<Long> ttls = products.stream()
.map(p -> redisTemplate.getExpire("product:" + p.getId(), TimeUnit.SECONDS))
.collect(Collectors.toSet());
// 10 个 TTL 不应该全部相同(随机抖动应该产生差异)
assertThat(ttls.size()).isGreaterThan(1);
// 验证 TTL 在合理范围内(30~35 分钟)
ttls.forEach(ttl -> {
assertThat(ttl).isGreaterThan(TimeUnit.MINUTES.toSeconds(28));
assertThat(ttl).isLessThan(TimeUnit.MINUTES.toSeconds(36));
});
}
@Test
void 缓存失效_删除后再查_重新从库加载() {
// given
Product product = productRepository.save(new Product(null, "测试商品", 199.0));
productCacheService.getProduct(product.getId()); // 写入缓存
// when - 失效缓存
productCacheService.invalidateProduct(product.getId());
// then - Redis 里没有了
assertThat(redisTemplate.hasKey("product:" + product.getId())).isFalse();
// when - 再次查询
Optional<Product> result = productCacheService.getProduct(product.getId());
// then - 重新从数据库加载,缓存重建
assertThat(result).isPresent();
assertThat(redisTemplate.hasKey("product:" + product.getId())).isTrue();
}
}四、三个踩坑实录
坑 1:序列化问题导致 Redis 读出 null
现象: 写入 Redis 时看起来成功了,但马上读出来是 null。监控显示 Redis 里确实有这个 key,value 也不为空。
原因: RedisTemplate<String, Product> 默认的序列化器是 JdkSerializationRedisSerializer,如果 Product 类没有实现 Serializable,或者反序列化时类的版本不兼容,读出来就是 null(而不是抛异常)。
解法:
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// Key 用 String 序列化
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// Value 用 Jackson JSON 序列化
Jackson2JsonRedisSerializer<Object> jsonSerializer =
new Jackson2JsonRedisSerializer<>(Object.class);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}坑 2:容器内 Redis 密码认证失败
现象: 测试启动时报 NOAUTH Authentication required,明明已经在 DynamicPropertySource 里配置了密码。
原因: Redis 密码认证需要在容器启动时通过 --requirepass 参数设置,同时 Spring Boot 的 spring.data.redis.password 配置要匹配。但如果使用了 @ServiceConnection,Redis 官方镜像的默认密码参数传递方式不同。
解法:
// 方式一:不用密码(测试环境不需要密码认证)
@Container
@ServiceConnection
static RedisContainer redis = new RedisContainer("redis:7.2-alpine");
// 方式二:用密码,手动配置
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7.2-alpine")
.withExposedPorts(6379)
.withCommand("redis-server", "--requirepass", "test123");
@DynamicPropertySource
static void config(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
registry.add("spring.data.redis.password", () -> "test123");
}坑 3:@SpringBootTest + Redis 发布订阅导致测试卡死
现象: 某个测试类跑完之后不结束,一直挂着,JVM 不退出。
原因: 项目里有 Redis 的 MessageListenerContainer,它在 Spring 上下文启动时会创建监听线程,测试结束时这个线程还在运行,导致 JVM 无法正常退出。
解法:
// 测试专用的 Redis 配置,禁用消息监听器
@TestConfiguration
public class TestRedisConfig {
@Bean
@Primary
public RedisMessageListenerContainer testMessageListenerContainer(
RedisConnectionFactory factory) {
// 返回一个空的容器,不注册任何 listener
return new RedisMessageListenerContainer();
}
}或者更简单——在 application-test.yml 里设置一个属性,让监听器 Bean 条件不满足,直接不创建。
五、Redis 分布式锁测试
分布式锁是 Redis 集成测试里最有价值的场景之一:
@Test
void 分布式锁_并发场景_同一时刻只有一个线程执行() throws InterruptedException {
String lockKey = "test:lock:order:1001";
AtomicInteger successCount = new AtomicInteger(0);
int threadCount = 10;
CountDownLatch latch = new CountDownLatch(threadCount);
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "locked", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(locked)) {
successCount.incrementAndGet();
Thread.sleep(100); // 模拟业务执行
redisTemplate.delete(lockKey);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
latch.await(10, TimeUnit.SECONDS);
executor.shutdown();
// 10 个并发,同一时刻只有一个能拿到锁
// 由于锁会被释放,最终可能有多个成功,但每次执行时保证互斥
assertThat(successCount.get()).isGreaterThan(0);
assertThat(successCount.get()).isLessThanOrEqualTo(threadCount);
// 验证锁最终被释放(测试结束时不应该存在这个 key)
// 注意:因为我们在获得锁后 100ms 内释放,最终 key 应该不存在
Boolean exists = redisTemplate.hasKey(lockKey);
assertThat(exists).isFalse();
}六、配置清单总结
src/test/resources/application-test.yml 的 Redis 相关配置:
spring:
data:
redis:
# 这里不配置 host/port,由 DynamicPropertySource 或 @ServiceConnection 注入
connect-timeout: 5s
timeout: 3s
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms用真实 Redis 测试缓存逻辑,那次雪崩事故是我最好的老师。加了随机 TTL 抖动之后,缓存集中过期的问题再也没出现过。
