Chapter 31

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:

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:

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:

  1. On lock() without a lease time, Redisson registers a Netty HashedWheelTimer task.
  2. Every leaseTime / 3 (default: 10 s), the task issues PEXPIRE key leaseTime to reset the TTL.
  3. unlock() cancels the timer task.
  4. 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

Issue: Lettuce command timeout spikes

Issue: Redisson WatchDog not renewing

Issue: Garbled values / deserialization failure

Issue: Redisson distributed lock not releasing after JVM kill

Rate this chapter
4.6  / 5  (3 ratings)

💬 Comments