1. 程式人生 > 實用技巧 >面試官:談一談你對 redis 分散式鎖的理解

面試官:談一談你對 redis 分散式鎖的理解

​為什麼需要分散式鎖

在 jdk 中為我們提供了多種加鎖的方式:

(1)synchronized 關鍵字

(2)volatile + CAS 實現的樂觀鎖

(3)ReadWriteLock 讀寫鎖

(4)ReenTrantLock 可重入鎖

等等,這些鎖為我們變成提供極大的便利性,保證在多執行緒的情況下,保證執行緒安全。

但是在分散式系統中,上面的鎖就統統沒用了。

我們想要解決分散式系統中的併發問題,就需要引入分散式鎖的概念。

鎖的準則

首先,為了確保分散式鎖可用,我們至少要確保鎖的實現同時滿足以下四個條件:

  • 互斥性。

在任意時刻,只有一個客戶端能持有鎖。

  • 不會發生死鎖。

即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖。

  • 具有容錯性。

只要大部分的Redis節點正常執行,客戶端就可以加鎖和解鎖。

  • 解鈴還須繫鈴人。

加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。

  • 具備可重入特性;

  • 具備非阻塞鎖特性;

即沒有獲取到鎖將直接返回獲取鎖失敗。

  • 高效能 & 高可用

多快好省一直使我們追求的目標,加鎖帶來的時間消耗太大,肯定使我們不想見到的。

  • 鎖的公平性

避免飽漢子不知餓漢子飢,餓漢子不知飽漢子虛。保證鎖的公平性也比較重要。

分散式鎖的實現方式多種多樣,此處選擇比較流行的 redis 進行我們的 redis 鎖實現。

單機版 Redis 的實現

我們首先來看一下 antirez 的實現 RedLock,這個也是一種流傳比較廣泛的版本。

antirez 是誰?

是 redis 的作者,那麼一個寫 redis 的,真的懂鎖嗎?

加鎖的實現

只需要下面的一條命令:

SET resource_name my_random_value NX PX 30000

看起來非常簡單,但是其中還是有很多學問的。

setnx

其實目前通常所說的setnx命令,並非單指redis的 setnx key value 這條命令。

一般代指redis中對set命令加上nx引數進行使用 set 這個命令,目前已經支援這麼多引數可選:

SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]

主要依託了它的key不存在才能set成功的特性

,個人理解類似於 putIfAbsent

PX 30000

為什麼需要設定過期時間?

根據墨菲定律,如果一件事情可能發生,那麼他就一定會發生。

如果當前鎖的持有者掛掉了,他持有的鎖永遠也無法釋放,那豈不是太悲劇了。

於是我們設定一個過期時間,讓 redis 為我們做一次兜底工作。

一般這個超時時間可以根據自己的業務靈活調整,大部分都不會超過 10min。

真正的高併發,如果鎖住了 10min,帶來的經濟損失也是比較客觀的。但是總比一直鎖住強的太多。

my_random_value 有什麼用

細心的同學一定發現了這裡的 value 是一個 my_random_value,一個隨機值。

這個值是用來做什麼的?

其實這個值是一種標識,最大的作用就是解鈴還須繫鈴人

不能你在洗手間鎖上門,準備解放身心的時候,別人直接把門打開了,這樣不就亂了套了。

我們可以讓一個執行緒持有唯一的標識,這樣在解鎖的時候就知道這個鎖是屬於自己的,大家井然有序,社會和平美好。

釋放鎖的實現

在完成操作之後,通過以下Lua指令碼來釋放鎖:

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

保證是鎖的持有者

這裡是先確認資源對應的value與客戶端持有的value是否一致,如果一致的話就釋放鎖。

保證原子性

注意上面的指令碼是通過 lua 指令碼實現的,必須是一個原子性操作。

  • eval 的原子性
Atomicity of scriptsRedis uses the same Lua interpreter to run all the commands. Also Redis guarantees that a script is executed in an atomic way: no other script or Redis command will be executed while a script is being executed. This semantic is similar to the one of MULTI / EXEC. From the point of view of all the other clients the effects of a script are either still not visible or already completed.However this also means that executing slow scripts is not a good idea. It is not hard to create fast scripts, as the script overhead is very low, but if you are going to use slow scripts you should be aware that while the script is running no other client can execute commands.

直接翻譯:

指令碼的原子性Redis使用相同的Lua直譯器來執行所有命令。 另外,Redis保證以原子方式執行指令碼:執行指令碼時不會執行其他指令碼或Redis命令。 這種語義類似於MULTI / EXEC中的一種。 從所有其他客戶端的角度來看,指令碼的效果還是不可見或已經完成。但是,這也意味著執行慢速指令碼不是一個好主意。 建立快速指令碼並不難,因為指令碼開銷非常低,但是如果要使用慢速指令碼,則應注意,在指令碼執行時,沒有其他客戶端可以執行命令。

java 程式碼的實現

maven 引入

<dependency>     <groupId>redis.clients</groupId>     <artifactId>jedis</artifactId>     <version>${jedis.version}</version> </dependency>

獲取鎖

/** * 嘗試獲取分散式鎖 * * expireTimeMills 保證當前程序掛掉,也能釋放鎖 * * requestId 保證解鎖的是當前程序(鎖的持有者) * * @param lockKey         鎖 * @param requestId       請求標識 * @param expireTimeMills 超期時間 * @return 是否獲取成功 * @since 0.0.1 */@Overridepublic boolean lock(String lockKey, String requestId, int expireTimeMills) {    String result = jedis.set(lockKey, requestId, LockRedisConst.SET_IF_NOT_EXIST, LockRedisConst.SET_WITH_EXPIRE_TIME, expireTimeMills);    return LockRedisConst.LOCK_SUCCESS.equals(result);}

釋放鎖

/** * 解鎖 * * (1)使用 requestId,保證為當前鎖的持有者 * (2)使用 lua 指令碼,保證執行的原子性。 * * @param lockKey   鎖 key * @param requestId 請求標識 * @return 結果 * @since 0.0.1 */@Overridepublic boolean unlock(String lockKey, String requestId) {    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";    Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));    return LockRedisConst.RELEASE_SUCCESS.equals(result);}

完整程式碼:https://github.com/houbb/lock

RedLock

看到這裡,你是不是覺得上面的實現已經很完美了?

但是遺憾的是,上面的實現有一個致命的缺陷,那就是單點問題。

當鎖服務所在的redis節點宕機時,會導致鎖服務不可用,資料恢復之後可能會丟失部分鎖資料。

為了解決明顯的單點問題,antirez 設計提出了RedLock演算法。

antirez 是何許人也?

如果你知道 redis,你就應該知道他。

實現步驟

RedLock的實現步驟可以看成下面幾步:

  1. 獲取當前時間t1,精確到毫秒;

  2. 依次向鎖服務所依賴的N個節點發送獲取鎖的請求,加鎖的操作和上面單節點的加鎖操作請求相同;

  3. 如果獲取了超過半數節點的資源鎖(>=N/2+1),則計算獲取鎖所花費的時間,計算方法是用當前時間t2減去t1,如果花費時間小於鎖的過期時間,則成功的獲取了鎖;

  4. 這時鎖的實際有效時間是設定的有效時間t0減去獲取鎖花費的時間(t2-t1);

  5. 如果在第3步沒有成功的獲取鎖,需要向所有的N個節點發送釋放鎖的請求,釋放鎖的操作和上面單節點釋放鎖操作一致;

由於引入了多節點的redis叢集,RedLock的可用性明顯是大於單節點的鎖服務的。

節點故障重啟

這裡需要說明一個節點故障重啟的例子:

  1. client1向5個節點請求鎖,獲取了a,b,c上的鎖;

  2. b節點故障重啟,丟失了client1申請的鎖;

  3. client2向5個節點請求鎖,獲取了b,d,e上的鎖;

這裡例子中,從客戶端角度來看,有兩個客戶端合法的在同一時間都持有同一資源的鎖,關於這個問題,antirez提出了延遲重啟(delayed restarts)的概念:在節點宕機之後,不要立即重啟恢復服務,而是至少經過一個完整鎖有效週期之後再啟動恢復服務,這樣可以保證節點因為宕機而丟失的鎖資料一定因為過期而失效。

接下來就是比較有趣的部分了。

Martin Flower 的分析

Martin Flower 首先說明,在沒有fencing token的保證之下,鎖服務可能出現的問題,他給出了下面的圖:

輸入圖片說明

Martin Flower 又是誰?

被稱為軟體開發教父的男人。以前拜讀過其寫的《重構》一書,確實厲害。

不怕大佬有文化,就怕大佬會說話。我們天天吹的微服務,就是 Matrin 大佬提出的。

客戶端停頓導致鎖失效

上圖說明的問題可以描述成下面的步驟:

  1. client1成功獲取了鎖,之後陷入了長時間GC中,直到鎖過期;

  2. client2在client1的鎖過期之後成功的獲取了鎖,並去完成資料操作;

  3. client1從GC中恢復,從它本身的角度來看,並不會意識到自己持有的鎖已經過期,去操作資料;

從上面的例子看出,這裡的鎖服務提供了完整的互斥鎖語義保證,從資源的角度來看,兩次操作都是合法的。

上面提到,RedLock根據隨機字串來作為單次鎖服務的token,這就意味著對於資源而言,無法根據鎖token來區分client持有的鎖所獲取的先後順序。

為此,Martin引入了fencing token機制,fencing token可以理解成採用全域性遞增的序列替代隨機字串,作為鎖token來使用。

這樣就可以從資源側確定client所攜帶鎖的獲取先後順序了。

客戶端停頓導致鎖失效

大佬就是大佬,張口就來 GC。

GC 對於 java go 這種語言大家肯定不陌生,對於寫 C/C++ 的開發者肯定很少接觸。

fencing token機制

除了沒有fencing機制保證之外,Martin還指出,RedLock依賴時間同步不同節點之間的狀態這種做法有問題。

具體可以看個例子:

  1. client1獲取節點a,b,c上的鎖;

  2. 節點c由於時間同步,發生了時鐘漂移,時鐘跳躍導致client1獲取的鎖失效;

  3. client2獲取節點c,d,e上的鎖;

本質上來看,RedLock通過不同節點的時鐘來進行鎖狀態的同步。

而在分散式系統中,物理時鐘本身就有可能出現問題,也就是說,RedLock的安全性保證建立在物理時鐘沒問題的假設上。

分散式系統中不同節點的協調一般不使用物理時鐘作為度量,相應的,Lamport提出邏輯時鐘作為分散式事件先後順序的度量。

引入鎖的目的

Martin還指出,引入鎖的主要目的無非以下兩個:

  1. 為了資源效率,避免不必要的重複昂貴計算;

  2. 為了正確性,保證資料正確;

對於第一點而言,採用單redis節點的鎖就可以滿足需求;對於第二點而言,則需要藉助更嚴肅的分散式協調系統(如zookeeper,etcd,consul等等)。

antirez 的反駁

在Martin發表自己對RedLock的分析之後,antirez也發表了自己的反駁。

針對Martin提出的兩點質疑,antirez分別提出反駁:

  1. 首先,antirez認為在RedLock中,雖然沒有用到fencing保證機制,但是隨機字串token也可以提供client到具體鎖的匹配對映;

  2. 其次,antirez認為分散式系統中的物理時鐘可以通過良好的運維來保證;

個人理解

關於第一點,隨機的 token 確實可以和客戶端做對映。但是這並沒有什麼卵用,除非我們再多加一個欄位,標識時間或者是順序。

如果這麼做,不如直接使用一個 fetching token。

關於第二點,將開發的鍋直接推到運維頭上了,也不是不可以,可惜大部分的現實情況總是沒有那麼美好。

不過隨著雲技術的興起,也許有一天所有的應用都在雲上,然後各大雲廠商統一運維,也不是不能解決這個問題。

但是 antirez 的反駁確實沒有說服我,所以我選擇 —— Matrin 的簡化版本。

一種實現方案

整體思路

我們在 antirez 的基礎上做一點點改進,引入 Matrin 提出的 fetching token 來解決 GC 的問題。

加鎖

client先獲取一個fencing token,攜帶fencing token去獲取資源相關的鎖,這時出現兩種情況:

  1. 鎖已被佔用,且鎖的fencing token大於此時client的fencing token,這種情況的主要原因是client在獲取fencing token之後出現了長時間GC;

  2. 鎖已被佔用,且鎖的fencing token小於此時的client的fencing token,這種情況就是之前有其他客戶端成功持有了鎖且還沒有釋放(這裡的釋放包括client主動釋放和鎖超時之後的被動釋放);

  3. 鎖未被佔用,成功加鎖;

解鎖

解鎖和 antirez 的方案類似,直接採用 lua 指令碼釋放。

對於鎖的持有者也是大同小異。

不足

當然這個方案的優點是可以解決 GC 問題,缺點依然比較明顯,就是無法解決 redis 單點問題。

不過我個人的工作經驗中,redis 一般都是採用叢集的方式,所以單點問題並沒有那麼嚴重。

就像我們平時儲存分散式 session 一樣。

當然,問題還是要面對的,解決方案也是有的。

其他方案

資料庫實現 https://houbb.github.io/2018/09/08/distributed-lock-sql

zookeeper 實現 https://houbb.github.io/2018/09/08/distributed-lock-zookeeper

只不過效能和維護的複雜度,這些問題都需要我們去權衡。