Java Clients: Jedis, Lettuce and Redisson
Chapter 31: Java Clients — Jedis, Lettuce, and Redisson
The Java ecosystem hosts the most mature Redis client libraries. Each of the three dominant clients serves a distinct role: Jedis prioritizes simplicity, Lettuce delivers async high-throughput via Netty, and Redisson provides a distributed-object abstraction layer. This chapter digs into their internals, configuration mechanics, and production tuning strategies.
31.1 Jedis: Synchronous Blocking Model
31.1.1 Connection Pool Internals (GenericObjectPool)
Jedis is not thread-safe — a single Jedis instance maps to one TCP socket. In multi-threaded environments you must manage instances through a pool. JedisPool wraps Apache Commons Pool2's GenericObjectPool<Jedis>.
Object lifecycle:
borrowObject(): Returns an idle instance if one exists; creates a new connection if belowmaxTotal; blocks up tomaxWaitmilliseconds if the pool is exhausted, then throwsJedisConnectionException.returnObject(): Returns the instance to the pool. If idle count exceedsmaxIdle, the surplus connection is destroyed.invalidateObject(): Called when a connection is found broken; discards the instance instead of returning it.
Key configuration:
JedisPoolConfig config = new JedisPoolConfig();
// Capacity
config.setMaxTotal(50); // Max connections (borrowed + idle)
config.setMaxIdle(20); // Max idle connections
config.setMinIdle(5); // Min idle connections (warm pool)
// Wait policy
config.setMaxWait(Duration.ofMillis(3000)); // Time to wait for an available connection
config.setBlockWhenExhausted(true); // Block (true) or throw immediately (false)
// Health checks
config.setTestOnBorrow(true); // PING before borrowing
config.setTestOnReturn(false); // Skip check on return (performance)
config.setTestWhileIdle(true); // Background eviction thread checks idle connections
config.setTimeBetweenEvictionRuns(Duration.ofSeconds(30));
config.setMinEvictableIdleTime(Duration.ofMinutes(1)); // Destroy idle connections older than this
JedisPool pool = new JedisPool(config, "localhost", 6379, 2000, "password");
Connection leak is the most common production failure with Jedis. If an exception escapes before close() is called, the connection is never returned. The pool's "borrowed" counter keeps rising until all slots are occupied and new requests hang indefinitely.
Always use try-with-resources:
// Correct: guaranteed return
try (Jedis jedis = pool.getResource()) {
jedis.set("user:1001", "Alice");
return jedis.get("user:1001");
}
// Wrong: may leak
Jedis jedis = pool.getResource();
jedis.set("user:1001", "Alice"); // exception here → never closed
jedis.close();
31.1.2 Pipeline
Each Redis command normally incurs one round-trip time (RTT): ~0.05 ms loopback, ~0.5 ms intra-datacenter, 5–50 ms cross-region. Pipeline batches multiple commands into one network write; the server executes them sequentially and sends all responses at once.
try (Jedis jedis = pool.getResource()) {
Pipeline pipe = jedis.pipelined();
for (int i = 0; i < 1000; i++) {
pipe.set("key:" + i, "value:" + i);
pipe.expire("key:" + i, 3600);
}
List<Object> results = pipe.syncAndReturnAll();
for (int i = 0; i < results.size(); i++) {
if (results.get(i) instanceof Exception e) {
log.error("Command {} failed: {}", i, e.getMessage());
}
}
}
Pipeline caveats:
- Not atomic: other clients' commands can interleave between your pipeline commands.
- Ideal batch size: 100–1000 commands. Larger batches consume substantial server-side output buffer memory.
- Cluster mode: all keys must hash to the same slot, or you must split commands per node manually.
31.1.3 JedisCluster
Set<HostAndPort> nodes = Set.of(
new HostAndPort("10.0.0.1", 7000),
new HostAndPort("10.0.0.2", 7001),
new HostAndPort("10.0.0.3", 7002)
);
JedisCluster cluster = new JedisCluster(
nodes, connectTimeout, socketTimeout, maxAttempts, "password", config
);
// API is identical to single-node; routing is transparent
cluster.set("order:1001", "pending");
String status = cluster.get("order:1001");
Internally, JedisCluster maintains a slot → node map covering all 16 384 slots and a separate JedisPool per node. On MOVED it refreshes the slot map; on ASK (slot migrating) it follows the redirect only for that request.
31.2 Lettuce: Asynchronous Multiplexing Model
31.2.1 Core Architecture: Built on Netty
Lettuce uses Netty's event-loop to multiplex many concurrent commands over a single TCP connection. Requests are queued in an in-flight command queue; responses are matched by position. This makes StatefulRedisConnection thread-safe with no pool required (though a pool is available when isolation is needed).
RedisClient client = RedisClient.create(
RedisURI.builder()
.withHost("localhost")
.withPort(6379)
.withPassword("password".toCharArray())
.withTimeout(Duration.ofSeconds(5))
.build()
);
// Single shared connection — safe across threads
StatefulRedisConnection<String, String> connection = client.connect();
31.2.2 Three Command Interfaces
Synchronous (blocks until response):
RedisCommands<String, String> sync = connection.sync();
sync.set("session:abc", "user:1001");
String userId = sync.get("session:abc");
Asynchronous (non-blocking CompletableFuture):
RedisAsyncCommands<String, String> async = connection.async();
RedisFuture<String> f = async.get("session:abc");
f.thenApply(v -> v != null ? v : "guest")
.thenAccept(userId -> log.info("User: {}", userId))
.exceptionally(ex -> { log.error("Redis error", ex); return null; });
// Fan-out: fire N commands, wait for all
RedisFuture<String> f1 = async.get("key1");
RedisFuture<String> f2 = async.get("key2");
RedisFuture<String> f3 = async.get("key3");
LettuceFutures.awaitAll(5, TimeUnit.SECONDS, f1, f2, f3);
Reactive (Project Reactor — native Spring WebFlux integration):
RedisReactiveCommands<String, String> reactive = connection.reactive();
// Single value
Mono<String> mono = reactive.get("session:abc");
mono.subscribe(System.out::println);
// Streaming — e.g., scan all user keys and load values
reactive.keys("user:*")
.flatMap(key -> reactive.hgetall(key))
.filter(map -> "active".equals(map.get("status")))
.collectList()
.subscribe(users -> log.info("Active users: {}", users.size()));
31.2.3 Connection Pooling (When Needed)
Connection pooling in Lettuce is useful when you need connection-level isolation (e.g., blocking commands, different databases):
GenericObjectPoolConfig<StatefulRedisConnection<String, String>> poolConfig =
new GenericObjectPoolConfig<>();
poolConfig.setMaxTotal(16);
poolConfig.setMaxIdle(8);
poolConfig.setMinIdle(2);
GenericObjectPool<StatefulRedisConnection<String, String>> pool =
ConnectionPoolSupport.createGenericObjectPool(client::connect, poolConfig);
try (StatefulRedisConnection<String, String> conn = pool.borrowObject()) {
conn.sync().set("key", "value");
}
31.2.4 Client Options for Production
ClientOptions options = ClientOptions.builder()
.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)
.autoReconnect(true)
.pingBeforeActivateConnection(true)
.socketOptions(SocketOptions.builder()
.connectTimeout(Duration.ofSeconds(5))
.keepAlive(SocketOptions.KeepAliveOptions.builder()
.enable()
.idle(Duration.ofSeconds(30))
.interval(Duration.ofSeconds(10))
.count(3)
.build())
.build())
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(3)))
.build();
client.setOptions(options);
31.2.5 Jedis vs. Lettuce Decision Matrix
| Dimension | Jedis | Lettuce |
|---|---|---|
| Thread safety | Not safe (pool required) | Safe (shared connection) |
| Programming model | Synchronous blocking | Sync / Async / Reactive |
| Connections per 100 threads | ~100 | ~1–8 |
| Underlying I/O | Plain Java socket | Netty NIO |
| Spring Boot default since | Boot 1.x | Boot 2.x |
| Cluster pipeline | Manual grouping | Auto grouping |
| Best fit | Simple apps, scripts | High concurrency, microservices |
31.3 Redisson: Distributed Object Layer
31.3.1 Philosophy
Redisson builds on Lettuce/Netty and exposes Redis data structures as idiomatic Java objects implementing java.util.concurrent interfaces (RLock implements Lock, RMap implements ConcurrentMap, etc.). This hides command-level complexity behind familiar APIs.
Config config = new Config();
config.useSingleServer()
.setAddress("redis://localhost:6379")
.setPassword("password")
.setConnectionPoolSize(10)
.setConnectionMinimumIdleSize(2)
.setTimeout(3000)
.setRetryAttempts(3)
.setRetryInterval(1500);
RedissonClient redisson = Redisson.create(config);
31.3.2 Distributed Lock and the WatchDog
RLock lock = redisson.getLock("order:lock:1001");
// Option A: no explicit lease — WatchDog keeps renewing
lock.lock();
try {
processOrder(1001);
} finally {
lock.unlock(); // stops the WatchDog renewal task
}
// Option B: explicit lease (WatchDog disabled)
lock.lock(30, TimeUnit.SECONDS);
// Option C: try-lock with wait timeout and lease timeout
boolean acquired = lock.tryLock(3 /*waitSec*/, 30 /*leaseSec*/, TimeUnit.SECONDS);
if (acquired) {
try { processOrder(1001); } finally { lock.unlock(); }
} else {
throw new LockAcquisitionException("Could not acquire order lock");
}
WatchDog mechanism in detail:
- On
lock()without a lease time, Redisson registers a NettyHashedWheelTimertask. - Every
leaseTime / 3(default: 10 s), the task issuesPEXPIRE key leaseTimeto reset the TTL. unlock()cancels the timer task.- If the JVM crashes, the timer never fires and the lock expires naturally after
leaseTime(30 s) — no deadlock.
MultiLock (acquire multiple locks atomically):
RLock lock1 = redisson.getLock("inventory:SKU001");
RLock lock2 = redisson.getLock("inventory:SKU002");
RedissonMultiLock multiLock = new RedissonMultiLock(lock1, lock2);
multiLock.lock();
try {
// safe to modify both SKUs
} finally {
multiLock.unlock();
}
31.3.3 Distributed Data Structures
// Distributed Map with automatic serialization
RMap<String, User> users = redisson.getMap("users");
users.put("user:1001", new User("Alice", 25));
User alice = users.get("user:1001");
users.fastPut("user:1002", new User("Bob", 30)); // no old-value return, faster
// Local-cached Map (L1 in-process + L2 Redis)
LocalCachedMapOptions<String, User> opts = LocalCachedMapOptions.<String, User>defaults()
.cacheSize(1000)
.timeToLive(Duration.ofMinutes(10))
.syncStrategy(LocalCachedMapOptions.SyncStrategy.INVALIDATE);
RLocalCachedMap<String, User> localMap = redisson.getLocalCachedMap("users", opts);
// Delayed Queue
RQueue<String> targetQueue = redisson.getQueue("tasks");
RDelayedQueue<String> delayedQueue = redisson.getDelayedQueue(targetQueue);
delayedQueue.offer("send-invoice:1001", 10, TimeUnit.MINUTES);
// Bloom Filter
RBloomFilter<Long> bloom = redisson.getBloomFilter("known_user_ids");
bloom.tryInit(10_000_000L, 0.001);
bloom.add(1001L);
boolean known = bloom.contains(9999L); // false (with 0.1% false-positive rate)
// Rate Limiter (token bucket)
RRateLimiter limiter = redisson.getRateLimiter("api:rate");
limiter.trySetRate(RateType.OVERALL, 1000, 1, RateIntervalUnit.SECONDS);
boolean ok = limiter.tryAcquire(1);
// Semaphore
RSemaphore sem = redisson.getSemaphore("db:pool:permits");
sem.trySetPermits(20);
sem.acquire();
try { queryDatabase(); } finally { sem.release(); }
31.3.4 Cluster Configuration
Config config = new Config();
config.useClusterServers()
.addNodeAddress(
"redis://10.0.0.1:7000",
"redis://10.0.0.2:7001",
"redis://10.0.0.3:7002"
)
.setReadMode(ReadMode.SLAVE)
.setLoadBalancer(new RoundRobinLoadBalancer())
.setScanInterval(2000)
.setMasterConnectionPoolSize(10)
.setSlaveConnectionPoolSize(10)
.setFailedSlaveReconnectionInterval(3000);
31.4 Production Selection Guide
| Scenario | Recommended | Reason |
|---|---|---|
| Standard Spring Boot service | Lettuce via Spring Data Redis | Pre-integrated, async-capable |
| Distributed locking / rate limiting | Redisson | WatchDog, MultiLock, built-in primitives |
| One-off scripts, admin tools | Jedis | Simple API, minimal dependencies |
| Reactive / WebFlux service | Lettuce Reactive | Native Project Reactor |
| Heavy object caching | Redisson RLocalCachedMap | L1+L2 reduces network calls |
Spring Boot YAML configuration:
spring:
data:
redis:
host: localhost
port: 6379
password: ${REDIS_PASSWORD}
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 2
max-wait: 3000ms
shutdown-timeout: 200ms
connect-timeout: 5000ms
timeout: 3000ms
RedisTemplate bean with proper serialization:
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> tpl = new RedisTemplate<>();
tpl.setConnectionFactory(factory);
StringRedisSerializer str = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer json = new GenericJackson2JsonRedisSerializer();
tpl.setKeySerializer(str);
tpl.setHashKeySerializer(str);
tpl.setValueSerializer(json);
tpl.setHashValueSerializer(json);
tpl.afterPropertiesSet();
return tpl;
}
}
31.5 Troubleshooting Common Issues
Issue: JedisConnectionException: Could not get a resource from the pool
- Diagnosis: run
INFO clientsand compareconnected_clientsagainstmaxTotal. - Root cause: connection leak (missing
close()), or peak concurrency exceeds pool size. - Fix: wrap all Jedis usage in try-with-resources; increase
maxTotal; identify and optimize slow commands that hold connections longer.
Issue: Lettuce command timeout spikes
- Symptom:
RedisCommandTimeoutExceptionoccurs intermittently under load. - Root cause: slow commands (KEYS, SORT, large LRANGE) blocking the server thread; network packet loss; server CPU saturation.
- Fix: enable
slowlog, set threshold to 10 ms; replace KEYS with SCAN; tuneCommandTimeoutto realistic values.
Issue: Redisson WatchDog not renewing
- Symptom: lock expires while the business logic is still running.
- Root cause: caller passed an explicit
leaseTimetolock(leaseTime, unit)— WatchDog is disabled in this mode. - Fix: use
lock()with no lease time to enable automatic renewal; or setleaseTimelong enough to cover worst-case execution.
Issue: Garbled values / deserialization failure
- Symptom: values stored in Redis are binary gibberish; retrieval throws
ClassCastException. - Root cause:
RedisTemplatedefaults to JDK serialization, which is not human-readable and is sensitive to class changes. - Fix: configure
GenericJackson2JsonRedisSerializerfor values andStringRedisSerializerfor keys.
Issue: Redisson distributed lock not releasing after JVM kill
- Symptom: other processes cannot acquire the lock after an abrupt crash.
- Root cause: lock was created with an explicit lease; with a WatchDog it would expire naturally.
- Expected behavior: WatchDog stops on crash → lock expires after
leaseTime(30 s default). VerifyleaseTimeis not set to a very large value.