Appearance
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
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:
continueThe 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?