distributed-cache: Spring Cache abstraction over Hazelcast, Infinispan and Redis (post 29)

This commit is contained in:
2026-10-15 02:30:04 +00:00
parent 565ca61814
commit 8c77a744e0
18 changed files with 472 additions and 1 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ Companion code for a series of posts on [ankurm.com](https://ankurm.com) coverin
| [`time-series/`](./time-series) | Time-Series Data in Java: QuestDB vs InfluxDB vs TimescaleDB | QuestDB 10.0.1, InfluxDB 3 Core 3.12.0, TimescaleDB 2.25.1 (PG17) |
| [`mongodb/`](./mongodb) | Spring Boot 4 with MongoDB: Spring Data MongoDB, Aggregations and Transactions | MongoDB 9.0.2 single-node replica set, Spring Data MongoDB |
| [`elasticsearch/`](./elasticsearch) | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9.4.8, Spring Data Elasticsearch 6.1.1 |
| `distributed-cache/` | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast, Infinispan, Redis |
| [`distributed-cache/`](./distributed-cache) | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast 5.5.0 (embedded), Infinispan 16.1.4 (embedded), Redis 8.8.3 (Testcontainers) |
Each module has its own `pom.xml`, a `docker-compose.yml` for its backing services, and a `docs/output/` directory holding the real, re-runnable transcripts that every number quoted in its post traces back to. There are no narrative doc chapters here — the deeper material for each post lives in accordions inside the post itself.
@@ -0,0 +1,3 @@
backend=hazelcast
rateFor("USD/EUR") call 1 -> method body ran, callCount 0 -> 1
evict("USD/EUR") then rateFor("USD/EUR") again -> method body ran again, callCount 1 -> 2
@@ -0,0 +1,3 @@
backend=hazelcast
rateFor("USD/INR") call 1 -> method body ran, callCount 2 -> 3
rateFor("USD/INR") call 2 -> served from cache, callCount stays 3
@@ -0,0 +1,3 @@
backend=infinispan
rateFor("USD/EUR") call 1 -> method body ran, callCount 0 -> 1
evict("USD/EUR") then rateFor("USD/EUR") again -> method body ran again, callCount 1 -> 2
@@ -0,0 +1,3 @@
backend=infinispan
rateFor("USD/INR") call 1 -> method body ran, callCount 2 -> 3
rateFor("USD/INR") call 2 -> served from cache, callCount stays 3
@@ -0,0 +1,3 @@
backend=redis
rateFor("USD/EUR") call 1 -> method body ran, callCount 0 -> 1
evict("USD/EUR") then rateFor("USD/EUR") again -> method body ran again, callCount 1 -> 2
@@ -0,0 +1,3 @@
backend=redis
rateFor("USD/INR") call 1 -> method body ran, callCount 2 -> 3
rateFor("USD/INR") call 2 -> served from cache, callCount stays 3
+119
View File
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>distributed-cache-demo</artifactId>
<version>1.0.0</version>
<name>distributed-cache-demo</name>
<properties>
<java.version>25</java.version>
<testcontainers.version>2.0.5</testcontainers.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Hazelcast: embedded member + its Spring CacheManager adapter -->
<dependency>
<groupId>com.hazelcast</groupId>
<artifactId>hazelcast-spring</artifactId>
</dependency>
<!-- Infinispan: embedded Spring Boot 4 starter, confirmed to exist on
Maven Central under this exact artifactId (not guessed). Its
auto-configuration unconditionally registers an
InfinispanCacheMeterBinderProvider bean, whose
@Lookup-annotated method references
io.micrometer.core.instrument.binder.MeterBinder - a class
the starter itself does not pull in. Without micrometer-core
on the classpath this fails bean creation at startup with
NoClassDefFoundError, even when this profile is never
activated (the starter's auto-configuration runs regardless
of @Profile). Confirmed by reading the real stack trace, not
assumed from the starter's docs, which don't mention it. -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>
<dependency>
<groupId>org.infinispan</groupId>
<artifactId>infinispan-spring-boot4-starter-embedded</artifactId>
</dependency>
<!-- Redis: real server via Testcontainers, not embedded -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- Infinispan's auto-configured embedded CacheManager registers
a JMX MBean under the fixed domain "org.infinispan" with no
per-instance disambiguation. That auto-configuration runs
unconditionally, in every Spring context this test JVM
creates, regardless of which profile is active - so with
Surefire's default of reusing one forked JVM across test
classes, the Hazelcast and Redis profile contexts each try
to register that same MBean domain a second/third time and
fail with ISPN000034 (confirmed from the real stack trace).
reuseForks=false gives each test class its own JVM, which
is the one reliable fix - there is no public toggle on
InfinispanEmbeddedConfigurationProperties to disable JMX. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<reuseForks>false</reuseForks>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,13 @@
package com.ankurm.distributedcache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class DistributedCacheDemoApplication {
public static void main(String[] args) {
SpringApplication.run(DistributedCacheDemoApplication.class, args);
}
}
@@ -0,0 +1,43 @@
package com.ankurm.distributedcache;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Stands in for a slow upstream call (a real rate provider, a legacy
* mainframe, whatever). The counter is the whole point: it only goes up
* when the method body actually runs, so a test can prove a second call
* for the same key was served from the cache without ever calling this
* class directly. The @Cacheable/@CacheEvict annotations are the entire
* caching story here - which CacheManager bean backs "exchangeRates" is
* decided elsewhere, by which Spring profile is active, and this class
* neither knows nor cares which one that is.
*/
@Service
public class ExchangeRateLookupService {
private final AtomicInteger callCount = new AtomicInteger();
@Cacheable("exchangeRates")
public BigDecimal rateFor(String currencyPair) {
callCount.incrementAndGet();
return switch (currencyPair) {
case "USD/INR" -> new BigDecimal("87.42");
case "USD/EUR" -> new BigDecimal("0.92");
default -> throw new IllegalArgumentException("unknown pair: " + currencyPair);
};
}
@CacheEvict("exchangeRates")
public void evict(String currencyPair) {
// no-op body; the annotation does the work
}
public int callCount() {
return callCount.get();
}
}
@@ -0,0 +1,46 @@
package com.ankurm.distributedcache;
import com.hazelcast.config.Config;
import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.spring.cache.HazelcastCacheManager;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
/**
* Active only under the "hazelcast" profile. A single embedded
* HazelcastInstance plays the role a real cluster member would - in
* production this is the same class, just joining other real members
* over the network instead of running alone in the test JVM.
* HazelcastCacheManager is Hazelcast's own adapter to Spring's
* CacheManager interface; ExchangeRateLookupService never sees it
* directly, it only sees the "exchangeRates" cache name.
*
* {@code @Primary} is required here, not decorative: the Infinispan
* starter on the classpath auto-configures its own CacheManager bean
* unconditionally (see InfinispanEmbeddedCacheManagerAutoConfiguration),
* regardless of which profile is active, so without @Primary Spring
* would see two CacheManager beans and this one would not reliably win.
*/
@Configuration
@Profile("hazelcast")
public class HazelcastCacheConfig {
@Bean(destroyMethod = "shutdown")
public HazelcastInstance hazelcastInstance() {
Config config = new Config();
config.setClusterName("distributed-cache-demo-" + System.nanoTime());
config.getNetworkConfig().getJoin().getMulticastConfig().setEnabled(false);
config.getNetworkConfig().getJoin().getAutoDetectionConfig().setEnabled(false);
return Hazelcast.newHazelcastInstance(config);
}
@Bean
@Primary
public CacheManager cacheManager(HazelcastInstance hazelcastInstance) {
return new HazelcastCacheManager(hazelcastInstance);
}
}
@@ -0,0 +1,31 @@
package com.ankurm.distributedcache;
import org.infinispan.configuration.cache.ConfigurationBuilder;
import org.infinispan.manager.EmbeddedCacheManager;
import org.infinispan.spring.starter.embedded.InfinispanCacheConfigurer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Unlike Hazelcast's HazelcastCacheManager and Spring Data Redis's
* RedisCacheManager, Infinispan's embedded CacheManager does not create
* an undeclared cache on first use - calling getCache("exchangeRates")
* without this configurer throws ISPN000436 ("no matching cache
* configuration exists"), confirmed by the real exception a first,
* configurer-less version of this class produced. An
* InfinispanCacheConfigurer bean is the starter's documented extension
* point for declaring a cache programmatically instead of in an
* external infinispan.xml. This bean is not @Profile-gated because the
* starter's own auto-configuration isn't either - it always creates an
* embedded CacheManager regardless of which profile is active, so this
* just makes sure the one cache this demo needs exists on it.
*/
@Configuration
public class InfinispanCacheConfig {
@Bean
public InfinispanCacheConfigurer exchangeRatesCacheConfigurer() {
return (EmbeddedCacheManager cacheManager) ->
cacheManager.defineConfiguration("exchangeRates", new ConfigurationBuilder().build());
}
}
@@ -0,0 +1,31 @@
package com.ankurm.distributedcache;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
/**
* Active only under the "redis" profile. Unlike the Hazelcast and
* Infinispan members, this CacheManager talks to a real out-of-process
* Redis server - here a Testcontainers container in tests, any real
* Redis instance (standalone or cluster) in production. The cache
* survives this application restarting; the embedded members' caches do
* not, which is the actual distinction this post is making, not just a
* difference in configuration style. @Primary disambiguates from the
* Infinispan starter's own unconditional auto-configured CacheManager
* bean - see the note on HazelcastCacheConfig for why that's needed.
*/
@Configuration
@Profile("redis")
public class RedisCacheConfig {
@Bean
@Primary
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
return RedisCacheManager.create(connectionFactory);
}
}
@@ -0,0 +1,70 @@
package com.ankurm.distributedcache;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The same two behaviours, asserted identically, against whichever
* CacheManager the active Spring profile wired up. That's deliberate:
* the point of the Spring Cache abstraction is that ExchangeRateLookupService
* and this test never change when the backend does - only the
* subclass's @ActiveProfiles (and, for Redis, the container) changes.
*
* ExchangeRateLookupService's callCount() is a singleton counter shared
* across every @Test method in a subclass's Spring context (JUnit does
* not create a fresh context per test method), so each test reads the
* counter before and after its own calls and asserts the *delta* rather
* than an absolute value - that way test execution order inside one
* class can never affect the result.
*/
abstract class CacheHitAndEvictionContractTest {
@Autowired
ExchangeRateLookupService service;
abstract String backendName();
@Test
void secondCallForSameKeyIsServedFromCache() {
int before = service.callCount();
service.rateFor("USD/INR");
int afterFirstCall = service.callCount();
service.rateFor("USD/INR");
int afterSecondCall = service.callCount();
Transcript t = new Transcript("29-" + backendName() + "-cache-hit.txt");
t.line("backend=%s", backendName());
t.line("rateFor(\"USD/INR\") call 1 -> method body ran, callCount %d -> %d", before, afterFirstCall);
t.line("rateFor(\"USD/INR\") call 2 -> served from cache, callCount stays %d", afterSecondCall);
t.write();
assertThat(afterFirstCall).isEqualTo(before + 1);
assertThat(afterSecondCall).isEqualTo(afterFirstCall);
}
@Test
void evictingAKeyForcesTheNextCallToRecompute() {
int before = service.callCount();
service.rateFor("USD/EUR");
int afterFirstCall = service.callCount();
service.evict("USD/EUR");
service.rateFor("USD/EUR");
int afterEvictAndRecall = service.callCount();
Transcript t = new Transcript("29-" + backendName() + "-cache-evict.txt");
t.line("backend=%s", backendName());
t.line("rateFor(\"USD/EUR\") call 1 -> method body ran, callCount %d -> %d", before, afterFirstCall);
t.line("evict(\"USD/EUR\") then rateFor(\"USD/EUR\") again -> method body ran again, callCount %d -> %d",
afterFirstCall, afterEvictAndRecall);
t.write();
assertThat(afterFirstCall).isEqualTo(before + 1);
assertThat(afterEvictAndRecall).isEqualTo(afterFirstCall + 1);
}
}
@@ -0,0 +1,13 @@
package com.ankurm.distributedcache;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest(classes = DistributedCacheDemoApplication.class)
@ActiveProfiles("hazelcast")
class HazelcastCacheTest extends CacheHitAndEvictionContractTest {
@Override
String backendName() {
return "hazelcast";
}
}
@@ -0,0 +1,22 @@
package com.ankurm.distributedcache;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/**
* No custom CacheManager bean exists for this profile at all - the
* Infinispan starter auto-configures its own SpringEmbeddedCacheManager
* unconditionally once it's on the classpath (confirmed by decompiling
* InfinispanEmbeddedCacheManagerAutoConfiguration), and nothing under
* the "hazelcast" or "redis" profiles is active to out-rank it with
* @Primary. The "infinispan" profile name exists only so this test
* class's intent reads the same way as its two siblings.
*/
@SpringBootTest(classes = DistributedCacheDemoApplication.class)
@ActiveProfiles("infinispan")
class InfinispanCacheTest extends CacheHitAndEvictionContractTest {
@Override
String backendName() {
return "infinispan";
}
}
@@ -0,0 +1,25 @@
package com.ankurm.distributedcache;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.test.context.ActiveProfiles;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@Testcontainers
@SpringBootTest(classes = DistributedCacheDemoApplication.class)
@ActiveProfiles("redis")
class RedisCacheTest extends CacheHitAndEvictionContractTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>(DockerImageName.parse("redis:8.8.3")).withExposedPorts(6379);
@Override
String backendName() {
return "redis";
}
}
@@ -0,0 +1,40 @@
package com.ankurm.distributedcache;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Writes docs/output/NN-*.txt while a test asserts the same numbers, so
* every figure quoted in the blog post is backed by an assertion that
* fails the build when it stops being true.
*/
final class Transcript {
private final List<String> lines = new ArrayList<>();
private final Path file;
Transcript(String name) {
this.file = Path.of("docs/output/" + name);
}
Transcript line(String s) {
lines.add(s);
System.out.println(s);
return this;
}
Transcript line(String fmt, Object... args) {
return line(fmt.formatted(args));
}
void write() {
try {
Files.createDirectories(file.getParent());
Files.writeString(file, String.join("\n", lines) + "\n");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}