Lettuce to Redisson Migration Guide
Lettuce is a genuinely good Redis client. It's the default in Spring Boot, it's built on Netty, it's thread-safe, and it speaks synchronous, asynchronous, and reactive APIs fluently. If all you need is to send commands to Redis or Valkey, Lettuce does that well and there's little reason to switch.
So this isn't a guide about Lettuce being bad. It's a guide for the point where you've outgrown a command-level client. The moment you start hand-rolling a distributed lock with SET NX PX and a Lua release script, reaching for a third-party library to get a near-cache, or wishing your Redis hash behaved like a java.util.Map, you've crossed a line. Lettuce gives you commands whereas Redisson gives you distributed objects, locks, caches, and services on top of Redis and Valkey. This guide shows you when that trade is worth it and exactly how to make the move.
Signs You've Outgrown Lettuce
If any of these describe your application, Redisson will remove a meaningful amount of code you're currently maintaining yourself:
- You need distributed locks or synchronizers - and you've discovered Lettuce has none, so you're building them by hand or pulling in a separate library.
- You want to work with
Map,List,Set,Queue, orDequesemantics instead of issuingHSET/LPUSH/ZADDcommands and mapping results yourself. - You need a managed near-cache or a JCache (JSR-107) provider, not just low-level client-side caching primitives.
- You want distributed services - remote invocation, a distributed executor/scheduler, live objects, or map-reduce.
- You're standardizing on Redis/Valkey-backed Spring Cache, Spring Session, Hibernate second-level cache, or Tomcat session replication and want them to work out of the box.
If instead you only issue commands, value the lowest possible overhead, and are happy with Spring Data Redis as-is, staying on Lettuce is a perfectly defensible choice. The rest of this guide is for teams in the first camp.
What Redisson Adds on Top of Lettuce
Distributed Locks - The Clearest Gap
This is the sharpest difference, because Lettuce has no lock primitive at all. The Lettuce maintainers declined to add one; the canonical answer is "implement it yourself or use another library." So in Lettuce you write something like an acquire with commands.set(key, token, SetArgs.Builder.nx().px(10000)), then a Lua script that deletes the key only if the token still matches, then your own logic for ownership checks, reentrancy, and lease renewal - and you test all of it under failover.
In Redisson it's three lines:
RLock lock = redisson.getLock("order:1234");
lock.lock();
try {
// critical section, safe across every node and JVM
} finally {
lock.unlock();
}
A watchdog automatically extends the lock's lease while the holder is alive (default 30 seconds, set via Config.lockWatchdogTimeout) when you call lock() with no explicit lease, so a long task won't lose its lock and a crashed holder won't keep it forever. If you'd rather cap the hold time yourself, tryLock(waitTime, leaseTime, unit) acquires within a bound and auto-releases after the lease. Redisson also provides fair locks, multi-locks, read/write locks, fenced locks, spin locks, semaphores, permit-expirable semaphores, and countdown latches - each with async, reactive, and RxJava variants.
Java Collections Instead of Raw Commands
With Lettuce you call commands on a connection and handle the wiring yourself. With Redisson you get a typed object that implements a familiar Java interface: RMap implements java.util.Map, RList implements List, RSet implements Set, and RQueue/RDeque/RBlockingQueue implement the queue interfaces - across 50+ objects. Your domain code stops thinking in commands and starts thinking in collections. The step-by-step section below shows the before-and-after for each type.
Caching - Managed Near-Cache and JCache
Here's where accuracy matters, because Redisson marketing sometimes overstates it. Lettuce does ship client-side caching, since version 6.0: ClientSideCaching and CacheFrontend, built on RESP3 push invalidation and CLIENT TRACKING. The honest gaps are that these are low-level primitives (you assemble the cache yourself), they require RESP3, and they're supported only on Redis standalone - not Cluster or Master/Replica.
Redisson provides a managed near-cache, RLocalCachedMap object, with configurable invalidation and eviction strategies, plus a JCache (JSR-107) provider, Spring Cache, and Hibernate second-level cache. Redisson's documentation reports local-cache reads run "up to 45x faster" than a standard map (a vendor benchmark figure - treat it as such, though the architectural win of serving hot reads from local memory is real). So the difference isn't "Lettuce has no caching"; it's "Lettuce has a primitive, Redisson has a managed cache layer and a JCache API."
Distributed Services
Redisson ships services with no Lettuce counterpart: a remote-invocation (RPC) service, a Live Object service that maps Java objects to Valkey hashes, a distributed executor service and scheduler, and a MapReduce service. In Redisson PRO these extend to enterprise-grade reliable messaging - a Reliable Queue and Reliable Pub/Sub that back a TCK-passing JMS provider. Lettuce gives you raw pub/sub (RTopic is Redisson's higher-level equivalent) and leaves the rest to you.
Step-by-Step Migration
1. Swap the Dependency
Remove Lettuce and add Redisson.
<!-- remove -->
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</dependency>
<!-- add -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>4.6.1</version>
</dependency>
Gradle: implementation 'org.redisson:redisson:4.6.1'.
Spring Boot users: Lettuce ships transitively with spring-boot-starter-data-redis, so exclude it and add Redisson's starter, which registers a RedissonConnectionFactory so your existing RedisTemplate code keeps working.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>4.6.1</version>
</dependency>
2. Replace Client Construction and Connection Setup
Lettuce builds a RedisClient, opens a StatefulRedisConnection, and gives you sync()/async()/reactive() command interfaces. Redisson centralizes everything in a Config and hands you a RedissonClient.
// Lettuce
RedisClient client = RedisClient.create("redis://127.0.0.1:6379");
StatefulRedisConnection<String, String> conn = client.connect();
RedisCommands<String, String> commands = conn.sync();
// Redisson
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379"); // or valkey://
RedissonClient redisson = Redisson.create(config);
Topology mappings: Lettuce's RedisClusterClient → config.useClusterServers().addNodeAddress(...); Sentinel → config.useSentinelServers().setMasterName(...).addSentinelAddress(...); Master/Replica → config.useMasterSlaveServers() or config.useReplicatedServers(). Lifecycle: Lettuce's connection.close() / client.shutdown() becomes redisson.shutdown().
Authentication and database selection sit on the same server config: setUsername(...) and setPassword(...) for ACL credentials, and setDatabase(int) to pick a logical database - the Redisson equivalent of the credentials and database index you set on Lettuce's RedisURI (Redis Cluster has no database index).
3. Translate Commands Into Objects
This is the heart of the migration. In Lettuce you call commands on a connection (commands.get("k")); in Redisson you obtain an object that models the data and call methods on it.
Strings / values:
// Lettuce
commands.set("user:1", "Ada");
String name = commands.get("user:1");
// Redisson
RBucket<String> bucket = redisson.getBucket("user:1");
bucket.set("Ada");
String name = bucket.get();
Set with TTL or only-if-absent (SETEX / SETNX):
// Lettuce
commands.setex("k", 60, "v");
commands.setnx("k", "v");
// Redisson - direct methods on RBucket
bucket.set("v", Duration.ofSeconds(60)); // SETEX
boolean created = bucket.trySet("v"); // SETNX
Binary values and partial updates (APPEND / GETRANGE / SETRANGE):
// Lettuce
commands.append("log", "entry");
String slice = commands.getrange("log", 0, 3);
commands.setrange("log", 0, "PREF");
// Redisson - RBinaryStream exposes the value as a stream and a channel
RBinaryStream stream = redisson.getBinaryStream("log");
ByteBuffer buf = ByteBuffer.allocate(4);
stream.getOutputStream().write("entry".getBytes()); // APPEND
stream.getChannel().write(buf); // SETRANGE
stream.getChannel().read(buf); // GETRANGE
For the range operations you seek the channel to the target offset first; getAsynchronousChannel() provides the non-blocking equivalent.
Counters:
// Lettuce
commands.incr("visits");
// Redisson
RAtomicLong visits = redisson.getAtomicLong("visits");
visits.incrementAndGet();
Key lifecycle and expiration:
// Lettuce
commands.expire("user:1", 60);
commands.del("user:1");
long count = commands.exists("user:1");
commands.rename("user:1", "user:2");
// Redisson - per-object methods for a single key…
RBucket<String> b = redisson.getBucket("user:1");
b.expire(Duration.ofSeconds(60));
b.delete();
boolean exists = b.isExists();
b.rename("user:2");
// …and RKeys for bulk or pattern operations
RKeys keys = redisson.getKeys();
keys.delete("user:1", "user:2");
Iterable<String> matched = keys.getKeysByPattern("user:*");
Hashes → maps:
// Lettuce
commands.hset("user:1", "name", "Ada");
String n = commands.hget("user:1", "name");
// Redisson - RMap implements java.util.Map
RMap<String, String> user = redisson.getMap("user:1");
user.put("name", "Ada");
String n = user.get("name");
Lists, sets, sorted sets:
// Lettuce
commands.rpush("tasks", "first");
commands.sadd("tags", "java");
commands.zadd("leaderboard", 100.0, "alice");
// Redisson - each implements the matching java.util type
RList<String> list = redisson.getList("tasks");
list.add("first");
RSet<String> set = redisson.getSet("tags");
set.add("java");
RScoredSortedSet<String> board = redisson.getScoredSortedSet("leaderboard");
board.add(100.0, "alice");
Transactions and pipelining:
// Lettuce
commands.multi();
commands.set("a", "1");
commands.set("b", "2");
commands.exec();
// Redisson - RBatch pipelines; RTransaction adds ACID semantics
RBatch batch = redisson.createBatch();
batch.getBucket("a").setAsync("1");
batch.getBucket("b").setAsync("2");
batch.execute();
Scripting:
// Lettuce
Long r = commands.eval("return 1", ScriptOutputType.INTEGER);
// Redisson
RScript script = redisson.getScript();
Long r = script.eval(RScript.Mode.READ_ONLY, "return 1", RScript.ReturnType.LONG);
Pub/Sub:
// Lettuce - dedicated pub/sub connection
StatefulRedisPubSubConnection<String, String> pubsub = client.connectPubSub();
pubsub.addListener(new RedisPubSubAdapter<String, String>() {
@Override public void message(String channel, String msg) { handle(msg); }
});
pubsub.sync().subscribe("news");
// Redisson
RTopic topic = redisson.getTopic("news");
topic.addListener(String.class, (channel, msg) -> handle(msg));
topic.publish("hello");
Distributed locks and the other synchronizers have no entry here because they map to no single command - in Lettuce you build them yourself, while Redisson exposes them as objects (shown earlier). If you need an atomic multi-step operation, RTransaction (redisson.createTransaction(...)) gives ACID semantics over Redisson objects, and a batch created with BatchOptions.defaults().skipResult() saves bandwidth when you don't need the replies. You can still run raw Lua via RScript, so migrating never traps you above the command layer.
For the exhaustive list, Redisson's command-to-object mapping reference maps every Redis and Valkey command to its equivalent object and method across the sync, reactive, and RxJava APIs. As that page notes, distributed features like locks, rate limiters, near-cache, and reliable messaging aren't listed there at all - because they correspond to no single command, which is precisely the layer you're migrating up into.
4. Reactive Code
Lettuce's RedisReactiveCommands (returning Mono/Flux) maps to Redisson's reactive client: redisson.reactive().getBucket("k") returns reactive objects, and redisson.rxJava() exposes an RxJava3 API. The programming model stays reactive; only the object model changes.
Technical Considerations and Implementation Pitfalls
-
Don't run two connection factories. In Spring Boot,
RedissonConnectionFactoryandLettuceConnectionFactorycan't coexist cleanly - do a full swap (exclude Lettuce as shown). If you need to opt out of Redisson's auto-configuration, excludeRedissonAutoConfigurationV2. -
Client-side caching prerequisites. If you were relying on Lettuce's client-side caching, note it required RESP3 and standalone topology; Redisson's
RLocalCachedMapworks across topologies but has its own invalidation/eviction semantics to learn. -
Blocking commands and the connection model. Lettuce shares one connection across threads except for blocking ops (
BLPOP) and transactions; Redisson manages its own pool, so this particular caveat goes away, but review your pool sizing after switching.