Yet another distributed lock for Ruby using Redis
MIT License
Yet another Ruby distributed lock using Redis, with emphasis in transparency.
Implements the locking algorithm described in the Redis SET command documentation:
SET {{key}} {{uuid_token}} NX PX {{ms_to_expire}}
EVAL "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return nil end" {{key}} {{uuid_token}}
It has the properties:
Requirements:
The required versions are needed for the new syntax of the SET command (using NX and EX/PX).
Install from RubyGems:
$ gem install mario-redis-lock
Or include it in your project's Gemfile
with Bundler:
gem 'mario-redis-lock', :require => 'redis_lock'
Acquire the lock to do_exclusive_stuff
:
RedisLock.acquire do |lock|
if lock.acquired?
do_exclusive_stuff # you are the only process with the lock, hooray!
else
oh_well # timeout, some other process has the lock and didn't release it before the retry_timeout
end
end
Or (equivalent)
lock = RedisLock.new
if lock.acquire
begin
do_exclusive_stuff # you are the only process with the lock, hooray!
ensure
lock.release
end
else
oh_well # timeout, some other process has the lock and didn't release it before the retry_timeout
end
The class method RedisLock.acquire(options, &block)
is more concise and releases the lock at the end of the block, even if do_exclusive_stuff
raises an exception.
The second alternative is a little more flexible.
fetch_with_lock
method, that works like most Cache.fetch(key, &block)
methods out there (if value is cached in that given key, return the cached value, otherwise run the block), but only executes the block from one of the processes that share that cache, avoiding the case when the cache is invalidated and all processes execute an expensive operation at the same time.Redis.new
) an instance of Redis, or an options hash to initialize an instance of Redis (see redis gem). You can also pass anything that "quaks" like redis, for example an instance of mock_redis, for testing purposes."RedisLock::default"
) Redis key used for the lock. If you need multiple locks, use a different (unique) key for each lock.10.0
) seconds to automatically release (expire) the lock after being acquired. Make sure to give enough time for your "exclusive stuff" to be executed, otherwise other processes could get the lock and start messing with the "exclusive stuff" before this one is done. The autorelease time is important, even when manually doing lock.realease
, because the process could crash before releasing the lock. Autorelease (expiration time) guarantees that the lock will always be released.true
) boolean to enable/disable consecutive acquire retries in the same acquire
call. If true, use retry_timeout
and retry_sleep
to specify how long and how often should the acquire
method block the thread (sleep) until able to get the lock.10.0
) seconds before giving up before the lock is released. Note that the execution thread is put to sleep while waiting. For a non-blocking approach, set retry
to false.0.1
) seconds to sleep between retries. For example: RedisLock.acquire(retry_timeout: 10.0, retry_sleep: 0.1){|lock| ... }
if the lock was acquired by other process and never released, will do almost 100 retries (a rerty every 0.1 seconds, plus a little extra to run the the SET
command) during 10 seconds, and finally yield with lock.acquired? == false
.Options can be set to other than the defaults when calling RedisLock.acquire
:
RedisLock.acquire(key: 'exclusive_stuff', retry: false) do |lock|
if lock.acquired?
do_exclusive_stuff
end
end
Or when creating a new lock instance:
lock = RedisLock.new(key: 'exclusive_stuff', retry: false, autorelease: 0.1)
if lock.acquire
do_exclusive_stuff_or_not
end
You can also configure default values with RedisLock.configure
:
RedisLock.configure do |defaults|
defaults.redis = Redis.new
defaults.key = "RedisLock::default"
defaults.autorelease = 10.0
defaults.retry = true
defaults.retry_timeout = 10.0
defaults.retry_sleep = 0.1
end
A good place to set defaults in a Rails app would be in an initializer like conf/initializers/redis_lock.rb
.
There are other Redis locks for Ruby: redlock-rb, redis-mutex, mlanett-redis-lock, redis-lock, jashmenn-redis-lock, ruby_redis_lock, robust-redis-lock, bfg-redis-lock, etc.
I realized I was not sure how most of them exactly work. What is exactly going on with the lock? When does it expire? How many times needs to retry? Is the thread put to sleep meanwhile?. By the time I learned how to tell if a lock is good or not, I learned enough to write my own, making it simple but explicit, to be used with confidence in my high scale production applications.
git checkout -b my-new-feature
)git commit -am 'Add some feature'
)git push origin my-new-feature
)Make sure you have installed Redis in localhost:6379. The DB 15 will be used for tests (and flushed after every test).
There is a rake task to play with an example: rake smoke_and_pass