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 aCacheManager
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.
- Defines a bean named
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 aRedisConnectionFactory
object, which is an interface for factory beans that can create Redis connections. It specifies that theLettuceConnectionFactory
should be used for creating the Redis connection. - Redis Template Bean: A bean method named
redisTemplate()
is defined. This method returns aRedisTemplate<String, Object>
object, which provides Redis data access methods. It sets the connection factory for the Redis template using theredisConnectionFactory()
method defined earlier. - Cache Manager Bean: A bean method overriding the
cacheManager()
method from theCachingConfigurerSupport
class is defined. This method configures and returns aCacheManager
object, which manages caching in the application. It specifies default cache configuration settings, such as the entry time-to-live (TTL) duration, usingRedisCacheConfiguration
. It builds and returns aRedisCacheManager
object using the configuredRedisConnectionFactory
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 aLettuceConnectionFactory
, which is a Redis connection factory implementation provided by Lettuce. - Redis Template: Defines a bean named
redisTemplate()
that returns aRedisTemplate<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 aCompositeCacheManager
, 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.
- Defines a bean named
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.
CompositeCacheManager iterates through its list of cache managers calling getCache(…), the first non-null cache is returned. In the example code provided in the article this would be the cache returned by the CaffeineCacheManager. The Redis cache will never be called.