3. Redis Transactions via Spring Data Redis
One particular problem is to use a Redis key to track the maximum. When setting the maximum, operations get-compare-set needs to be synchronized. On Redis, a cheap way to achieve the correct result is to use Redis's GETSET
. According to Redis documentation, GETSET
"atomically sets key to value and returns the old value stored at key." The approach is to GETSET
with the new max and make sure the returned max is not larger; otherwise throw an exception. Since we are optimistic that race conditions like this rarely occur, instead of throwing an exception right away, we retry a couple times. The assumption is that, if we retry a limited number of times, the correct results will highly likely go through (speaking of being optimistic).
/**
* Checks if the new value is greater than the current max. If so,
* sets the max to the new value.
*/
void setMax(final long newVal) {
long maxAtHand = newVal;
long maxInRedis = valueOps.get(maxKey);
int retryCount = 0;
final int retryLimit = 5;
while (maxAtHand > maxInRedis && retryCount < retryLimit) {
maxInRedis = maxAtHand;
maxAtHand = valueOps.getAndSet(maxKey, maxInRedis);
retryCount++;
}
if (maxAtHand > maxInRedis) {
throw new RuntimeException("Updating max with for key "
+ maxKey + " has failed.");
}
}
If the transaction is beyond a simple GETSET
, Redis does support optimistic locking via "transactions" using WATCH
, MULTI
, and EXEC
.
MULTI
and EXEC
together draw a transactional boundary within which multiple commands are queued up together such that commands from other clients will never be executed in between. However, an very important difference from typical transactions in relational database systems is that there are no rollbacks in Redis transactions. Failures, when they happen, they have been committed, even within transactions.
Optimistic locking is achieved by the command WATCH
. Keys put on watch will be monitored for changes. One simple pattern would be:
WATCH
// Read. May UNWATCH and abandon at any time before EXEC
// if the desired updates are already done.
MULTI
// Write based on the values read. May DISCARD at any time
// before EXEC.
EXEC
There is a twist to use these commands with Spring Data Redis (or perhaps any connection-pool-based Redis client). According to the Spring documentation, "These operations are available on RedisTemplate, however RedisTemplate is not guaranteed to execute all operations in the transaction using the same connection." Different connections imply different clients. Instead of executing watch(), multi(), and exec() directly on RedisTemplate, the correct procedure is as follows, "Spring Data Redis provides the SessionCallback interface for use when multiple operations need to be performed with the same connection, as when using Redis transactions." That said, the Redis commands for transactions need to execute within an instance of SessionCallback.
SessionCallback<String> callback = new SessionCallback<String>() {
@Override
public <K, V> String execute(RedisOperations<K, V> operations)
throws DataAccessException {
operations.watch();
// Read
operations.multi();
// Write operations
operations.exec();
}
};
redisTemplate.execute(callback);
Note, being consistent with the Redis' exec()
command, RedisOperations.exec()
returns null
results when the execution is aborted due to watch violations.
Remember we are still doing optimistic locking here. So, to make it truly ready for production work, wrap the above code into a retry loop with exponential backoffs:
SessionCallback<String> callback = new SessionCallback<String>() {
@Override
public <K, V> String execute(RedisOperations<K, V> operations)
throws DataAccessException {
int retryLimit = 10;
long delay = 10;
List<Object> results = null;
int i = 0;
while (results == null && i < retryLimit) {
try {
Thread.sleep(delay << i);
} catch (InterruptedException e) {
new RuntimeException(e);
}
operations.watch();
// Read
operations.multi();
// Write operations
results = operations.exec();
i++;
}
}
};
redisTemplate.execute(callback);
Optimistic locking with retry is simple to implement and works well when race conditions rarely occur. However, the solution runs into problems when the load increases and the chance of race conditions increases with the load. This is where distributed locks come to rescue. With Redis it is easy to implement distributed locks.
The critical piece is the Redis command SETNX key value
(set if not exist) which returns a boolean indicating if the key has been set. To acquire the lock, use SETNX objectId randomString
to check if the ID of the distributed object has been set. If it has not been set yet, we will have set it and we will have acquired the lock. If it has already been set, some other remote process is working on the object, we will either block or work on other objects. That is the basic idea of using SETNX
to do distributed locking.
But that is not enough. A process may acquire the lock and never release it (due to code error, runtime crash, etc). To avoid that, we must associate an expiration time with the lock so that, after a reasonable amount time, the lock expires and is removed by Redis. In fact, every call to acquire the lock has the duty to set the expire if it is not set yet.
Another potentially dangerous scenario is that a process, which is not the owner of the lock, releases the lock. To avoid that, we guard the lock with a UUID (universally unique identifier). At the time the lock is being acquired, we generate a UUID and set the lock's value to be the UUID. When the lock is being released, we ask for the UUID and verify that it is the owner that is releasing the lock.
To put it together, here is the pseudo-code:
// Returns a UUID if the lock is acquired
// or null if the lock is not acquired
acquire(lock: String): String
Generate a uuid
SETNX lock uuid
If the lock is set
Return uuid
Else
Set expire on the lock if it is not set
Return null
release(lock: String, uuid: String): boolean
Get the uuid for the lock
If the uuid matches
Delete the lock
Return TRUE
Else
Return FALSE