Data Caching in a Spring Boot application with Redis
Photo by Art Wall - Kittenprint / Unsplash
Caching is the process of storing frequently accessed data to serve them quickly when needed. It reduces the response time of service and a load of requests to handle by a service. You need a caching solution when your service receives many requests and the data requested doesn't change much.
There are many types of caching: Application Caching, Database Caching, DNS Caching, Client-Side Caching, CDN Cache, and API Gateway Cache.
This post focus on implementing data caching to improve the responsiveness of a web application and reduce calls to the database.
Picture found at: https://www.gigaspaces.com/blog/in-memory-cache/
The use case
You have an electronic e-commerce website where users can browse all the products or search for a specific product using some criteria. As the business grows, your products catalog also increases, you have more users searching for products on your website, and your server starts struggling to serve the requests.
Yet, the search made by users is often similar in terms of criteria; hence, it produces the same result, but your server still has to process each request.
We can store the search result in an In-Memory database; when a request with the criteria comes in, the computed result is sent to the client.
We will see how to solve this problem using Redis and Spring Boot.
Prerequisites
You must need these tools installed on your computer to follow this tutorial.
- JDK 11 or higher - Download's link
- Maven 3.5 or higher - Download's link
- Docker - Download's link
We need Docker to run two containers for Redis and MySQL, respectively.
Set up the Redis instance
Run the command below to start a Docker container from the Redis image you can find in the Docker Hub.
docker run -d -p 6379:6379 --name redisdb
Set up the project
I prepared a Spring Boot project with the endpoints we need so we can focus on data caching. Before cloning the project, start the Docker container from the MySQL image that the project will connect to and store the data.
docker run -d -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=product-inventory --name mysqldb -p 3307:3306 mysql:8.0
Let's clone the project from the GitHub repository and setup locally:
git clone https://github.com/tericcabrel/springboot-caching.git
cd springboot-caching
mvn install
mvn spring-boot:run
The application will start on port 8030 and add 05 categories and 32 products to the database. Here are the endpoints of the application:
Endpoint | Method | Actions |
---|---|---|
/categories | GET | Retrieve all categories |
/products | GET | Retrieve all products |
/products/search | GET | Search products by name, category, price, and availability |
/products | POST | Add a new product |
I provided a Postman collection with the request prepared for you. You can download the JSON and import it.
Test the endpoint
Requests to endpoint /products
and /products/search
take at least 03 seconds to complete because I added a sleep of 3 seconds in the file src/main/java/com/tericcabrel/springbootcaching/services/ProductService.java
to simulate the server pressure and high computation.
Now we will update the application so that the request hits the database only if the data to retrieve doesn't exist in the cache.
Configure Redis in Spring Boot
We need a Redis client for Java to interact with the Redis server. We will use Lettuce that comes with Spring Data.
Update the pom.xml to add the Maven dependencies, then run mvn install
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Update the application.properties to define the credentials to connect to the Redis instance from the Spring Boot application:
# Redis configuration
spring.redis.host=localhost
spring.redis.port=6379
Create a package configs, then create a file called RedisConfiguration.java
and add the code below:
package com.tericcabrel.springbootcaching.configs;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
@Configuration
@EnableRedisRepositories(value = "com.tericcabrel.springbootcaching.repositories")
public class RedisConfiguration {
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
RedisProperties properties = redisProperties();
RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
configuration.setHostName(properties.getHost());
configuration.setPort(properties.getPort());
return new LettuceConnectionFactory(configuration);
}
@Bean
public RedisTemplate<byte[], byte[]> redisTemplate() {
RedisTemplate<byte[], byte[]> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
return template;
}
@Bean
@Primary
public RedisProperties redisProperties() {
return new RedisProperties();
}
}
Redis is a key-value database, so we must create a model representing this structure. In the package models, create a file named CacheData.java
and add the code below:
package com.tericcabrel.springbootcaching.models;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.experimental.Accessors;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;
@AllArgsConstructor
@Getter
@Accessors(chain = true)
@RedisHash("cacheData")
public class CacheData {
@Id
private String key;
@Indexed
private String value;
}
Create the repository for the CacheData model in the package repositories
called CacheDataRepository.java and add the code below:
package com.tericcabrel.springbootcaching.repositories;
import com.tericcabrel.springbootcaching.models.CacheData;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CacheDataRepository extends CrudRepository<CacheData, String> {
}
Run the application to make sure everything still works as expected.
Cache the request
We have two endpoints to cache the data; the first one to cache is the one to retrieve all the products in the database.
To store data in Redis, you need a unique key to identify it. This ensures you don't duplicate data.
Cache the route /products
Update the function of this endpoint in the file ProductController.java:
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
private final CacheDataRepository cacheDataRepository;
private final ObjectMapper objectMapper;
public ProductController(ProductService productService, CacheDataRepository cacheDataRepository) {
this.productService = productService;
this.cacheDataRepository = cacheDataRepository;
this.objectMapper = new ObjectMapper();
}
@GetMapping
public ResponseEntity<ProductListResponse> getAll() throws InterruptedException, JsonProcessingException {
Optional<CacheData> optionalCacheData = cacheDataRepository.findById("allProducts");
// Cache hit
if (optionalCacheData.isPresent()) {
String productAsString = optionalCacheData.get().getValue();
TypeReference<List<Product>> mapType = new TypeReference<List<Product>>() {};
List<Product> productList = objectMapper.readValue(productAsString, mapType);
return ResponseEntity.ok(new ProductListResponse(productList));
}
// Cache miss
List<Product> productList = productService.findAll();
String productsAsJsonString = objectMapper.writeValueAsString(productList);
CacheData cacheData = new CacheData("allProducts", productsAsJsonString);
cacheDataRepository.save(cacheData);
return ResponseEntity.ok(new ProductListResponse(productList));
}
}
Re-run the application and test the endpoint:
As we can see, the first response was slow, but the next one was very fast ⚡
Cache the route /products/search
The route /products return the same data every time we call it, but with this route, the result varies depending on the query parameters value.
If you find the product named "iPhone" in the category "Phone", you get three results (I built the dataset ?), but if you change the category to "Computer", you get no result.
To have a cache key unique, you must concatenate the values of all the query parameters.
Update the function of this endpoint in the file ProductController.java:
@RestController
@RequestMapping("/products")
public class ProductController {
private final ProductService productService;
private final CacheDataRepository cacheDataRepository;
private final ObjectMapper objectMapper;
public ProductController(ProductService productService, CacheDataRepository cacheDataRepository) {
this.productService = productService;
this.cacheDataRepository = cacheDataRepository;
this.objectMapper = new ObjectMapper();
}
@GetMapping("/search")
public ResponseEntity<ProductListResponse> search(@Valid SearchProductDto searchProductDto) throws InterruptedException, JsonProcessingException {
String cacheKey = searchProductDto.buildCacheKey("searchProducts");
Optional<CacheData> optionalCacheData = cacheDataRepository.findById(cacheKey);
// Cache hit
if (optionalCacheData.isPresent()) {
String productAsString = optionalCacheData.get().getValue();
TypeReference<List<Product>> mapType = new TypeReference<List<Product>>() {};
List<Product> productList = objectMapper.readValue(productAsString, mapType);
return ResponseEntity.ok(new ProductListResponse(productList));
}
List<Product> productList = productService.search(searchProductDto);
String productsAsJsonString = objectMapper.writeValueAsString(productList);
CacheData cacheData = new CacheData(cacheKey, productsAsJsonString);
cacheDataRepository.save(cacheData);
return ResponseEntity.ok(new ProductListResponse(productList));
}
}
Below is the function to build the cache key in the class SearchProductDto.java:
public String buildCacheKey(String keyPrefix) {
StringBuilder builder = new StringBuilder(keyPrefix);
builder.append("-").append(name.toLowerCase());
if (category != null) {
builder.append("-").append(category.toLowerCase());
}
builder.append("-").append(minPrice);
builder.append("-").append(maxPrice);
if (available != null) {
builder.append("-").append(available);
}
return builder.toString();
}
Re-run the application and test the endpoint:
Cache invalidation
There are only two hard things in Computer Science: cache invalidation and naming things. — Phil Karlton
Now we have improved request-response time and reduced the database load, we have an issue with data consistency. Once a request is cached, the other's request will get the cached data even if the data are updated (new product, product price update, product delete, etc...).
We must define a way to delete the content in the cache based on some action.
For the route /products, if we add, update or delete a product, we must delete the cache key allProducts
in Redis. It is done with the method of the cache data repository:
// add this line after the creation, update or deletion of a product
cacheDataRepository.deleteById("allProducts");
For the route /products/search, it is tricky. The cache key is generated dynamically; hence we can't guess which one to delete. Every time we add, update or delete a product, the product category is updated, so deleting all the keys that contain this category will enable the system to respond with updated data.
- If we update the price of a product, we delete the key containing the category name of the former.
- If we add a new product, we delete keys containing the category name
- If we delete a product, we delete keys containing the category name
The cache data repository doesn't provide a function to find the key with a specific string; fortunately, we can add that function using Dynamic queries in the repository.
@Repository
public interface CacheDataRepository extends CrudRepository<CacheData, String> {
List<CacheData> findByIdContainingIgnoreCase(String keyword);
}
We can use it like this:
List<String> cacheKeys = cacheDataRepository.findByIdContainingIgnoreCase("Phone")
.stream()
.map((CacheData::getKey))
.toList();
cacheDataRepository.deleteAllById(cacheKeys);
Another alternative
You can cache data with a Time To Live (TTL). For example, if you set the TTL to 60 seconds, the cached data will be deleted from the cache after this time.
The main benefit is to avoid adding cache invalidation statements everywhere you mutate data. The disadvantage of this solution is that your user will have inconsistent data for a short amount of time.
Wrap up
Caching is the solution to improve the performance of your application and save you a ton of money if applied at the right moment on the correct data.
You can find the code source on the GitHub repository.
Follow me on Twitter or subscribe to my newsletter to avoid missing the upcoming posts and the tips and tricks I occasionally share.