Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Redis with Spring Boot

Redis beyond caching: choosing the right data structure, distributed locks that are actually safe, Redis Streams as a queue, and configuring Lettuce for Sentinel and Cluster.

Intermediate5 min readUpdated
On this page

Most Spring applications use Redis as a cache and stop there. The data structures underneath support rate limiters, leaderboards, session stores, job queues and locks — usually with less code and far less latency than the database-backed equivalent.

Key Takeaways

  • Pick the data structure, not just the key: sorted sets and hashes remove code you would otherwise write.
  • StringRedisTemplate for string values, a JSON-serialising RedisTemplate for objects. Never JDK serialisation.
  • A correct distributed lock needs an atomic set-with-TTL, a unique token, and a Lua release. Use Redisson rather than writing it.
  • Always set a TTL. Redis is memory; keys without expiry are a slow leak.
  • Redis is a dependency, not a guarantee — design for it being briefly unavailable.

Choosing the structure

StructureSpring APIGood for
StringopsForValue()Counters, flags, cached JSON
HashopsForHash()Objects where you update single fields
ListopsForList()FIFO queues, recent-items feeds
SetopsForSet()Membership, unique visitors, tags
Sorted SetopsForZSet()Leaderboards, rate limits, priority queues
StreamopsForStream()Durable event log with consumer groups
HyperLogLogopsForHyperLogLog()Approximate cardinality in 12KB

The sorted set is the one most people under-use. A sliding-window rate limiter is four commands and no application state:

SlidingWindowRateLimiter.java
@Component
public class SlidingWindowRateLimiter {
 
    private final StringRedisTemplate redis;
 
    public boolean tryAcquire(String key, int limit, Duration window) {
        long now = System.currentTimeMillis();
        long cutoff = now - window.toMillis();
        String k = "rl:" + key;
 
        // Drop requests older than the window, then count what remains.
        redis.opsForZSet().removeRangeByScore(k, 0, cutoff);
        Long count = redis.opsForZSet().zCard(k);
 
        if (count != null && count >= limit) return false;
 
        redis.opsForZSet().add(k, UUID.randomUUID().toString(), now);
        redis.expire(k, window);      // never leave a key without a TTL
        return true;
    }
}

For strict correctness under concurrency, move those four commands into a Lua script so they execute atomically. The version above can slightly overshoot the limit under heavy contention, which is often acceptable and worth knowing about either way.

Templates and serialisation

RedisConfig.java
@Configuration
public class RedisConfig {
 
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        var template = new RedisTemplate<String, Object>();
        template.setConnectionFactory(factory);
 
        // Keys as plain strings so they are readable in redis-cli.
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
 
        // JSON values: readable, language-neutral, and safe across deployments
        // that add a field. JDK serialisation is none of those things.
        var json = new GenericJackson2JsonRedisSerializer(
                JsonMapper.builder()
                        .addModule(new JavaTimeModule())
                        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                        .build());
        template.setValueSerializer(json);
        template.setHashValueSerializer(json);
 
        template.afterPropertiesSet();
        return template;
    }
}

JDK serialisation deserves the warning it gets. It couples every cached value to the exact class shape, so adding a field breaks existing entries; and deserialising attacker-controlled bytes is a well-documented remote code execution path.

Distributed locks

A safe lock needs an atomic set with TTL, a unique token, watchdog renewal, and a compare-and-delete release.

Each of those four elements prevents a specific failure. Without the TTL, a crashed holder blocks the lock forever. Without the token, instance B can delete a lock that instance A has since re-acquired. Without the watchdog, a job slower than the TTL loses its lock mid-run. Without the Lua script, the check and the delete are not atomic and the race returns.

Redisson implements all of it:

RedissonLockExample.java
@Service
public class InventoryService {
 
    private final RedissonClient redisson;
 
    public void reserve(String sku, int quantity) {
        RLock lock = redisson.getLock("inventory:" + sku);
        try {
            // Wait up to 5s for the lock; auto-extend while held.
            if (!lock.tryLock(5, TimeUnit.SECONDS)) {
                throw new ResourceBusyException(sku);
            }
            applyReservation(sku, quantity);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(ex);
        } finally {
            if (lock.isHeldByCurrentThread()) lock.unlock();
        }
    }
}

One caveat no library removes: during a Redis failover, a lock acquired on the old primary may not have replicated. Redis locks are an optimisation to reduce contention, not a correctness guarantee. If double execution would be financially harmful, back the lock with a database unique constraint or an idempotency key.

Streams as a durable queue

A Redis List used as a queue loses the message if a consumer crashes mid-processing. Streams add consumer groups and acknowledgement:

StreamConsumer.java
@Component
public class OrderStreamConsumer implements StreamListener<String, MapRecord<String, String, String>> {
 
    private final StringRedisTemplate redis;
 
    @Override
    public void onMessage(MapRecord<String, String, String> record) {
        try {
            process(record.getValue());
            // Only acknowledge after successful processing. Unacknowledged
            // entries stay in the pending list and can be reclaimed.
            redis.opsForStream().acknowledge("orders", "workers", record.getId());
        } catch (Exception ex) {
            log.error("failed to process {} — left pending for reclaim", record.getId(), ex);
        }
    }
}

Entries that stay pending too long can be reclaimed with XAUTOCLAIM by another consumer — the recovery mechanism a plain list has no equivalent for. Streams are not Kafka: retention is bounded by memory and there is no partition rebalancing. For moderate volumes with Redis already deployed, they are a reasonable middle ground.

Sentinel and Cluster

application.yml
spring:
  data:
    redis:
      timeout: 500ms                 # fail fast; a hung cache must not hang the request
      lettuce:
        pool:
          max-active: 16
        cluster:
          refresh:
            adaptive: true
            period: 30s
      sentinel:
        master: mymaster
        nodes: 'sentinel-1:26379,sentinel-2:26379,sentinel-3:26379'

Sentinel gives automatic failover on a single dataset. Cluster shards across nodes using 16384 hash slots, so it adds capacity as well as availability, at the cost of restricting multi-key operations to a single slot. Hash tags let you control placement: order:{4711}:items and order:{4711}:meta hash only on 4711 and therefore land on the same node.

The timeout line is the one that matters most for resilience. The default is long enough that a partitioned Redis will exhaust your request threads before anything gives up.

What to take away

Reach past the string API — sorted sets, hashes and streams replace a surprising amount of application code. Serialise as JSON, put a TTL on every key, keep timeouts short, and treat Redis as a fast dependency that will occasionally be gone rather than as part of your correctness model.

Frequently Asked Questions

Is SETNX enough for a distributed lock?
Only with care. You need SET key value NX PX ttl in one atomic command, a unique token per holder, and a Lua script that checks the token before deleting so you cannot release someone else lock. Even then the lock is not safe across a failover. Use Redisson, which handles the token, the watchdog renewal and the release script for you.
Lettuce or Jedis?
Lettuce, the Spring Boot default. It is netty-based, non-blocking, and a single connection is thread-safe, so most applications need no pool at all. Jedis needs a connection per thread. Choose Jedis only if a library you depend on requires it.
Why do my multi-key operations fail on Redis Cluster?
Cluster shards by hash slot, and a command touching keys in different slots is rejected. Force related keys into the same slot with a hash tag: user:{42}:profile and user:{42}:sessions hash only on the braced part and land together.

Related tutorials