Redis as simple distribution lock for simple cases

I came across a system issue recently that some of our batch jobs written as ruby rake tasks ended up having duplicated invocations, which causes either duplicated records in database or duplicated alerts in slack. It is definitely an infra issue but until it is fixed from infra side, I need figure out a way to address it from the coding perspective. Trying to rewrite everything as idempotent as possible is not an option and neither is enhancing application level validations or database uniqueness constraints, because not all jobs can be idempotent and not all jobs write to databases, besides the issues are caused by race conditions not from two threads but from two processes. After some research and experiment, I decided to use Redis as a simple distribution lock solution, which is already available in our application and needs zero extra setup, and it is a single instance therefore no replication sync concern. Redis is single-threaded architecture so no matter how many concurrent clients try to write to it, it is guaranteed to be in sequential and atomic. I figure out I could make use of this nature to fix the issue faced.

Example of a task

namespace :main do
  task :hello do
    sleep 1 # simulate long-running task
    puts '***hello, world***'
  end
end

Run without lock

$ bundle exec rake main:hello & bundle exec rake main:hello &
[1] 38358
[2] 38359
***hello, world***
***hello, world***

[2]  + done       bundle exec rake main:hello
[1]  + done       bundle exec rake main:hello

Two processes run it simultaneously, and it runs twice.

Add redis distribution lock

  • Define lock
namespace :lock do
  redis = Redis.new

  task :before do
    task_name = Rake.application.top_level_tasks[0]
    lock_obtained = redis.set task_name, '1', ex: 60, nx: true
    abort "Another process is already running #{task_name}" unless lock_obtained
  end

  task :after do
    task_name = Rake.application.top_level_tasks[0]
    redis.del task_name
  end
end
  • Enhance the task with lock
Rake::Task['main:hello'].enhance(['lock:before']) do
  Rake::Task['lock:after'].execute
end

Run with lock

$ bundle exec rake main:hello & bundle exec rake main:hello &
[1] 38560
[2] 38561

Another process is already running main:hello
[1]  - exit 1     bundle exec rake main:hello

***hello, world***
[2]  + done       bundle exec rake main:hello

Two processes run it simultaneously, but it runs only once.

Summary

The point is to make use of the nx: true option of the set command, which means only set the key if it does not already exist. With redis single-threaded design, we can be sure that only one process will successfully set the key, denoting that only one process obtains the lock.

For referencee, Distributed Locks with Redis has more details on implementing more reliable and safer algorithem based locks such as Redlock for more complicated cases.

redis