談談對Redis分佈式鎖的理解


談談對Redis分佈式鎖的理解

Figure1

在分佈式場景下,分佈式鎖對於大家來說是再熟悉不過的了。什麼是分佈式鎖呢?顧名思義,就是分佈式場景下使用的鎖。比如一個集群,有三臺機器,三臺機器會對同一個緩存中的共享資源進行操作,此時就需要加鎖,同一時刻只允許集群中的一臺實例對這個共享資源進行操作,這個鎖就是分佈式鎖。分佈式鎖有多種實現方式,比如使用Redis或者Zookeeper等等。今天筆者會根據Redis官方文檔關於分佈式鎖的說明來談談使用Redis實現的分佈式鎖。附上官方文檔的鏈接:https://redis.io/topics/distlock。由於筆者從事Go語言開發,本文將使用Go語言代碼來做說明。

我們要分兩種情況來討論分佈式鎖的實現,第一種情況是單實例Redis,也就是說我們將要操作的Redis集群只有一臺實例;另一種是多實例Redis,Redis集群中有多臺實例。

我們先從簡單的單實例Redis說起。Redis集群只有一臺實例的時候,我們就不需要考慮Redis集群內部的問題了,我們只要考慮業務代碼中鎖的實現就可以了。先看加鎖的代碼:

<code>SET resource_name my_random_value NX PX 30000/<code>

官方文檔中給出了這條Redis命令,用於加鎖。

resource_name就是鎖的名稱,也就是Key。

my_random_value就是對應的Value。這個value在各個client之間必須是唯一的,也就是說,這個value是代表這個client的一個唯一id。為什麼必須是唯一的呢?筆者會在下文講到解鎖操作的時候說明原因。

NX就是"SET if Not eXists",這也是最關鍵的一條指令。如果這個Key不存在,則設置這個Key並返回1;如果存在,則無法設置這個Key並返回0。在分佈式鎖中,當一個client試圖去獲取鎖,也就是要去SetNx,如果返回了1,那麼就表示設置鎖成功;而如果返回了0,就代表當前這個鎖存在,這個鎖存在就意味著它正在被另一個client持有,所以此時客戶端就得等待重新去獲取鎖。

PX就是expire,也就是這個鎖的過期時間。為何要設置過期時間呢?我們看下面這段問題代碼。

談談對Redis分佈式鎖的理解

Figure2

在這段代碼中,先進行了上鎖,然後執行業務邏輯,然後再釋放鎖。然而,執行業務邏輯的時候,報錯了,導致代碼crash了,後續的解鎖操作也無法執行。造成的後果就是這把鎖永遠也不會被釋放,也就是死鎖了。所以,設置鎖過期時間的目的就是為了預防這種case,不能說因為業務代碼本身的問題導致鎖不可用了。設置了過期時間之後,假設遇上的這種問題代碼,也不會死鎖,因為鎖到期了會自動釋放。

SET key value NX PX millisecond 這條指令是保證原子性的。早期時候的Redis不支持這條指令,有些代碼採用的方法是這樣:

<code>SETNX key value
EXPIRE key millisecond/<code>

這種做法不保證原子性,假設第一條指令執行成功了,第二條指令執行失敗了,那麼後果就是這把鎖永遠不會過期,也就是死鎖。所以說,設置鎖操作和設置鎖過期時間操作,必須保證原子性。

說完了加鎖,我們再來說說解鎖。

解鎖說起來很簡單,就是把那個Key給刪了就搞定了。然而,現實應用中肯定不是這麼簡單的。

先來說說上文中提到的,為什麼鎖的value必須是代表持有鎖的client的唯一id。答案是,為了防止一個client持有的鎖,被另一個client給釋放了。我們看下邊這段問題代碼。

談談對Redis分佈式鎖的理解

Figure3

在這段代碼中,先進行上鎖,然後在代碼退出的時候(defer 相當於 finally)進行解鎖。可是如果上鎖的時候,剛好網不好,上鎖失敗了,此時代碼就會退出。然而,

假設就在這個client上鎖失敗到代碼退出的這一個時間間隔裡面,另一個client進行上鎖操作並且上鎖成功了。另一個client上鎖成功之後,這個上鎖失敗的client執行了解鎖操作,也就是DEL Key,那麼後果便是,這個上鎖失敗的client,把剛剛上鎖成功的client持有的鎖給釋放了,這就很尷尬了。為了預防這種bad case,我們要做的就是,讓client在釋放鎖之前,先判斷一下,將要釋放的鎖,是不是自己的鎖,如果是自己的鎖才能釋放,否則,不能動人家的鎖。那麼如何判斷呢,當然就是通過value來判斷了。

解鎖步驟如下:

  1. client根據鎖的key獲取value。
  2. client判斷這個value和自己持有的那個value是否保持一致。
  3. 如果一致,則斷定這把鎖是自己的,執行DEL key操作釋放鎖;如果不一致,則直接退出該流程。

必須注意的是,以上三個步驟必須保證原子性。為什麼呢?設想一種bad case。假設這個解鎖流程不是原子性的,如下面代碼所示。

談談對Redis分佈式鎖的理解

Figure4

假設此時鎖是由這個client持有的,當這個client執行到GET key命令獲取到value之後到執行DEL key命令這段時間間隔內,這個key過期了;與此同時,另一個client1試圖獲取鎖並且獲取成功了。然後client由於判斷到clientId和value是相等的,進行了DEL key操作,造成的後果便是,client把client1剛剛拿到的鎖給釋放了。所以,釋放鎖的三個步驟必須保證原子性。如何保證呢?當然是使用LUA腳本。

談談對Redis分佈式鎖的理解

Figure5

如圖Figure5便是使用的LUA腳本。具體代碼實現如下圖Figure6所示。

談談對Redis分佈式鎖的理解

Figure6

從獲取值,判斷值到刪除Key,這一流程是保證原子性的。

那麼鎖的過期時間,我們怎麼設定呢?其實不能高度依賴於這個過期時間。為什麼這麼說呢?因為代碼執行的時候,難免會遇到一些不可預料的case導致執行時間變長。假設鎖的過期時間設置為兩秒,結果業務邏輯不小心執行了四秒,不就出問題了。此時,我們就需要一個monitor,來監控當前業務執行情況,如果發現要超時了,事情還沒做完,那麼就需要續租這把鎖了。也就是說,要重新設置這把鎖的過期時間來保障業務邏輯的正確執行。如下圖代碼所示。

談談對Redis分佈式鎖的理解

Figure11

筆者寫了個demo。代碼中fn就是要執行的業務函數邏輯,在執行之前,開啟一個新的協程(可理解為線程)來作為monitor。然後執行業務代碼。業務邏輯協程和監控協程通過Go語言的通道進行通信。當業務代碼執行完成之後,便會通知monitor結束並退出。monitor中,每個一段時間,如果沒有收到業務邏輯發來的退出指令,就認為,時間不夠用了,便會進行一次續租,延長鎖的過期時間。這樣就可以保證業務邏輯順利執行完成。可能有讀者會問,如果業務邏輯一直不結束,鎖不就一直無法釋放了嗎?那就是業務邏輯的問題了,不是鎖的問題。


上文中講的Redis鎖的實現,主要是針對單Redis實例的情況下的。然而,如果操作的Redis集群中有多個Redis實例,就不是這麼簡單的了。因為這個時候,我們就要考慮到Redis集群內部可能會出現的問題了。

Redis集群內部有一個主從同步的機制,寫入Redis的時候,寫的是主機,然後主機會異步地同步寫入的內容到從機。假設主機宕機了,剩餘的從機就會進行一次主機選舉,選舉出新的主機來接收請求。

此時設想一種bad case。client1要加鎖,向Redis主機寫入鍵值對。就在成功寫入主機到主機準備同步給從機的那一瞬間,主機宕機了。此時client1並不知道這件事情,它認為自己已經持有了這把鎖了。此時Redis集群內部剩餘的從機進行了一次Leader選舉,選舉出了新的主機。然而,新的主機內部並沒有client1剛剛設置的鎖,因為原先的主機還沒來得及同步消息給從機就掛了。此時,client2試圖去獲取鎖並且成功了,此時的鎖寫入在了新的主機中。那麼後果便是,兩個client同時在操作一個共享資源。

這種由於Redis集群本身的問題造成的bad case著實很頭疼。Redis官方文檔給出的解決方案是: RedLock

談談對Redis分佈式鎖的理解

Figure7

先說一個前提。Redis使用多實例集群的目的就是保證Redis的高可用性。如果一個Redis節點掛了,由於有多個節點,Redis服務依舊可用。但是,按照傳統的主從同步的方式,如上文中所說,會使得Redis分佈式鎖出現問題。按照RedLock算法的邏輯,為了既保證高可用又能防止多Redis實例造成問題,要做的就是,多個Redis節點相互之間獨立,沒有主從關係。假設分佈式鎖要使用五個Redis節點來保證高可用性,那麼這五個Redis節點都是主節點,他們相互之間保持獨立。

我們接著說RedLock算法,如圖Figure7所示。此時我們的Redis集群有N個節點,按照上面所說的,他們都是主節點,相互之間獨立。當client進行加鎖操作的時候,它會對所有的節點都進行加鎖,也就是設置Key Value。對所有的節點進行加鎖,固然會有一筆時間開銷。所以成功加鎖之後,Redis鎖實際過期時間是client原本設定的過期時間,減去加鎖花去的時間。RedLock算法也採用了大多數原則,官方文檔給出的說法是:As long as the majority of Redis nodes are up, clients are able to acquire and release locks. 只要大多數Redis節點可用,則可以獲取鎖成功。Figure7中的步驟5寫到:只要client成功地在至少(N/2 + 1)個節點上成功寫入的鍵值對,那麼加鎖成功。否則,加鎖失敗。而且加鎖失敗的時候,還要把剛剛成功寫入鍵值對的那幾個節點中的鍵值對給DEL掉,不然會影響其它client獲取鎖。

大體思路說完了,接下來看看代碼吧。官方文檔中給出了不同語言版本的RedLock實現,筆者下載了Go語言版本來說說具體實現吧。為了方便理解,筆者在代碼中新增了一些註釋。

談談對Redis分佈式鎖的理解

Figure8

Figure8是鎖的結構體,結構體中的每一個字段筆者都進行了註釋說明。其中的factor字段是用於官方文檔中提到的補償時間漂移:just a few milliseconds in order to compensate for clock drift between processes。筆者也並不熟悉所謂的clock drift,此處不做說明,讀者可自行去了解。

談談對Redis分佈式鎖的理解

Figure9

Figure9是加鎖操作,每一個步驟都進行了說明。該步驟遵循Figure7中所說的步驟。其中的

acquirerelease操作便是按照上文中講述單節點Redis環境下的加鎖和解鎖操作。

談談對Redis分佈式鎖的理解

Figure10

Figure10是解鎖操作,上文中已經說明。

RedLock算法不能說徹底解決了Redis多節點引發的分佈式鎖問題,它是讓這樣的bad case變得更加不容易發生了而已。在現實工作中,我們其實不會自己去實現這樣一套邏輯,大型互聯網公司中,都會有一個完善的團隊來保障各基礎服務的可靠性。筆者目前就職於BATTMD中的某個大廠,在進行redis操作時,可以視redis集群為一個高度可靠的“單節點”,因為redis團隊會封裝好一切。


分享到:


相關文章: