分布式鎖的各種實現,看完這篇你就懂了!
前言
今天我們講講分布式鎖,網上相關的內容有很多,但是比較分散,剛好自己剛學習完總結下,分享給大家,文章內容會比較多,我們先從思維導圖中了解要講的內容。
圖片
什么是分布式鎖
分布式鎖是控制分布式系統之間同步訪問共享資源的一種方式,通過互斥來保持一致性。
了解分布式鎖之前先了解下線程鎖和進程鎖:
線程鎖:主要用來給方法、代碼塊加鎖。當某個方法或代碼使用鎖,在同一時刻僅有一個線程執行該方法或該代碼段。線程鎖只在同一JVM中有效果,因為線程鎖的實現在根本上是依靠線程之間共享內存實現的,比如Synchronized、Lock等
進程鎖:控制同一操作系統中多個進程訪問某個共享資源,因為進程具有獨立性,各個進程無法訪問其他進程的資源,因此無法通過synchronized等線程鎖實現進程鎖
比如Golang語言中的sync包就提供了基本的同步基元,如互斥鎖
但是以上兩種適合在單體架構應用,但是分布式系統中多個服務節點,多個進程分散部署在不同節點機器中,此時對于資源的競爭,上訴兩種對節點本地資源的鎖就無效了。
這個時候就需要分布式鎖來對分布式系統多進程訪問資源進行控制,因此分布式鎖是為了解決分布式互斥問題!
圖片
分布式鎖的特性
互斥
互斥性很好理解,這也是最基本功能,就是在任意時刻,只能有一個客戶端才能獲取鎖,不能同時有兩個客戶端獲取到鎖。
避免死鎖
為什么會出現死鎖,因為獲取鎖的客戶端因為某些原因(如down機等)而未能釋放鎖,其它客戶端再也無法獲取到該鎖,從而導致整個流程無法繼續進行。
圖片
面對這種情況,當然有解決辦法啦!
引入過期時間:通常情況下我們會設置一個 TTL(Time To Live,存活時間) 來避免死鎖,但是這并不能完全避免。
1. 比如TTL為5秒,進程A獲得鎖
2. 問題是5秒內進程A并未釋放鎖,被系統自動釋放,進程B獲得鎖
3. 剛好第6秒時進程A執行完,又會釋放鎖,也就是進程A釋放了進程B的鎖
僅僅加個過期時間會設計到兩個問題:鎖過期和釋放別人的鎖問題
鎖附加唯一性:針對釋放別人鎖這種問題,我們可以給每個客戶端進程設置【唯一ID】,這樣我們就可以在應用層就進行檢查唯一ID。
自動續期:鎖過期問題的出現,是我們對持有鎖的時間不好進行預估,設置較短的話會有【提前過期】風險,但是過期時間設置過長,可能鎖長時間得不到釋放。
這種情況同樣有處理方式,可以開啟一個守護進程(watch dog),檢測失效時間進行續租,比如Java技術棧可以用Redisson來處理。
可重入:
一個線程獲取了鎖,但是在執行時,又再次嘗試獲取鎖會發生什么情況?
是的,導致了重復獲取鎖,占用了鎖資源,造成了死鎖問題。
我們了解下什么是【可重入】:指的是同一個線程在持有鎖的情況下,可以多次獲取該鎖而不會造成死鎖,也就是一個線程可以在獲取鎖之后再次獲取同一個鎖,而不需要等待鎖釋放。
解決方式:比如實現Redis分布式鎖的可重入,在實現時,需要借助Redis的Lua腳本語言,并使用引用計數器技術,保證同一線程可重入鎖的正確性。
容錯
容錯性是為了當部分節點(redis節點等)宕機時,客戶端仍然能夠獲取鎖和釋放鎖,一般來說會有以下兩種處理方式:
一種像etcd/zookeeper這種作為鎖服務能夠自動進行故障切換,因為它本身就是個集群,另一種可以提供多個獨立的鎖服務,客戶端向多個獨立鎖服務進行請求,某個鎖服務故障時,也可以從其他服務獲取到鎖信息,但是這種缺點很明顯,客戶端需要去請求多個鎖服務。
分類
本文會講述四種關于分布式鎖的實現,按實現方式來看,可以分為兩種:自旋、watch監聽
自旋方式
基于數據庫和基于Redis的實現就是需要在客戶端未獲得鎖時,進入一個循環,不斷的嘗試請求是否能獲得鎖,直到成功或者超時過期為止。
監聽方式
這種方式只需要客戶端Watch監聽某個key就可以了,鎖可用的時候會通知客戶端,客戶端不需要反復請求,基于zooKeeper和基于Etcd實現分布式鎖就是用這種方式。
實現方式
分布式鎖的實現方式有數據庫、基于Redis緩存、ZooKeeper、Etcd等,文章主要從這幾種實現方式并結合問題的方式展開敘述!
基于MySQL
利用數據庫表來實現實現分布式鎖,是不是感覺有點疑惑,是的,我再寫之前收集資料的時候也有點疑問,雖然這種方式我們并不推崇,但是我們也可以作為一個方案來進行了解,我們看看到底怎么做的:
比如在數據庫中創建一個表,表中包含方法名等字段,并在方法名name字段上創建唯一索引,想要執行某個方法,就使用這個方法名向表中插入一條記錄,成功插入則獲取鎖,刪除對應的行就是鎖釋放。
//鎖記錄表
CREATE TABLE `lock_info` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`name` varchar(64) NOT NULL COMMENT '方法名',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_name` (`method_name`)
) ENGINE=InnoD
這里主要是用name字段作為唯一索引來實現,唯一索引保證了該記錄的唯一性,鎖釋放就直接刪掉該條記錄就行了。
缺點也很多:
- 1. 數據庫是單點,非常依賴數據庫的可用性
- 2. 需要額外自己維護TTL
- 3. 在高并發常見下數據庫讀寫是非常緩慢
這里我們就不用過多的文字了,現實中我們更多的是用基于內存存儲來實現分布式鎖。
基于Redis
面試官問:你了解分布式鎖嗎?想必絕大部分面試者都會說關于Redis實現分布式鎖的方式,OK,進入正題【基于Redis分布式鎖】
Redis 的分布式鎖, setnx 命令并設置過期時間就行嗎?
setnx lkey lvalue expire lockKey 30
正常情況下是可以的,但是這里有個問題,雖然setnx是原子性的,但是setnx + expire就不是了,也就是說setnx和expire是分兩步執行的,【加鎖和超時】兩個操作是分開的,如果expire執行失敗了,那么鎖同樣得不到釋放。
關于為什么要加鎖和超時時間的設定在文章開頭【避免死鎖】有提到,不明白的可以多看看。
Redis正確的加鎖命令是什么?
//保證原子性執行命令
SET lKey randId NX PX 30000
randId是由客戶端生成的一個隨機字符串,該客戶端加鎖時具有唯一性,主要是為了避免釋放別人的鎖。
我們來看這么一樣流程,如下圖:
圖片
- 1. Client1 獲取鎖成功。
- 2. 由于Client1 業務處理時間過長, 鎖過期時間到了,鎖自動釋放了
- 3. Client2 獲取到了對應同一個資源的鎖。
- 4. Client1 業務處理完成,釋放鎖,但是釋放掉了Client2 持有的鎖。
- 5. 而Client3此時還能獲得鎖,同樣Client2此時持有鎖,都亂套了。??
而這個randId就可以在釋放鎖的時候避免了釋放別人的鎖,因為在釋放鎖的時候,Client需要先獲取到該鎖的值(randId),判斷是否相同后才能刪除。
if (redis.get(lKey).equals(randId)) {
redis.del(lockKey);
}
加鎖的時候需要原子性,釋放鎖的時候該怎么做到原子性???
這個問題很好,我們在加鎖的時候通過原子性命令避免了潛在的設置過期時間失敗問題,釋放鎖同樣是Get + Del兩條命令,這里同樣存在釋放別人鎖的問題。
腦瓜嗡嗡的??,咋那么多需要考慮的問題呀,看累了休息會??,咋們繼續往下看!
這里問題的根源在于:鎖的判斷在客戶端,釋放在服務端,如下圖:
圖片
所以 應該將鎖的判斷和刪除都在redis服務端進行,可以借助lua腳本保證原子性,釋放鎖的核心邏輯【GET、判斷、DEL】,寫成 Lua 腳,讓Redis執行,這樣實現能保證這三步的原子性。
// 判斷鎖是自己的,才釋放
if redis.call("GET",KEYS[1]) == ARGV[1]
then
return redis.call("DEL",KEYS[1])
else
return 0
end
如果Client1獲取到鎖后,因為業務問題需要較長的處理時間,超過了鎖過期時間,該怎么辦?
既然業務執行時間超過了鎖過期時間,那么我們可以給鎖續期呀,比如開啟一個守護進程,定時監測鎖的失效時間,在快要過期的時候,對鎖進行自動續期,重新設置過期時間。
Redisson框架中就實現了這個,就要WatchDog(看門狗):加鎖時沒有指定加鎖時間時會啟用 watchdog 機制,默認加鎖 30 秒,每 10 秒鐘檢查一次,如果存在就重新設置 過期時間為 30 秒(即 30 秒之后它就不再續期了)
圖片
嗯嗯,這應該就比較穩健了吧!??
嘿嘿,以上這些都是鎖在「單個」Redis 實例中可能產生的問題,確實單節點分布式鎖能解決大部分人的需求。但是通常都是用【Redis Cluster】或者【哨兵模式】這兩種方式實現 Redis 的高可用,這就有主從同步問題發生。??
試想這樣的場景:
- 1. Client1請求Master加鎖成功
- 2. 然而Master異常宕機,加鎖信息還未同步到從庫上(主從復制是異步的)
- 3. 此時從庫Slave1被哨兵提升為新主庫,鎖信息不在新的主庫上(未同步到Slave1)
圖片
面對這種問題,Redis 的作者提出一種解決方 Redlock, 是基于多個 Redis 節點(都是 Master)的一種實現,該方案基于 2 個前提:
- 1. 不再需要部署從庫和哨兵實例,只部署主庫
- 2. 但主庫要部署多個,官方推薦至少 5 個實例
Redlock加鎖流程:
1. Client先獲取「當前時間戳T1」
2. Client依次向這 5 個 Redis 實例發起加鎖請求(用前面講到的 SET 命令),且每個請求會設置超時時間(毫秒級,要遠小于鎖的有效時間),如果某一個實例加鎖失敗(包括網絡超時、鎖被其它人持有等各種異常情況),就立即向下一個 Redis 實例申請加鎖
3. 如果Client從 >=3 個(大多數)以上 Redis 實例加鎖成功,則再次獲取「當前時間戳T2」,如果 T2 - T1 < 鎖的過期時間,此時,認為客戶端加鎖成功,否則認為加鎖失敗
4. 加鎖成功,去操作共享資源(例如修改 MySQL 某一行,或發起一個 API 請求)
5. 加鎖失敗,Client向「全部節點」發起釋放鎖請求(前面講到的 Lua 腳本釋放鎖)
Redlock釋放鎖:
客戶端向所有 Redis 節點發起釋放鎖的操作
圖片
問題 1:為什么要在多個實例上加鎖?
本質上為了容錯,我們看圖中的多個Master示例節點,實際夠構成了一個分布式系統,分布式系統中總會有異常節點,多個實例加鎖的話,即使部分實例異常宕機,剩余的實例加鎖成功,整個鎖服務依舊可用!
問題 2:為什么步驟 3 加鎖成功后,還要計算加鎖的累計耗時?
加鎖操作的針對的是分布式中的多個節點,所以耗時肯定是比單個實例耗時更,還要考慮網絡延遲、丟包、超時等情況發生,網絡請求次數越多,異常的概率越大。
所以即使 N/2+1 個節點加鎖成功,但如果加鎖的累計耗時已經超過了鎖的過期時間,那么此時的鎖已經沒有意義了
問題 3:為什么釋放鎖,要操作所有節點?
主要是為了保證清除節點異常情況導致殘留的鎖!
比如:在某一個 Redis 節點加鎖時,可能因為「網絡原因」導致加鎖失敗。
或者客戶端在一個 Redis 實例上加鎖成功,但在讀取響應結果時,網絡問題導致讀取失敗,那這把鎖其實已經在 Redis 上加鎖成功了。
所以說釋放鎖的時候,不管以前有沒有加鎖成功,都要釋放所有節點的鎖。
這里有一個關于Redlock安全性的爭論,這里就一筆帶過吧,大家有興趣可以去看看:
Java面試365:RedLock紅鎖安全性爭論(上)4 贊同 · 0 評論文章
圖片
基于Etcd
Etcd是一個Go語言實現的非常可靠的kv存儲系統,常在分布式系統中存儲著關鍵的數據,通常應用在配置中心、服務發現與注冊、分布式鎖等場景。
本文主要從分布式鎖的角度來看Etcd是如何實現分布式鎖的,Let's Go !
Etcd特性介紹:
- ? Lease機制:即租約機制(TTL,Time To Live),etcd可以為存儲的kv對設置租約,當租約到期,kv將失效刪除;同時也支持續約,keepalive
- ? Revision機制:每個key帶有一個Revision屬性值,etcd每進行一次事務對應的全局Revision值都會+1,因此每個key對應的Revision屬性值都是全局唯一的。通過比較Revision的大小就可以知道進行寫操作的順序
- ? 在實現分布式鎖時,多個程序同時搶鎖,根據Revision值大小依次獲得鎖,避免“驚群效應”,實現公平鎖
- ? Prefix機制:也稱為目錄機制,可以根據前綴獲得該目錄下所有的key及其對應的屬性值
- ? Watch機制:watch支持watch某個固定的key或者一個前綴目錄,當watch的key發生變化,客戶端將收到通知
為什么這些特性就可以讓Etcd實現分布式鎖呢?因為Etcd這些特性可以滿足實現分布式鎖的以下要求:
- ? 租約機制(Lease):用于支撐異常情況下的鎖自動釋放能力
- ? 前綴和 Revision 機制:用于支撐公平獲取鎖和排隊等待的能力
- ? 監聽機制(Watch):用于支撐搶鎖能力
- ? 集群模式:用于支撐鎖服務的高可用
有了這些知識理論我們一起看看Etcd是怎么實現分布式鎖的,因為我自己也是Golang開發,這里我們也放一些代碼。
先看流程,再結合代碼注釋!
圖片
func main() {
config := clientv3.Config{
Endpoints: []string{"xxx.xxx.xxx.xxx:2379"},
DialTimeout: 5 * time.Second,
}
// 獲取客戶端連接
client, err := clientv3.New(config)
if err != nil {
fmt.Println(err)
return
}
// 1. 上鎖(創建租約,自動續租,拿著租約去搶占一個key )
// 用于申請租約
lease := clientv3.NewLease(client)
// 申請一個10s的租約
leaseGrantResp, err := lease.Grant(context.TODO(), 10) //10s
if err != nil {
fmt.Println(err)
return
}
// 拿到租約的id
leaseID := leaseGrantResp.ID
// 準備一個用于取消續租的context
ctx, cancelFunc := context.WithCancel(context.TODO())
// 確保函數退出后,自動續租會停止
defer cancelFunc()
// 確保函數退出后,租約會失效
defer lease.Revoke(context.TODO(), leaseID)
// 自動續租
keepRespChan, err := lease.KeepAlive(ctx, leaseID)
if err != nil {
fmt.Println(err)
return
}
// 處理續租應答的協程
go func() {
select {
case keepResp := <-keepRespChan:
if keepRespChan == nil {
fmt.Println("lease has expired")
goto END
} else {
// 每秒會續租一次
fmt.Println("收到自動續租應答", keepResp.ID)
}
}
END:
}()
// if key 不存在,then設置它,else搶鎖失敗
kv := clientv3.NewKV(client)
// 創建事務
txn := kv.Txn(context.TODO())
// 如果key不存在
txn.If(clientv3.Compare(clientv3.CreateRevision("/cron/lock/job7"), "=", 0)).
Then(clientv3.OpPut("/cron/jobs/job7", "", clientv3.WithLease(leaseID))).
Else(clientv3.OpGet("/cron/jobs/job7")) //如果key存在
// 提交事務
txnResp, err := txn.Commit()
if err != nil {
fmt.Println(err)
return
}
// 判斷是否搶到了鎖
if !txnResp.Succeeded {
fmt.Println("鎖被占用了:", string(txnResp.Responses[0].GetResponseRange().Kvs[0].Value))
return
}
// 2. 處理業務(鎖內,很安全)
fmt.Println("處理任務")
time.Sleep(5 * time.Second)
// 3. 釋放鎖(取消自動續租,釋放租約)
// defer會取消續租,釋放鎖
}
不過clientv3提供的concurrency包也實現了分布式鎖,我們可以更便捷的實現分布式鎖,不過內部實現邏輯差不多:
- 1. 首先concurrency.NewSession方法創建Session對象
- 2. 然后Session對象通過concurrency.NewMutex 創建了一個Mutex對象
- 3. 加鎖和釋放鎖分別調用Lock和UnLock
基于ZooKeeper
ZooKeeper 的數據存儲結構就像一棵樹,這棵樹由節點組成,這種節點叫做 Znode
加鎖/釋放鎖的過程是這樣的
圖片
1. Client嘗試創建一個 znode 節點,比如/lock,比如Client1先到達就創建成功了,相當于拿到了鎖
2. 其它的客戶端會創建失敗(znode 已存在),獲取鎖失敗。
3. Client2可以進入一種等待狀態,等待當/lock 節點被刪除的時候,ZooKeeper 通過 watch 機制通知它
4. 持有鎖的Client1訪問共享資源完成后,將 znode 刪掉,鎖釋放掉了
5. Client2繼續完成獲取鎖操作,直到獲取到鎖為止
ZooKeeper不需要考慮過期時間,而是用【臨時節點】,Client拿到鎖之后,只要連接不斷,就會一直持有鎖。即使Client崩潰,相應臨時節點Znode也會自動刪除,保證了鎖釋放。
Zookeeper 是怎么檢測這個客戶端是否崩潰的呢?
每個客戶端都與 ZooKeeper 維護著一個 Session,這個 Session 依賴定期的心跳(heartbeat)來維持。
如果 Zookeeper 長時間收不到客戶端的心跳,就認為這個 Session 過期了,也會把這個臨時節點刪除。
當然這也并不是完美的解決方案
以下場景中Client1和Client2在窗口時間內可能同時獲得鎖:
1. Client 1 創建了 znode 節點/lock,獲得了鎖。
2. Client 1 進入了長時間的 GC pause。(或者網絡出現問題、或者 zk 服務檢測心跳線程出現問題等等)
3. Client 1 連接到 ZooKeeper 的 Session 過期了。znode 節點/lock 被自動刪除。
4. Client 2 創建了 znode 節點/lock,從而獲得了鎖。
5. Client 1 從 GC pause 中恢復過來,它仍然認為自己持有鎖。
好,現在我們來總結一下 Zookeeper 在使用分布式鎖時優劣:
Zookeeper 的優點:
- 1. 不需要考慮鎖的過期時間,使用起來比較方便
- 2. watch 機制,加鎖失敗,可以 watch 等待鎖釋放,實現樂觀鎖
缺點:
- 1. 性能不如 Redis
- 2. 部署和運維成本高
- 3. 客戶端與 Zookeeper 的長時間失聯,鎖被釋放問題
總結
文章內容比較多,涉及到的知識點也很多,如果看一遍沒理解,那么建議你收藏一下多讀幾遍,構建好對于分布式鎖你的情景結構。
總結一下吧,本文主要總結了分布式鎖和使用方式,實現分布式鎖可以有多種方式。
數據庫:通過創建一條唯一記錄來表示一個鎖,唯一記錄添加成功,鎖就創建成功,釋放鎖的話需要刪除記錄,但是很容易出現性能瓶頸,因此基本上不會使用數據庫作為分布式鎖。
Redis:Redis提供了高效的獲取鎖和釋放鎖的操作,而且結合Lua腳本,Redission等,有比較好的異常情況處理方式,因為是基于內存的,讀寫效率也是非常高。
Etcd:利用租約(Lease),Watch,Revision機制,提供了一種簡單實現的分布式鎖方式,集群模式讓Etcd能處理大量讀寫,性能出色,但是配置復雜,一致性問題也存在。
Zookeeper:利用ZooKeeper提供的節點同步功能來實現分布式鎖,而且不用設置過期時間,可以自動的處理異常情況下的鎖釋放。
如果你的業務數據非常敏感,在使用分布式鎖時,一定要注意這個問題,不能假設分布式鎖 100% 安全。
當然也需要結合自己的業務,可能大多數情況下我們還是使用Redis作為分布式鎖,一個是我們比較熟悉,然后性能和處理異常情況也有較多方式,我覺得滿足大多數業務場景就可以了。