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


image

​為什么需要分布式鎖

在 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的保證之下,鎖服務可能出現的問題,他給出了下面的圖:

image

輸入圖片說明

Martin Flower 又是誰?

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

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

image

客戶端停頓導致鎖失效

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

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

  2. client2在client1的鎖過期之后成功的獲取了鎖,並去完成數據操作;

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

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

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

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

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

image

客戶端停頓導致鎖失效

大佬就是大佬,張口就來 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認為分布式系統中的物理時鍾可以通過良好的運維來保證;

image

個人理解

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

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

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

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

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

image

一種實現方案

整體思路

我們在 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

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

image


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM