BytePitch Logo
blog header Redis Lettuce Integration with Java Spring Boot bytepitch

Engineering Team

2018-12-04

15 mins

Redis
Java
Spring Boot
Lettuce

Redis Lettuce Integration with Java Spring Boot

This is a brief article on how we integrated a Redis-based cache for one of our client’s services. The idea was to create CRUD repositories for some of the most frequently requested entities in order to reduce load on a third-party provider we were using at the time. The issue was a common one: we needed to make a vast number of calls to this provider, and it was not scaling efficiently.
Introducing Redis as a middle caching layer for already processed content allowed us to scale effectively.
Depending on bandwidth, latency, CPU, RAM, and client configurations, Redis can handle hundreds of thousands of requests per second. More detailed benchmark information can be found here: .

We began by looking into which frameworks we could pair with Java Spring, which we were using for the microservice in question.

How Redis Works
Redis is an open-source, in-memory data structure store that uses RAM for data storage. As such, all Redis data is held primarily in the server’s main memory. This means both read and write operations are extremely fast — in fact, most requests to Redis complete in under a millisecond, allowing a single server to handle millions of concurrent requests every second.

Thanks to this performance, Redis can be used as a database, cache, and message broker, wherever low latency is key.

redisDataTypes.png
The database itself is essentially a dictionary mapping keys to values, guaranteeing constant-time access by key. You can store high-level data structures such as lists, sets, and hashes, as well as bitmaps and bitfields for efficiently storing Boolean and numeric data. (Source: )
Typical Redis Architecture and Performance
A typical Redis architecture will look like the following:
redisArchitecture.png
Redis can be configured in several ways to address the limits and volatility of RAM. Besides replying with an “out of memory” error when memory is full, Redis can be set up to evict data based on usage frequency, recentness, or random policies.
Every key in Redis can also have a time-to-live (TTL) value that causes it to be deleted automatically once expired. Redis supports asynchronous replication, maintaining one or more secondary replicas that are kept up to date by a single primary source.
Depending on whether Redis is used in single-instance or clustered mode, it can also be set up for high availability. Databases are scaled by partitioning the keyspace and assigning each partition dedicated memory, compute, and network resources. Redis Cluster automatically partitions data among multiple Redis servers across different nodes.
Redis Features for Database, Cache, and Message Broker
  • Extremely fast read/write operations

  • Data persistence and replication

  • Support for multiple data structures (lists, sets, hashes, bitmaps)

  • Clustered setup for high availability and scaling

Possible Redis Frameworks for Java
For internal reasons, we were more inclined towards these two frameworks. Other options such as are also available and widely used.
Lettuce vs Jedis

Whilst Jedis is simple to use and supports a vast number of Redis features, it is not thread-safe and therefore requires connection pooling in multi-threaded environments. Connection pooling, however, comes at the cost of maintaining a physical connection per Jedis instance, which increases the number of Redis connections.

Lettuce, on the other hand, is built on , and its connection instances can be shared across multiple threads. This means a multi-threaded application can use a single connection regardless of how many concurrent threads interact with Lettuce.
Synchronous vs Asynchronous Communication in Lettuce
Another reason we opted for Lettuce was its native support for asynchronous operations. Because it’s built on top of Netty — a multithreaded, event-driven I/O framework — Lettuce allows you to utilise system resources more efficiently by avoiding idle threads waiting for I/O operations.

This asynchronous model enables your threads to remain productive, performing other tasks whilst waiting for I/O. However, there are cases where synchronous access might be preferable, for instance when tasks are very short-lived or require immediate data consistency.

Redis Configuration in Spring Boot
Creating Redis Configuration Beans in Spring Boot

To begin, create a new configuration class and annotate it as follows:

@Configuration @EnableConfigurationProperties(RedisProperties.class) public class RedisConfig { }

This will generate the configuration bean with the corresponding properties. Next, include the host and port for your Redis server in your application properties:

@Value("${redis.host}") private String redisHost; @Value("${redis.port}") private int redisPort;

We’ll then create beans for our client resources and a standalone configuration. We’ll also define client options.

@Bean(destroyMethod = "shutdown") ClientResources clientResources() { return DefaultClientResources.create(); } @Bean public RedisStandaloneConfiguration redisStandaloneConfiguration() { return new RedisStandaloneConfiguration(redisHost, redisPort); } @Bean public ClientOptions clientOptions() { return ClientOptions.builder() .disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS) .autoReconnect(true) .build(); }

Here, the client options specify that Redis will reject any commands if disconnected, whilst automatically attempting to reconnect should the connection fail.

Lettuce Pooling Configuration for Multi-threaded Applications
Lettuce provides a pooling client configuration that allows us to create a connection factory with multiple reusable connections. Instead of a single connection, we have a pool from which connections can be borrowed and returned as needed.

Having multiple connections available is advantageous because:

  • It enables concurrent Redis communication from multiple threads.

  • It avoids creating connections on the fly, improving performance.

  • Configuration is defined once and reused.

  • It simplifies the setup of Redis clusters.

@Bean LettucePoolingClientConfiguration lettucePoolConfig(ClientOptions options, ClientResources dcr) { return LettucePoolingClientConfiguration.builder() .poolConfig(new GenericObjectPoolConfig()) .clientOptions(options) .clientResources(dcr) .build(); } @Bean public RedisConnectionFactory connectionFactory( RedisStandaloneConfiguration redisStandaloneConfiguration, LettucePoolingClientConfiguration lettucePoolConfig) { return new LettuceConnectionFactory(redisStandaloneConfiguration, lettucePoolConfig); } @Bean @ConditionalOnMissingBean(name = "redisTemplate") @Primary public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate<Object, Object> template = new RedisTemplate<>(); template.setConnectionFactory(redisConnectionFactory); return template; }

By marking the redisTemplate bean as @Primary, we ensure that this particular instance is used wherever multiple qualifying beans exist.

Using Redis with Clusters and Distributed Locks
When working in a clustered environment, we must ensure that identical instances of the same service do not perform the same task simultaneously. To prevent this, we use , which guarantees that scheduled tasks run at most once at a given moment.

@Value("${app.environment}") private String ENV; @Value("${taskScheduler.poolSize}") private int tasksPoolSize; @Value("${taskScheduler.defaultLockMaxDurationMinutes}") private int lockMaxDuration; @Bean public LockProvider lockProvider(RedisConnectionFactory connectionFactory) { return new RedisLockProvider(connectionFactory, ENV); } @Bean public ScheduledLockConfiguration taskSchedulerLocker(LockProvider lockProvider) { return ScheduledLockConfigurationBuilder .withLockProvider(lockProvider) .withPoolSize(tasksPoolSize) .withDefaultLockAtMostFor(Duration.ofMinutes(lockMaxDuration)) .build(); }

Implementing a Redis Cache Repository in Spring Boot

The RedisTemplate provides convenient methods for saving and retrieving various collection types (hashes, lists, sets, etc.). We can add/get/delete from cache very easily as long as we define a collection and a key:

// add template.opsForHash().put(collection, hkey, OBJECT_MAPPER.writeValueAsString(object)); // delete template.opsForHash().delete(collection, hkey); // get OBJECT_MAPPER.readValue( String.valueOf(template.opsForHash().get(collection, hkey)), class );

To check if a Redis connection is available:

template.getConnectionFactory().getConnection().ping() != null

And the repository class:

@Repository @Slf4j public class CacheRepository<T> implements DataCacheRepository<T> { @Autowired RedisTemplate template; // and we're in business private static final ObjectMapper OBJECT_MAPPER; private static final TimeZone DEFAULT_TIMEZONE = TimeZone.getTimeZone("UTC"); static { OBJECT_MAPPER = new ObjectMapper(); OBJECT_MAPPER.setTimeZone(DEFAULT_TIMEZONE); } // implement methods @Override public boolean add(String collection, String hkey, T object) { try { String jsonObject = OBJECT_MAPPER.writeValueAsString(object); template.opsForHash().put(collection, hkey, jsonObject); return true; } catch (Exception e) { log.error("Unable to add object of key {} to cache collection '{}': {}", hkey, collection, e.getMessage()); return false; } } @Override public boolean delete(String collection, String hkey) { try { template.opsForHash().delete(collection, hkey); return true; } catch (Exception e) { log.error("Unable to delete entry {} from cache collection '{}': {}", hkey, collection, e.getMessage()); return false; } } @Override public T find(String collection, String hkey, Class<T> tClass) { try { String jsonObj = String.valueOf(template.opsForHash().get(collection, hkey)); return OBJECT_MAPPER.readValue(jsonObj, tClass); } catch (Exception e) { if(e.getMessage() == null){ log.error("Entry '{}' does not exist in cache", hkey); } else { log.error("Unable to find entry '{}' in cache collection '{}': {}", hkey, collection, e.getMessage()); } return null; } } @Override public Boolean isAvailable() { try{ String status = template.getConnectionFactory().getConnection().ping(); if (status != null) { return true; } } catch (Exception e) { log.warn("Redis server is not available at the moment."); } return false; } @Override public Boolean isAvailable() { try{ return template.getConnectionFactory().getConnection().ping() != null; } catch (Exception e) { log.warn("Redis server is not available at the moment."); } return false; } }

In this scenario, the autowired annotation will automatically load the Redis template bean we’ve created before. DataCacheRepository is an interface with simple add / find / delete methods. Instead of creating your own interface, you can also use a CRUD repository.

Unit Testing Redis Integration in Java Spring Boot

It’s straightforward to mock Redis calls when writing unit tests. For instance:

// you can also have this be false if you want to cover that case when(potatoesRepository.isAvailable()).thenReturn(true); // add method returns true if successful when(potatoesRepository.addPotato(anyString(), any(Potato.class))).thenReturn(true); // calls super class find method and returns null when(potatoesRepository.findPotato(anyString())).thenReturn(null); // test fail case // ... when(potatoesRepository.findPotato(anyString())).thenReturn(potato); // test success case //...

In this unitary test, we’re attempting to save an object to its respective collection on the Redis server. The key for said collection is a simple string. We trigger a fail case by returning a null object and a success case by returning a valid one. For the sake of exposition, we’ve included both cases on the same unitary test.

Cache Invalidation Strategies with Redis

There are several approaches to invalidating cached data:

  • Defining a TTL (time-to-live) for saved data

  • Triggering a delete call when data changes on the provider side

  • Scheduling a method to clean certain collections periodically

  • Using LRU (Least Recently Used) eviction policies (Redis LRU Docs) https://redis.io/topics/lru-cache](https://redis.io/topics/lru-cache)

You should select the approach based on the data you’re handling.

For instance, if objects have a defined lifespan, defining a TTL and letting Redis invalidate them automatically is often best. For objects that change based on user interaction or microservice events, a delete trigger is more suitable.

Final Thoughts
In this scenario, integrating Redis with Spring Boot using Lettuce allowed us to implement a fast, reliable caching layer that significantly reduced the load on our third-party provider. By leveraging asynchronous, thread-safe connections and connection pooling, we were able to handle multiple concurrent requests efficiently while maintaining simplicity in our code.
For any Spring Boot application dealing with frequent API calls or high-read workloads, adding Redis as a cache layer can dramatically improve performance and scalability. With proper configuration, cache invalidation, and optional distributed locks, your services can remain responsive and robust even under heavy load.

Share

image placeholder

Unlocking the power within.

We use cookies to personalise and improve your experience on our website.
By clicking “Accept” you consent to ourCookies Policy