Skip to content

3. Redis Transactions via Spring Data Redis

Eric Wu edited this page May 19, 2015 · 28 revisions

Optimistic locking using GETSET

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.");
    }
}

Optimistic locking using WATCH, MULTI, and EXEC

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);

Distributed locks

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