Data Caching in a Spring Boot application with Redis

Photo by Art Wall - Kittenprint / Unsplash
Photo by Art Wall - Kittenprint / Unsplash

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.

Caching things every Programmer must know
Hello everyone. Caching is one of the simplest yet complex topics and one of the basic things every programmer must understand. In this…

This post focus on implementing data caching to improve the responsiveness of a web application and reduce calls to the database.

Data caching workflow

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.

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

Testing API locally
Testing API locally

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:

Test API routes with caching implemented
Test API routes with caching implemented

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.