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.
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.
StringRedisTemplatefor string values, a JSON-serialisingRedisTemplatefor 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
| Structure | Spring API | Good for |
|---|---|---|
| String | opsForValue() | Counters, flags, cached JSON |
| Hash | opsForHash() | Objects where you update single fields |
| List | opsForList() | FIFO queues, recent-items feeds |
| Set | opsForSet() | Membership, unique visitors, tags |
| Sorted Set | opsForZSet() | Leaderboards, rate limits, priority queues |
| Stream | opsForStream() | Durable event log with consumer groups |
| HyperLogLog | opsForHyperLogLog() | 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:
@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
@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
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:
@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:
@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
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?
Lettuce or Jedis?
Why do my multi-key operations fail on Redis Cluster?
Related tutorials
- Spring Data JPA Deep DiveEntity mapping that scales: relationship pitfalls, diagnosing and fixing the N+1 problem, derived queries versus Specifications, pagination that stays fast, and JPA auditing.
- Validation & Data IntegrityJakarta Bean Validation in Spring Boot: the full constraint set, custom validators, validation groups, cross-field rules, method validation and where each layer belongs.
- Scheduling & Async ProcessingScheduled tasks and async methods done properly: fixedRate versus fixedDelay, sizing executors, exception handling that does not silently swallow, and distributed locking with ShedLock.
- Logging & Debugging in ProductionLogging that helps at 3am: choosing levels that mean something, MDC correlation IDs across threads, structured JSON output, async appenders, and what must never be logged.