How CoCache Handles Multi-Instance Synchronization for Cache Updates

CoCache achieves multi-instance synchronization by publishing lightweight eviction events whenever data changes, broadcasting these across JVMs via a pluggable CacheEvictedEventBus, and ensuring each instance clears its local clientSideCache while retaining the distributed store as the authoritative source.

When scaling a Spring Boot application horizontally, maintaining cache consistency across multiple JVM instances becomes critical. The CoCache library (ahoo-wang/cocache) addresses this challenge through a coherent cache architecture that combines local caching performance with distributed consistency mechanisms.

Two-Layer Cache Architecture

CoCache implements a tiered caching strategy that balances speed with consistency. Each instance maintains a fast, in-process client-side cache (clientSideCache) serving as the L1 layer, while a distributed cache (distributedCache)—such as Redis or Hazelcast—acts as the shared L2 layer and source of truth.

The DefaultCoherentCache class orchestrates these layers. When an application reads data, it first checks the local cache; on a miss, it falls back to the distributed store. This design ensures that even if local caches become stale, the distributed layer remains authoritative.

Event-Driven Synchronization Flow

Multi-instance synchronization relies on an event-driven protocol that invalidates stale entries across all running instances. The flow operates through four distinct phases:

  1. Write or Evict triggers an event – When setCache or evict is invoked, the instance updates both cache layers and publishes a CacheEvictedEvent containing the cacheName, key, and the publisher's unique clientId.
  2. Publish via event bus – The CacheEvictedEventBus serializes and transmits the event across the transport layer.
  3. Receive via subscriber – Every instance listens through a CacheEvictedSubscriber that deserializes incoming messages.
  4. Handle by clearing local cache – Receiving instances evict the key from their clientSideCache only, leaving the distributed cache untouched.

Publishing Eviction Events

In DefaultCoherentCache.kt, the setCache method (lines 42-49) demonstrates how write operations trigger synchronization. When a value is written or evicted, the instance creates a CacheEvictedEvent and delegates to the configured event bus.

// Conceptual flow based on DefaultCoherentCache#setCache
fun setCache(key: String, value: CacheValue) {
    clientSideCache.set(key, value)
    distributedCache.set(key, value)
    val event = CacheEvictedEvent(cacheName, key, clientId)
    cacheEvictedEventBus.publish(event)
}

The CacheEvictedEventBus Abstraction

The CacheEvictedEventBus interface defines the contract for cross-instance communication. CoCache ships with two implementations:

  • GuavaCacheEvictedEventBus – Uses Google's EventBus for single-JVM scenarios, useful for testing or monolithic deployments.
  • RedisCacheEvictedEventBus – Leverages Redis pub/sub for distributed environments, enabling any number of JVM instances to stay synchronized.

The Redis implementation uses StringRedisTemplate.convertAndSend on a channel named after the cache. In RedisCacheEvictedEventBus.kt (lines 42-44), the publish method serializes the event and transmits it to the channel.

Receiving and Processing Events

For Redis-based synchronization, RedisCacheEvictedEventBus#register (lines 46-55) creates a MessageListenerAdapter for each subscriber and binds it to the cache-specific channel. Incoming messages are deserialized using EvictedEvents.fromMessage and forwarded to the subscriber's onEvicted handler.

Local Cache Invalidation Logic

The critical consistency logic resides in DefaultCoherentCache#onEvicted (lines 58-68). This method performs two validation checks before clearing local state:

  • Cache name validation – Ensures the event targets this specific cache instance.
  • Self-event filtering – Compares the event's clientId with the local instance ID to skip events published by itself, preventing unnecessary local evictions.

When these conditions pass, the method removes the key from clientSideCache only. The distributed cache remains unchanged because it already contains the latest value (or has been updated by the originating instance).

Cache Stampede Protection

Beyond synchronization, DefaultCoherentCache protects against cache stampedes through per-key fine-grained locking. In the getCache method (lines 88-105), the implementation uses keyLocks to ensure that only one thread per instance performs the heavy-weight load-from-source operation when a cache miss occurs. This prevents multiple simultaneous requests from overwhelming the underlying data source while the first thread populates the cache.

Configuring Multi-Instance Synchronization

To enable cross-instance consistency in a Redis-backed environment, configure the RedisCacheEvictedEventBus and wire it into the coherent cache factory:

import me.ahoo.cache.consistency.*
import me.ahoo.cache.spring.redis.RedisCacheEvictedEventBus
import org.springframework.data.redis.listener.RedisMessageListenerContainer

// Configure the event bus with Redis
val redisTemplate = StringRedisTemplate(redisConnectionFactory)
val listenerContainer = RedisMessageListenerContainer()
listenerContainer.connectionFactory = redisConnectionFactory
val evictedBus = RedisCacheEvictedEventBus(redisTemplate, listenerContainer)

val coherentFactory = DefaultCoherentCacheFactory(evictedBus)

// Define cache layers
val clientCache = CaffeineCache(...)          // L1: Local Caffeine cache
val distributedCache = RedisCache(...)        // L2: Shared Redis cache

// Create coherent cache instance
val userCache = coherentFactory.create(
    CoherentCacheConfiguration(
        cacheName = "userCache",
        clientSideCache = clientCache,
        distributedCache = distributedCache,
        keyConverter = StringKeyConverter(),
        cacheSource = UserRepositoryDataSource()
    )
)

// Usage automatically triggers synchronization
userCache.setCache(userId, CacheValue.of(userData))
userCache.evict(userId)  // Broadcasts eviction to all instances

Verifying Consistency Across Instances

The MultipleInstanceSyncSpec test suite validates the multi-instance synchronization behavior. This harness spins up two separate coherent cache instances—currentCache and otherCache—and asserts that write or eviction operations on one instance immediately invalidate the local cache of the other, while both maintain consistency with the distributed store.

Summary

  • Two-layer architecture combines fast local caching (clientSideCache) with a shared distributed store (distributedCache) as the authoritative source.
  • Event-driven invalidation uses CacheEvictedEventBus to broadcast lightweight eviction events whenever values change.
  • Pluggable transport supports both in-process (Guava) and distributed (Redis) event propagation via the CacheEvictedEventBus interface.
  • Self-event filtering prevents unnecessary local cache clears by checking the clientId in DefaultCoherentCache#onEvicted.
  • Stampede protection uses per-key locks in DefaultCoherentCache#getCache to coordinate concurrent load operations.

Frequently Asked Questions

How does CoCache handle network partitions or temporary Redis unavailability?

If the Redis event bus becomes unavailable, instances continue operating using their local caches and the distributed cache layer. While eviction events will not propagate during the outage, instances can still read from and write to the distributed store. Once connectivity restores, the Redis pub/sub resumes normal operation. For critical consistency requirements, applications may implement shorter TTLs on the local cache as a fallback mechanism.

What prevents all instances from simultaneously reloading data after receiving an eviction event?

CoCache prevents reload storms through two mechanisms. First, eviction events only clear the local cache (clientSideCache) without triggering immediate reloads—subsequent reads lazily repopulate the cache. Second, the keyLocks mechanism in DefaultCoherentCache#getCache ensures that if multiple concurrent requests hit a cold key, only one thread performs the load operation while others wait, preventing stampedes on the underlying data source.

Can CoCache's multi-instance synchronization work without Spring Boot?

Yes, while the Redis implementation resides in the cocache-spring-redis module and uses Spring Data Redis, the core abstraction in cocache-core is framework-agnostic. You can implement the CacheEvictedEventBus interface using other messaging systems like Apache Kafka, RabbitMQ, or Hazelcast's native pub/sub. The GuavaCacheEvictedEventBus demonstrates a pure Java implementation suitable for non-Spring contexts.

How does CoCache distinguish between events from different cache instances?

Each DefaultCoherentCache instance is assigned a unique clientId through CoherentCacheConfiguration. When publishing CacheEvictedEvent, the instance includes this identifier. In DefaultCoherentCache#onEvicted, the implementation compares the event's clientId with the local instance's ID and skips processing if they match. This self-filtering prevents an instance from clearing its own cache immediately after writing, while ensuring all other instances invalidate their stale entries.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →