分布式鎖是為了解決在分布式環境里,當多個節點同時競爭訪問同一個資源帶來數據不一致的問題,分布式鎖除了要滿足互斥、無死鎖、可重入等特性外,還需要考慮高可用、高性能等問題。分布式鎖比較流行的實現方式有三種:

  1. 數據庫
  2. Redis
  3. Zookeeper

本文主要介紹使用Redis時如何實現分布式鎖,使用Redis實現分布式鎖的最低要求需要滿足以下三點:

  1. 互斥,在任何給定時刻,只有一個客戶端可以持有鎖。
  2. 無死鎖,即使鎖定資源的客戶端崩潰或分區,也始終可以獲取鎖定。
  3. 容錯能力,只要大多數Redis節點都處于運行狀態,客戶端就可以獲取和釋放鎖。

互斥和無死鎖可以可以使用Redis的setnx指令保證只能有一個節點設置鎖,同時設置一個超時時間保證在極端情況下能自動釋放鎖,容錯能力可部署Redis集群。

基于故障轉移Redis分布式鎖的實施

使用Redis實施分布式鎖最簡單的方法是在實例在創建一個key,使用Redis的超時功能,在有限的時間內保持key的可用性,以便最終將其釋放。最后當客戶端處理完成后刪除key。

一看使用Redis實施分布式鎖還蠻容易,不需要過多的步驟,但是在單實例里面如果實例發生宕機或網絡不可用時會發怎么辦呢?這個也蠻好解決我們可以加個奴隸使用一主一從,如果主服務器不可用還可以使用它。這樣會帶來另外一個問題,你們發現這樣實施后鎖沒法保證互斥性了,比如發生以下的情況:

  1. 客戶端A獲取Master節點中的鎖
  2. Slave節點同步key之前,Master宕機
  3. Slave節點升級為Master節點
  4. 客戶端再次去獲取同樣的鎖,獲取鎖成功。安全違規!

畫個圖比較清楚一點:

Redis分布式鎖實施方案

單個實例實施分布式鎖

在解決單實例Redis分布式鎖的限制之前,先看一下如果正確的使用Redis在單實例中實施分布式鎖,要獲取分布式鎖,必遵循循以下方法:

SET resource_name my_random_value NX PX 30000

該命令只有在key不存在時才設置key(NX選項),并且會設置到期時間為30000ms(PX選項),key的值為一個隨機值,唯一的限制是必需保持該值在所有的鎖定請求中必需唯一。

使用隨機值是為了更安全的釋放鎖,只有只有key中存放的值是期望的值時才釋放鎖了,為了保證判斷值是否一致與刪除key的原子性,使用Lua腳本來完成:

if redis.call("get",KEYS[1]) == ARGV[1] then
 return redis.call("del",KEYS[1])
else
 return 0
end

ReidsLock算法

在分布式環境中,如何保證Redis鎖的高可用呢?假設有N個Master節點,所有節點相互獨立,不采用任務復制或其他協調系統。比如我們有5個Redis主服務器,為了獲取鎖客戶端相當做如下操作:

  1. 可以以毫秒為單位獲取當前時間
  2. 生成key和隨機值和超時時間
  3. 發送Redis命令的超時時間盡可能的小,這樣當節點故障時能快速的訪問下一個節點
  4. 順序發送獲取鎖命令
  5. 客戶使用當前時間減去第一步中獲取的時間得到鎖的有效時間,當有至少3個節點獲取鎖功能,并且鎖的有效時間小于鎖的超時時間才認為成功獲取到鎖
  6. 如果客戶端由于某種原因(無法鎖定N / 2 + 1個實例或有效時間為負),則認為獲取鎖失敗,它將嘗試解鎖所有實例。
Redis分布式鎖實施方案

上圖簡單畫出5臺Redis獲取鎖的過程,客戶端A成功在Redis 1、Redis 2、Redis 4中獲取到鎖。偽代碼大概是這樣子:

long timestamp = System.currentTimeMillis();
String lockKey = xxx
String value = xxx
long timeout = 5000
int n = 5
int successCount = 0;
for (i = 0; i < n; i++){
 if(
 redis[i].setnx(lockKey, value, timeout) 
 ){
 //設置成功
 successCount++;
 }
 
 if(successCount == (N / 2 + 1) ) 
 break;
}
//獲取鎖總花費時間
long spent = System.currentTimeMillis() - timestamp 
//獲取成功的數量等于 (N / 2 + 1)并且,有交時間小時超時時間
if(successCount == (N / 2 + 1) && 
 (timeout - spent) > 0
){
 //成功獲取到鎖
}else{
 //獲取鎖失敗,需要釋放鎖
}

使用此算法面臨的問題

  1. 因為有多臺Redis服務器,發送命令時間和過期時間多少會有一些延遲
  2. 某臺機器崩潰鎖丟失,重啟后其他客戶端可以再次鎖定
  3. 使用持久化解決鎖的丟失,如果停電key未寫入磁盤
  4. ...

如何解決超時時間延遲的問題?

只要保證使用鎖的時間小時有效時間,在鎖過期之前釋放鎖,就可以忽略這些延遲,實事上這個很難保證。

如何解決機器崩潰問題?

使用持久化方案,將鎖信息保存到磁盤,因為Redis過期是從語義上實現的,故障期間Redis服務器時間仍然流逝,所以當Redis服務器重啟后,過期時間還是可以保持一致的。

鎖數據未持久化到磁盤如何處理?

如果鎖未持久化入磁盤,那會跟問題2一樣,重啟后其他客戶端可再次獲取鎖,解決這個問題可以將持久性設置中始終啟用fsync = always,反過來這將完全破壞性能。

為了保證這一點,我們只需要使一個實例在崩潰后至少不可用超過我們使用的最大TTL(即實例崩潰時存在的所有鎖的所有鍵)所需的時間即可。無效并自動釋放。

使用延遲重新啟動,即使沒有任何種類的Redis持久性,也基本上可以實現安全性,但是請注意,這可能會轉化為可用性損失。例如,如果大多數實例崩潰,則系統將無法在全局范圍內使用TTL(此處,全局范圍是指在此期間根本沒有資源可鎖定)。