Learnitweb

Implementing Cache with Redisson in a Spring Boot Reactive Application

In this tutorial, we will extend our existing Spring Boot application to integrate caching using Redisson. The goal is to create a clean architecture that separates caching logic from the service layer using a template method design pattern, so we can reuse caching logic across different entities.

We will also demonstrate performance testing using Gatling, followed by implementing a cache abstraction that interacts with Redis.


1. Performance Testing Best Practices

Before integrating caching, it’s important to establish a baseline for your application’s performance.

  1. Use Gatling in Non-GUI Mode for Scripts
    • Avoid opening the GUI for performance tests unless you are debugging.
    • GUI mode is mainly useful for seeing your script run visually.
  2. Command-line execution example:
    • Navigate to Gatling’s bin directory.
    • Run your script using: ./gatling.sh -s ProductServiceSimulation -rf results/v1
    • Here:
      • -s specifies the simulation script.
      • -rf specifies the folder to store the results in HTML format.
  3. Observing Results:
    • Once the test completes, open Gatling GUI and inspect the aggregate report.
    • Example baseline: 36,600 requests per second for local setup.
    • Adjust the load based on CPU/memory limits. For example, reduce virtual users from 200 to 50–100 if needed.

2. Adding Redisson to the Project

Add Redisson dependency in your pom.xml. Use the recommended version 3.16.1:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.16.1</version>
</dependency>
  • Redisson will provide Redis-based caching with Spring Boot support.
  • Ensure Spring Boot Starter is properly configured.

3. Designing the Cache Template

To avoid polluting the service layer with repetitive cache logic, we use the Template Method Pattern. This separates caching concerns from business logic.

3.1 Create an Abstract Cache Template

public abstract class CacheTemplate<K, E> {

    // Abstract methods to be implemented for each entity
    protected abstract Mono<E> getFromSource(K key);
    protected abstract Mono<Void> updateSource(K key, E entity);
    protected abstract Mono<Void> deleteFromSource(K key);

    // Template methods for caching
    public Mono<E> get(K key) {
        return getFromCache(key)
            .switchIfEmpty(
                getFromSource(key)
                    .flatMap(entity -> updateCache(key, entity).thenReturn(entity))
            );
    }

    public Mono<E> update(K key, E entity) {
        return updateSource(key, entity)
            .flatMap(updated -> removeFromCache(key).thenReturn(updated));
    }

    public Mono<E> delete(K key) {
        return deleteFromSource(key)
            .flatMap(v -> removeFromCache(key).thenReturn(null));
    }

    // Cache operations
    protected abstract Mono<E> getFromCache(K key);
    protected abstract Mono<Void> updateCache(K key, E entity);
    protected abstract Mono<Void> removeFromCache(K key);
}

3.2 Key Points:

  • get(): Checks cache first, then source, then updates cache.
  • update(): Updates the source first, then removes the cached entry.
  • delete(): Deletes from source, then clears the cache.
  • Insert: Cache is updated only on a get call, not during initial inserts.

4. Implementing a Redis Cache Template

We create a concrete implementation using Redisson reactive maps:

@Component
public class ProductCacheTemplate extends CacheTemplate<String, Product> {

    private final RMapReactive<String, Product> map;
    private final ProductRepository repository;

    @Autowired
    public ProductCacheTemplate(RedissonClient redisson, ProductRepository repository) {
        this.map = redisson.getMap("product-cache");
        this.repository = repository;
    }

    @Override
    protected Mono<Product> getFromSource(String key) {
        return repository.findById(key);
    }

    @Override
    protected Mono<Void> updateSource(String key, Product entity) {
        return repository.save(entity).then();
    }

    @Override
    protected Mono<Void> deleteFromSource(String key) {
        return repository.deleteById(key);
    }

    @Override
    protected Mono<Product> getFromCache(String key) {
        return map.get(key);
    }

    @Override
    protected Mono<Void> updateCache(String key, Product entity) {
        return map.fastPut(key, entity).then();
    }

    @Override
    protected Mono<Void> removeFromCache(String key) {
        return map.fastRemove(key).then();
    }
}
  • Uses RMapReactive for reactive, non-blocking Redis operations.
  • The cache key is the product ID, and the value is the product entity.
  • All methods return Mono for reactive chaining.

5. Refactoring Service Layer to Use Cache

Update the ProductService to delegate cache operations to the ProductCacheTemplate:

@Service
public class ProductServiceV1 {

    private final ProductCacheTemplate cacheTemplate;

    @Autowired
    public ProductServiceV1(ProductCacheTemplate cacheTemplate) {
        this.cacheTemplate = cacheTemplate;
    }

    public Mono<Product> getProduct(String id) {
        return cacheTemplate.get(id);
    }

    public Mono<Product> updateProduct(String id, Product product) {
        return cacheTemplate.update(id, product);
    }

    public Mono<Void> deleteProduct(String id) {
        return cacheTemplate.delete(id).then();
    }
}
  • The service no longer contains caching logic.
  • Cache operations are handled by the template.
  • This improves maintainability and makes the service layer cleaner.

6. Controller Changes

Update the controller to use versioned endpoints:

@RestController
@RequestMapping("/product/v1")
public class ProductControllerV1 {

    private final ProductServiceV1 service;

    @Autowired
    public ProductControllerV1(ProductServiceV1 service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    public Mono<Product> getProduct(@PathVariable String id) {
        return service.getProduct(id);
    }

    @PutMapping("/{id}")
    public Mono<Product> updateProduct(@PathVariable String id, @RequestBody Product product) {
        return service.updateProduct(id, product);
    }

    @DeleteMapping("/{id}")
    public Mono<Void> deleteProduct(@PathVariable String id) {
        return service.deleteProduct(id);
    }
}

Implementing Product Caching with Redis and Local Cache in Spring Boot

In this tutorial, we will enhance our Spring Boot application by implementing caching for product entities. We will cover three versions of caching:

  1. V1: Basic service without caching.
  2. V2: Redis-based caching.
  3. V3: Redis caching with local cache map options for improved performance.

We will also demonstrate performance testing with JMeter and discuss best practices for warm-up, cache hits, and data propagation across multiple instances.


1. Product Service Implementation with Redis Cache

1.1 Service Class Setup

  • Create a service class and annotate it with @Service.
  • Instead of using a repository, we will use a Redis cache template (ReactiveRedisTemplate<Integer, Product>).
@Service
public class ProductCacheService {

    private final ReactiveRedisTemplate<Integer, Product> cacheTemplate;

    public ProductCacheService(ReactiveRedisTemplate<Integer, Product> cacheTemplate) {
        this.cacheTemplate = cacheTemplate;
    }

    public Mono<Product> getProduct(Integer id) {
        return cacheTemplate.opsForValue().get(id);
    }

    public Mono<Product> updateProduct(Product product) {
        return cacheTemplate.opsForValue()
                .set(product.getId(), product)
                .thenReturn(product);
    }

    public Mono<Void> deleteProduct(Integer id) {
        return cacheTemplate.opsForValue().delete(id).then();
    }
}

Key points:

  • getProduct() fetches from the cache.
  • updateProduct() updates both cache and database.
  • deleteProduct() removes the product from the cache and database.

2. Product Controller

  • Create a controller to expose endpoints for CRUD operations.
  • Use V2Service (cache-enabled) for endpoints.
@RestController
@RequestMapping("/products")
public class ProductController {

    private final ProductCacheService productService;

    public ProductController(ProductCacheService productService) {
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public Mono<Product> getProduct(@PathVariable Integer id) {
        return productService.getProduct(id);
    }

    @PutMapping
    public Mono<Product> updateProduct(@RequestBody Product product) {
        return productService.updateProduct(product);
    }

    @DeleteMapping("/{id}")
    public Mono<Void> deleteProduct(@PathVariable Integer id) {
        return productService.deleteProduct(id);
    }
}

3. Redis Cache Behavior and Testing

  • Cache Eviction: When a product is updated, it is evicted from the cache to ensure the next get fetches updated data.
  • Delete Operation: Removing a product clears it from both cache and database.
  • Warm-up Requests: Always run a short warm-up period before performance tests to avoid initial latency.

4. Performance Testing with JMeter

4.1 Test Setup

  • Run a 60-second warm-up test before the main test.
  • Main test duration: 5 minutes (or longer for real-world simulation).
  • Test endpoints: /products/{id} for GET, /products for PUT.

4.2 Observations

  • Adding Redis improved throughput significantly.
  • Warm-up ensures cache is populated, avoiding artificial cold-cache latency.
  • For realistic results, do not clear the cache between warm-up and main tests.

5. Local Cache Map Implementation (V3)

  • For better performance and reduced network latency, we can combine Redis with a local cache map.
public class ProductLocalCacheTemplate extends ProductCacheService {

    private final Map<Integer, Product> localCache = new ConcurrentHashMap<>();

    public ProductLocalCacheTemplate(ReactiveRedisTemplate<Integer, Product> cacheTemplate) {
        super(cacheTemplate);
    }

    @Override
    public Mono<Product> getProduct(Integer id) {
        Product product = localCache.get(id);
        if (product != null) {
            return Mono.just(product);
        }
        return super.getProduct(id).doOnNext(p -> localCache.put(id, p));
    }

    @Override
    public Mono<Product> updateProduct(Product product) {
        localCache.put(product.getId(), product);
        return super.updateProduct(product);
    }

    @Override
    public Mono<Void> deleteProduct(Integer id) {
        localCache.remove(id);
        return super.deleteProduct(id);
    }
}
  • Local cache map reduces Redis calls for frequently accessed products.
  • Updates and deletions propagate asynchronously to maintain consistency.

6. Running Multiple Instances

  • Package the application using mvn clean package.
  • Start multiple instances to verify cache consistency.
  • V3 with local cache ensures both Redis and local maps are in sync across instances.

7. Performance Results

  • Redis alone increased throughput to 600–1200 requests per second.
  • Using local cache map in combination with Redis further increased throughput to ~2000 requests per second.
  • Short-duration tests show immediate improvement, but long-duration tests provide more realistic averages.

8. Best Practices

  1. Always run warm-up requests to populate caches before measuring performance.
  2. Use local cache maps when running multiple instances to reduce network overhead.
  3. Implement proper cache eviction and propagation strategies to ensure consistency.
  4. Monitor Redis metrics to avoid performance degradation in production.