Yet another Ruby distributed lock using Redis, with emphasis in transparency.
Implements the locking algorithm described in the Redis SET command documentation:
- Acquire lock with
SET {{key}} {{uuid_token}} NX PX {{ms_to_expire}}
- Release lock with
EVAL "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return nil end" {{key}} {{uuid_token}}
- Auto release lock if expires
It has the properties:
- Mutual exclusion: At any given moment, only one client can hold a lock
- Deadlock free: Eventually it is always possible to acquire a lock, even if the client that locked a resource crashed or gets partitioned
- NOT fault tolerant: if the REDIS instance goes down, the lock doesn't work. For a lock wiht liveness guarantee, see redlock-rb, that can use multiple REDIS instances to handle the lock.
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.
- Beer Waiter: Run many threads at the same time, all them try to get a beer in 3 seconds using the same lock. Some will get it, some will timeout.
- Dog Pile Effect: See how to implement a
fetch_with_lock
method, that works like mostCache.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: (default
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. - key: (default
"RedisLock::default"
) Redis key used for the lock. If you need multiple locks, use a different (unique) key for each lock. - autorelease: (default
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 doinglock.realease
, because the process could crash before releasing the lock. Autorelease (expiration time) guarantees that the lock will always be released. - retry: (default
true
) boolean to enable/disable consecutive acquire retries in the sameacquire
call. If true, useretry_timeout
andretry_sleep
to specify how long and how often should theacquire
method block the thread (sleep) until able to get the lock. - retry_timeout: (default
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, setretry
to false. - retry_sleep: (default
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 theSET
command) during 10 seconds, and finally yield withlock.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.
- Fork it ( http://github.com/marioizquierdo/redis-lock/fork )
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create new Pull Request
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