redis實現分佈式鎖 單實例情況 多實例情況 RedLock

背景

多個實例搶一個任務,需要分佈式鎖協調,只有一個實例能搶到鎖並執行任務。
如何實現一個分佈式鎖?

基於redis的實現

版本1:單實例情況

使用setnx命令獲取鎖,再加上簡單的Lua腳本來釋放鎖。

例如,要獲取key foo的鎖,客戶端可以嘗試:

SETNX lock.foo <current unix time + lock timeout + 1>

如果命令返回1,客戶端獲取了鎖,lock.foo的值就是一個鎖失效時間。後面客戶端應該使用 DEL lock.foo 來釋放鎖。
如果命令返回0,說明已經有別的客戶端set成功了,key已經被其他客戶端鎖上了,我們可以直接返回獲取鎖失敗

處理死鎖

如果客戶端掛了,會不會無法釋放鎖?

這個key對應的value包含了unix時間戳。如果當前unix時間 大於 這個value,那麼這個鎖就無效了。

當我們發現超時的時候,不能直接調用DEL方法然後SETNX。因爲這又有競爭(多個客戶端檢測到超時鎖並且嘗試去釋放他):

  • C1和C2讀到lock.foo,檢查到時間戳已經超時;
  • C1 DEL 鎖
  • C1 SETNX 成功 獲得了鎖
  • C2 DEL 鎖
  • C2 SETNX 成功 獲得了鎖

於是C1 C2都獲得了鎖,不符合要求。

那麼正確的算法是啥?

  • GET 獲取鎖,看看時間戳超了沒
  • 如果超時: GETSET lock.foo <current unix timestamp + lock timeout + 1> (設置key,並返回舊的值)
  • 如果獲取到了時間戳還是過期的,說明獲得了鎖;如果獲取到的時間戳沒有過期,說明別人設置成功了,認爲還是獲取不到鎖

爲了使得算法更robust,客戶端獲取了鎖之後,需要在解鎖前檢查時間戳超時沒。因爲客戶端出問題的情況有很多,可能是掛掉,也可能是因爲出現某些問題阻塞了好久之後才del,這時候會刪掉別人獲得的鎖。

代碼實現以及分析

根據上面的算法描述,我們可以看到正確和比較robust的算法是這樣的:(獲取失敗不重試)

int lockVal = current unix time + lock timeout + 1;
int ret = `SETNX lock.foo <val>`
if (ret == 1) {
	// 成功獲取鎖, 可以執行任務。。。
	doSomethingAndReleaseLock(lockVal);
} else {
	// 獲取鎖失敗,需要檢查超時沒
	int ts = `GET lock.foo`;
	if (ts > System.currentTimeMillis()) {
		// 發現超時了,可以嘗試再去獲取鎖
		int lockVal = current unix time + lock timeout + 1;
		int retTs = `GETSET lock.foo <lockVal>`
		if (retTs == ts) {
			// 再次獲取發現時間戳沒被改變,說明確實是我搶到了,那就ok
			doSomethingAndReleaseLock(lockVal);
		} else {
			// 再次獲取發現時間戳被別人改了,說明別的客戶端搶在前面set好了,我還是獲取失敗,返回失敗
			return false;
		}
	} else {
		// 還沒超時,確實獲取鎖失敗了,返回失敗
		return false;
	}
}

private void doSomethingAndReleaseLock(int lockVal) {
	try {
		doSomething();
	} finally {
		int ts = `GET lock.foo`;
		if (ts == lockVal) {
			// 確實還沒超時,不是別人新設置的時間戳,可以釋放鎖
			`DEL lock.foo`;
		}
	}
}

上面的方法的問題:
1、看着還是比較複雜
2、而且最後釋放鎖的時候不是原子性的

  • 客戶端A GET,發現是期望的lockVal,正準備刪呢,阻塞了/切換線程了😂,阻塞了好久好久,鎖都已經超時了被別人獲取到了
  • 阻塞結束,執行下一句DEL,還是把別人的鎖給刪了😂

版本2:改進

加鎖:

SET resource_name my_random_value NX EX 3

這個命令,只有在key不存在的時候(NX),會設置key,3s超時(EX)。

釋放鎖:

爲了保證刪不了別的客戶端的鎖:將value設置爲一個隨機值,這個值是用來安全的釋放鎖的。刪除(釋放鎖)的時候利用lua腳本告訴Redis:只有當存在這個key而且保存的value真的等於我期望的值的時候,才刪除key。腳本如下:

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

這個設計是爲了避免刪除別的客戶端設置的鎖。比如,一個客戶端獲取鎖,然後阻塞了好久,key超時了,然後刪除lock,可是這個lock已經被別的客戶端獲取了。直接用DEL是不安全的,因爲客戶端會刪除別的客戶端的鎖。但是我們用上面的腳本,每個lock都用一個隨機字符串”簽名“了,就只有自己能刪自己的了。

這個算法的僞代碼

int ret = `SET lock.foo my_random_value NX EX 3`
if (ret == 1) {
	// 成功獲取鎖, 執行任務。。。
	doSomethingAndReleaseLock();
} else {
	return false;
}

private void doSomethingAndReleaseLock(String my_random_value) {
	try {
		doSomething();
	} finally {
		// 調用lua腳本:
		if redis.call("get",KEYS[1]) == ARGV[1] then
    		return redis.call("del",KEYS[1])
		else
    		return 0
		end
	}
}

用lua腳本DEL保證了原子性

上面的方法在單實例的情況下就已經ok了。然而!現在我們的系統哪還有單實例的呢。在分佈式系統下又會引入新的問題了,這時候就需要RedLock算法了。

RedLock

上面的方法應該還是存在問題,所以官網也推薦了RedLock

java的實現是:
Redisson (Java implementation).

爲什麼failover-based 的實現不足夠?

現有的方法一般是創建一個key,只有一定的生存時間,使用redis expire特性。當客戶端需要釋放資源,刪除key。

表面上works well,但是問題是:如果redis master掛了怎麼辦?一般我們會有個從庫!當master掛了就用這個從庫。很不幸這是不可行的。這麼做的話我們不能實現mutual exclusion,因爲Redis集羣是異步的。

這個模型有明顯的競爭問題:
1、客戶端A從master獲取鎖
2、master掛了,在key同步到從庫之前
3、從庫升級成大佬master
4、客戶端B獲取到了鎖。。

如果我們的應用,允許多個客戶端同時持有鎖,那問題不大,上面的方法是ok的,否則的話咱們再來看下RedLock:

RedLock 算法

分佈式情況下,我們假設有N個redis master。這些節點是完全相互獨立的,所以我們不用複製或者同步系統。

上面已經說過怎麼安全地在單個實例的情況下獲得和釋放鎖了。我們認爲單實例下就用這種算法來獲得和釋放鎖。

在我們的例子中,我們設置N=5。

獲取鎖

1、獲取當前時間(毫秒)
2、逐個獲取N個實例的鎖,使用相同的key和隨機value。當在每個實例設置鎖,客戶端使用一個小的超時時間(相對自動釋放時間而言)。比如,如果自動釋放鎖是10s,這個超時時間可以是5-50ms。這個避免了客戶端長時間阻塞於連接那些掛了的redis node:如果一個實例不可用,我們應該儘快連接別的實例。
3、客戶端計算一下獲得鎖花了多少時間(通過減去第一步獲取的時間)。只有獲得大多數實例的鎖(在這個例子裏是3),而且花的時間在鎖的有效時間內,才認爲是真正的獲得鎖:(如有效時間是3s,獲取全部實例的鎖花了1s, <3s,就認爲獲取鎖成功)
4、如果獲得了鎖,鎖的真正有效時間應該是初始設定的有效時間減去流逝的時間(3-1=2s)
5、如果客戶端由於某些原因獲取鎖失敗了(可能是沒到大多數【3個】,也可能是第4步算的實際有效時間是個負數),他會嘗試解鎖所有的實例(就算是他不能解鎖的他也要去嘗試)

算法是異步的嗎?

這個算法依賴於這麼一個假設:儘管整個過程中沒有一個同步的時鐘,但是各個實例的本地時間大約都是差不多的(誤差對比起釋放鎖的時間相對小)這個假設是模擬了現實世界的電腦,每個電腦有本地的時鐘,我們通常都可以允許不同電腦之間有一點點的時間誤差。

現在我們需要更好地說一下我們的“mutual exclusion rule”:只有滿足下麪條件才能得到保證:獲取鎖的客戶端在【鎖有效時間內 減去一些時間(用於補償時鐘飄移,就那麼幾毫秒)】結束他要乾的活(第3步獲得的)

如果想進一步瞭解 時鐘漂移 請參考:Leases: an efficient fault-tolerant mechanism for distributed file cache consistency

失敗重試

當客戶端獲取鎖失敗,它應該隨機等一會然後重試。這個是爲了“去同步”:多個客戶端在同時嘗試獲取相同資源的鎖(這個可能會導致split brain condition腦裂,沒人能贏)客戶端嘗試去獲取大多數的redis實例的鎖,這個動作越快,發生腦裂的機會就越小。所以理想情況下,客戶端應該使用多路複用發送SET command給N個實例

值得強調的是,客戶端獲取鎖失敗的時候,需要儘快釋放鎖,這樣就不用等待key超時才能再次獲取key。(然而如果網絡分區發生了的話,客戶端就不再能跟redis 實例聯繫了,可用性就降低了,因爲需要等待key超時)

釋放鎖

釋放鎖比較簡單,就是直接釋放所有實例的鎖,無論客戶端是不是能夠成功獲得鎖,都去嘗試解鎖

參考

其實redis官網直接就有介紹

這篇文章也不錯

其他工具(mysql、zk)實現分佈式鎖

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章