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