How to Manage Transactions in Valkey and Redis on Java

Published on
July 13, 2026

Grouping several operations so they either all succeed or all fail is one of the oldest guarantees in data management — and one of the trickiest to get right on an in-memory data store. If you are building on Valkey or Redis with Java and you need a set of writes to behave as a single unit, you have two very different paths available: the native transaction commands that ship with the server, and the higher-level transactional API that Redisson layers on top.

This guide walks through both. You will see how native transactions work, where their guarantees quietly stop short of what most developers expect, and how Redisson fills that gap with true rollback, isolation, Spring integration, and distributed XA transactions.

What a Transaction Means in Valkey and Redis

A transaction lets you execute a group of commands as a single isolated step. No other client can slip a command in between the ones you have grouped, and the group runs in sequence from start to finish.

Natively, Valkey and Redis build transactions on four commands:

  • MULTI — marks the start of a transaction block. Commands issued after it are queued rather than executed.
  • EXEC — runs every queued command in order, as one isolated unit.
  • DISCARD — abandons the queued commands before they run.
  • WATCH — marks one or more keys for conditional, optimistic locking. If any watched key changes before EXEC, the transaction is aborted.

On the surface this looks like the transaction model you know from relational databases. In practice, it behaves quite differently — and the differences are exactly where teams get caught out.

Native Transactions in Java With a Low-Level Client

With a low-level client such as Jedis, a native transaction maps almost directly onto the server commands. Imagine transferring funds between two accounts, where the debit and the credit must happen together or not at all:

Jedis jedis = new Jedis("localhost", 6379);

// The transaction holds a connection, so close it with try-with-resources.
try (AbstractTransaction tx = jedis.multi()) {
    tx.incrBy("account:1:balance", -100);  // debit
    tx.incrBy("account:2:balance", 100);   // credit
    List<Object> results = tx.exec();
}

Both incrBy calls are queued after MULTI and applied together when EXEC runs. So far, so good.

For conditional updates you reach for optimistic locking with WATCH. You watch the keys you depend on, read them, compute the new state, and only then open the transaction. If another client modified a watched key in the meantime, EXEC returns null and you have to retry the whole thing yourself:

jedis.watch("account:1:balance");
int balance = Integer.parseInt(jedis.get("account:1:balance"));

if (balance >= 100) {
    try (AbstractTransaction tx = jedis.multi()) {
        tx.incrBy("account:1:balance", -100);
        tx.incrBy("account:2:balance", 100);
        List<Object> results = tx.exec();

        if (results == null) {
            // A watched key changed — the transaction was aborted.
            // Re-read and try again.
        }
    }
}

This works, but notice how much responsibility sits on your shoulders: watch the right keys, re-read, detect the aborted transaction, and build your own retry loop. And there is a deeper problem hiding underneath.

The Catch: Native Transactions Don't Roll Back

The most common misconception about Valkey and Redis transactions is that they are atomic in the relational sense — that if something goes wrong, every change is undone. They are not.

The "all or nothing" guarantee only applies at queue time. If a command can't be queued — say it's syntactically invalid — the whole transaction is discarded before anything runs. But once EXEC begins, a command that fails at runtime does not cancel the others. The rest of the queued commands still execute, the failed one simply returns an error, and there is no automatic rollback to put things back the way they were.

Concretely: if you queue a debit and a credit, and the credit command errors at runtime because the value has the wrong type, the debit has already happened. The money has left one account and never arrived at the other. There is no ROLLBACK to call. DISCARD only helps before EXEC — it abandons the queue; it cannot reverse commands that have already run.

WATCH gives you optimistic concurrency, but it shifts the burden of detecting conflicts and retrying onto your application code, and it still does nothing about partial failures during execution.

For a counter increment or two, you can live with this. For anything resembling business logic — orders, inventory, balances, multi-object updates — the gap between "what native transactions promise" and "what your application needs" is real. That gap is exactly what Redisson closes.

Managing Transactions With Redisson

Redisson is the Valkey and Redis Java client and real-time data platform. Instead of exposing raw MULTI/EXEC, it provides a transactional API that operates over its distributed Java objects — RMap, RSet, RBucket, and more — with semantics that match what Java developers actually expect from a transaction.

Under the hood, a Redisson transaction takes locks on the objects it writes and maintains a list of data-modification operations until you commit or roll back. That design is what makes genuine rollback possible: nothing is finalized on the server until commit(), so rollback() can cleanly discard the pending changes. The transaction isolation level is READ_COMMITTED, meaning a transaction only ever reads committed data.

Here is the same fund-transfer scenario, this time with Redisson:

RedissonClient redisson = Redisson.create(config);

RTransaction transaction = redisson.createTransaction(TransactionOptions.defaults());

RMap<String, Integer> accounts = transaction.getMap("accounts");
// Balances are assumed to exist; use getOrDefault in real code to avoid NPEs.
accounts.put("account:1", accounts.get("account:1") - 100);  // debit
accounts.put("account:2", accounts.get("account:2") + 100);  // credit

try {
    transaction.commit();
} catch (TransactionException e) {
    transaction.rollback();
}

If anything inside the transaction fails, rollback() undoes the pending modifications — both the debit and the credit — so you never end up in the half-applied state that native transactions can leave behind. This is the single biggest reason teams reach for Redisson transactions: real, predictable rollback.

Which Objects Can Take Part in a Transaction

A defined set of Redisson objects can participate in a transaction:

  • RMap
  • RMapCache
  • RLocalCachedMap
  • RSet
  • RSetCache
  • RBucket

You obtain transactional instances of these from the RTransaction object itself (for example transaction.getMap("accounts")), not from the root RedissonClient. Anything you read or write through those handles becomes part of the transaction.

Tuning Behavior With TransactionOptions

TransactionOptions.defaults() is enough to get started, but you can tune timeouts and retry behavior to match your environment:

TransactionOptions options = TransactionOptions.defaults()
    // Sync timeout between the master participating in the transaction and its replicas.
    // Default: 5000 ms
    .syncSlavesTimeout(5, TimeUnit.SECONDS)

    // Response timeout. Default: 3000 ms
    .responseTimeout(3, TimeUnit.SECONDS)

    // Interval between attempts to send the transaction if it wasn't sent already.
    // Default: 1500 ms
    .retryInterval(2, TimeUnit.SECONDS)

    // Number of attempts to send the transaction. Default: 3
    .retryAttempts(3)

    // If the transaction isn't committed within this timeout it rolls back automatically.
    // Default: 5000 ms
    .timeout(5, TimeUnit.SECONDS);

The timeout option is worth calling out: if a transaction hasn't committed within the window, it rolls back automatically — a useful safety net against stuck or abandoned transactions.

Synchronous, Asynchronous, Reactive, and RxJava APIs

Redisson exposes transactions through all four of its programming models, so the transactional API fits whatever style your codebase already uses.

Asynchronous commit with a rollback fallback:

RFuture<Void> future = transaction.commitAsync();
future.exceptionally(exception -> {
    transaction.rollbackAsync();
    return null;
});

Reactive:

RedissonReactiveClient redisson = Redisson.create(config).reactive();

RTransactionReactive transaction = redisson.createTransaction(TransactionOptions.defaults());
RMapReactive<String, String> map = transaction.getMap("myMap");
map.put("1", "2");

Mono<Void> mono = transaction.commit();
mono.onErrorResume(e -> transaction.rollback());

An RTransactionRx variant is available for RxJava3 in the same shape.

Spring Transaction Manager Integration

If you already manage transactions declaratively with Spring's @Transactional, Redisson plugs straight into it. Register a RedissonTransactionManager and Spring drives commit and rollback for you:

@Configuration
@EnableTransactionManagement
public class RedissonTransactionContextConfig {

    @Bean
    public TransactionalBean transactionBean() {
        return new TransactionalBean();
    }

    @Bean
    public RedissonTransactionManager transactionManager(RedissonClient redisson) {
        return new RedissonTransactionManager(redisson);
    }

    @Bean(destroyMethod = "shutdown")
    public RedissonClient redisson(@Value("classpath:/redisson.yaml") Resource configFile)
            throws IOException {
        Config config = Config.fromYAML(configFile.getInputStream());
        return Redisson.create(config);
    }
}

public class TransactionalBean {

    @Autowired
    private RedissonTransactionManager transactionManager;

    @Transactional
    public void transfer() {
        RTransaction transaction = transactionManager.getCurrentTransaction();
        RMap<String, Integer> accounts = transaction.getMap("accounts");
        accounts.put("account:1", accounts.get("account:1") - 100);
        accounts.put("account:2", accounts.get("account:2") + 100);
    }
}

Annotate a method with @Transactional, grab the current transaction from the manager, and write through it. Spring commits when the method returns normally and rolls back if it throws.

Reactive Spring Transactions

For WebFlux and other reactive applications, Spring's reactive transaction management (introduced in Spring Framework 5.2) works the same way through ReactiveRedissonTransactionManager. Register it in place of the blocking manager and return a Mono from your @Transactional method — Spring commits when the Mono completes and rolls back if it signals an error:

@Configuration
@EnableTransactionManagement
public class RedissonReactiveTransactionConfig {

    @Bean
    public TransactionalBean transactionBean() {
        return new TransactionalBean();
    }

    @Bean
    public ReactiveRedissonTransactionManager transactionManager(RedissonReactiveClient redisson) {
        return new ReactiveRedissonTransactionManager(redisson);
    }

    @Bean(destroyMethod = "shutdown")
    public RedissonReactiveClient redisson(@Value("classpath:/redisson.yaml") Resource configFile)
            throws IOException {
        Config config = Config.fromYAML(configFile.getInputStream());
        return Redisson.create(config).reactive();
    }
}

public class TransactionalBean {

    @Autowired
    private ReactiveRedissonTransactionManager transactionManager;

    @Transactional
    public Mono<Void> transfer() {
        Mono<RTransactionReactive> transaction = transactionManager.getCurrentTransaction();
        return transaction.flatMap(t -> {
            RMapReactive<String, Integer> accounts = t.getMap("accounts");
            return accounts.get("account:1")
                .flatMap(from -> accounts.put("account:1", from - 100))
                .then(accounts.get("account:2"))
                .flatMap(to -> accounts.put("account:2", to + 100))
                .then();
        });
    }
}

Retrieve the current transaction with getCurrentTransaction(), compose your reactive operations on it, and return the resulting Mono. The transaction's lifecycle is bound to that Mono, so you never call commit or rollback yourself.

Distributed (XA) Transactions With JTA

Sometimes a Valkey or Redis update has to be coordinated with another resource — a relational database, a message queue — so that all of them commit or none do. For these distributed transactions, Redisson provides an RXAResource implementation that participates in JTA transactions:

// Transaction obtained from a JTA-compatible transaction manager
Transaction globalTransaction = transactionManager.getTransaction();

RXAResource xaResource = redisson.getXAResource();
globalTransaction.enlistResource(xaResource);

RTransaction transaction = xaResource.getTransaction();
RBucket<String> bucket = transaction.getBucket("myBucket");
bucket.set("simple");
RMap<String, String> map = transaction.getMap("myMap");
map.put("myKey", "myValue");

transactionManager.commit();

Enlist the RXAResource with your global transaction, perform your Valkey or Redis writes through the enlisted transaction, and let the JTA manager coordinate the two-phase commit across every resource.

Transactions in a Cluster

In a Valkey or Redis cluster, keys are distributed across hash slots, and a multi-key operation must stay within a single slot. To keep the objects in a transaction collocated on the same slot, use a hash tag — the part of the key inside {} braces — so they hash together:

RMap<String, String> map = transaction.getMap("accounts{user:1}");
RSet<String> set = transaction.getSet("history{user:1}");

Without a shared hash tag, spreading a transaction's keys across multiple slots triggers a CROSSSLOT error from the server. Designing your keys with collocation in mind from the start saves a lot of pain later.

Transactions or Distributed Locks?

Transactions are not always the right tool. If your goal is simply to make sure that only one process touches a resource at a time — rather than to group several writes into an atomic unit — a distributed lock is often a simpler, cheaper fit. A good rule of thumb: reach for a transaction when you need atomicity across multiple objects with rollback, and reach for a lock when you need mutual exclusion around a critical section. Many real systems use both.

Transactions in Valkey and Redis: The Bottom Line

Native Valkey and Redis transactions are perfectly serviceable for grouping a handful of commands, but their guarantees stop where most business logic begins: there is no rollback after execution, and conflict handling is left to you. Redisson closes that gap with an RTransaction API that delivers real rollback, READ_COMMITTED isolation, a full range of sync, async, reactive, and RxJava interfaces, and first-class Spring and JTA/XA integration — all over the distributed Java objects you are already using.

Frequently Asked Questions

Do Valkey and Redis Transactions Support Rollback?

Not natively. Native MULTI/EXEC is atomic only at queue time — once execution starts, a command that fails at runtime does not roll the others back, and DISCARD only works before EXEC. Redisson's RTransaction adds true rollback by holding modifications until commit.

What Is the Difference Between Native Transactions and Redisson Transactions?

Native transactions queue server commands and run them in one isolated step, with no rollback after execution and optimistic locking left to your code. Redisson transactions operate over distributed Java objects, use locks plus a deferred modification list to provide genuine rollback, run at READ_COMMITTED isolation, and integrate with Spring and JTA.

What Isolation Level Do Redisson Transactions Use?

READ_COMMITTED — a transaction reads only data that has been committed.

Can I Use Transactions Across a Valkey or Redis Cluster?

Yes, as long as the keys involved are collocated on the same hash slot using a {} hash tag. Keys spread across slots produce a CROSSSLOT error.