基于 Redis 的 lock 正是基于其單進(jìn)程單線程及其原子操作來實(shí)現(xiàn)的。對于 Redis 來說,同一時(shí)刻只可能有一個(gè)命令正在操作,也就是說在 Redis 的層面上,請求是串行進(jìn)行的。
SETNX
SETNX 是 Redis 的一個(gè)命令,完整形式是這樣的:
SETNX key value
它是 ‘set if not exists’ 的簡寫,正如其描述一樣 SETNX 的作用是給 key 賦值,并且僅當(dāng)目標(biāo) key 不存在時(shí)才能成功并返回 1
locking 實(shí)現(xiàn)主要就是基于 Redis 的這個(gè)命令
其過程是這樣的:
- 首先 ClientA 需要獲取鎖,然后 SETNX lock_name time_stamp 返回 1 ,加鎖成功
- 此時(shí) ClientB 想要獲取該鎖,嘗試 SETNX 結(jié)果因?yàn)?key 已存在 返回 0,知道鎖正被占用然后選擇等待或返回
- ClientA 做完要做的事情后,使用 DEL 命令刪除 lock_name 來完成釋放鎖的操作
- 此時(shí)該鎖可被其他 Client 搶占
看起來很完美,但其實(shí)這中間存在著很多細(xì)節(jié)上的問題,大致分析一下:
- 因?yàn)?ClientA 的 SETNX 和 DEL 是分開的操作,那么其中一個(gè)問題就是假如 ClientA 在加鎖之后因?yàn)槟承┰驔]有釋放掉這個(gè)鎖,就會(huì)導(dǎo)致很嚴(yán)重的后果
- 上面的問題其實(shí)在設(shè)計(jì)時(shí)已經(jīng)被想到,解決辦法就是給這個(gè)鎖加上過期時(shí)間,鎖超過過期時(shí)間之后,其他的 Client 就可以釋放掉它,然后再通過 SETNX 搶占
- 但是在上面的解決方法中其實(shí)有另外一個(gè)問題存在,那就是假如存在這樣的一種情況:
- ClientA 在加鎖后沒有釋放鎖,此時(shí)有 ClientB 和 ClientC 在等待
- 當(dāng)鎖超時(shí)時(shí),B 和 C 同時(shí)檢測到了超時(shí),然后執(zhí)行 DEL 操作,再 SETNX 搶鎖
- 其單個(gè)操作都是原子性的,那么當(dāng) C 先 DEL 后又 SETNX 搶到了鎖這時(shí) B 執(zhí)行了 DEL 又把鎖釋放了,最后兩個(gè)都獲得了鎖
- 還有另一種情況,假如 ClientA 并沒有死,而是在執(zhí)行一個(gè)很耗時(shí)的操作,鎖過期了也沒執(zhí)行完,然后在別人搶占了鎖之后,它完成了然后執(zhí)行了 DEL 釋放鎖的操作。。。GG
下面來看下 redis-objects 這個(gè) gem 是如何實(shí)現(xiàn)這個(gè) locking 的
Locking
看源碼實(shí)現(xiàn)很簡單,一共也就幾十行代碼
# Get the lock and execute the code block. Any other code that needs the lock
# (on any server) will spin waiting for the lock up to the :timeout
# that was specified when the lock was defined.
def lock(&block)
expiration = nil
try_until_timeout do
expiration = generate_expiration
# Use the expiration as the value of the lock.
break if redis.setnx(key, expiration)
# Lock is being held. Now check to see if it's expired (if we're using
# lock expiration).
# See "Handling Deadlocks" section on http://redis.io/commands/setnx
if !@options[:expiration].nil?
old_expiration = redis.get(key).to_f
if old_expiration < Time.now.to_f
# If it's expired, use GETSET to update it.
expiration = generate_expiration
old_expiration = redis.getset(key, expiration).to_f
# Since GETSET returns the old value of the lock, if the old expiration
# is still in the past, we know no one else has expired the locked
# and we now have it.
break if old_expiration < Time.now.to_f
end
end
end
begin
yield
ensure
# We need to be careful when cleaning up the lock key. If we took a really long
# time for some reason, and the lock expired, someone else may have it, and
# it's not safe for us to remove it. Check how much time has passed since we
# wrote the lock key and only delete it if it hasn't expired (or we're not using
# lock expiration)
if @options[:expiration].nil? || expiration > Time.now.to_f
redis.del(key)
end
end
end
先來看它獲取鎖的過程,首先會(huì)在超時(shí)時(shí)間內(nèi)不斷地循環(huán)嘗試獲取鎖
def try_until_timeout
if @options[:timeout] == 0
yield
else
start = Time.now
while Time.now - start < @options[:timeout]
yield
sleep 0.1
end
end
raise LockTimeout, "Timeout on lock #{key} exceeded #{@options[:timeout]} sec"
end
可以看到,在超時(shí)時(shí)間內(nèi)每隔 0.1 秒嘗試一次
再回到 lock 方法,嘗試獲取鎖的過程如下:
- 使用 SETNX 命令嘗試獲取鎖,如果成功跳出循環(huán)往下執(zhí)行真正的邏輯
- 如果失敗就去校驗(yàn)鎖的過期時(shí)間,如果沒有過期就等待進(jìn)入下一輪的嘗試
- 如果檢查到鎖已過期,就使用 GETSET 命令給 lock_key 賦值并返回原值,看其是否超過期,沒有就等待下一輪嘗試
- 如果過期就拿到鎖,可以開始干自己的事了
- 做完了自己的事后,在釋放鎖的操作前會(huì)前檢查下是否已經(jīng)過了自己獲取鎖時(shí)定下的過期時(shí)間,如果已經(jīng)超時(shí)就不進(jìn)行釋放鎖的操作