分佈式鎖#
內容來自
一、基本概念#
分佈式鎖是指分佈式環境下,系統部署在多個機器中,實現多進程分佈式互斥的一種鎖。為了保證多個進程能看到鎖,鎖被存在公共存儲(比如 Redis、Memcache、數據庫等三方存儲中),以實現多個進程並發訪問同一個臨界資源,同一時刻只有一個進程可訪問共享資源,確保數據的一致性。
應用場景#
- 提高性能以及效率: 使用分佈式鎖可以避免不同節點重複相同的工作,這些工作會浪費資源,比如用戶付了錢之後有可能不同節點會發出多封短信。
- 保證數據的正確性: 加分佈式鎖同樣可以避免破壞正確性的發生,如果兩個節點在同一條數據上面操作,比如多個節點機器對同一個訂單操作不同的流程有可能會導致該筆訂單最後狀態出現錯誤,造成損失。
分佈式鎖的特點#
互斥性
: 和我們本地鎖一樣互斥性是最基本,但是分佈式鎖需要保證在不同節點的不同線程的互斥。可重入性
: 同一個節點上的同一個線程如果獲取了鎖之後那麼也可以再次獲取這個鎖。鎖超時
: 和本地鎖一樣支持鎖超時,防止死鎖。高效,高可用
: 加鎖和解鎖需要高效,同時也需要保證高可用防止分佈式鎖失效,可以增加降級。支持阻塞和非阻塞
: 和 ReentrantLock 一樣支持 lock 和 trylock 以及 tryLock (long timeOut)。支持公平鎖和非公平鎖(可選)
: 公平鎖的意思是按照請求加鎖的順序獲得鎖,非公平鎖就相反是無序的。這個一般來說實現的比較少。
使用分佈式鎖的注意事項#
- 注意分佈式鎖的開銷。
- 注意加鎖的粒度。
- 加鎖的方式。
分佈式鎖的實現#
基於數據庫實現
分佈式鎖,這裡的數據庫指的是關係型數據庫 (MySQL)。基於Redis實現
分佈式鎖。基於 ZooKeeper 實現
分佈式鎖。- 其他中間件實現,比如 Consul。
二、基於數據庫實現分佈式鎖#
基於表主鍵唯一實現#
利用主鍵唯一的特性,如果有多個請求同時提交到數據庫的話,數據庫會保證只有一個操作可以成功,那麼我們就可以認為操作成功的那個線程獲得了該方法的鎖,當方法執行完畢之後,想要釋放鎖的話,刪除這條數據庫記錄即可。
缺點#
-
強依賴數據庫的可用性,存在數據庫單點問題。
改進思路:做數據庫主從高可用設計。
-
沒有失效時間限制,比如釋放鎖刪除數據庫記錄失敗,就會導致阻塞。
改進思路: 做一個定時任務,每隔一定時間把數據庫中的超時數據清理一遍。
-
只支持非阻塞,因為獲得鎖是通過數據庫 insert 操作,一旦操作失敗只能重新請求獲取鎖。
改進思路:可以參考類似自旋鎖的思想,通過外層while循環插入操作直到成功。
-
不支持可重入操作,同一個線程在沒有釋放鎖之前無法再次獲得該鎖,因為數據中數據已經存在唯一記錄了。
改進思路: 借鑒ReentrantLock的實現思路,添加一個字段記錄當前獲得鎖的機器的主機信息和線程信息,那麼下次再獲取鎖的時候先查詢數據庫,如果當前機器的主機信息和線程信息在數據庫可以查到的話,直接把鎖分配給他就可以了。
-
非公平鎖,能否獲得鎖全憑運氣。
改進思路:建一張中間表,將等待鎖的線程全記錄下來,並根據創建時間排序,只有最先創建的允許獲取鎖。
-
在 MySQL 數據庫中採用主鍵衝突防重,在大並發情況下有可能會造成鎖表現象。
改進思路: 在程序中產生主鍵,而不是通過數據庫自動生成主鍵進行防重。
基於表字段版本號實現#
這個策略源於 mysql 的 mvcc 機制,使用這個策略其實本身沒有什麼問題,唯一的問題就是對數據表侵入較大,我們要為每個表設計一個版本號字段,然後寫一條判斷 sql 每次進行判斷,增加了數據庫操作的次數,在高並發的要求下,對數據庫連接的開銷也是無法忍受的。
基於數據庫排他鎖做分佈式鎖#
在查詢語句後面增加for update
,數據庫會在查詢過程中給數據庫表增加排他鎖,當某條記錄被加上排他鎖之後,其他線程無法再在該行記錄上增加排他鎖。獲得排他鎖的線程即可獲得分佈式鎖,當獲取到鎖之後,可以執行方法的業務邏輯,執行完方法之後,通過 connection.commit () 操作來釋放鎖。
注意: InnoDB 引擎在加鎖的時候,只有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖。這裡我們希望使用行級鎖,就要給要執行的方法字段名添加索引,值得注意的是,這個索引一定要創建成唯一索引,否則會出現多個重載方法之間無法同時被訪問的問題。重載方法的話建議把參數類型也加上。
分析#
解決了基於表主鍵唯一實現
的超時無法釋放鎖和阻塞鎖的問題:
- for update 語句會在執行成功後立即返回,在執行失敗時一直處於阻塞狀態,直到成功。
- 使用這種方式,服務宕機之後數據庫會自己把鎖釋放掉。
不足:
- 還是無法直接解決數據庫單點和可重入問題。
- 可能存在性能上的問題:MySQL 會對查詢進行優化,即便在條件中使用了索引字段,但是否使用索引來檢索數據是由 MySQL 通過判斷不同執行計劃的代價來決定的,如果 MySQL 認為全表掃效率更高,比如對一些很小的表,它就不會使用索引,這種情況下 InnoDB 將使用表鎖,而不是行鎖。
- 使用排他鎖來進行分佈式鎖的 lock,那麼一個排他鎖長時間不提交,就會佔用數據庫連接。一旦類似的連接變得多了,就可能把數據庫連接池撐爆。
三、基於 Redis 實現分佈式鎖#
基於 redis 的 setnx ()、expire () 方法做分佈式鎖#
setnx()
setnx 的含義就是 SET if Not Exists,其主要有兩個參數 setnx (key, value)。該方法是原子的,如果 key 不存在,則設置當前 key 成功,返回 1;如果當前 key 已經存在,則設置當前 key 失敗,返回 0。
expire()
expire 設置過期時間,要注意的是 setnx 命令不能設置 key 的超時時間,只能通過 expire () 來對 key 設置。
步驟
- setnx (lockkey, 1) 如果返回 0,則說明佔位失敗;如果返回 1,則說明佔位成功。
- expire () 命令對 lockkey 設置超時時間,為的是避免死鎖問題。
- 執行完業務代碼後,可以通過 delete 命令刪除 key。
注意
在第一步 setnx () 執行成功後,在 expire () 命令執行成功前,發生了宕機的現象,那麼就依然會出現死鎖的問題。
基於 redis 的 setnx ()、get ()、getset () 方法做分佈式鎖#
這個方案的背景主要是在 setnx () 和 expire () 的方案上針對可能存在的死鎖問題,做了一些優化。
getset()
這個命令主要有兩個參數 getset (key,newValue)。該方法是原子的,對 key 設置 newValue 這個值,並且返回 key 原來的舊值。假設 key 原來是不存在的,那麼多次執行這個命令,會出現下邊的效果:
- getset (key, "value1") 返回 null 此時 key 的值會被設置為 value1。
- getset (key, "value2") 返回 value1 此時 key 的值會被設置為 value2。
- 依次類推。
步驟
- setnx (lockkey, 當前時間 + 過期超时时間),如果返回 1,則獲取鎖成功;如果返回 0 則沒有獲取到鎖,轉向 2。
- get (lockkey) 獲取值 oldExpireTime,並將這個 value 值與當前的系統時間進行比較,如果小於當前系統時間,則認為這個鎖已經超時,可以允許別的請求重新獲取,轉向 3。
- 計算 newExpireTime = 當前時間 + 過期超时时間,然後 getset (lockkey, newExpireTime) 會返回當前 lockkey 的值 currentExpireTime。
- 判斷 currentExpireTime 與 oldExpireTime 是否相等,如果相等,說明當前 getset 設置成功,獲取到了鎖。如果不相等,說明這個鎖又被別的請求獲取走了,那麼當前請求可以直接返回失敗,或者繼續重試。
- 在獲取到鎖之後,當前線程可以開始自己的業務處理,當處理完畢後,比較自己的處理時間和對於鎖設置的超時時間,如果小於鎖設置的超時時間,則直接執行 delete 釋放鎖;如果大於鎖設置的超時時間,則不需要再鎖進行處理。
其他拓展方案#
- 基於 Redlock 做分佈式鎖。
- 基於 redisson 做分佈式鎖,GitHub 地址。
四、基於 ZooKeeper 實現分佈式鎖#
zookeeper 鎖相關基礎知識#
zk 一般由多個節點構成(單數)
,採用 zab 一致性協議。因此可以將 zk 看成一個單點結構,對其修改數據其內部自動將所有節點數據進行修改而後才提供查詢服務。zk 的數據以目錄樹的形式
,每個目錄稱為 znode, znode 中可存儲數據(一般不超過 1M),還可以在其中增加子節點。子節點有三種類型
。序列化節點,每在該節點下增加一個節點自動給該節點的名稱上自增。臨時節點,一旦創建這個 znode 的客戶端與服務器失去聯繫,這個 znode 也將自動刪除。最後就是普通節點。Watch 機制
,client 可以監控每個節點的變化,當產生變化會給 client 產生一個事件。
基本方案#
- 原理:利用臨時節點與 watch 機制。每個鎖佔用一個普通節點 /lock,當需要獲取鎖時在 /lock 目錄下創建一個臨時節點,創建成功則表示獲取鎖成功,失敗則 watch/lock 節點,有刪除操作後再去爭鎖。臨時節點好處在於當進程掛掉後能自動上鎖的節點自動刪除即取消鎖。
- 缺點:所有取鎖失敗的進程都監聽父節點,很容易發生羊群效應,即當釋放鎖後所有等待進程一起來創建節點,並發量很大。
優化方案#
原理#
上鎖改為創建臨時有序節點,每個上鎖的節點均能創建節點成功,只是其序號不同。只有序號最小的可以擁有鎖,如果這個節點序號不是最小的則 watch 序號比本身小的前一個節點 (公平鎖)。
步驟#
- 在 /lock 節點下創建一個有序臨時節點 (EPHEMERAL_SEQUENTIAL)。
判斷創建的節點序號是否最小,如果是最小則獲取鎖成功。不是則取鎖失敗,然後 watch 序號比本身小的前一個節點。 - 當取鎖失敗,設置 watch 後則等待 watch 事件到來後,再次判斷是否序號最小。
- 獲取鎖成功則執行代碼,最後釋放鎖(刪除該節點)。
五、分佈式鎖的安全問題#
參考鏈接#
GC 的 STW (stop-the-world)#
Java 的虛擬機在進行垃圾回收的時候可能會發生 STW 現象,即全局暫停,例如 CMS 垃圾回收器,他會有兩個階段進行 STW 防止引用繼續進行變化。那么有可能會出現下面圖:
如圖所示:客戶端 1 在獲得鎖之後發生了很長時間的 GC pause,在此期間,它獲得的鎖過期了,而客戶端 2 獲得了鎖。當客戶端 1 從 GC pause 中恢復過來的時候,它不知道自己持有的鎖已經過期了,它依然向共享資源(上圖中是一個存儲服務)發起了寫數據請求,而這時鎖實際上被客戶端 2 持有,因此兩個客戶端的寫請求就有可能衝突(鎖的互斥作用失效了)。
時鐘發生跳躍#
假設有 5 個 Redis 節點 A, B, C, D, E:
- 客戶端 1 從 Redis 節點 A, B, C 成功獲取了鎖(多數節點)。可能由於
網絡問題
,與 D 和 E 通信失敗。 - 節點 C 上的時鐘發生了向前跳躍,導致它上面維護的鎖快速過期。
- 客戶端 2 從 Redis 節點 C, D, E 成功獲取了同一個資源的鎖(多數節點)。
- 客戶端 1 和客戶端 2 現在都認為自己持有了鎖。
這個例子可以看到假如鎖的過期失效機制強依賴於時間,一旦系統的時鐘變得不準確,算法的安全性也就保證不了了。不過基於 zk 實現的分佈式鎖倒是不需要依賴時間,而是依賴每個節點的 Session。
長時間的網絡延遲#
在一個分佈式系統中,網絡問題是無法避免的。長時間的網絡延遲可以產生與上述兩個問題類似的情況,A 節點已經獲得鎖,但因為網絡延時超過超時時間限制而失效,這個時候 B 節點請求獲取了鎖,等待恢復後 A 節點並不知道鎖已經失效,就會存在 A 和 B 衝突問題。