Valkey and Redis Best Practices for Java Applications

Published on
September 2, 2026

Almost every "Redis best practices" list on the internet is advice about a server: set maxmemory, disable transparent huge pages, watch your persistence settings, keep an eye on fragmentation. That advice is fine, and your platform team has probably already applied it.

It is also not where your incidents come from. In a Java service, the overwhelming majority of Valkey and Redis production problems originate inside the JVM — in client defaults nobody read. Which node your reads go to. How many connections you open per cluster node. How long a command waits before it gives up, and how many times it tries. Whether the lock you took is still yours. What happens the first time a second service, written in another language, tries to read a value your Java service wrote.

This is a checklist for that layer. Sixteen practices, each pinned to the exact default value or the exact command behind it, taken from the Redisson configuration reference for version 4.7. Five of the sixteen are not client settings at all — they are server configuration or data-modelling decisions. They are here because of how they reach your code: as a stale read, a green health check with one path throwing, or twenty seconds of patience on an eight-second deadline. Treat the whole thing as the Redis performance tuning pass that happens in your own code rather than in redis.conf. Everything here applies equally to Valkey and to Redis unless a section says otherwise, and there is a quick-reference table of every default further down.

Connection Pooling and Client Configuration

1. Create One Client and Share It

A RedissonClient is thread-safe and owns connection pools, a Netty event loop, topology monitoring and the reconnection state machine. It is meant to be created once, at application startup, and shared by every thread in the process. Creating one per request — or per service class, or inside a try-with-resources block — is the most expensive of these to get wrong, because it multiplies everything below it: each instance opens its own pools and its own event loop threads against the same servers.

@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() {
    Config config = new Config();
    config.useClusterServers()
          .addNodeAddress("redis://10.0.1.10:6379")
          .setMasterConnectionPoolSize(64)      // per master node
          .setMasterConnectionMinimumIdleSize(24)
          .setSlaveConnectionPoolSize(64)       // per replica, separate setting
          .setSlaveConnectionMinimumIdleSize(24);
    return Redisson.create(config);
}

The pool sizes are the part worth reading twice. In every replicated topology there are two independent pairs of settings: masterConnectionPoolSize / masterConnectionMinimumIdleSize govern master nodes, and slaveConnectionPoolSize / slaveConnectionMinimumIdleSize govern replicas. All four default to 64 and 24, and all four are per node. On a six-master cluster with three replicas that is 24 idle connections held open against each of nine nodes from startup, and a ceiling of 64 per node under load — from a single JVM. (In single-server mode the equivalents are connectionPoolSize and connectionMinimumIdleSize, with the same values.) Multiply by the number of JVMs behind your load balancer before you assume the defaults are safe: a fleet of forty pods against that same nine-node deployment can reach the server's own maxclients without anything appearing wrong on the client side.

This is also where the client library choice stops being academic. A bare Jedis instance is not thread-safe and has to be pooled; Lettuce multiplexes many threads over a single shared connection, with pooling optional; Redisson multiplexes over a managed pool sized per node. If you have not made that decision deliberately, Jedis vs Lettuce and the Redis Java client and Valkey Java client overviews cover the trade-offs, and the maxclients arithmetic is worked through in full in how to connect to Redis in Java.

2. Know Which Node Your Reads Go To

This is the default that surprises people most. In every replicated topology — Cluster, Sentinel, master/slave and replicated — Redisson's readMode defaults to SLAVE. Reads are served from replicas.

That is usually what you want: it spreads read load across nodes that would otherwise sit idle. But replication in Valkey and Redis is asynchronous, and the consequence is exact: a read issued immediately after a write can return the value from before the write. Post a comment, redirect, render the page from a replica that has not caught up yet, and the comment is missing. The bug is intermittent, load-dependent, and almost never reproducible locally, because a single-node dev environment has no replica to be stale.

config.useClusterServers()
      .setReadMode(ReadMode.MASTER);   // default is SLAVE

The practice is not "always use MASTER". It is to decide per workload and write the decision down. Read-after-write paths — session state, a just-submitted form, anything a user is about to see again — belong on MASTER. Caches, leaderboards, feature lookups and analytics reads are fine on SLAVE, and better there. If the split matters enough, run two clients with two readMode settings against the same cluster. Background on the topologies themselves: Redis Cluster, Sentinel, and how the two differ.

3. Set Timeout and Retry Deliberately, Then Do the Arithmetic

Four defaults interact here, and almost nobody adds them up:

  • timeout3000 ms, how long the client waits for a response
  • retryAttempts4, and these are retries, on top of the original send
  • retryDelayEqualJitterDelay(1s, 2s), a jittered wait between attempts
  • connectTimeout10000 ms

Multiply them out, because the total is longer than it looks. Four retries after the first send is five sends, each willing to wait three seconds, with a jittered one-to-two-second gap between them. A command against a node that has stopped responding therefore burns on the order of twenty seconds before it finally throws.

If the HTTP request that triggered it has an eight-second timeout, the caller gave up twelve seconds ago and the thread is still parked. Under load that is how a slow Valkey node turns into an exhausted servlet thread pool: not because the cache is down, but because the client is being patient on a deadline the client does not know about.

Make the client's worst case shorter than the deadline above it. For a user-facing request path, a 1,000 ms timeout with two retries and retryDelay shortened to match — EqualJitterDelay(Duration.ofMillis(200), Duration.ofMillis(500)) — puts the worst case under four seconds rather than twenty. Shorten the delay along with the timeout or the change does half its job: leave the default in place and those two waits alone add 1.5 to 3 seconds on top of the timeouts, roughly doubling whatever you thought you had configured. Keep the generous defaults for background workers, where latency is not the constraint.

Redisson jitters its retries by default, which is less common than it should be; the reasoning, and what the other Java clients actually do, is in exponential backoff and jitter in Java. If the pressure is arriving faster than you can shed it, the mechanism you want is backpressure, not a longer timeout.

4. Keep Connections Alive Through the Infrastructure

Load balancers, NAT gateways and cloud firewalls silently drop idle TCP connections, typically after a few minutes. A pooled connection that has been sitting idle behind one of those is dead but still in the pool, and the request that draws it pays a full timeout to find out.

Two settings handle this. pingConnectionInterval defaults to 30000 ms — the client sends a PING on idle connections every thirty seconds, which both keeps the path warm and detects a broken connection before a real command does. idleConnectionTimeout defaults to 10000 ms and controls when the pool closes surplus connections above the minimum-idle floor. Leave the ping enabled. If you are behind an AWS NLB or a similarly aggressive idle reaper, shorten the interval rather than disabling it, and raise the minimum-idle floor for your topology so the pool is not constantly rebuilding what the network keeps tearing down.

Keys, Memory Management and Data Shape

5. Never Call KEYS From Application Code

KEYS walks the entire keyspace and Valkey and Redis are single-threaded on the command path, so the whole server stops for the duration. On a keyspace of a few thousand it is invisible. On ten million it is a multi-second outage for every other client, caused by one background job doing housekeeping.

Use SCAN, which returns a cursor and a bounded slice per call. Redisson does this for you:

RKeys keys = redisson.getKeys();

// SCAN-backed, lazily iterated — safe on a large keyspace.
Iterable<String> found = keys.getKeys(
        KeysScanOptions.defaults()
                       .pattern("session:*")
                       .chunkSize(500));   // COUNT per SCAN call

for (String key : found) {
    // ...
}

The same rule applies one level down: HGETALL on a hash with a million fields is the same blocking problem in miniature, and HSCAN is the same answer. The full treatment is in KEYS, SCAN and pattern matching in Java and HGETALL vs HSCAN. While you are auditing for blocking commands, check for FLUSHALL and FLUSHDB too — clearing the cache from Java explains why those belong in an admin tool and not in a scheduled task.

6. Key Naming Conventions, and a TTL on Every Cache Key

Key names are data. Each one is stored in memory alongside its value, so the schema you pick is a capacity decision as much as a readability one: the difference between u:1 and application:production:user:profile:1 is about 36 bytes per key, which is hundreds of megabytes across tens of millions of keys and gigabytes across a hundred million. Pick a short colon-delimited convention — service:entity:id, so billing:invoice:8841 — before you have a million keys rather than after, because it also makes the keyspace greppable, makes per-prefix memory analysis possible, and stops two teams colliding on user:1.

Then set a TTL on everything that is a cache. A key without an expiry is a key that lives until someone deletes it, and nobody deletes it. Redisson exposes expiry on every object:

RBucket<Session> bucket = redisson.getBucket("session:" + id);
bucket.set(session, Duration.ofMinutes(30));

// Per-entry TTL inside a map, which plain hashes cannot do:
RMapCache<String, Product> cache = redisson.getMapCache("products");
cache.put("sku-1", product, 10, TimeUnit.MINUTES);

Per-entry expiry inside a hash is worth knowing about because the naive alternative — one key per entry so each can carry its own TTL — scatters what should be one object across the keyspace. RMapCache implements it in the client; RMapCacheNative pushes it down to the server's own hash-field expiration, which requires Valkey 9.0+ or Redis 7.4+. See Redis TTL for the command-level detail.

7. Set an Eviction Policy — the Default Is Not One

Two server defaults combine badly — this is one of the few items on the list your platform team owns rather than you, and it is here because of how it reaches your Java code. Most developers meet it for the first time at 3am. maxmemory defaults to 0 — unlimited — and maxmemory-policy defaults to noeviction.

With no limit set, a cache that never expires anything grows until the kernel's OOM killer takes the process. With a limit set but the policy left alone, the server stops accepting writes and starts returning OOM command not allowed when used memory > 'maxmemory'., while reads keep working — so your monitoring sees a healthy cache with a rising error rate on one code path.

The Java side of this is worth knowing precisely, because it is one of the few failures Redisson does not soften. The -OOM reply is decoded into a dedicated RedisOutOfMemoryException, which is not in the retryable family, so the write fails on the first attempt: no retries, no timeout budget consumed. That is the right behaviour — retrying cannot create memory — but it means a capacity problem reaches you as a sudden error rate on one code path rather than as the gradual latency creep engineers watch for.

If the instance is a cache, say so: maxmemory-policy allkeys-lru (or allkeys-lfu, which is usually better for skewed access) lets the server make room on its own. If the instance is a primary datastore, noeviction is correct and you must manage capacity another way. What you should not have is a cache with the datastore policy. Eviction policies compares all eight, cache eviction covers the general mechanism, and LRU vs LFU is the choice between the two you will actually use.

8. Keep Collections Bounded

A single Valkey or Redis key lives entirely on one node. A hash with ten million fields, a list that is only ever pushed to, a sorted set that accumulates every event of the day — each one is a "big key", and each one causes three separate problems: the operations that touch it block the single-threaded server for milliseconds at a time, the node holding it fills up while its siblings sit half-empty, and it cannot be split without changing your data model. In Cluster, this is the sharding problem in its most common form — the slot distribution is fine, but one slot holds a hundred times the data of the others.

The fixes are structural, not configurational. Shard the key yourself (events:2026-09-02:00 through :23). Cap the collection at write time — Redisson's RRingBuffer and the XADD MAXLEN option on streams both do this. Iterate rather than fetching whole — RMap's entry-set iterator is HSCAN-backed, while readAllMap() is a single HGETALL. And watch for the hot-key variant of the same problem, where the key is small but every request in the fleet reads it — that one is solved with a near cache (RLocalCachedMap), not by resharding. Redis data structures in Java and the Redis data types reference map the options.

Round Trips and Payloads: Where Performance Tuning Pays

9. Batch With RBatch Instead of Looping

A loop that issues one command per iteration pays one network round trip per iteration. At 0.5 ms round trip, fetching a thousand products takes half a second in which the server itself is idle almost the whole time. It is a common "Redis is slow" report that turns out to be nothing of the kind.

RBatch batch = redisson.createBatch(BatchOptions.defaults());
RMapAsync<Long, Product> products = batch.getMap("products");

for (Long id : ids) {
    products.getAsync(id);
}

BatchResult<?> result = batch.execute();   // one round trip

The same thousand lookups become a single round trip. Two caveats. First, batching is not a transaction — by default the commands are simply sent together and other clients' commands can interleave; BatchOptions.ExecutionMode.REDIS_WRITE_ATOMIC and its siblings change that if you need it. Second, an unbounded batch is its own problem: the server queues every reply in memory until you read it, so a batch of a million commands is a memory event rather than an optimization. Chunk large jobs at around ten thousand commands — send, read the replies, send the next chunk — or use skipResult() when you do not need the responses at all. Pipelining has the full mechanics, including skipResult() for fire-and-forget writes.

10. Choose the Codec on Purpose

Redisson's default codec is Kryo5Codec. It is fast and compact, and it is the right default for a single Java application. It has two properties you need to have decided about consciously:

  • It is Java-only. A Python or Go service reading the same key sees opaque binary. If anything other than the JVM will ever read this data, Kryo is the wrong choice and you will discover it at the worst possible moment.
  • It is sensitive to class shape. Adding or reordering fields can break deserialization of values written by the previous version — which, during a rolling deployment, is exactly what happens.
Config config = new Config();
config.setCodec(new JsonJacksonCodec());   // polyglot, human-readable, larger

The rule of thumb: Kryo5Codec for a cache read only by one Java service, JSON for anything shared across languages or teams, StringCodec when the values are already strings and you want them readable in redis-cli. Whichever you pick, keep the TTLs short enough that a schema change ages out rather than needing a migration, and treat a codec change itself as a breaking change to the keyspace. The full comparison — including the compression wrappers and the deserialization risk in SerializationCodec — is in data serialization codecs for Valkey and Redis on Java; the general concepts are in serialization and deserialization.

Correctness Under Concurrency

11. Let the Watchdog Manage Lock Leases

The SETNX-plus-expiry construction every tutorial reaches for leaves two problems open. The first is releasing a lock you no longer own; RLock closes it by not using SETNX at all — it runs a Lua script over a hash keyed by owner, so the lock is reentrant and can only be released by the thread that took it. The second has no such tidy answer: how long should the lock live? Too short and it expires while the work is still running, so two threads hold it at once. Too long and a crashed JVM blocks everyone else for minutes.

Redisson's answer is the watchdog. Acquire a lock without a lease time and the client sets a 30-second expiry (lockWatchdogTimeout) and then keeps renewing it in the background for as long as the owning JVM is alive. If the JVM dies, renewal stops and the lock expires on its own within thirty seconds. You never guess a number.

RLock lock = redisson.getLock("order:" + orderId);

// No leaseTime → watchdog renews while this JVM lives.
lock.lock();
try {
    process(orderId);
} finally {
    lock.unlock();
}

Passing a lease time turns the watchdog off. lock(10, TimeUnit.SECONDS) means the lock is released after ten seconds no matter what your code is doing — which is right only when you can genuinely bound the critical section, and wrong the moment the work inside it makes a network call.

One more default worth knowing, because it is the mitigation for the classic failover-loses-the-lock scenario: checkLockSyncedSlaves defaults to true with slavesSyncTimeout at 1000 ms, so an acquisition waits for the lock to reach replicas before it returns. Where the lock is protecting money rather than politeness — the distinction a Java distributed lock exists to make — add fencing — RFencedLock issues a monotonically increasing token you can validate at the resource, which is the only construction that survives a paused JVM. See how to use Redis locks in Java, Redis lock and the Redlock algorithm for the argument in full.

12. Make Read-Modify-Write Atomic

The pattern below is wrong, and it is written in every codebase:

// Two threads, two round trips, one lost update.
int count = bucket.get();
bucket.set(count + 1);

Between the read and the write, another process does the same thing, and one increment vanishes. There are three correct alternatives, and the third is the one you need least often. First, use an operation that is already atomic on the server — RAtomicLong.incrementAndGet() (INCR), RMap.addAndGet() (HINCRBY/HINCRBYFLOAT), RMap.fastPutIfAbsent() (HSETNX), RScoredSortedSet.addScore() (ZINCRBY). Each is one command, and most read-modify-write code exists only because its author did not know one was available. One trap: the ConcurrentMap methods RMap inherits — compute(), merge(), computeIfAbsent() — are not in this group. Redisson implements those client-side under a per-key lock, which is correct but much more expensive than addAndGet().

Second, use a Lua script, which the server runs as a single unit — the right answer whenever the operation needs branching or touches several keys in one slot. Third, use a transaction with WATCH for optimistic locking, which is the only one of the three that can fail and be retried; Redis transactions explains why MULTI/EXEC is not a rollback. Reaching for a distributed lock to protect a single counter is the expensive way to solve a problem the datastore already solved, and the failure mode you are guarding against is a plain race condition.

13. Use RRateLimiter Rather Than Rolling Your Own

Rate limiting is the read-modify-write problem above in its most common disguise, and hand-rolled implementations get it wrong in the same way every time: INCR the counter, read it back, compare it to the limit, and set an expiry — four decisions across two round trips, with a window in the middle where two requests both see a count below the limit. Under the load that makes rate limiting necessary, that window is open constantly.

RRateLimiter limiter = redisson.getRateLimiter("api:" + tenantId);

// 100 permits per second, shared across every JVM in the fleet.
limiter.trySetRate(RateType.OVERALL, 100, Duration.ofSeconds(1));

if (!limiter.tryAcquire(1)) {
    throw new TooManyRequestsException();
}

RRateLimiter runs the whole decision as one atomic script on the server, so there is no window and no lock. RateType.OVERALL shares one budget across every client; RateType.PER_CLIENT gives each Redisson instance its own. Rate limiter covers the algorithms, and distributed rate limiting with Spring Boot has the Spring integration.

14. Respect the Pub/Sub Multiplexing Limit

This one is invisible until it isn't. subscriptionsPerConnection defaults to 5 and subscriptionConnectionPoolSize to 50. Together they set a ceiling of roughly 250 concurrent subscriptions per node.

That ceiling is generous for an application with a dozen topics. It is nowhere near enough for the design that reaches for one topic per user or per session. In Cluster the limit is per node and Redisson routes each channel to the master owning its slot, so a six-master cluster gives you roughly 1,500 — but ten thousand connected users still means ten thousand subscriptions, and the app stalls well short of that with an error that reads like a connection problem rather than a design problem. New subscribers block waiting for a slot and fail after subscriptionTimeout, which defaults to 7500 ms.

config.useClusterServers()
      .setSubscriptionsPerConnection(50)     // default 5
      .setSubscriptionConnectionPoolSize(50);

Raising the numbers buys headroom, but the structural fix is fewer topics: one topic per channel of interest with routing in the payload, rather than one per subscriber. In Cluster there is a second thing to know — subscriptionMode defaults to MASTER, so subscriptions land on master nodes, and ordinary pub/sub messages are broadcast to every node in the cluster regardless of who is listening. Sharded pub/sub (RShardedTopic) confines a channel to one shard and is what you want at scale. Partitioning pub/sub topics covers the pattern, Redis Pub/Sub the basics, and pub/sub is fire-and-forget — a subscriber that is disconnected when a message is published never receives it, so for delivery guarantees use streams or RReliableTopic. Pub/Sub in Java has the working examples.

Security Best Practices and Operations

15. TLS, ACLs, and Never on a Public Interface

The protocol is fast partly because it assumes a trusted network. An unauthenticated instance reachable from the internet is found by scanners quickly, and exposed Valkey and Redis endpoints remain a standard target for cryptomining campaigns.

The Java-side checklist is short. Use rediss:// for TLS and leave sslVerificationMode at its default of STRICT — the temptation to relax it while chasing a certificate problem is exactly how it ends up relaxed in production. Use ACL users with a username and the narrowest command set the service needs, not a shared requirepass. Keep credentials out of application.yml and in whatever secret store you already run; if you are on a managed service, prefer a credential provider over a static password — IAM on ElastiCache and Entra ID on Azure both have Java integrations. And treat NOAUTH Authentication required as the useful signal it is rather than an error to route around. Connecting over TLS/SSL, password encryption in Java and the NOAUTH error have the specifics.

16. Instrument the Client, Not Just the Server

Server dashboards tell you the server is healthy. They do not tell you that one service's connection pool is saturated, that a particular command is timing out for one pod, or that a codec is deserializing oversized values on your hottest path. Those are client-side numbers, and if you are not collecting them the first evidence of a problem is a user complaint.

Collect connection-pool utilization, per-command latency percentiles, retry and timeout counts, and — if you use one — near-cache hit ratio. Redisson PRO exports them through Micrometer, so they land in Prometheus, Datadog or whatever you already run, and its tracing support puts a slow Valkey call inside the request span that caused it. Redis client metrics in Java and client tracing cover the setup.

Redis Best Practices Quick Reference: Redisson's Configuration Defaults

Every value below is the shipped default. Most incidents in this article are one of these behaving exactly as documented, in a context nobody checked.

SettingDefaultWhy it matters
codecKryo5CodecFast and compact, but Java-only and sensitive to class changes
readModeSLAVEReads hit replicas — read-after-write can return stale data
timeout3000 msResponse wait per attempt
retryAttempts4Retries, not total attempts — five sends in the worst case
retryDelayEqualJitterDelay(1s, 2s)Jittered by default — 0.5–1 s before the first retry, 1–2 s after
connectTimeout10000 msConnection establishment only
idleConnectionTimeout10000 msWhen surplus idle connections are closed
pingConnectionInterval30000 msKeeps connections alive through NAT and idle reapers
masterConnectionPoolSize / slaveConnectionPoolSize64 / 64Per node — multiply by nodes, then by JVMs
masterConnectionMinimumIdleSize / slaveConnectionMinimumIdleSize24 / 24Held open per node from startup
connectionPoolSize / connectionMinimumIdleSize64 / 24The single-server-mode equivalents of the two rows above
subscriptionsPerConnection5With the pool size below, ~250 subscriptions per node
subscriptionConnectionPoolSize50The other half of that ceiling
subscriptionTimeout7500 msHow long a new subscriber waits once that ceiling is reached
lockWatchdogTimeout30000 msApplies only when no leaseTime is given
checkLockSyncedSlavestrueLock acquisition waits for replica acknowledgement
slavesSyncTimeout1000 msHow long it waits
nettyThreads / threads32 / 16Shared across all connections in the client
protocolRESP2Set RESP3 for server-assisted client-side caching
sslVerificationModeSTRICTLeave it there
Cluster subscriptionModeMASTERSubscriptions land on masters; sharded topics avoid the broadcast

Valkey-Specific Notes and Cluster Best Practices

Everything above applies identically to both servers. Three things do not, and all three catch Java teams during a migration.

Valkey lies about its version, on purpose. INFO reports redis_version:7.2.4 for compatibility with tooling that parses that field, while the real version sits two lines below it in valkey_version, and the fork is identified by server_name:valkey. (server_mode is not the field you want — it holds standalone, sentinel or cluster, and it is the field Redis calls redis_mode.) Any code — yours or a library's — that gates a feature on redis_version will conclude that a Valkey 9 server is Redis 7.2 and quietly refuse to use anything newer.

Feature availability diverges by version, not by fork. Server-side hash-field expiration, which is what RMapCacheNative and the native cache integrations are built on, requires Valkey 9.0+ or Redis 7.4+. A Valkey 8 cluster does not have it, and neither does Redis 7.2 — the fork is not the question, the version is.

The licence, not the protocol, is why most teams are here. Valkey is BSD-licensed and a drop-in for the Redis wire protocol, so the application-side migration is usually a connection-string change rather than a rewrite. Valkey vs Redis compares them properly, Valkey is the short version, and migrating from Redis to Valkey in Java is the step-by-step.

Cluster Best Practices, in One Place

Four items on this checklist behave differently the moment you move from a single server to Cluster, and they are worth collecting. Pools are per node, so every default multiplies by masters, replicas and JVMs (practice 1); readMode still defaults to SLAVE (practice 2); a big key is slot skew, because no resharding splits one key (practice 8); and subscriptionMode defaults to MASTER while ordinary Pub/Sub broadcasts fleet-wide (practice 14). Two more are specific to Cluster: seed addresses must resolve to nodes that will still be reachable after a failover, and multi-key operations only work when the keys hash to the same slot, which is what hash tags exist to force. Connecting to a Redis Cluster in Java and running it on Kubernetes cover both.

Redis Anti-Patterns: What Not to Do

Anti-patternWhat goes wrongDo this instead
A client instance per requestSocket and thread exhaustionOne shared RedissonClient
KEYS in application codeBlocks the whole serverSCAN via getKeys(KeysScanOptions…)
Cache keys with no TTLMemory grows until eviction or OOMExpiry on every cache write
Default noeviction on a cacheWrites fail while reads succeedallkeys-lru or allkeys-lfu
One command per loop iterationN round trips for one logical operationRBatch
Get-then-set countersLost updates under concurrencyRAtomicLong or Lua
A lock with a guessed lease timeExpires mid-work, or blocks after a crashThe watchdog, no leaseTime
A hand-rolled INCR-and-check rate limiterTwo round trips with a window where both requests passRRateLimiter, one atomic script
One pub/sub topic per userHits the ~250-subscription ceilingFewer topics, routing in the payload
Kryo across polyglot servicesNon-Java readers see binaryJSON codec
Cache-aside with no stampede guardThundering herd on expiryRead-through, or a lock on the reload

Frequently Asked Questions

What Are the Most Important Redis Best Practices for a Java Application?

Share one client instance across the application, decide whether reads should go to a master or a replica rather than accepting the default, make the client's total timeout-plus-retry budget shorter than the request deadline above it, never call KEYS, give cache keys a TTL and the server an eviction policy, batch commands instead of looping, and let the lock watchdog manage lease times. Those seven cover the large majority of production incidents in Java services.

Why Does My Java Application Read Stale Data From Redis?

Most often because reads are being served from a replica. In Redisson's Cluster, Sentinel, replicated and master/slave modes readMode defaults to SLAVE, and replication is asynchronous, so a read issued immediately after a write can arrive before the replica has caught up. Set readMode to MASTER on read-after-write paths. The other common cause is a near cache without invalidation.

How Many Redis Connections Should a Java Service Open?

Fewer than you think — past a modest number, extra connections add memory and context-switching without adding throughput. Redisson defaults to a pool of 64 with 24 minimum idle per node — separately for masters and replicas — so a six-master, three-replica cluster holds 216 idle connections open from a single JVM before it has served a request. Size from measured concurrency, then multiply by the number of instances in your fleet and check the result against the server's maxclients.

Should I Use a Distributed Lock or a Lua Script?

A Lua script, whenever the whole operation can be expressed in one. The server runs it atomically with no lock to acquire, renew or release, and no risk of the lock expiring mid-operation. Reach for a distributed lock when the critical section spans more than the datastore — calling an external API, writing to a relational database, or coordinating work that takes longer than a script should run.

What TTL Should I Set on Cache Keys?

Short enough that stale data expires before it causes a visible problem, long enough that the hit ratio stays worth having. Minutes for user-facing data that changes, hours for reference data, and add a small random offset so a batch of keys written together does not expire together and produce a stampede. Never leave it unset: a key without a TTL is permanent, and permanent cache entries are how instances fill up.

Do These Best Practices Differ Between Valkey and Redis?

Barely. Both are single-threaded on the command path, share the wire protocol and behave identically for everything in this checklist. The differences that matter to a Java application are version-gated rather than fork-gated — server-side hash-field expiration needs Valkey 9.0+ or Redis 7.4+ — plus one trap: Valkey reports redis_version:7.2.4 for compatibility, so any feature detection that reads that field will underestimate the server.

Which Redis Performance Tuning Changes Matter Most in a Java Application?

Batching, the codec and the pool. Collapsing per-item loops into an RBatch removes a round trip per item and is usually worth more than every server-side change combined. Choosing a codec that matches the payload — and keeping payloads small enough that deserialization is not the bottleneck — comes second. Sizing the connection pool from measured concurrency rather than the defaults comes third. Server-side redis.conf tuning matters far less than these three, because once you have stopped issuing blocking commands, a Java service is rarely bottlenecked inside Valkey or Redis itself.

What Are the Most Important Redis Security Best Practices for a Java Service?

Connect over TLS with rediss:// and leave sslVerificationMode at STRICT; authenticate with an ACL user scoped to the commands the service actually issues rather than a shared requirepass; keep the credential in a secret store or, on a managed service, use a credential provider instead of a static password; and never expose the instance on a public interface. One that is specific to Java: a binary codec is a trust boundary, so do not deserialize values written by a system you do not control.

Next Steps

If you are applying this list to an existing service, the highest-yield order is: check readMode, add up your timeout budget, grep for KEYS, then audit TTLs and the eviction policy. Everything after that is optimization. For the caching best practices these sit inside, see Java caching strategies and ten Valkey and Redis use cases in Java; for Spring specifically, Spring Boot caching with @Cacheable and session management for Java microservices. Every default quoted above is documented in the Redisson configuration reference.

Redisson gives Java applications distributed locks, caches, queues and collections over Valkey and Redis, with jittered retries, lock watchdogs and SCAN-backed key iteration configured out of the box. Redisson PRO adds advanced caching, data partitioning, Micrometer metrics and tracing, and the Reliable Queue — try it for free.