前言
對于鎖大家肯定不會陌生,比如 synchronized 關鍵字 和 ReentrantLock 可重入鎖,一般我們用其在多執行緒環境中控制對資源的并發訪問,但是隨著業務的發展,分布式的概念逐漸出現在我們系統中,我們在開發的程序中經常需要進行多個系統之間的互動,于是上面的加鎖方法就會失去作用,于是在分布式鎖就自然而然的誕生了,接下來我們來聊一聊分布式鎖實作的幾種方式,
分布式鎖的使用場景
-
效率性:使用分布式鎖可以避免不同節點重復相同的作業,
-
正確性:分布式鎖可以避免破壞正確性的發生,如果兩個節點在同一條資料上面操作,比如多個節點機器對同一個訂單操作不同的流程有可能會導致該筆訂單最后狀態出現錯誤,造成損失,
分布式鎖的幾種特性
-
互斥性:和我們本地鎖一樣互斥性是最基本,但是分布式鎖需要保證在不同節點的不同執行緒的互斥,
-
可重入性:同一個節點上的同一個執行緒如果獲取了鎖之后那么也可以再次獲取這個鎖,
-
鎖超時:和本地鎖一樣支持鎖超時,防止死鎖,
-
高效,高可用:加鎖和解鎖需要高效,同時也需要保證高可用防止分布式鎖失效,可以增加降級,
-
支持阻塞和非阻塞:和ReentrantLock一樣支持lock和trylock以及tryLock(long timeOut),
-
支持公平鎖和非公平鎖(可選):公平鎖的意思是按照請求加鎖的順序獲得鎖,非公平鎖就相反是無序的,
分布式鎖的幾種實作方式
分布式鎖有以下幾個方式:
- MySql
- Zk
- Redis
- 一些自研的分布式鎖(Chubby)
一、基于 Mysql 實作分布式鎖
1、首先,我們需要創建一個鎖表:
CREATE TABLE `resource_lock` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `resource_name` varchar(128) NOT NULL DEFAULT '' COMMENT '資源名稱', 'node_info' varchar(128) DEFAULT '0' COMMENT '節點資訊', 'count' int(11) NOT NULL DEFAULT '0' COMMENT '鎖的次數,統計可重入鎖', 'desc' varchar(128) DEFAULT NULL COMMENT '額外的描述資訊', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間', PRIMARY KEY ('id'), UNIQUE KEY 'un_resource_name' ('resource_name') ) ENGINE=InnoDB DEFAULT CHARSET = utf8mb4;
2、lock
先進行查詢,如果有值,那么需要比較 node_info 是否一致,這里的 node_info 可以用機器 IP 和執行緒名字來表示,如果一致那么就加可重入鎖 count 的值,如果不一致那么就回傳 false ,如果沒有值那么直接插入一條資料,偽代碼如下:
// 添加事務,原子性 @Transaction public void lock() { if (select * from resource_lock where resource_name = 'xxx' for update;) { // 判斷節點資訊是否一致 if (currentNodeInfo == resultNodeInfo) { // 保住鎖的可重入性 update resource_lock set count = count + 1 where resource_name = 'xxx'; return true; } else { return false; } } else { // 插入新資料 insert into resourceLock; return true; } }
3、tryLock
偽代碼如下:
public boolean tryLock(long timeOut) { long stTime = System.currentTimeMillis(); long endTimeOut = stTime + timeOut; while (endTimeOut > stTime) { if (mysqlLock.lock()) { return true; } // 休眠3s后重試 LockSupport.parkNanos(1000 * 1000 * 1000 * 1); stTime = System.currentTimeMillis(); } return false; }
4、unlock
偽代碼如下:
@Transaction public boolean unlock() { // 查詢是否有資料 if (select * from resource_lock where resource_name = 'xxx' for update;) { // count為1那么可以洗掉,如果大于1那么需要減去1, if (count > 1) { update count = count - 1; } else { delete; } } else { return false; } }
5、定時清理因為機器宕機導致的鎖未被釋放的問題
啟動一個定時任務,當這個鎖遠超過任務的執行時間,沒有被釋放我們就可以認定是節點掛了然后將其直接釋放,
二、基于單Redis節點的分布式鎖
首先,Redis客戶端為了獲取鎖,向Redis節點發送如下命令:
SET resource_name my_random_value NX PX 30000
上面的命令如果執行成功,則客戶端成功獲取到了鎖,接下來就可以訪問共享資源了;而如果上面的命令執行失敗,則說明獲取鎖失敗,
注意,在上面的SET命令中:
- my_random_value是由客戶端生成的一個隨機字串,它要保證在足夠長的一段時間內在所有客戶端的所有獲取鎖的請求中都是唯一的,
- NX表示只有當resource_name對應的key值不存在的時候才能SET成功,這保證了只有第一個請求的客戶端才能獲得鎖,而其它客戶端在鎖被釋放之前都無法獲得鎖,
- PX 30000表示這個鎖有一個30秒的自動過期時間,當然,這里30秒只是一個例子,客戶端可以選擇合適的過期時間,
最后,當客戶端完成了對共享資源的操作之后,執行下面的Redis Lua腳本來釋放鎖:
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
這段Lua腳本在執行的時候要把前面的my_random_value作為 ARGV[1] 的值傳進去,把 resource_name 作為 KEYS[1] 的值傳進去,
至此,基于單Redis節點的分布式鎖的演算法就描述完了,
關鍵點總結
第一點:過期時間
首先第一個問題,這個鎖必須要設定一個過期時間,否則的話,當一個客戶端獲取鎖成功之后,假如它崩潰了,或者由于發生了網路分割(network partition)導致它再也無法和Redis節點通信了,那么它就會一直持有這個鎖,而其它客戶端永遠無法獲得鎖了,而且把這個過期時間稱為鎖的有效時間(lock validity time),獲得鎖的客戶端必須在這個時間之內完成對共享資源的訪問,
第二點:獲取鎖
第二個問題,第一步獲取鎖的操作,網上不少文章把它實作成了兩個Redis命令:
SETNX resource_name my_random_value EXPIRE resource_name 30
雖然這兩個命令和前面演算法描述中的一個SET命令執行效果相同,但卻不是原子的,如果客戶端在執行完SETNX后崩潰了,那么就沒有機會執行EXPIRE了,導致它一直持有這個鎖,
第三點:my_random_value
第三個問題,設定一個隨機字串 my_random_value 是很有必要的,它保證了一個客戶端釋放的鎖必須是自己持有的那個鎖,
假如獲取鎖時SET的不是一個隨機字串,而是一個固定值,那么可能會發生下面的執行序列:
- 客戶端1獲取鎖成功,
- 客戶端1在某個操作上阻塞了很長時間,
- 過期時間到了,鎖自動釋放了,
- 客戶端2獲取到了對應同一個資源的鎖,
- 客戶端1從阻塞中恢復過來,釋放掉了客戶端2持有的鎖,
- 之后,客戶端2在訪問共享資源的時候,就沒有鎖為它提供保護了,
第四點:Lua腳本
第四個問題,釋放鎖的操作必須使用Lua腳本來實作,釋放鎖其實包含三步操作:獲取、判斷和洗掉,用Lua腳本來實作能保證這三步的原子性,
否則,如果把這三步操作放到客戶端邏輯中去執行的話,就有可能發生與前面第三個問題類似的執行序列:
- 客戶端1獲取鎖成功,
- 客戶端1訪問共享資源,
- 客戶端1為了釋放鎖,先執行'GET'操作獲取隨機字串的值,
- 客戶端1判斷隨機字串的值,與預期的值相等,
- 客戶端1由于某個原因阻塞住了很長時間,
- 過期時間到了,鎖自動釋放了,
- 客戶端2獲取到了對應同一個資源的鎖,
- 客戶端1從阻塞中恢復過來,執行DEL操縱,釋放掉了客戶端2持有的鎖,
實際上,在上述第三個問題和第四個問題的分析中,如果不是客戶端阻塞住了,而是出現了大的網路延遲,也有可能導致類似的執行序列發生,
這四個問題,只要實作分布式鎖的時候加以注意,就都能夠被正確處理,
但除此之外,還有一個問題,是由 failover(故障轉移) 引起的,卻是基于單Redis節點的分布式鎖無法解決的,正是這個問題催生了Redlock的出現,
多個Redis節點的情況下會產生的問題
這個問題是這樣的,假如Redis節點宕機了,那么所有客戶端就都無法獲得鎖了,服務變得不可用,為了提高可用性,我們可以給這個Redis節點掛一個Slave,當Master節點不可用的時候,系統自動切到Slave上(failover),但由于Redis的主從復制(replication)是異步的,這可能導致在failover程序中喪失鎖的安全性,
例如下面的執行序列:
- 客戶端1從Master獲取了鎖,
- Master宕機了,存盤鎖的key還沒有來得及同步到Slave上,
- Slave升級為Master,
- 客戶端2從新的Master獲取到了對應同一個資源的鎖,
于是,客戶端1和客戶端2同時持有了同一個資源的鎖,鎖的安全性被打破,
三、分布式鎖 Redlock
前面介紹的基于單Redis節點的分布式鎖在failover的時候會產生解決不了的安全性問題,因此antirez提出了新的分布式鎖的演算法Redlock,它基于N個完全獨立的Redis節點(通常情況下N可以設定成5),
運行Redlock演算法的客戶端依次執行下面各個步驟,來完成獲取鎖的操作:
1、獲取當前時間(毫秒數),
2、按順序依次向N個Redis節點執行獲取鎖的操作,這個獲取操作跟前面基于單Redis節點的獲取鎖的程序相同,包含隨機字串my_random_value,也包含過期時間(比如PX 30000,即鎖的有效時間),
為了保證在某個Redis節點不可用的時候演算法能夠繼續運行,這個獲取鎖的操作還有一個超時時間(time out),它要遠小于鎖的有效時間(幾十毫秒量級),客戶端在向某個Redis節點獲取鎖失敗以后,應該立即嘗試下一個Redis節點,
這里的失敗,應該包含任何型別的失敗,比如該Redis節點不可用,或者該Redis節點上的鎖已經被其它客戶端持有(注:Redlock原文中這里只提到了Redis節點不可用的情況,但也應該包含其它的失敗情況),
3、計算整個獲取鎖的程序總共消耗了多長時間,計算方法是用當前時間減去第1步記錄的時間,如果客戶端從大多數Redis節點(>= N/2+1)成功獲取到了鎖,并且獲取鎖總共消耗的時間沒有超過鎖的有效時間(lock validity time),那么這時客戶端才認為最侄訓取鎖成功;否則,認為最侄訓取鎖失敗,
4、如果最侄訓取鎖成功了,那么這個鎖的有效時間應該重新計算,它等于最初的鎖的有效時間減去第3步計算出來的獲取鎖消耗的時間,
5、如果最侄訓取鎖失敗了(可能由于獲取到鎖的Redis節點個數少于N/2+1,或者整個獲取鎖的程序消耗的時間超過了鎖的最初有效時間),那么客戶端應該立即向所有Redis節點發起釋放鎖的操作(即前面介紹的Redis Lua腳本),
上面描述的只是獲取鎖的程序,而釋放鎖的程序比較簡單:客戶端向所有Redis節點發起釋放鎖的操作,不管這些節點當時在獲取鎖的時候成功與否,
由于N個Redis節點中的大多數能正常作業就能保證Redlock正常作業,因此理論上它的可用性更高,我們前面討論的單Redis節點的分布式鎖在failover的時候鎖失效的問題,在Redlock中不存在了,但如果有節點發生崩潰重啟,還是會對鎖的安全性有影響的,具體的影響程度跟Redis對資料的持久化程度有關,
節點崩潰可能導致的問題
假設一共有5個Redis節點:A, B, C, D, E,設想發生了如下的事件序列:
1、客戶端1成功鎖住了A, B, C,獲取鎖成功(但D和E沒有鎖住),
2、節點C崩潰重啟了,但客戶端1在C上加的鎖沒有持久化下來,丟失了,
3、節點C重啟后,客戶端2鎖住了C, D, E,獲取鎖成功,
4、這樣,客戶端1和客戶端2同時獲得了鎖(針對同一資源),
在默認情況下,Redis的AOF持久化方式是每秒寫一次磁盤(即執行fsync),因此最壞情況下可能丟失1秒的資料,為了盡可能不丟資料,Redis允許設定成每次修改資料都進行fsync,但這會降低性能,當然,即使執行了fsync也仍然有可能丟失資料(這取決于系統而不是Redis的實作),
所以,上面分析的由于節點重啟引發的鎖失效問題,總是有可能出現的,為了應對這一問題,antirez又提出了延遲重啟(delayed restarts)的概念,
也就是說,一個節點崩潰后,先不立即重啟它,而是等待一段時間再重啟,這段時間應該大于鎖的有效時間(lock validity time),這樣的話,這個節點在重啟前所參與的鎖都會過期,它在重啟后就不會對現有的鎖造成影響,
客戶端應該向所有Redis節點發起釋放鎖的操作?
在最后釋放鎖的時候,antirez在演算法描述中特別強調,客戶端應該向所有Redis節點發起釋放鎖的操作,也就是說,即使當時向某個節點獲取鎖沒有成功,在釋放鎖的時候也不應該漏掉這個節點,這是為什么呢?
設想這樣一種情況,客戶端發給某個Redis節點的獲取鎖的請求成功到達了該Redis節點,這個節點也成功執行了SET操作,但是它回傳給客戶端的回應包卻丟失了,這在客戶端看來,獲取鎖的請求由于超時而失敗了,但在Redis這邊看來,加鎖已經成功了,
因此,釋放鎖的時候,客戶端也應該對當時獲取鎖失敗的那些Redis節點同樣發起請求,實際上,這種情況在異步通信模型中是有可能發生的:客戶端向服務器通信是正常的,但反方向卻是有問題的,
四、基于zk實作分布式鎖
ZooKeeper是以Paxos演算法為基礎分布式應用程式協調服務,Zk的資料節點和檔案目錄類似,所以我們可以用此特性實作分布式鎖,
基本實作步驟如下:
1、客戶端嘗試創建一個znode節點,比如/lock,那么第一個客戶端就創建成功了,相當于拿到了鎖;而其它的客戶端會創建失敗(znode已存在),獲取鎖失敗,
2、持有鎖的客戶端訪問共享資源完成后,將znode刪掉,這樣其它客戶端接下來就能來獲取鎖了,
注意:這里的znode應該被創建成ephemeral的(臨時節點),這是znode的一個特性,它保證如果創建znode的那個客戶端崩潰了,那么相應的znode會被自動洗掉,這保證了鎖一定會被釋放,
可能存在的問題
看起來這個鎖相當完美,沒有Redlock過期時間的問題,而且能在需要的時候讓鎖自動釋放,但其實也存在這其中也存在問題,
ZooKeeper是怎么檢測出某個客戶端已經崩潰了呢?
實際上,每個客戶端都與ZooKeeper的某臺服務器維護著一個Session,這個Session依賴定期的心跳(heartbeat)來維持,如果ZooKeeper長時間收不到客戶端的心跳(這個時間稱為Sesion的過期時間),那么它就認為Session過期了,通過這個Session所創建的所有的ephemeral型別的znode節點都會被自動洗掉,
假如按照下面的順序執行:
1、客戶端1創建了znode節點/lock,獲得了鎖,
2、客戶端1進入了長時間的GC pause,
3、客戶端1連接到ZooKeeper的Session過期了,znode節點/lock被自動洗掉,
4、客戶端2創建了znode節點/lock,從而獲得了鎖,
5、客戶端1從GC pause中恢復過來,它仍然認為自己持有鎖,
由上面的執行順序,可以發現最后客戶端1和客戶端2都認為自己持有了鎖,沖突了,所以說,用ZooKeeper實作的分布式鎖也不一定就是安全的,該有的問題它還是有,
zk的watch機制
ZooKeeper有個很特殊的機制--watch機制,這個機制可以這樣來使用,比如當客戶端試圖創建 /lock 節點的時候,發現它已經存在了,這時候創建失敗,但客戶端不一定就此對外宣告獲取鎖失敗,
客戶端可以進入一種等待狀態,等待當/lock節點被洗掉的時候,ZooKeeper通過watch機制通知它,這樣它就可以繼續完成創建操作(獲取鎖),這可以讓分布式鎖在客戶端用起來就像一個本地的鎖一樣:加鎖失敗就阻塞住,直到獲取到鎖為止,
參考文章
- https://juejin.cn/post/6844903688088059912
- Redlock的演算法:https://redis.io/topics/distlock
- https://github.com/redisson/redisson
- linux的同步IO操作函式: sync、fsync與fdatasync:https://my.oschina.net/u/1377774/blog/529847
- https://mp.weixin.qq.com/s/JTsJCDuasgIJ0j95K8Ay8w
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/415221.html
標籤:Java
上一篇:換掉 Log4j2!tinylog 橫空出世,無需定義 logger 變數,簡單、輕量、性能爆炸!
下一篇:python錯誤與例外
