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.
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>
spring-boot-starter-data-redis
: It is a Starter for using Redis key-value data store with Spring Data Redis and the Lettuce clientspring-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