1. 程式人生 > 其它 >分散式鎖及其常見實現方式

分散式鎖及其常見實現方式

1. 什麼是分散式鎖?

在分散式系統中,為了保證對資料的修改有最終一致性,通常使用分散式鎖或者分散式事務。比如常見的多個系統同時修改商品,既依賴於現有資料也要修改資料,如果沒有限制,高併發情況下很可能最終資料是錯誤的。

與單機鎖不同,分散式鎖更加複雜,需要考慮網路延遲、服務阻塞等,通常具有如下特點:

  • 同一時間只能有一個執行緒擁有鎖;
  • 高可用,獲取和釋放鎖必須可靠;
  • 高效能,獲取和釋放鎖必須快速完成;
  • 可重入,已獲取鎖的執行緒可以再次獲取鎖而不會發生死鎖;
  • 過期失效,避免死鎖;
  • 阻塞(根據業務需要)。

2. 基於資料庫實現分散式鎖

2.1 基於表主鍵唯一實現分散式鎖

利用資料庫主鍵唯一的特性,可以基於唯一主鍵保證多次操作只有一次成功。在資料庫中建立一個表,表中包含方法名等欄位,並在方法名欄位上建立唯一索引,想要執行某個方法,就使用這個方法名向表中插入資料,成功插入則獲取鎖,執行完成後刪除對應的行資料釋放鎖。釋放鎖時,直接刪除資料庫記錄即可。

此方案存在的問題是強依賴資料庫,容易形成熱點,資料庫鎖表導致的超時會影響效能,或者資料庫宕機會導致服務不可用。並且,資料庫本身沒有失效機制,如果任務崩潰會導致資料庫中的鎖不能被釋放。資料庫插入操作本身沒有阻塞機制,故無法實現分散式鎖的阻塞等待,任務執行緒可能需要重複嘗試插入。由於唯一主鍵的存在,持有鎖的執行緒也無法重複獲得鎖,其他執行緒競爭鎖的過程中也無法根據優先順序進行分配。

2.2 基於表字段版本號做分散式鎖

在資料庫中為表增加一個版本號欄位,每次操作時判斷版本號,只有版本號一致才能進行對應的修改,修改後版本號加 1,通過 CAS 的方式進行修改。

此實現會增加資料庫操作的次數,高併發情況下可能效能不好。

2.3 基於資料庫排他鎖做分散式鎖

for update是一種行級鎖,又叫排它鎖,一旦使用者對某個行施加了行級加鎖,則該使用者可以查詢也可以更新被加鎖的資料行,其它使用者只能查詢但不能更新被加鎖的資料行。我們可以認為獲得排他鎖的執行緒即獲得分散式鎖,任務執行完成後通過 commit 來釋放鎖。for update 語句會在執行成功後立即返回,在執行失敗時一直處於阻塞狀態,直到成功。

注意: InnoDB 引擎在加鎖的時候,只有通過索引進行檢索的時候才會使用行級鎖,否則會使用表級鎖。這裡我們希望使用行級鎖,就要給要執行的方法欄位名新增索引,值得注意的是,這個索引一定要建立成唯一索引,否則會出現多個過載方法之間無法同時被訪問的問題。過載方法的話建議把引數型別也加上。
但是 MySQL 會對查詢進行優化,即便在條件中使用了索引欄位,但是否使用索引來檢索資料是由 MySQL 通過判斷不同執行計劃的代價來決定的,如果 MySQL 認為全表掃效率更高,比如對一些很小的表,它就不會使用索引,這種情況下 InnoDB 將使用表鎖,而不是行鎖。

3. 基於 Redis 實現分散式鎖

3.1 setnx()、expire() 方法實現分散式鎖

setnx 的含義就是 SET if Not Exists,主要有兩個引數 setnx(key, value)。該方法是原子的,如果 key 不存在,則設定當前 key 成功,返回 1;如果當前 key 已經存在,則設定當前 key 失敗,返回 0。setnx 命令不能設定 key 的超時時間,只能通過 expire() 來設定。

鎖的實現步驟:

  1. 呼叫 setnx(lockkey, 1) 獲取鎖。如果返回 1,則獲取鎖成功。
  2. 呼叫 expire() 命令對 lockkey 設定超時時間。
  3. 執行完業務程式碼後,通過 delete 命令刪除 lockkey。

這個方案如果在第一步 setnx 執行成功後,在 expire() 命令執行成功前,發生了宕機的現象,那麼就依然會出現死鎖的問題。

3.2 setnx()、get()、getset()方法實現分散式鎖

這個方案是對上一個方案的優化版本。

getset() 命令主要有兩個引數 getset(key,newValue)。該方法是原子的,對 key 設定 newValue 這個值,並且返回 key 原來的舊值。假設 key 原來是不存在的,那麼首次執行的返回值是 null。

鎖的實現步驟:

  1. 呼叫 setnx(lockkey, 當前時間+過期超時時間) 獲取鎖。如果返回 1,則獲取鎖成功。如果返回 0,則獲取鎖失敗,進一步呼叫 get 方法判斷。
  2. get(lockkey) 獲取上次設定的過期時間 oldExpireTime 。如果 oldExpireTime 小於當前系統時間,則認為這個鎖已經超時,進一步呼叫 getset 方法判斷。
  3. getset(lockkey, newExpireTime 當前時間+過期超時時間) 設定新的過期時間 newExpireTime,並返回之前的值 currentExpireTime。如果 currentExpireTime 與 oldExpireTime 相等,則獲取鎖成功,不相等則說明鎖被其他請求搶走了。
  4. 執行完業務程式碼後,要判斷下鎖有沒有超時,如果沒有超時通過 delete 命令刪除 lockkey,如果超時了則不處理(可能已被搶走)。

這個方案任務處理超時或發生宕機時,無需擔心鎖超時問題,下次請求可以判斷出實際上鎖已經超時了。

4. 基於 ZooKeeper 實現分散式鎖

zookeeper 由多個節點構成(單數),採用 zab 一致性協議。因此可以將 zk 看成一個單點結構,對其修改資料其內部自動將所有節點資料進行修改而後才提供查詢服務

zookeeper 資料是目錄樹的形式,每個目錄稱為 znode, znode 中可儲存資料(一般不超過 1M),還可以在其中增加子節點

節點有三種類型。

zookeeper 提供了 Watch 機制,client 可以監控每個節點變化,當產生變化會給 client 產生一個事件

可以利用臨時節點與 watch 機制實現分散式鎖。每個鎖佔用一個普通節點 /lock,當需要獲取鎖時在 /lock 目錄下建立一個臨時節點,建立成功則表示獲取鎖成功,失敗則 watch/lock 節點,有刪除操作後再去爭鎖。臨時節點好處在於當程序掛掉後鎖的節點自動刪除不會發生死鎖。

缺點在於所有取鎖失敗的程序都監聽父節點,很容易發生羊群效應,即當釋放鎖後所有等待程序一起來建立節點,併發量很大。

一個可行的優化方案是上鎖改為建立臨時有序節點,每個上鎖的節點均能建立節點成功,只是其序號不同。只有序號最小的可以擁有鎖,如果這個節點序號不是最小的則 watch 序號比本身小的前一個節點 (公平鎖)。watch 事件到來後,再次判斷是否序號最小。取鎖成功則執行程式碼,最後釋放鎖(刪除該節點)。

效能上可能沒有快取服務那麼高,因為每次在建立鎖和釋放鎖的過程中,都要動態建立、銷燬臨時節點來實現鎖功能。zookeeper 中建立和刪除節點只能通過 Leader 服務器來執行,然後將資料同步到所有的 Follower 機器上。

5. 總結

分散式鎖比較複雜,也比較容易發生死鎖。目前主流的實現方式包括:

  • 基於資料庫實現分散式鎖。
  • 基於 Redis 實現分散式鎖。
  • 基於 ZooKeeper 實現分散式鎖。