Published on

Advanced Redis Patterns with Spring Boot 4.1: Streams, Rate Limits, Sessions, and Analytics

Authors
  • avatar
    Name
    Maria
    Twitter

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:

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.

RoleExampleMay Redis be authoritative?Failure behavior
Rebuildable cacheProduct viewNoFall back to source database
Ephemeral signalWebSocket fan-outUsually noMissed signal may be acceptable
Rate-limit stateAPI request budgetSometimesDecide fail-open or fail-closed
Web session storeAuthenticated sessionOperationally importantLost session logs users out
Work queueRedis StreamDepends on contractPending recovery and retention required
LeaderboardGame score projectionUsually derivedRebuild or persist source events
Approximate analyticsUnique visitorsNo exact guaranteeEstimate is acceptable
Distributed coordinationLock or permitNo durable invariantDatabase 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

RequirementRedis structure
Scalar value with TTLString
Field-level object accessHash
Unique membershipSet
Ranking or time-ordered windowSorted set
Append-only messages with consumer groupsStream
Approximate unique countHyperLogLog
Compact boolean positionsBitmap
Geospatial radius searchGeospatial index
Transient broadcastPub/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:

  1. remove timestamps outside the window;
  2. count current entries;
  3. reject when the limit is reached;
  4. add the new request when allowed;
  5. 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:

CapabilityPub/SubStreams
Offline subscriber catches upNoYes, while entries are retained
Consumer groupsNoYes
AcknowledgementNoYes
Pending-entry recoveryNoYes
Replay by IDNoYes
Transient broadcastYesNot 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/EXEC for grouped execution and optional WATCH;
  • 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.

Official References