分佈式鎖的各種實現,看完這篇你就懂了!

前言

今天我們講講分佈式鎖,網上相關的內容有很多,但是比較分散,剛好自己剛學習完總結下,分享給大家,文章內容會比較多,我們先從思維導圖中瞭解要講的內容。

什麼是分佈式鎖

分佈式鎖是控制分佈式系統之間同步訪問共享資源的一種方式,通過互斥來保持一致性。

瞭解分佈式鎖之前先了解下線程鎖和進程鎖:

線程鎖:主要用來給方法、代碼塊加鎖。當某個方法或代碼使用鎖,在同一時刻僅有一個線程執行該方法或該代碼段。線程鎖只在同一 JVM 中有效果,因爲線程鎖的實現在根本上是依靠線程之間共享內存實現的,比如 Synchronized、Lock 等

進程鎖:控制同一操作系統中多個進程訪問某個共享資源,因爲進程具有獨立性,各個進程無法訪問其他進程的資源,因此無法通過 synchronized 等線程鎖實現進程鎖

比如 Golang 語言中的 sync 包就提供了基本的同步基元,如互斥鎖

但是以上兩種適合在單體架構應用,但是分佈式系統中多個服務節點,多個進程分散部署在不同節點機器中,此時對於資源的競爭,上訴兩種對節點本地資源的鎖就無效了。

這個時候就需要分佈式鎖來對分佈式系統多進程訪問資源進行控制,因此分佈式鎖是爲了解決分佈式互斥問題!

分佈式鎖的特性

互斥

互斥性很好理解,這也是最基本功能,就是在任意時刻,只能有一個客戶端才能獲取鎖,不能同時有兩個客戶端獲取到鎖。

避免死鎖

爲什麼會出現死鎖,因爲獲取鎖的客戶端因爲某些原因 (如 down 機等) 而未能釋放鎖,其它客戶端再也無法獲取到該鎖,從而導致整個流程無法繼續進行。

面對這種情況,當然有解決辦法啦!

引入過期時間:通常情況下我們會設置一個 TTL(Time To Live,存活時間) 來避免死鎖,但是這並不能完全避免。

  1. 1. 比如 TTL 爲 5 秒,進程 A 獲得鎖

  2. 2. 問題是 5 秒內進程 A 並未釋放鎖,被系統自動釋放,進程 B 獲得鎖

  3. 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. 1. 數據庫是單點,非常依賴數據庫的可用性

  2. 2. 需要額外自己維護 TTL

  3. 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. 1. Client1 獲取鎖成功。

  2. 2. 由於 Client1 業務處理時間過長, 鎖過期時間到了,鎖自動釋放了

  3. 3. Client2 獲取到了對應同一個資源的鎖。

  4. 4. Client1 業務處理完成,釋放鎖,但是釋放掉了 Client2 持有的鎖。

  5. 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. 1. Client1 請求 Master 加鎖成功

  2. 2. 然而 Master 異常宕機,加鎖信息還未同步到從庫上(主從複製是異步的)

  3. 3. 此時從庫 Slave1 被哨兵提升爲新主庫,鎖信息不在新的主庫上(未同步到 Slave1)

面對這種問題,Redis 的作者提出一種解決方 Redlock, 是基於多個 Redis 節點(都是 Master)的一種實現,該方案基於 2 個前提:

  1. 1. 不再需要部署從庫和哨兵實例,只部署主庫

  2. 2. 但主庫要部署多個,官方推薦至少 5 個實例

Redlock 加鎖流程:

  1. 1. Client 先獲取「當前時間戳 T1」

  2. 2. Client 依次向這 5 個 Redis 實例發起加鎖請求(用前面講到的 SET 命令),且每個請求會設置超時時間(毫秒級,要遠小於鎖的有效時間),如果某一個實例加鎖失敗(包括網絡超時、鎖被其它人持有等各種異常情況),就立即向下一個 Redis 實例申請加鎖

  3. 3. 如果 Client 從 >=3 個(大多數)以上 Redis 實例加鎖成功,則再次獲取「當前時間戳 T2」,如果 T2 - T1 < 鎖的過期時間,此時,認爲客戶端加鎖成功,否則認爲加鎖失敗

  4. 4. 加鎖成功,去操作共享資源(例如修改 MySQL 某一行,或發起一個 API 請求)

  5. 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 特性介紹:

爲什麼這些特性就可以讓 Etcd 實現分佈式鎖呢?因爲 Etcd 這些特性可以滿足實現分佈式鎖的以下要求:

有了這些知識理論我們一起看看 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. 1. 首先 concurrency.NewSession 方法創建 Session 對象

  2. 2. 然後 Session 對象通過 concurrency.NewMutex 創建了一個 Mutex 對象

  3. 3. 加鎖和釋放鎖分別調用 Lock 和 UnLock

基於 ZooKeeper

ZooKeeper 的數據存儲結構就像一棵樹,這棵樹由節點組成,這種節點叫做 Znode

加鎖 / 釋放鎖的過程是這樣的

  1. 1. Client 嘗試創建一個 znode 節點,比如 / lock,比如 Client1 先到達就創建成功了,相當於拿到了鎖

  2. 2. 其它的客戶端會創建失敗(znode 已存在),獲取鎖失敗。

  3. 3. Client2 可以進入一種等待狀態,等待當 / lock 節點被刪除的時候,ZooKeeper 通過 watch 機制通知它

  4. 4. 持有鎖的 Client1 訪問共享資源完成後,將 znode 刪掉,鎖釋放掉了

  5. 5. Client2 繼續完成獲取鎖操作,直到獲取到鎖爲止

ZooKeeper 不需要考慮過期時間,而是用【臨時節點】,Client 拿到鎖之後,只要連接不斷,就會一直持有鎖。即使 Client 崩潰,相應臨時節點 Znode 也會自動刪除,保證了鎖釋放。

Zookeeper 是怎麼檢測這個客戶端是否崩潰的呢?

每個客戶端都與 ZooKeeper 維護着一個 Session,這個 Session 依賴定期的心跳 (heartbeat) 來維持。

如果 Zookeeper 長時間收不到客戶端的心跳,就認爲這個 Session 過期了,也會把這個臨時節點刪除。

當然這也並不是完美的解決方案

以下場景中 Client1 和 Client2 在窗口時間內可能同時獲得鎖:

  1. 1. Client 1 創建了 znode 節點 / lock,獲得了鎖。

  2. 2. Client 1 進入了長時間的 GC pause。(或者網絡出現問題、或者 zk 服務檢測心跳線程出現問題等等)

  3. 3. Client 1 連接到 ZooKeeper 的 Session 過期了。znode 節點 / lock 被自動刪除。

  4. 4. Client 2 創建了 znode 節點 / lock,從而獲得了鎖。

  5. 5. Client 1 從 GC pause 中恢復過來,它仍然認爲自己持有鎖。

好,現在我們來總結一下 Zookeeper 在使用分佈式鎖時優劣:

Zookeeper 的優點:

  1. 1. 不需要考慮鎖的過期時間,使用起來比較方便

  2. 2. watch 機制,加鎖失敗,可以 watch 等待鎖釋放,實現樂觀鎖

缺點:

  1. 1. 性能不如 Redis

  2. 2. 部署和運維成本高

  3. 3. 客戶端與 Zookeeper 的長時間失聯,鎖被釋放問題

總結

文章內容比較多,涉及到的知識點也很多,如果看一遍沒理解,那麼建議你收藏一下多讀幾遍,構建好對於分佈式鎖你的情景結構。

總結一下吧,本文主要總結了分佈式鎖和使用方式,實現分佈式鎖可以有多種方式。

數據庫:通過創建一條唯一記錄來表示一個鎖,唯一記錄添加成功,鎖就創建成功,釋放鎖的話需要刪除記錄,但是很容易出現性能瓶頸,因此基本上不會使用數據庫作爲分佈式鎖。

Redis:Redis 提供了高效的獲取鎖和釋放鎖的操作,而且結合 Lua 腳本,Redission 等,有比較好的異常情況處理方式,因爲是基於內存的,讀寫效率也是非常高。

Etcd:利用租約 (Lease),Watch,Revision 機制,提供了一種簡單實現的分佈式鎖方式,集羣模式讓 Etcd 能處理大量讀寫,性能出色,但是配置複雜,一致性問題也存在。

Zookeeper:利用 ZooKeeper 提供的節點同步功能來實現分佈式鎖,而且不用設置過期時間,可以自動的處理異常情況下的鎖釋放。

如果你的業務數據非常敏感,在使用分佈式鎖時,一定要注意這個問題,不能假設分佈式鎖 100% 安全。

當然也需要結合自己的業務,可能大多數情況下我們還是使用 Redis 作爲分佈式鎖,一個是我們比較熟悉,然後性能和處理異常情況也有較多方式,我覺得滿足大多數業務場景就可以了。

參考:

https://mp.weixin.qq.com/s/Fkga3KaU0fBv5zXM-b8JhA

https://zhuanlan.zhihu.com/p/378797329

https://www.cnblogs.com/aganippe/p/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/wL9MRnx8HVXNFOt6ZTWELw