Skip to content

Transactions

To ensure the atomicity of multiple operations, mature databases typically support transactions, and Redis is no exception. Using transactions in Redis is straightforward; unlike relational databases, we don't need to grasp complex transaction models. However, this simplicity comes with a less stringent transaction model, meaning we can't use Redis transactions in the same way as relational databases.

Basic Usage of Redis Transactions

Every transaction involves begin, commit, and rollback. In Redis, these correspond to multi, exec, and discard. The basic structure is as follows:

javascript
multi();
try {
    command1();
    command2();
    ....
    exec();
} catch(Exception e) {
    discard();
}

In Redis, the commands look similar:

plaintext
> multi
OK
> incr books
QUEUED
> incr books
QUEUED
> exec
(integer) 1
(integer) 2

transactions.webp

Here, commands are queued until exec is called, executing the entire transaction queue at once. Thanks to Redis's single-threaded nature, it doesn't have to worry about other commands interfering during execution, ensuring "atomic" execution.

Atomicity

Atomicity means either all operations in a transaction succeed or none do. Is Redis transaction execution truly atomic? Consider this example:

plaintext
> multi
OK
> set books iamastring
QUEUED
> incr books
QUEUED
> set poorman iamdesperate
QUEUED
> exec
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
> get books
"iamastring"
> get poorman
"iamdesperate"

The transaction fails midway because a string can't undergo a mathematical operation. Even so, subsequent commands still execute, meaning Redis transactions can't be considered truly atomic; they only satisfy isolation, ensuring the current transaction isn't interrupted by others.

Discard

Redis provides a discard command to discard all commands in the transaction queue before exec is executed:

plaintext
> get books
(nil)
> multi
OK
> incr books
QUEUED
> incr books
QUEUED
> discard
OK
> get books
(nil)

After discard, no commands are executed, as if the commands between multi and discard never occurred.

Optimization

In Redis, sending each command to the transaction queue involves network I/O, which can increase linearly with the number of commands. Thus, clients often use pipelining alongside transactions to reduce multiple I/O operations to a single one. For example, in Python, this is mandatory:

python
pipe = redis.pipeline(transaction=True)
pipe.multi()
pipe.incr("books")
pipe.incr("books")
values = pipe.execute()

Watch

In scenarios where multiple clients modify an account balance, Redis lacks a multiplyby command. We can retrieve the balance, multiply it, and write it back, but this creates concurrency issues. A distributed lock could solve this, but can we use optimistic locking instead?

Redis provides a watch mechanism as a form of optimistic locking. This allows us to monitor one or more key variables before executing the transaction:

python
while True:
    do_watch()
    commands()
    multi()
    send_commands()
    try:
        exec()
        break
    except WatchError:
        continue

The watch command monitors key variables before the transaction begins. If the watched variable changes before exec, the transaction fails, prompting the client to retry.

plaintext
> watch books
OK
> incr books  # modified
(integer) 1
> multi
OK
> incr books
QUEUED
> exec  # transaction failed
(nil)

When exec returns null, the client knows the transaction failed, often raising a WatchError.

Notes

Redis prohibits executing watch between multi and exec, requiring all watches to occur before multi to avoid errors.

Now, let's implement the doubling operation in Python:

python
# -*- coding: utf-8
import redis

def key_for(user_id):
    return "account_{}".format(user_id)

def double_account(client, user_id):
    key = key_for(user_id)
    while True:
        pipe = client.pipeline(transaction=True)
        pipe.watch(key)
        value = int(pipe.get(key))
        value *= 2  # double
        pipe.multi()
        pipe.set(key, value)
        try:
            pipe.execute()
            break  # success
        except redis.WatchError:
            continue  # retry
    return int(client.get(key))  # re-fetch balance

client = redis.StrictRedis()
user_id = "abc"
client.setnx(key_for(user_id), 5)  # initialization
print(double_account(client, user_id))

Now, here's the Java implementation:

java
import java.util.List;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;

public class TransactionDemo {

  public static void main(String[] args) {
    Jedis jedis = new Jedis();
    String userId = "abc";
    String key = keyFor(userId);
    jedis.setnx(key, String.valueOf(5));  // initialization
    System.out.println(doubleAccount(jedis, userId));
    jedis.close();
  }

  public static int doubleAccount(Jedis jedis, String userId) {
    String key = keyFor(userId);
    while (true) {
      jedis.watch(key);
      int value = Integer.parseInt(jedis.get(key));
      value *= 2; // double
      Transaction tx = jedis.multi();
      tx.set(key, String.valueOf(value));
      List<Object> res = tx.exec();
      if (res != null) {
        break; // success
      }
    }
    return Integer.parseInt(jedis.get(key)); // re-fetch balance
  }

  public static String keyFor(String userId) {
    return String.format("account_%s", userId);
  }
}

Often, Python is noted for its brevity compared to Java. However, in this example, Java's code isn't significantly longer, only about 50% more.

Thought Question

Why can't Redis transactions support rollback?

Transactions has loaded