redis分布式鎖的實現主要是基于redis的setnx 命令(setnx命令解釋見:http://doc.redisfans.com/string/setnx.html),我們來看一下setnx命令的作用:
1、redis分布式鎖的基本實現
redis加鎖命令:
SETNX resource_name my_random_value PX 30000
這個命令的作用是在只有這個key不存在的時候才會設置這個key的值(NX選項的作用),超時時間設為30000毫秒(PX選項的作用) 這個key的值設為“my_random_value”。這個值必須在所有獲取鎖請求的客戶端里保持唯一。
SETNX 值保持唯一的是為了確保安全的釋放鎖,避免誤刪其他客戶端得到的鎖。舉個例子,一個客戶端拿到了鎖,被某個操作阻塞了很長時間,過了超時時間后自動釋放了這個鎖,然后這個客戶端之后又嘗試刪除這個其實已經被其他客戶端拿到的鎖。所以單純的用DEL指令有可能造成一個客戶端刪除了其他客戶端的鎖,通過校驗這個值保證每個客戶端都用一個隨機字符串’簽名’了,這樣每個鎖就只能被獲得鎖的客戶端刪除了。
既然釋放鎖時既需要校驗這個值又需要刪除鎖,那么就需要保證原子性,redis支持原子地執行一個lua腳本,所以我們通過lua腳本實現原子操作。代碼如下:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
2、業務邏輯執行時間超出鎖的超時限制導致兩個客戶端同時持有鎖的問題
如果在加鎖和釋放鎖之間的邏輯執行得太長,以至于超出了鎖的超時限制,就會出現問題。因為這時候第一個線程持有的鎖過期了,臨界區的邏輯還沒有執行完,這個時候第二個線程就提前重新持有了這把鎖,導致臨界區代碼不能得到嚴格的串行執行。
不難發現正常情況下鎖操作完后都會被手動釋放,常見的解決方案是調大鎖的超時時間,之后若再出現超時帶來的并發問題,人工介入修正數據。這也不是一個完美的方案,因為但業務邏輯執行時間是不可控的,所以還是可能出現超時,當前線程的邏輯沒有執行完,其它線程乘虛而入。并且如果鎖超時時間設置過長,當持有鎖的客戶端宕機,釋放鎖就得依靠redis的超時時間,這將導致業務在一個超時時間周期內不可用。
基本上,如果在執行計算期間發現鎖快要超時了,客戶端可以給redis服務實例發送一個Lua腳本讓redis服務端延長鎖的時間,只要這個鎖的key還存在而且值還等于客戶端設置的那個值。 客戶端應當只有在失效時間內無法延長鎖時再去重新獲取鎖(基本上這個和獲取鎖的算法是差不多的)。
當鎖超時時間快到期且邏輯未執行完,延長鎖超時時間的偽代碼:
if redis.call("get",KEYS[1]) == ARGV[1] then
redis.call("set",KEYS[1],ex=3000)
else
getDLock();//重新獲取鎖
3、redis的單點故障主從切換帶來的兩個客戶端同時持有鎖的問題
生產中redis一般是主從模式,主節點掛掉時,從節點會取而代之,客戶端上卻并沒有明顯感知。原先第一個客戶端在主節點中申請成功了一把鎖,但是這把鎖還沒有來得及同步到從節點,主節點突然掛掉了。然后從節點變成了主節點,這個新的節點內部沒有這個鎖,所以當另一個客戶端過來請求加鎖時,立即就批準了。這樣就會導致系統中同樣一把鎖被兩個客戶端同時持有,不安全性由此產生。
不過這種不安全也僅僅是在主從發生 failover 的情況下才會產生,而且持續時間極短,業務系統多數情況下可以容忍。
4、RedLock算法
如果你很在乎高可用性,希望掛了一臺 redis 完全不受影響,可以考慮 redlock。 Redlock 算法是由Antirez 發明的,它的流程比較復雜,不過已經有了很多開源的 library 做了良好的封裝,用戶可以拿來即用,比如 redlock-py。
import redlock
addrs = [{
"host": "localhost",
"port": 6379,
"db": 0
}, {
"host": "localhost",
"port": 6479,
"db": 0
}, {
"host": "localhost",
"port": 6579,
"db": 0
}]
dlm = redlock.Redlock(addrs)
success = dlm.lock("user-lck-laoqian", 5000)
if success:
print 'lock success'
dlm.unlock('user-lck-laoqian')
else:
print 'lock failed'
RedLock算法的核心原理:
使用N個完全獨立、沒有主從關系的Redis master節點以保證他們大多數情況下都不會同時宕機,N一般為奇數。一個客戶端需要做如下操作來獲取鎖:
1.獲取當前時間(單位是毫秒)。
2.輪流用相同的key和隨機值在N個節點上請求鎖,在這一步里,客戶端在每個master上請求鎖時,會有一個和總的鎖釋放時間相比小的多的超時時間。比如如果鎖自動釋放時間是10秒鐘,那每個節點鎖請求的超時時間可能是5-50毫秒的范圍,這個可以防止一個客戶端在某個宕掉的master節點上阻塞過長時間,如果一個master節點不可用了,我們應該盡快嘗試下一個master節點。
3.客戶端計算第二步中獲取鎖所花的時間,只有當客戶端在大多數master節點上成功獲取了鎖((N/2) +1),而且總共消耗的時間不超過鎖釋放時間,這個鎖就認為是獲取成功了。
4.如果鎖獲取成功了,那現在鎖自動釋放時間就是最初的鎖釋放時間減去之前獲取鎖所消耗的時間。
5.如果鎖獲取失敗了,不管是因為獲取成功的鎖不超過一半(N/2+1)還是因為總消耗時間超過了鎖釋放時間,客戶端都會到每個master節點上釋放鎖,即便是那些他認為沒有獲取成功的鎖。
5、知識擴展
5.1為什么lua腳本結合redis命令可以實現原子性
Redis 提供了非常豐富的指令集,但是用戶依然不滿足,希望可以自定義擴充若干指令來完成一些特定領域的問題。Redis 為這樣的用戶場景提供了 lua 腳本支持,用戶可以向服務器發送 lua 腳本來執行自定義動作,獲取腳本的響應數據。Redis 服務器會單線程原子性執行 lua 腳本,保證 lua 腳本在處理的過程中不會被任意其它請求打斷。
5.2 redis 可重入分布式鎖
要實現可重入鎖,方法很簡單,當加鎖失敗時判斷鎖的值是不是跟當前線程設置值相同,偽代碼如下:
if setnx == 0
if get(key) == my_random_value
//重入
else
//不可重入
else
//獲取了鎖,等價于可重入
參考文檔:
- 《Redis官方文檔》用Redis構建分布式鎖 http://ifeve.com/redis-lock/
- 《redis深度歷險--再談分布式鎖》 https://juejin.im/book/5afc2e5f6fb9a07a9b362527/section/5b4c19216fb9a04fb8773ed1