Caching with Spring Boot 3, Lettuce, and Redis Sentinel

Abhishek Anand
Javarevisited
Published in
4 min readJan 24, 2023
Caching with Spring Boot 3, Lettuce, and Redis Sentinel

In this tutorial, I will walk you through how to connect to Redis Sentinel from Spring Boot and use it for caching.

What is Redis Sentinel?

Redis Sentinel is the high-availability solution offered by Redis, In case of a failure in your Redis cluster, Sentinel will automatically detect the point of failure and bring the cluster back to stable mode without any human intervention. In other words, Redis Sentinel is a system that can resist Redis deployment without human intervention.

Redis Sentinel setup

Dependencies

First, let's add the below starters to make our life easier.

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
  1. spring-boot-starter-data-redis : It is a Starter for using Redis key-value data store with Spring Data Redis and the Lettuce client
  2. spring-boot-starter-cache : Starter for Spring Framework’s caching support, it provides super handy annotations for working with any caching provider.

Connecting to Redis Sentinel from Spring Boot 3

Now, we are going to create a connection to “Redis Sentinel” with the help of the below configuration class. As I told you before, we are going to use Lettuce to connect to Redis. We are going to create a new instance of RedisSentinelConfiguration class with the help of RedisProperties class and use it in the constructor of LettuceConnectionFactory.

application.properties

spring.data.redis.port=26379
spring.data.redis.password=
spring.data.redis.sentinel.master=mymaster
spring.data.redis.sentinel.nodes=localhost
spring.cache.type=redis
spring.cache.redis.cache-null-values=false
spring.cache.redis.time-to-live=300000
spring.data.redis.timeout=600ms

RedisConfig.java

package com.medium.ldapservice.config;

import java.time.Duration;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.cache.Cache;
import org.springframework.cache.annotation.CachingConfigurer;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisSentinelConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.GenericToStringSerializer;
import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import io.lettuce.core.ReadFrom;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Configuration
@EnableCaching
@RequiredArgsConstructor
public class RedisConfig implements CachingConfigurer {

@Value("${spring.cache.redis.time-to-live}")
private long redisTimeToLive;

@Value("${spring.data.redis.timeout}")
private Duration redisCommandTimeout;

private final RedisProperties redisProperties;

@Bean
protected LettuceConnectionFactory redisConnectionFactory() {
RedisSentinelConfiguration sentinelConfig = new RedisSentinelConfiguration()
.master(redisProperties.getSentinel().getMaster());
redisProperties.getSentinel().getNodes().forEach(s -> sentinelConfig.sentinel(s, redisProperties.getPort()));
sentinelConfig.setPassword(RedisPassword.of(redisProperties.getPassword()));

LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.commandTimeout(redisCommandTimeout).readFrom(ReadFrom.REPLICA_PREFERRED).build();
return new LettuceConnectionFactory(sentinelConfig, clientConfig);
}

@Bean
public RedisTemplate<String, Object> redisTemplate() {
final RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new GenericToStringSerializer<>(Object.class));
redisTemplate.setHashValueSerializer(new JdkSerializationRedisSerializer());
redisTemplate.setValueSerializer(new JdkSerializationRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}

@Override
@Bean
public RedisCacheManager cacheManager() {
return RedisCacheManager.builder(this.redisConnectionFactory()).cacheDefaults(this.cacheConfiguration())
.build();
}

@Bean
public RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(redisTimeToLive))
.disableCachingNullValues()
.serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
}

@Override
public CacheErrorHandler errorHandler() {
return new CacheErrorHandler() {
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
log.info("Failure getting from cache: " + cache.getName() + ", exception: " + exception.toString());
}

@Override
public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {
log.info("Failure putting into cache: " + cache.getName() + ", exception: " + exception.toString());
}

@Override
public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
log.info("Failure evicting from cache: " + cache.getName() + ", exception: " + exception.toString());
}

@Override
public void handleCacheClearError(RuntimeException exception, Cache cache) {
log.info("Failure clearing cache: " + cache.getName() + ", exception: " + exception.toString());
}
};
}

}

The RedisProperties we defined is a @ConfigrationProperties class. It maps the fields in its own class with the spring.data.redis prefix which we defined in application.properties and presents it to us. We have defined our connection configuration with the fields in the RedisProperties.

redisConnectionFactory() -> With the help of RedisSentinelConfiguration class, we set up RedisConnection via RedisConnectionFactory. Then we create a LettuceClientConfiguration where we set a custom command timeout(default is 60s) and preference for reading from slaves instead of master.

cacheManager() -> We initialize the cache Manager to the Redis Sentinel connection created above and also supply the configuration for the cache with the below cache configuration bean. This helps with the @Cacheable annotation.

cacheConfiguration() ->The behavior of RedisCache created with RedisCacheManager is defined with RedisCacheConfiguration. The configuration lets you set key expiration times, prefixes, and RedisSerializer implementations for converting to and from the binary storage format.

errorHandler() -> We define a custom error handler to handle any exceptions occurred during any Redis command execution. This helps to connect to the actual source of data instead of throwing an exception if the Redis command execution failed.

redisTemplate() -> It is used to define the way how serialization/deserialization of keys and values will be done when stored and fetched from the Redis cache.

How to use it?

We can simply interact with Redis Cache using the @Cacheable annotation. Annotate this on top of service methods like below.

 @Cacheable(value = "user", key = "#username",unless="#result.size()==0")
@Override
public List<Map<String, Object>> getAttributes(String username) {
//Return from DB
}

This will create a key user:<username> and store the value returned from the function in the cache only if the returned result size is greater than zero. Empty values will not create an entry in the Redis cache.

Setting up a local Redis Sentinel cluster

Install Docker on your machine and start it. Create the below file.

docker-compose.yml

version: '2'

networks:
app-tier:
driver: bridge

services:
redis:
image: 'bitnami/redis:latest'
environment:
- ALLOW_EMPTY_PASSWORD=yes
ports:
- '6379:6379'
networks:
- app-tier
redis-sentinel:
image: 'bitnami/redis-sentinel:latest'
environment:
- REDIS_MASTER_HOST=localhost
- REDIS_SENTINEL_RESOLVE_HOSTNAMES=yes
ports:
- '26379:26379'
networks:
- app-tier

Bring up the Redis Sentinel using the below command

docker-compose up -d

Connect to Redis Sentinel from the command line

redis-cli -h localhost -p 26379

You can run Sentinel commands now like to find the master IP address.

Connect to the Redis node from the command line

redis-cli -h localhost -p 6379

You can run the below command to see the list of keys stored in the Redis Node

keys *

If you liked the article please take a minute to offer me a clap 👏 (you can clap multiple times), follow me, or even buy me a ☕️https://www.buymeacoffee.com/abhiandy

--

--

Abhishek Anand
Javarevisited

Spring Boot Advocate | Senior Software Engineer @ Intuit | ex-VMware