- Published on
Advanced Redis Patterns with Spring Boot 4.1: Streams, Rate Limits, Sessions, and Analytics
- Authors

- Name
- Maria
Redis is useful because one server exposes several specialized data structures with predictable command semantics. That does not make every Redis feature the right replacement for PostgreSQL, Kafka, a workflow engine, or an identity provider.
The important design question is not:
Can Redis implement this?
It is:
What happens when Redis restarts, fails over, evicts a key,
delivers a message twice, loses a Pub/Sub subscriber,
or becomes unavailable during this operation?
This guide uses Spring Boot 4.1, Java 25, Spring Data Redis 4.1, and Redis 8-compatible commands. It focuses on patterns that are distinct from two deeper topics already covered elsewhere:
- event-driven cache invalidation with Kafka and Redis;
- distributed locking with Redisson, leases, and fencing.
Caching and locking appear here only to define boundaries. The main implementation topics are Redis Streams, sorted sets, atomic rate limiting, HyperLogLog, Spring Session, Pub/Sub, pipelining, and operational safety.
TL;DR Choose a Redis data type from the failure semantics, not only from command speed. Use Pub/Sub only when missed messages are acceptable. Use Streams with consumer groups, acknowledgements, pending-entry recovery, trimming, and idempotent handlers. Use Lua or Redis Functions for atomic multi-command rate limits. Treat HyperLogLog as an estimate, sessions as security-sensitive state, and Redis Cluster multi-key operations as hash-slot constrained.
Decide What Redis Is Allowed to Own
Redis can play several roles, but each role needs a different durability and failure policy.
| Role | Example | May Redis be authoritative? | Failure behavior |
|---|---|---|---|
| Rebuildable cache | Product view | No | Fall back to source database |
| Ephemeral signal | WebSocket fan-out | Usually no | Missed signal may be acceptable |
| Rate-limit state | API request budget | Sometimes | Decide fail-open or fail-closed |
| Web session store | Authenticated session | Operationally important | Lost session logs users out |
| Work queue | Redis Stream | Depends on contract | Pending recovery and retention required |
| Leaderboard | Game score projection | Usually derived | Rebuild or persist source events |
| Approximate analytics | Unique visitors | No exact guarantee | Estimate is acceptable |
| Distributed coordination | Lock or permit | No durable invariant | Database or fenced resource remains authoritative |
Do not store the only copy of an irreversible business fact in Redis merely because AOF is enabled.
Examples that should normally remain in a durable system of record:
ledger entries
payment ownership
inventory reservations
legal consent
order history
authorization grants
Redis can accelerate or coordinate those systems. It should not silently replace their durability model.
Choose a Data Structure from the Access Pattern
| Requirement | Redis structure |
|---|---|
| Scalar value with TTL | String |
| Field-level object access | Hash |
| Unique membership | Set |
| Ranking or time-ordered window | Sorted set |
| Append-only messages with consumer groups | Stream |
| Approximate unique count | HyperLogLog |
| Compact boolean positions | Bitmap |
| Geospatial radius search | Geospatial index |
| Transient broadcast | Pub/Sub |
A list can implement a simple FIFO queue, but it does not provide consumer groups, a pending-entry list, acknowledgement, replay position, or stale-consumer recovery. Use a Stream when those properties matter.
Project Setup
Let Spring Boot manage the compatible Spring Data Redis and Lettuce versions.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath />
</parent>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Connection configuration:
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
username: ${REDIS_USERNAME:}
password: ${REDIS_PASSWORD:}
connect-timeout: 2s
timeout: 2s
ssl:
enabled: ${REDIS_SSL_ENABLED:false}
management:
endpoints:
web:
exposure:
include: health,info,metrics
For local development:
docker run \
--name redis-local \
--publish 6379:6379 \
--detach \
redis:8-alpine
A floating major tag is acceptable for a local example. Pin a validated patch version in CI and production.
Prefer StringRedisTemplate for Explicit Protocols
Many Redis patterns are easiest to understand when keys, fields, and values are explicit strings.
@Service
public class FeatureFlagStore {
private final StringRedisTemplate redis;
public FeatureFlagStore(
StringRedisTemplate redis
) {
this.redis = redis;
}
public void enable(
String environment,
String flagName
) {
redis.opsForHash().put(
"feature-flags:" + environment,
flagName,
"enabled"
);
}
public boolean isEnabled(
String environment,
String flagName
) {
return "enabled".equals(
redis.opsForHash().get(
"feature-flags:" + environment,
flagName
)
);
}
}
This avoids hidden Java class metadata and makes values easier to inspect from other languages.
Use Typed JSON Instead of Generic Polymorphic Serialization
When storing one known value type, configure a typed serializer.
public record PlayerProfile(
UUID playerId,
String displayName,
String region
) {}
@Configuration
public class PlayerProfileRedisConfiguration {
@Bean
RedisTemplate<String, PlayerProfile>
playerProfileRedisTemplate(
RedisConnectionFactory connectionFactory
) {
RedisTemplate<String, PlayerProfile> template =
new RedisTemplate<>();
template.setConnectionFactory(
connectionFactory
);
template.setKeySerializer(
RedisSerializer.string()
);
template.setValueSerializer(
new JacksonJsonRedisSerializer<>(
PlayerProfile.class
)
);
template.afterPropertiesSet();
return template;
}
}
Spring Data Redis 4 uses Jackson 3 serializers:
JacksonJsonRedisSerializer
GenericJacksonJsonRedisSerializer
The older classes whose names contain Jackson2 are deprecated for removal.
Use generic polymorphic serialization only when the type model is controlled and the type validator is restrictive. A permissive type resolver increases deserialization risk and couples stored data to Java implementation names.
Version Keys and Payloads
Redis values can outlive one application deployment.
Use a namespace version when the stored format changes incompatibly:
catalog:v2:product:123
session-profile:v3:9b4...
leaderboard:v1:global
A safe rollout can be:
deploy code that reads v2 and falls back to v1
start writing v2
let v1 keys expire
remove v1 fallback later
Do not assume every historical JSON payload can be deserialized by the newest class.
Design Keys as an Operational Contract
A key should communicate:
application
environment
feature
resource scope
resource identifier
schema version
Example:
commerce:prod:rate:{tenant-42}:checkout:v1
Avoid:
- secrets;
- email addresses;
- access tokens;
- raw user-generated paths;
- unbounded request bodies;
- locale-dependent formatting;
- keys without TTL for temporary data.
In Redis Cluster, multi-key commands, transactions, and Lua scripts require all participating keys to be in the same hash slot. Text inside {...} is the hash tag:
rate:{tenant-42}:checkout
rate:{tenant-42}:refund
Both keys use the tenant-42 hash tag and therefore map to the same slot.
Do not force unrelated high-volume keys into one hash tag. That can create a hot slot.
Sorted Sets for Leaderboards
A sorted set stores unique members ordered by a numeric score.
@Service
public class LeaderboardService {
private static final String KEY =
"game:v1:leaderboard:global";
private final StringRedisTemplate redis;
public LeaderboardService(
StringRedisTemplate redis
) {
this.redis = redis;
}
public double addPoints(
UUID playerId,
long points
) {
if (points <= 0) {
throw new IllegalArgumentException(
"Points must be positive"
);
}
Double score = redis.opsForZSet()
.incrementScore(
KEY,
playerId.toString(),
points
);
if (score == null) {
throw new RedisSystemException(
"Redis returned no score",
null
);
}
return score;
}
public List<LeaderboardEntry> top(
int limit
) {
if (limit < 1 || limit > 100) {
throw new IllegalArgumentException(
"Limit must be between 1 and 100"
);
}
Set<ZSetOperations.TypedTuple<String>> rows =
redis.opsForZSet()
.reverseRangeWithScores(
KEY,
0,
limit - 1L
);
if (rows == null) {
return List.of();
}
AtomicInteger rank =
new AtomicInteger(1);
return rows.stream()
.map(row ->
new LeaderboardEntry(
rank.getAndIncrement(),
UUID.fromString(
row.getValue()
),
row.getScore()
)
)
.toList();
}
}
public record LeaderboardEntry(
int rank,
UUID playerId,
double score
) {}
Sorted-set scores are floating-point values. Integer scores are exact only within the range safely represented by the underlying number format.
Do not store currency balances in sorted-set scores. Persist money in a decimal-capable durable database and use Redis only for a derived ranking.
Make Leaderboards Rebuildable
A leaderboard update can fail after the durable game result commits.
A reliable design is:
PostgreSQL game result
-> durable event
-> leaderboard projection in Redis
Redis remains a projection. If the key is lost, replay the result events or rebuild from PostgreSQL.
For seasonal rankings, use a key per season and set retention deliberately:
game:v1:leaderboard:season:2026-07
Do not run ZRANGE 0 -1 on an unbounded production leaderboard. Always request a bounded range.
Atomic Sliding-Window Rate Limiting
A sliding-window log can use a sorted set:
member -> unique request identifier
score -> request time in milliseconds
One request must atomically:
- remove timestamps outside the window;
- count current entries;
- reject when the limit is reached;
- add the new request when allowed;
- refresh the key TTL.
Executing those commands separately creates races. Use a Lua script.
sliding-window-rate-limit.lua:
local time = redis.call('TIME')
local now = (time[1] * 1000)
+ math.floor(time[2] / 1000)
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local requestId = ARGV[3]
local windowStart = now - window
redis.call(
'ZREMRANGEBYSCORE',
KEYS[1],
0,
windowStart
)
local count = redis.call(
'ZCARD',
KEYS[1]
)
if count >= limit then
return -1
end
redis.call(
'ZADD',
KEYS[1],
now,
tostring(now) .. ':' .. requestId
)
redis.call(
'PEXPIRE',
KEYS[1],
window + 1000
)
return limit - count - 1
The script uses Redis server time so decisions do not depend on application-clock skew.
Configuration:
@Configuration
public class RateLimitScriptConfiguration {
@Bean
RedisScript<Long> slidingWindowRateLimitScript() {
return RedisScript.of(
new ClassPathResource(
"redis/sliding-window-rate-limit.lua"
),
Long.class
);
}
}
Service:
@Service
public class SlidingWindowRateLimiter {
private final StringRedisTemplate redis;
private final RedisScript<Long> script;
public SlidingWindowRateLimiter(
StringRedisTemplate redis,
RedisScript<Long> script
) {
this.redis = redis;
this.script = script;
}
public RateLimitDecision allow(
String tenantId,
String operation,
int limit,
Duration window
) {
if (limit <= 0) {
throw new IllegalArgumentException(
"Limit must be positive"
);
}
String key = "rate:{"
+ canonicalTenant(tenantId)
+ "}:"
+ canonicalOperation(operation);
Long remaining = redis.execute(
script,
List.of(key),
Long.toString(
window.toMillis()
),
Integer.toString(limit),
UUID.randomUUID().toString()
);
if (remaining == null) {
throw new RedisSystemException(
"Rate-limit script returned null",
null
);
}
return new RateLimitDecision(
remaining >= 0,
Math.max(0, remaining)
);
}
}
public record RateLimitDecision(
boolean allowed,
long remaining
) {}
The script is atomic on one Redis shard. It does not make the decision durable across every possible failover.
Choose Fail-Open or Fail-Closed Explicitly
When Redis is unavailable, a rate limiter needs a policy.
Fail open
Continue the request without a Redis decision.
Suitable for:
low-risk browsing
best-effort protection
operations with another downstream quota
Risk:
an attacker may exceed the intended limit during Redis failure
Fail closed
Reject or delay the request.
Suitable for:
login attempts
password reset
expensive report generation
provider quotas
security-sensitive mutations
Risk:
a Redis incident can deny legitimate traffic
Do not catch every Redis exception and silently allow all requests without documenting that behavior.
For security limits, add durable account-level controls where appropriate. Redis should not be the only protection against credential attacks.
Rate-Limit Identity Must Be Trusted
Unsafe keys:
rate:{X-Forwarded-For supplied directly by client}
rate:{unverified userId request parameter}
Use an identity derived from:
- authenticated subject;
- verified API key;
- trusted gateway client address;
- tenant membership;
- provider account.
Normalize the operation name to a finite allowlist. Do not permit arbitrary path strings to create unbounded keys.
HyperLogLog for Approximate Cardinality
HyperLogLog estimates how many unique values were observed without storing every value.
@Service
public class DailyVisitorCounter {
private final StringRedisTemplate redis;
public DailyVisitorCounter(
StringRedisTemplate redis
) {
this.redis = redis;
}
public void record(
LocalDate day,
String pseudonymousVisitorId
) {
String key = "analytics:v1:visitors:"
+ day;
redis.opsForHyperLogLog().add(
key,
pseudonymousVisitorId
);
redis.expire(
key,
Duration.ofDays(90)
);
}
public long estimate(
LocalDate day
) {
Long result = redis
.opsForHyperLogLog()
.size(
"analytics:v1:visitors:"
+ day
);
return result == null
? 0
: result;
}
}
Redis HyperLogLog uses bounded memory and has an expected standard error around 0.81%.
Use it for:
approximate daily visitors
approximate unique devices
approximate campaign reach
Do not use it for:
billing
legal reporting
exact entitlement counts
inventory
security decisions
The estimate can move slightly and is not a list of members. If the business needs exact identities, use a Set or durable analytics store and accept its memory or storage cost.
Merge HyperLogLogs Without a Shared Temporary Key
A destination key created for every request can produce key churn.
For recurring reports, create a stable, scoped destination:
public long estimateRange(
LocalDate start,
LocalDate end
) {
if (end.isBefore(start)) {
throw new IllegalArgumentException(
"End date precedes start date"
);
}
List<String> sourceKeys =
start.datesUntil(
end.plusDays(1)
)
.map(day ->
"analytics:v1:visitors:"
+ day
)
.toList();
String destination =
"analytics:v1:visitors:range:"
+ start
+ ":"
+ end;
redis.opsForHyperLogLog().union(
destination,
sourceKeys.toArray(String[]::new)
);
redis.expire(
destination,
Duration.ofMinutes(5)
);
Long estimate = redis
.opsForHyperLogLog()
.size(destination);
return estimate == null
? 0
: estimate;
}
For high-cardinality query combinations, perform aggregation in a dedicated analytics path rather than creating a Redis key for every possible date range.
Sets for Exact Membership
A Set is appropriate when membership itself matters.
@Service
public class FeatureAudience {
private final StringRedisTemplate redis;
public boolean addMember(
String campaignId,
UUID userId
) {
Long added = redis.opsForSet().add(
"campaign:v1:"
+ campaignId
+ ":audience",
userId.toString()
);
return Long.valueOf(1L)
.equals(added);
}
public boolean contains(
String campaignId,
UUID userId
) {
Boolean member = redis.opsForSet()
.isMember(
"campaign:v1:"
+ campaignId
+ ":audience",
userId.toString()
);
return Boolean.TRUE.equals(member);
}
}
Avoid SMEMBERS on an unbounded set. Use membership checks, bounded scanning, or a different storage model.
Pub/Sub Is a Transient Broadcast
Redis Pub/Sub delivers messages to currently connected subscribers. It uses at-most-once semantics.
A subscriber that is disconnected when a message is published does not receive it later.
Suitable use cases:
WebSocket fan-out hint
live dashboard refresh signal
noncritical local-cache hint with TTL fallback
operator UI notification
Unsuitable use cases:
payment command
order event
durable cache invalidation without a fallback
audit record
workflow step
Publisher:
@Service
public class LiveUpdatePublisher {
private final StringRedisTemplate redis;
public void publish(
String dashboardId,
String message
) {
redis.convertAndSend(
"dashboard:v1:"
+ dashboardId,
message
);
}
}
Listener container:
@Configuration
public class LiveUpdateRedisConfiguration {
@Bean
RedisMessageListenerContainer
liveUpdateListenerContainer(
RedisConnectionFactory connectionFactory,
MessageListener liveUpdateListener
) {
RedisMessageListenerContainer container =
new RedisMessageListenerContainer();
container.setConnectionFactory(
connectionFactory
);
container.addMessageListener(
liveUpdateListener,
new PatternTopic(
"dashboard:v1:*"
)
);
return container;
}
}
@Component
public class LiveUpdateListener
implements MessageListener {
@Override
public void onMessage(
Message message,
byte[] pattern
) {
String payload =
new String(
message.getBody(),
StandardCharsets.UTF_8
);
deliverToConnectedClients(payload);
}
}
The durable state should exist elsewhere. A client reconnect should be able to reload the current dashboard without replaying every Pub/Sub message.
Pub/Sub Is Not a Reliable Cache-Invalidation Bus
Deleting a shared Redis key after a PostgreSQL update can race with transaction commit, and a Pub/Sub subscriber can be offline.
For durable cross-service invalidation, publish a committed change through an outbox and Kafka, then let each cache projection invalidate idempotently.
Use Pub/Sub only as a low-latency hint when TTL or another durable path limits missed-message impact.
Redis Streams for Recoverable Work
Redis Streams are persistent append-only structures until entries are trimmed. Consumer groups divide work among consumers and maintain a Pending Entries List for delivered but unacknowledged entries.
This is different from Pub/Sub:
| Capability | Pub/Sub | Streams |
|---|---|---|
| Offline subscriber catches up | No | Yes, while entries are retained |
| Consumer groups | No | Yes |
| Acknowledgement | No | Yes |
| Pending-entry recovery | No | Yes |
| Replay by ID | No | Yes |
| Transient broadcast | Yes | Not its primary model |
A Stream can be appropriate for:
a work queue contained within one platform
moderate retention
a small number of consumer groups
operations already dependent on Redis
Kafka is usually a better fit for:
long retention
many independent consumers
high partition count
cross-team event contracts
large replay workloads
broker-centric operations and tooling
Do not choose Streams only because Redis is already installed. Include retention, recovery, scaling, and ownership in the decision.
Append Explicit Stream Records
public record ImageResizeRequested(
UUID eventId,
UUID imageId,
int width,
int height,
Instant requestedAt
) {}
@Service
public class ImageResizeStreamPublisher {
private static final String STREAM =
"image-resize:v1";
private final StringRedisTemplate redis;
public ImageResizeStreamPublisher(
StringRedisTemplate redis
) {
this.redis = redis;
}
public RecordId publish(
ImageResizeRequested event
) {
Map<String, String> fields =
Map.of(
"eventId",
event.eventId()
.toString(),
"imageId",
event.imageId()
.toString(),
"width",
Integer.toString(
event.width()
),
"height",
Integer.toString(
event.height()
),
"requestedAt",
event.requestedAt()
.toString()
);
StringRecord record =
StreamRecords
.string(fields)
.withStreamKey(STREAM);
RecordId recordId =
redis.opsForStream()
.add(record);
if (recordId == null) {
throw new RedisSystemException(
"Stream append returned no ID",
null
);
}
return recordId;
}
}
The Stream entry ID identifies the Redis record. The business eventId identifies the logical request and remains stable when an application retries publication deliberately.
Provision the Consumer Group
Create the group as deployment infrastructure:
redis-cli XGROUP CREATE \
image-resize:v1 \
image-workers-v1 \
0-0 \
MKSTREAM
Starting at 0-0 lets the new group process retained history. Starting at $ means only entries added after group creation are visible as new work.
Treat the starting position as a migration decision, not a code default.
Consume and Acknowledge After Processing
@Configuration
public class ImageResizeStreamConfiguration {
private static final String STREAM =
"image-resize:v1";
private static final String GROUP =
"image-workers-v1";
@Bean(
initMethod = "start",
destroyMethod = "stop"
)
StreamMessageListenerContainer<
String,
MapRecord<String, String, String>
> imageResizeContainer(
RedisConnectionFactory connectionFactory,
StringRedisTemplate redis,
ImageResizeHandler handler,
@Value(
"${redis.stream.consumer-name:local-worker}"
)
String consumerName
) {
var options =
StreamMessageListenerContainer
.StreamMessageListenerContainerOptions
.<String,
MapRecord<
String,
String,
String
>>
builder()
.pollTimeout(
Duration.ofSeconds(1)
)
.batchSize(20)
.build();
var container =
StreamMessageListenerContainer
.create(
connectionFactory,
options
);
container.receive(
Consumer.from(
GROUP,
consumerName
),
StreamOffset.create(
STREAM,
ReadOffset.lastConsumed()
),
record -> {
handler.handle(record);
redis.opsForStream()
.acknowledge(
STREAM,
GROUP,
record.getId()
);
}
);
return container;
}
}
Use a stable consumer name for the lifetime of one process, such as a Kubernetes Pod name. Do not create a new random consumer name on every poll.
ReadOffset.lastConsumed() is the appropriate consumer-group offset for new deliveries. Polling from latest() can skip entries that arrive during gaps between polls.
Make the Handler Idempotent
Acknowledgement happens after the handler returns. These failures can still occur:
database commit succeeds
process crashes before XACK
entry remains pending
entry is claimed or redelivered
handler runs again
Use a database uniqueness boundary.
CREATE TABLE processed_redis_stream_events (
consumer_name VARCHAR(150) NOT NULL,
event_id UUID NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (
consumer_name,
event_id
)
);
@Service
public class ImageResizeHandler {
private final ProcessedStreamEventRepository processed;
private final ImageJobRepository jobs;
@Transactional
public void handle(
MapRecord<
String,
String,
String
> record
) {
UUID eventId = UUID.fromString(
required(
record,
"eventId"
)
);
if (!processed.tryInsert(
"image-workers-v1",
eventId
)) {
return;
}
jobs.createIfAbsent(
eventId,
UUID.fromString(
required(
record,
"imageId"
)
),
Integer.parseInt(
required(
record,
"width"
)
),
Integer.parseInt(
required(
record,
"height"
)
)
);
}
}
The processed marker and business update must commit in the same PostgreSQL transaction.
Do not acknowledge before the transaction commits. Do not catch a database exception, log it, and return normally.
Recover Pending Entries
A delivered entry remains in the group's Pending Entries List until XACK.
Monitor:
redis-cli XPENDING \
image-resize:v1 \
image-workers-v1
A recovery worker can reclaim entries idle beyond a safe processing deadline:
redis-cli XAUTOCLAIM \
image-resize:v1 \
image-workers-v1 \
recovery-worker \
60000 \
0-0 \
COUNT 100
The 60000 value means entries idle for at least 60 seconds are eligible.
Choose the idle threshold above the normal processing duration. Claiming an entry that is still being processed can create concurrent duplicate work.
Idempotency protects durable state, but external side effects may need their own idempotency key.
Pending Entries Are Not New Entries
A consumer that reads only with > receives new entries that have never been delivered to the group. It does not automatically finish work abandoned in another consumer's pending list.
Production Stream consumers need both:
new-entry consumption
pending-entry recovery
Without recovery, a crashed consumer can leave work pending forever.
Trim Streams Deliberately
A Stream grows until entries are deleted or trimmed.
Approximate maximum-length trimming:
redis-cli XTRIM \
image-resize:v1 \
MAXLEN \
~ \
100000
Before choosing a limit, consider:
- maximum consumer outage;
- number of entries per second;
- pending entries;
- replay requirements;
- payload size;
- number of groups;
- persistence and replication capacity.
Do not trim entries that a lagging group still needs unless the business has an alternate recovery path.
Newer Redis versions provide finer deletion controls around consumer groups, but baseline designs should not depend on silent deletion of unacknowledged work.
Streams Do Not Make PostgreSQL and Redis Atomic
This code has a failure window:
PostgreSQL commit
then XADD to Redis Stream
If the process crashes between those steps, the event is lost.
For a business fact that must be published after a PostgreSQL commit, use:
PostgreSQL transactional outbox
-> relay
-> Redis Stream
or a change-data-capture boundary.
The Stream provides durable consumption after XADD. It does not make the preceding database transaction atomic with Redis.
Spring Session with Redis
Redis-backed HttpSession lets several instances of the same web application share session state without sticky routing.
Add:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-session-data-redis</artifactId>
</dependency>
Configuration:
spring:
session:
timeout: 30m
redis:
namespace: commerce:session:v1
server:
servlet:
session:
cookie:
http-only: true
secure: true
same-site: strict
Spring Boot auto-configures Spring Session when the starter is present.
Store small, stable session attributes:
public record SessionIdentity(
UUID subjectId,
UUID selectedTenantId,
Set<String> roles
) implements Serializable {}
Prefer primitives, strings, UUIDs, and compact stable records. Avoid placing JPA entities, lazy proxies, large shopping carts, or request bodies in the session.
Spring Session uses Java serialization by default unless configured otherwise. Class changes across rolling deployments can therefore break deserialization. Test mixed-version deployments or configure a controlled JSON strategy.
Redis Is Not an SSO Provider
Redis-backed sessions solve:
several instances of one application
need access to the same server-side session
Single sign-on across applications requires an identity protocol and provider, normally OAuth 2.0 or OpenID Connect.
Redis may store each application's session after authentication. It does not issue trusted identity or replace token validation.
Treat Session State as Security-Sensitive
Define:
- session TTL;
- logout and revocation behavior;
- session fixation protection;
- concurrent-session policy;
- namespace isolation;
- encryption requirements;
- failover and data-loss behavior;
- incident response for stolen session IDs.
A Redis failover that loses recent session state may log users out. It must not accidentally grant access.
Do not put bearer tokens in Redis keys or logs. Use TLS, ACLs, private networking, and restricted administrative access.
Pipelining Reduces Round Trips, Not Consistency Risk
Pipelining sends multiple commands without waiting for each reply.
@Service
public class BulkPresenceWriter {
private final StringRedisTemplate redis;
public void record(
Map<UUID, Instant> lastSeen
) {
redis.executePipelined(
(RedisCallback<Object>)
connection -> {
StringRedisSerializer serializer =
new StringRedisSerializer();
lastSeen.forEach(
(userId, instant) -> {
byte[] key =
serializer.serialize(
"presence:v1:"
+ userId
);
byte[] value =
serializer.serialize(
instant.toString()
);
connection.stringCommands()
.set(
key,
value
);
connection.keyCommands()
.pExpire(
key,
Duration
.ofMinutes(
5
)
.toMillis()
);
}
);
return null;
}
);
}
}
Pipelining improves network efficiency. It is not atomic. Another client can observe intermediate states.
Use it for independent commands where partial completion can be retried or tolerated.
Redis Transactions Do Not Behave Like PostgreSQL Transactions
MULTI queues commands and EXEC runs them sequentially. Redis does not provide a relational-style rollback when one queued command has a runtime error.
Spring Data Redis uses SessionCallback when multiple transaction operations must use one connection.
Use:
- pipeline for fewer network round trips;
MULTI/EXECfor grouped execution and optionalWATCH;- Lua or Redis Functions for atomic read-modify-write logic;
- PostgreSQL for durable relational transactions and constraints.
The original rate limiter tried to read ZCARD inside a Redis transaction and use the value immediately. Commands queued after MULTI do not return their final result until EXEC, so that structure cannot make the admission decision correctly. The Lua script fixes that boundary.
Do Not Add a Connection Pool by Habit
Lettuce can share one thread-safe native connection for ordinary non-blocking, non-transactional commands. Spring's RedisTemplate manages connection acquisition safely.
Pooling is useful for dedicated blocking or transactional connections and specific throughput profiles. It is not automatically the first fix for every Redis latency problem.
Measure:
command latency
pending commands
connection creation
blocking operations
event-loop saturation
pool wait time
Redis server saturation
Increasing a pool cannot fix a hot key, slow Lua script, network delay, or Redis CPU saturation.
Sentinel, Cluster, and Persistence Solve Different Problems
These terms are often treated as one “production Redis” checklist, but they address different concerns.
Replication
Replicas copy data from a primary.
Useful for:
failover candidates
read scaling where stale replica reads are acceptable
operational recovery
Replication is asynchronous. A primary can acknowledge a write before every replica has received it.
Sentinel
Sentinel monitors a primary-replica deployment and coordinates primary failover.
It does not shard the keyspace.
Redis Cluster
Cluster partitions the keyspace across hash slots and can replicate each shard.
It provides horizontal data distribution, but introduces:
- multi-key hash-slot constraints;
- resharding behavior;
- more complex failover;
- hot-shard risk;
- topology-aware clients;
- cross-slot command restrictions.
RDB and AOF
RDB creates point-in-time snapshots.
AOF records write operations according to the configured fsync policy.
Persistence choices affect:
write latency
possible data-loss window
restart duration
disk use
backup strategy
recovery testing
They do not transform Redis into a relational database or make Redis and PostgreSQL one transaction.
Match Durability to the Role
Rebuildable cache
Possible policy:
replication for availability
no persistence or lightweight persistence
flush keys after an unsafe restore
rebuild from PostgreSQL
Session store
Possible policy:
high availability
persistence according to logout tolerance
tested failover
bounded session TTL
A small data-loss window may log users out, which can be acceptable. It must not grant access.
Redis Stream queue
Possible policy:
replication
AOF policy aligned with accepted message-loss objective
stream retention
pending-entry recovery
backup or upstream replay path
If losing one accepted queue entry is unacceptable, ensure the publication source can replay it or use infrastructure designed for the required durability.
Rate limiter
Possible policy:
high availability
short TTL
defined fail-open or fail-closed behavior
no assumption that counters survive every failover
Redis Cluster and Multi-Key Patterns
A Lua script can access only keys available to the shard that runs it.
Good:
rate:{tenant-42}:checkout
rate:{tenant-42}:refund
Risky:
rate:{tenant-42}:checkout
global:rate:checkout
The two keys may map to different slots.
Before deploying Cluster, review every use of:
MGET and MSET
set intersection or union
HyperLogLog merge
Lua scripts
MULTI/EXEC
renames
blocking operations on multiple keys
Design hash tags around one bounded operation, not around the entire application.
Avoid Hot Keys and Big Keys
A key can become a bottleneck even when the cluster has many shards.
Common hot keys:
one global leaderboard
one global rate-limit counter
one Pub/Sub channel with extreme fan-out
one Stream with insufficient partitioning strategy
one session for a shared service account
Common big keys:
unbounded Set
untrimmed Stream
Hash with millions of fields
List containing an entire backlog
one value containing a huge JSON document
Mitigations include:
- partition by a stable business dimension;
- use time-bucketed keys;
- bound result ranges;
- apply TTL;
- trim Streams;
- move large history to durable storage;
- use HyperLogLog when approximation is acceptable;
- avoid fetching complete collections.
Do not shard a leaderboard randomly if the product needs a globally exact rank. That requirement may need a different architecture or a controlled merge.
Long Scripts and Commands Affect Other Clients
Lua scripts execute atomically, but atomic does not mean free.
A script that scans a large key or loops over unbounded input can delay other commands on that shard.
Keep scripts:
- deterministic;
- bounded;
- free from external I/O;
- small in argument and result size;
- tested against production-like key sizes.
Avoid production commands that scan the entire keyspace synchronously.
Use cursor-based SCAN, HSCAN, SSCAN, and ZSCAN for operational iteration, while remembering that cursor scans are not a consistent snapshot.
Virtual Threads Do Not Increase Redis Capacity
Virtual threads can make blocking Java code easier to scale, but they do not increase:
Redis CPU
network bandwidth
command processing capacity
connection-pool capacity
memory
replication throughput
Creating thousands of virtual threads that all hit one hot key can overload Redis faster.
Bound concurrency at the feature level:
maximum concurrent Stream handlers
maximum batch size
maximum in-flight rate-limit checks
maximum leaderboard rebuild workers
Measure downstream saturation rather than Java thread count alone.
Security
Redis should not be exposed directly to the public internet.
Use:
- private networking;
- TLS where required;
- ACL users with feature-specific command permissions;
- credential rotation;
- separate environments;
- restricted administrative access;
- encrypted backups;
- command auditing where available.
Avoid putting sensitive values in key names because keys appear in diagnostics and operational tooling.
Unsafe:
session:user@example.com
reset-token:eyJ...
profile:resident-registration-number
Safer:
session:v1:8b145...
reset:v1:sha256-token-digest
profile:v1:internal-uuid
Store only the data needed for the feature and apply retention.
Deserialization Is a Security Boundary
Do not accept arbitrary Java type metadata from an untrusted Redis value.
An attacker who can write to Redis may attempt to create payloads that abuse permissive polymorphic deserialization.
Prefer:
StringRedisTemplate;- typed
JacksonJsonRedisSerializer<T>; - explicit DTOs;
- schema or key versioning;
- restrictive type validation;
- Redis ACLs preventing unauthorized writes.
Do not use native Java serialization for cross-application contracts.
Observability
Monitor Redis itself and each application pattern.
Server and topology
used memory
memory fragmentation
evicted keys
expired keys
connected and blocked clients
replication offset and lag
failover events
cluster slot health
persistence errors
AOF rewrite and RDB status
command latency
network input and output
Streams
stream length
group lag
pending-entry count
oldest pending age
consumer idle time
acknowledgement failures
reclaimed entries
processing latency
trim rate
Rate limiting
allowed and denied requests
Redis errors
script latency
fail-open or fail-closed count
keys created
hot identities
Sessions
active sessions
session creation and expiry
deserialization failures
logout and revocation failures
Redis availability
Analytics and leaderboards
projection update failures
rebuild duration
key size
rank query latency
HyperLogLog report age
Use bounded metric labels such as:
feature
operation
outcome
region
redis_role
Do not use session IDs, user IDs, request IDs, or Redis keys as metric labels.
Cache Hit Ratio Is Not a Universal Redis Health Metric
A high cache hit ratio says little about:
Stream pending entries
session loss
rate-limit correctness
hot keys
replication lag
stale values
Observe the semantics of each feature rather than relying on one Redis dashboard.
Testing with a Real Redis Container
Use a real Redis instance for command semantics.
@Testcontainers
@SpringBootTest
class SlidingWindowRateLimiterTest {
@Container
static GenericContainer<?> redis =
new GenericContainer<>(
DockerImageName.parse(
"redis:8-alpine"
)
)
.withExposedPorts(6379);
@DynamicPropertySource
static void redisProperties(
DynamicPropertyRegistry registry
) {
registry.add(
"spring.data.redis.host",
redis::getHost
);
registry.add(
"spring.data.redis.port",
() -> redis.getMappedPort(
6379
)
);
}
@Autowired
SlidingWindowRateLimiter limiter;
@Test
void admitsOnlyTheConfiguredNumber() {
int allowed = IntStream.range(
0,
100
)
.parallel()
.map(index ->
limiter.allow(
"tenant-1",
"login",
10,
Duration.ofSeconds(5)
).allowed()
? 1
: 0
)
.sum();
assertThat(allowed)
.isEqualTo(10);
}
}
The test verifies server-side atomicity under concurrency. A mocked StringRedisTemplate cannot prove that.
Required Stream Failure Tests
Test:
Crash after delivery, before database commit
The entry remains pending and can be retried.
Crash after database commit, before acknowledgement
The entry is delivered again, and the idempotency constraint prevents duplicate state.
Dead consumer
Another worker reclaims the entry after the idle threshold.
Poison message
The system records or moves the failing entry according to an explicit quarantine policy rather than retrying forever.
Trimming
A lagging group is not silently deprived of entries required by the recovery objective.
Redis restart or failover
The accepted data-loss window matches the documented durability policy.
Test Session Compatibility
During a rolling deployment:
version A creates session
version B reads session
version B updates session
version A reads or rejects it predictably
Test logout, TTL, revocation, and serialization changes.
Do not discover during production rollout that the new version cannot deserialize every active session.
Troubleshooting
WRONGTYPE Operation against a key
The same key was written with a different Redis data type.
Check:
- namespace version;
- feature prefixes;
- environment separation;
- old deployment code;
- manual operations.
Do not delete an unknown production key before identifying its owner.
CROSSSLOT Keys in request do not hash to the same slot
A multi-key operation or script uses keys in different Cluster hash slots.
Add a deliberate shared hash tag only when those keys form one bounded operation.
Stream entries remain pending
Check:
- handler exceptions;
- missing acknowledgement;
- consumer name;
XPENDING;- recovery worker;
- processing timeout;
- database transaction duration;
- poison entries.
Stream consumers receive only new records
The group may have been created at $, or the consumer may read only new entries while old entries remain pending under another consumer.
Inspect group and pending state.
Pub/Sub notifications disappear
This is expected when subscribers disconnect. Use Streams or a durable broker when replay is required.
Rate limiter allows too many requests
Check:
- commands executed outside the script;
- identity normalization;
- several Redis clusters used by different replicas;
- fail-open behavior;
- script key slot;
- duplicate timestamps without unique members.
Rate limiter rejects everyone during Redis failure
The implementation is fail-closed. Confirm that this is the intended security policy and that Redis availability matches the dependency.
Session deserialization fails after deployment
Check:
- Java serialization;
- renamed classes;
- changed serial version;
- removed fields;
- mixed application versions;
- namespace migration.
A new namespace can be safer than attempting to deserialize incompatible historical sessions.
Redis latency rises after adding virtual threads
Check command concurrency, hot keys, script duration, network throughput, and server CPU. More application concurrency can increase queueing at Redis.
Memory grows continuously
Check:
- keys without TTL;
- untrimmed Streams;
- pending entries;
- large Sets and Hashes;
- abandoned version namespaces;
- temporary HyperLogLog merge keys;
- session expiry configuration;
- output-buffer growth.
Review Checklist
Before production:
- What role does Redis play?
- What is the durable source of truth?
- What happens when Redis is unavailable?
- Is the feature fail-open or fail-closed?
- Are keys versioned and bounded?
- Are serializers explicit and safe?
- Do temporary keys have TTLs?
- Are Cluster hash-slot constraints satisfied?
- Can one key become hot or unbounded?
- Is Pub/Sub message loss acceptable?
- Do Streams use consumer groups and manual acknowledgement?
- Is pending-entry recovery implemented?
- Are Stream handlers idempotent?
- Is Stream retention sufficient?
- Is the rate limiter atomic?
- Is the rate-limit identity trusted?
- Is HyperLogLog approximation acceptable?
- Are sessions compatible across rolling deployments?
- Does the durability configuration match the role?
- Are Redis ACLs, TLS, and network controls applied?
- Have restart, failover, duplicate, and concurrency tests passed?
Conclusion
Advanced Redis usage is not about using every data type in one application. It is about choosing a narrow Redis contract that remains understandable during failure.
A reliable Spring Boot design should:
- keep durable business facts in an appropriate system of record;
- select data structures from access and recovery requirements;
- use typed, versioned serialization;
- design Cluster-compatible keys;
- use sorted sets for bounded rankings and atomic window algorithms;
- use HyperLogLog only where approximation is acceptable;
- treat Pub/Sub as transient at-most-once broadcast;
- operate Streams with acknowledgement, pending recovery, retention, and idempotency;
- treat session storage as security-sensitive;
- distinguish pipelines, transactions, and scripts;
- avoid unbounded keys and scripts;
- match Sentinel, Cluster, and persistence to the actual role;
- measure feature correctness rather than Redis availability alone.
Redis is a strong component when its speed is paired with explicit durability, consistency, security, and recovery boundaries.