Skip to main content

Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis

Spring’s cache abstraction makes a specific promise: annotate a method with @Cacheable, and the business code never knows or cares which product is actually storing the cached value. This post puts that promise to the test with three genuinely different backends — Hazelcast and Infinispan, both embedded in-process, and Redis, a real out-of-process server — running the exact same service class and the exact same test logic against each, swapped only by which CacheManager bean wins.

The interesting part turned out not to be the happy path, which is identical across all three, but three real failures along the way: a missing transitive dependency that breaks startup regardless of which backend you asked for, a JMX collision between embedded cache managers sharing one test JVM, and one backend that — unlike the other two — refuses to create a cache you never declared.

Versions used in this post. Spring Boot 4.1.1 · com.hazelcast:hazelcast-spring 5.5.0 · org.infinispan:infinispan-spring-boot4-starter-embedded 16.1.4 · Redis server 8.8.3 (Testcontainers) · JDK 25 (Temurin) · Testcontainers 2.0.5. All three resolved and verified via mvn dependency:tree and javap against the real jars — none of the class names or artifact coordinates below were assumed from documentation.

The cache-agnostic service

One service, annotated, with a counter that only moves when the method body actually executes — the whole point of every test below is proving that counter doesn’t move on a cache hit:

@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();
    }
}

Full repository: ExchangeRateLookupService.java. This class is identical regardless of which profile is active — everything that differs between backends lives in a @Configuration class picked by Spring profile, never in here.

Hazelcast and Redis: both need @Primary, and here’s why

@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);
    }
}

Full repository: HazelcastCacheConfig.java.

@Configuration
@Profile("redis")
public class RedisCacheConfig {

    @Bean
    @Primary
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        return RedisCacheManager.create(connectionFactory);
    }
}

Full repository: RedisCacheConfig.java. Both configs are gated behind their own @Profile, and both mark their CacheManager bean @Primary — not out of habit.

Going deeper: why @Primary isn’t optional here

The Infinispan starter’s auto-configuration registers its own CacheManager bean (SpringEmbeddedCacheManager, via InfinispanEmbeddedCacheManagerAutoConfiguration) completely unconditionally — there is no @Profile or @ConditionalOnProperty guarding it, because this starter has no concept of “only when selected.” So the moment the dependency is on the classpath, that bean exists in every Spring context this application creates, hazelcast and redis profiles included. Without @Primary on the Hazelcast/Redis beans, Spring would see two candidate CacheManager beans whenever either profile is active, and annotation-driven caching would either fail to wire or silently pick the wrong one depending on bean-resolution tie-breaking. @Primary makes the choice explicit rather than incidental.

Infinispan: the backend that needs its cache declared up front

Infinispan needs no custom CacheManager bean at all — its starter auto-configures one as soon as it’s on the classpath, which is exactly the ambient bean the previous section was disambiguating against. The first version of this demo relied on that and nothing else, and the very first test against it failed:

org.infinispan.commons.CacheConfigurationException: ISPN000436: Cache 'exchangeRates' has been requested, but no matching cache configuration exists

Transcript: 29-infinispan-cache-config-error.txt — the real exception from the first, configurer-less version of this demo, not a reconstruction. Unlike Hazelcast’s and Redis’s cache managers, which create a cache on first use with sensible defaults, Infinispan’s embedded CacheManager refuses to serve a cache it was never told about. The starter’s documented escape hatch is an InfinispanCacheConfigurer bean, which gets a callback with the raw EmbeddedCacheManager to define configurations on directly:

@Configuration
public class InfinispanCacheConfig {

    @Bean
    public InfinispanCacheConfigurer exchangeRatesCacheConfigurer() {
        return (EmbeddedCacheManager cacheManager) ->
                cacheManager.defineConfiguration("exchangeRates", new ConfigurationBuilder().build());
    }
}

Full repository: InfinispanCacheConfig.java. This bean isn’t @Profile-gated either — for the same reason the auto-configuration isn’t: Infinispan’s embedded manager exists in every context regardless of profile, so this just makes sure the one cache this demo needs is defined on it whenever it does.

Going deeper: a missing dependency that breaks every profile, not just Infinispan’s

Before any of the three profiles could start at all, every one of them failed the same way: NoClassDefFoundError: io/micrometer/core/instrument/binder/MeterBinder, tracing back to a bean called infinispanCacheMeterBinderProvider. The Infinispan starter unconditionally registers a Micrometer metrics-binder bean whose @Lookup-annotated method signature references a Micrometer class the starter does not pull in transitively. Because that auto-configuration has no profile guard, the failure showed up under the hazelcast and redis profiles too, which made it genuinely confusing to diagnose the first time — the stack trace mentioned Infinispan on an attempt to test Hazelcast. The fix is a plain io.micrometer:micrometer-core dependency with no version, managed by Boot’s own BOM.

Going deeper: why the test suite needs reuseForks=false

Each of the three test classes below gets its own Spring context (different @ActiveProfiles means a different context-cache key), but by default Surefire reuses one forked JVM across every test class in a module. Infinispan’s auto-configured DefaultCacheManager registers a JMX MBean under the fixed domain name org.infinispan — and since that auto-configuration runs in every context, the second and third context created in the same JVM each try to register that same MBean domain again and fail with ISPN000034: The 'org.infinispan' JMX domain is already in use. There is no public property on the starter to disable JMX registration; the fix that actually works is <reuseForks>false</reuseForks> on the Surefire plugin, giving every test class a clean JVM.

Where the cached value actually lives Hazelcast / Infinispan embedded – library code in this JVM HazelcastCacheManager / SpringEmbeddedCacheManager gone when this process exits Redis separate server, reached over the network RedisCacheManager -> RedisConnectionFactory survives this process restarting ExchangeRateLookupService’s @Cacheable code is identical either way – only which of these a profile wires up differs.

Same assertions, three backends

One abstract test class asserts the behaviour; three concrete subclasses each activate a different profile (Redis’s additionally starts a Testcontainers container). Because callCount() is a singleton counter shared by every @Test method in one Spring context, each test reads the counter before and after its own calls and asserts the delta, so method execution order can never affect the result:

@Test
void secondCallForSameKeyIsServedFromCache() {
    int before = service.callCount();
    service.rateFor("USD/INR");
    int afterFirstCall = service.callCount();
    service.rateFor("USD/INR");
    int afterSecondCall = service.callCount();

    assertThat(afterFirstCall).isEqualTo(before + 1);
    assertThat(afterSecondCall).isEqualTo(afterFirstCall);
}

Full repository: CacheHitAndEvictionContractTest.java, with its three concrete subclasses HazelcastCacheTest.java, InfinispanCacheTest.java, and RedisCacheTest.java.

All six tests (two behaviours × three backends) pass. The cache-hit transcript for each backend — same assertion, same numbers, different CacheManager underneath:

backend=hazelcast
rateFor("USD/INR") call 1 -> method body ran, callCount 2 -> 3
rateFor("USD/INR") call 2 -> served from cache, callCount stays 3

Transcript: 29-hazelcast-cache-hit.txt.

backend=infinispan
rateFor("USD/INR") call 1 -> method body ran, callCount 2 -> 3
rateFor("USD/INR") call 2 -> served from cache, callCount stays 3

Transcript: 29-infinispan-cache-hit.txt.

backend=redis
rateFor("USD/INR") call 1 -> method body ran, callCount 2 -> 3
rateFor("USD/INR") call 2 -> served from cache, callCount stays 3

Transcript: 29-redis-cache-hit.txt. And eviction, which forces the next call to recompute, also identical across all three:

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

Transcript: 29-redis-cache-evict.txt (Hazelcast’s and Infinispan’s eviction transcripts show the identical pattern: 29-hazelcast-cache-evict.txt, 29-infinispan-cache-evict.txt).

What this post didn’t cover

  • No real multi-node cluster. Hazelcast and Infinispan each ran as a single embedded member in one JVM here. Their actual distributed behaviour — partitioning, replication, split-brain handling — only shows up with more than one real member, which this demo never starts.
  • No serialization tuning. All three backends serialize BigDecimal through their own defaults (Java serialization for Hazelcast, Infinispan’s own marshaller, Spring Data Redis’s default serializer for Redis). Production use of any of them typically means choosing an explicit, more compact serialization format.
  • No cache statistics or eviction policy tuning. Size limits, TTLs, and eviction policies are all real configuration surfaces on every one of these backends that this demo leaves at defaults.

When each backend is the right tool

NeedReach for
In-process cache, no extra infrastructure to runHazelcast or Infinispan, embedded
Cache shared across multiple application instancesRedis, or Hazelcast/Infinispan in real cluster mode
Cache that must survive an application restartRedis (or any out-of-process store)
A cache manager that creates caches on demandHazelcast or Redis – not Infinispan without an InfinispanCacheConfigurer

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.