Enterprise Java

Implement Two-Level Cache With Spring

Caching is a technique used to store data temporarily in a faster storage layer to improve the performance and responsiveness of applications. Let’s explore how to implement a two-level cache in Spring.

1. Understanding First Level and Second Level Cache

1.1 First level of Cache

The first level of cache is typically an in-memory cache specific to a single instance of an application. This cache stores data locally within the application instance, minimizing the need to repeatedly access slower storage layers, such as databases. Common in-memory caching libraries include Caffeine, Guava, and Ehcache. Characteristics of the First Level Cache:

  • Local to a single application instance
  • Fast access due to in-memory storage
  • Non-persistent and usually limited by the application’s memory
  • Does not share data across multiple instances

1.2 Second level of Cache

The second level of cache is a distributed cache that enables multiple instances of an application to share cached data. This type of cache is typically implemented using external caching systems such as Redis, Memcached, or Hazelcast. A distributed cache helps maintain the consistency and availability of cached data across different application instances. Characteristics of the First Level Cache:

  • Shared across multiple application instances
  • Can be persistent, ensuring data availability even after restarts
  • Supports larger data volumes compared to in-memory caches
  • Requires network access, which can introduce latency compared to in-memory caches

2. Implement the First Level of Cache

The first level of cache is typically an in-memory cache that stores data within a single application instance. For this purpose, we can use Caffeine, a high-performance, Java-based caching library. Caffeine offers an in-memory cache with features inspired by Google Guava. It is designed to be efficient, flexible, and user-friendly, providing various cache eviction policies, loading mechanisms, and other advanced features. Here are several powerful features that make Caffeine a popular choice for caching in Java applications:

  • High Performance: Caffeine is designed to offer fast and efficient caching, with low latency and high throughput.
  • Flexible Eviction Policies: Caffeine supports various eviction policies, such as size-based eviction, time-based eviction, and reference-based eviction.
  • Automatic Loading: Caffeine can automatically load entries into the cache, reducing the complexity of manual cache management.
  • Asynchronous Operations: Caffeine supports asynchronous cache loading and eviction, improving application performance and responsiveness.
  • Statistics and Monitoring: Caffeine provides built-in support for gathering cache statistics, helping developers monitor and optimize cache performance.

For the below implementation, I have used Caffeine running on Docker.

2.1 Add Maven Dependencies

Ensure you have the necessary dependencies in your pom.xml or build.gradle file.

pom.xml

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<dependency>
   <groupId>com.github.ben-manes.caffeine</groupId>
   <artifactId>caffeine</artifactId>
</dependency>

2.2 Enable Caching in Spring Boot

Enable caching in your main application class using the @EnableCaching annotation.

package com.jcg.example;

@SpringBootApplication
@EnableCaching
public class Application {
   public static void main(String[] args) {
	   SpringApplication.run(Application.class, args);
   }
}

2.3 Configure the Cache Manager

Define a CacheManager bean using Caffeine in your configuration class.

  • Configuration: The class is annotated with @Configuration, indicating that it serves as a configuration class in a Spring application.
  • Cache Manager
    • Defines a bean named cacheManager() that returns a CacheManager instance.
    • Creates a CaffeineCacheManager instance with the name “items”.
    • Sets up the Caffeine cache with specific properties:
      • Initial capacity of 100.
      • Maximum size of 500.
      • Expiry after access time of 10 minutes.
    • Returns the configured cache manager instance.
package com.jcg.example.config;

@Configuration
public class CacheConfig {
   @Bean
   public CacheManager cacheManager() {
	   CaffeineCacheManager cacheManager = new CaffeineCacheManager("items");
	   cacheManager.setCaffeine(Caffeine.newBuilder()
			   .initialCapacity(100)
			   .maximumSize(500)
			   .expireAfterAccess(10, TimeUnit.MINUTES));
	   return cacheManager;
   }
}

2.4 Annotate Service Methods

Use @Cacheable to annotate methods that you want to cache.

package com.jcg.example.service;

@Service
public class ItemService {
   @Cacheable("items")
   public Item getItemById(Long id) {
	   // Simulate a slow database call
	   try {
		   Thread.sleep(3000);
	   } catch (InterruptedException e) {
		   e.printStackTrace();
	   }
	   return new Item(id, "ItemName");
   }
}

3. Implement the Second Level of Cache

The second level of cache is a distributed cache that enables multiple instances of the application to share cached data. Redis is a suitable choice for this purpose.

Redis is an open-source, in-memory data structure store utilized as a database, cache, and message broker. It offers support for a variety of data structures including strings, hashes, lists, sets, sorted sets, bitmaps, and geospatial indexes with radius queries. Renowned for its high performance, flexibility, and extensive feature set, Redis is favored in diverse applications. Redis boasts several potent features contributing to its popularity across various use cases:

  • In-Memory Storage: Redis stores data in memory, ensuring fast read and write operations.
  • Persistence: Redis supports various persistence mechanisms to store data on disk, providing durability.
  • Data Structures: Redis supports a rich set of data structures, allowing for versatile data manipulation.
  • Replication: Redis supports master-slave replication, enhancing data availability and scalability.
  • High Availability: Redis Sentinel provides high availability and monitoring, ensuring automatic failover.
  • Cluster Support: Redis Cluster enables horizontal partitioning of data across multiple nodes.

For the below implementation, I have used Redis running on Docker.

3.1 Add Maven Dependencies

Add Redis dependencies to your pom.xml or build.gradle.

pom.xml

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

3.2 Configure Redis

Configure Redis connection settings in application.properties or application.yml.

application.properties

spring.redis.host=localhost
spring.redis.port=6379

3.3 Define a Redis Cache Manager

Create a configuration class to set up the Redis cache manager.

  • Configuration: This Java class named RedisConfig is a configuration class.
  • Redis Connection Factory Bean: A bean method named redisConnectionFactory() is defined. This method returns a RedisConnectionFactory object, which is an interface for factory beans that can create Redis connections. It specifies that the LettuceConnectionFactory should be used for creating the Redis connection.
  • Redis Template Bean: A bean method named redisTemplate() is defined. This method returns a RedisTemplate<String, Object> object, which provides Redis data access methods. It sets the connection factory for the Redis template using the redisConnectionFactory() method defined earlier.
  • Cache Manager Bean: A bean method overriding the cacheManager() method from the CachingConfigurerSupport class is defined. This method configures and returns a CacheManager object, which manages caching in the application. It specifies default cache configuration settings, such as the entry time-to-live (TTL) duration, using RedisCacheConfiguration. It builds and returns a RedisCacheManager object using the configured RedisConnectionFactory and cache defaults.
package com.jcg.example.config;

@Configuration
public class RedisConfig extends CachingConfigurerSupport {
   @Bean
   public RedisConnectionFactory redisConnectionFactory() {
	   return new LettuceConnectionFactory();
   }

   @Bean
   public RedisTemplate redisTemplate() {
	   RedisTemplate template = new RedisTemplate();
	   template.setConnectionFactory(redisConnectionFactory());
	   return template;
   }

   @Bean
   @Override
   public CacheManager cacheManager() {
	   RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
			   .entryTtl(Duration.ofMinutes(10));
	   return RedisCacheManager.builder(redisConnectionFactory())
			   .cacheDefaults(cacheConfig)
			   .build();
   }
}

3.4 Combine First and Second Level Cache

We can implement a simple two-level cache using a combination of Caffeine and Redis.

  • Configuration: The class is annotated with @Configuration, indicating that it is a Spring configuration class.
  • The class extends CachingConfigurerSupport, implying that it provides custom configuration for caching in the Spring application.
  • Redis Connection Factory: Defines a bean named redisConnectionFactory() that returns a LettuceConnectionFactory, which is a Redis connection factory implementation provided by Lettuce.
  • Redis Template: Defines a bean named redisTemplate() that returns a RedisTemplate<String, Object>. This bean sets up a Redis template with the previously defined Redis connection factory.
  • Cache Manager:
    • Defines a bean named cacheManager() that returns a CompositeCacheManager, indicating that it manages multiple cache managers.
    • Creates a CaffeineCacheManager named “items”, configured with specific properties such as initial capacity, maximum size, and expiry after access time.
    • Configures a default Redis cache using RedisCacheConfiguration. The entries in this cache expire after 10 minutes.
    • Builds a RedisCacheManager using the Redis connection factory and the default Redis cache configuration.
    • Sets up the composite cache manager to manage both the Caffeine and Redis cache managers.
package com.jcg.example.config;

@Configuration
public class TwoLevelCacheConfig extends CachingConfigurerSupport {
   @Bean
   public RedisConnectionFactory redisConnectionFactory() {
	   return new LettuceConnectionFactory();
   }

   @Bean
   public RedisTemplate redisTemplate() {
	   RedisTemplate template = new RedisTemplate();
	   template.setConnectionFactory(redisConnectionFactory());
	   return template;
   }

   @Bean
   public CacheManager cacheManager() {
	   CompositeCacheManager cacheManager = new CompositeCacheManager();
	   CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager("items");
	   caffeineCacheManager.setCaffeine(Caffeine.newBuilder()
			   .initialCapacity(100)
			   .maximumSize(500)
			   .expireAfterAccess(10, TimeUnit.MINUTES));

	   RedisCacheConfiguration redisCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
			   .entryTtl(Duration.ofMinutes(10));
	   RedisCacheManager redisCacheManager = RedisCacheManager.builder(redisConnectionFactory())
			   .cacheDefaults(redisCacheConfig)
			   .build();

	   cacheManager.setCacheManagers(Arrays.asList(caffeineCacheManager, redisCacheManager));
	   return cacheManager;
   }
}

4. Implement the Integration Tests

Integration tests verify that the caching mechanisms work as expected.

The Java class CacheIntegrationTest is annotated with @SpringBootTest, signifying it as a Spring Boot test class, where a Spring Boot context is established for testing. Additionally, @AutoConfigureMockMvc is employed to automatically configure a MockMvc instance for the test, facilitating HTTP request testing. The annotation @ActiveProfiles("test") activates the “test” profile, which may contain configurations tailored for testing environments. Within the test class, the ItemService dependency is injected using @Autowired.

The whenCacheMiss_thenDataIsFetchedFromService() test method is defined to evaluate caching functionality. It starts a timer to measure execution time and proceeds to fetch an item with ID 1 from the ItemService, anticipating a cache miss as it is the initial request for this item. The method then calculates the time taken for this operation and asserts that it exceeds 3000 milliseconds, indicative of a cache miss. Subsequently, the same item is fetched again, expecting a cache hit this time. The method asserts that the time taken for the second request is less than 100 milliseconds, affirming a cache hit. Through this test, the caching behavior of the ItemService is validated, ensuring correct caching functionality for cache hits and misses.

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
public class CacheIntegrationTest {
   @Autowired
   private ItemService itemService;

   @Test
   public void whenCacheMiss_thenDataIsFetchedFromService() {
	   long start = System.currentTimeMillis();
	   Item item1 = itemService.getItemById(1L);
	   long timeTaken = System.currentTimeMillis() - start;
	   assertTrue(timeTaken > 3000); // Indicates cache miss

	   start = System.currentTimeMillis();
	   Item item2 = itemService.getItemById(1L);
	   timeTaken = System.currentTimeMillis() - start;
	   assertTrue(timeTaken < 100); // Indicates cache hit
   }
}

You can also verify cache interactions using mock frameworks if needed.

5. Conclusion

Understanding the concepts of first-level and second-level cache is essential for optimizing the performance of your applications. First-level cache provides fast, in-memory access to frequently accessed data within a single instance, while second-level cache offers a shared, distributed solution across multiple instances. By combining these caching strategies, you can achieve a balance between speed and scalability.

Yatin Batra

An experience full-stack engineer well versed with Core Java, Spring/Springboot, MVC, Security, AOP, Frontend (Angular & React), and cloud technologies (such as AWS, GCP, Jenkins, Docker, K8).
Subscribe
Notify of
guest

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

0 Comments
Inline Feedbacks
View all comments
Back to top button