一文徹底搞定分散式鎖設計
概述
所謂分散式鎖,就是在分散式網路環境中對本地鎖機制的升級,製造分散式環境下的臨界區。保證操作的原子性。 一句話概之就是保證多台伺服器在執行某一段程式碼時保證只有一台伺服器執行。
為什麼需要分散式鎖呢 ? 單機多執行緒環境是JVM鎖就搞定了。但是現在的微服務架構是跨多進程的,需要保證進程級別的互斥性,所以需要分散式鎖保證,既然是進程級別就需要依賴外部獨立的系統。
不管是正常工作中,還是各大面試場景中都使用爛了,但是真要把這個東西徹底搞清楚還不是那麼簡單的,所以本地進行詳細的闡述。
目前本人使用場景主要是, 在常用快取讀取不到再從DB讀取數據,完了之後把結果set到快取的時候,對於一些大事務DB操作負載很重,要是不限制,完全並發。則會導致DB負載過大,導致資料庫連接數被打滿,進而引起 RT 明顯增大,進而出現各種穩定性問題。所以必須加以限制保證查詢DB 和set 快取的原子性,只有一個執行緒能操作成功,其餘執行緒等待該執行緒操作完畢之後,直接從快取讀取即可。此舉會大大提高業務穩定性和性能。
實現分散式鎖需要考察的幾點
完成分散式鎖必須滿足的條件核心三原則:
- 互斥性。在任何時刻,保證只有一個客戶端持有鎖。
- 不能出現死鎖。如果在一個客戶端持有鎖的期間,這個客戶端崩潰了,也要保證後續的其他客戶端可以上鎖。
- 保證上鎖和解鎖都是同一個客戶端。
一般分散式鎖的手段如下:
- MySQL 唯一索引 (行鎖 for update )
- 使用 ZooKeeper,基於臨時有序節點。(節點序號最小的獲取到鎖)
- 使用Redis,基於setnx命令。
MySQL 並發能力有限,所以不建議作為分散式鎖解決方案。 ZooKeeper 並發能力業有限,在高並發時會有」驚群「 效應,一般僅僅作為分散式協調元數據存儲和 HA解決方案。 Redis是最適合分散式鎖的 一來實現相對簡單,二來 性能很佳。
所以本文主要講解下 Redis實現的思路
Redis實現分散式鎖主要利用Redis的setnx命令。setnx是SET if not exists(如果不存在,則 SET)的簡寫。
加鎖:使用setnx key value命令,如果key不存在,設置value(加鎖成功)。如果已經存在lock(也就是有客戶端持有鎖了),則設置失敗(加鎖失敗)。
解鎖:使用del命令,通過刪除鍵值釋放鎖。釋放鎖之後,其他客戶端可以通過setnx命令進行加鎖。
因為每個分散式鎖 對應不同的業務場景,所以 KEY 可以定義成不同的業務含義的KEY。比如 transaction_create_key。
value可以使用 UUID 保證唯一性,用於標識加鎖的客戶端,進而保證加鎖和解鎖都是同一個客戶端。 此處是必須的,主要原因如下:
執行緒A 加鎖成功之後執行業務操作,由於時間很長,導致鎖已經過期刪除了,中間執行緒B 有搶佔了這個相同的鎖,最後執行緒A 執行完業務之後再去釋放鎖,如果不進行 value 驗證加鎖人身份就會導致 執行緒A 把 執行緒B的鎖給釋放了。 具體流程圖如下:

最常用的解決方案如下, 以下也是絕大數人面試甚至生產中使用的方案。

以上滿足原則1 和原則3 (注意之後所謂分析都是圍繞是否滿足原則分析)此處類比數學證明題的先有定理,再分析問題的時候依照定理推理出新的定理。
原則 2 沒法保證,執行緒A 加鎖之後奔潰退出了,導致鎖還沒有釋放;執行緒B則會一直申請不到鎖進入循環等待,導致死鎖。如下圖所示:

因為鎖必須要有超時時間,保證即使執行緒A 奔潰退出了,鎖到期了依然可以釋放掉,執行緒B 就能獲取到鎖了。這也是大部分人能想到的。 此時用到了 Redis的另外一個原子命令 jedis.set(key, requestId, "NX", "EX", expireTime);

問題一 超時時間設置
但是上述有個問題就是超時時間設置多大 ? 太小了沒等業務執行完畢就把鎖釋放了,起不到獨佔的效果。太大了當執行緒A奔潰之後,鎖一直不釋放會增大 執行緒B的等待時間。 此處大部分人的解決方案是,設置一個默認值比如 5S , 因為大部分業務操作時間不會超過5S ,即使奔潰了,鎖釋放時間最大5S , 下一個執行緒申請到鎖的最大時間5S的話是一個臨界值。
這種方式大部分情況不會出現問題,但是不夠嚴謹,網路抖動等服務執行時間是沒法預估的。
以上可行解決方案如下:
給鎖續期,在Redisson框架實現分散式鎖的思路,就使用watchDog機制實現鎖的續期。
當加鎖成功後,同時開啟守護執行緒,默認有效期是30秒,每隔10秒就會給鎖續期到30秒,只要持有鎖的客戶端沒有宕機,就能保證一直持有鎖,直到業務程式碼執行完畢由客戶端自己解鎖,如果宕機了自然就在有效期失效後自動解鎖。
執行緒A開啟守護執行緒,相當於電子狗的概念,比如我設置過期時間是 5S , 電子狗可以每隔4S 就給鎖續期到 5S;只要執行緒A存活在執行鎖就一直不會釋放。當執行緒A 主動釋放鎖或者 執行緒A奔潰的時候鎖到期了,鎖就釋放了,執行緒B就能正確的獲取到鎖了保證臨界區的原子性。
至於為啥執行緒A奔潰了,鎖到期了就會釋放,這塊就用到了守護執行緒的特性,守護執行緒不會單獨存在只會綁定業務執行緒,當業務執行緒結束了,守護執行緒就沒有存在的必要了,也就不會繼續續期了。

問題二 鎖不可重入
以上方案還是有問題,就是鎖不可重入,執行緒A只能加鎖一次執行一次再釋放鎖
在Redisson實現可重入鎖的思路,使用Redis的哈希表存儲可重入次數,當加鎖成功後,使用hset命令,value(重入次數)則是1。
KEYS[1] 就是加鎖的key,比如 transaction_create_key
ARGV[2] 就是加鎖的唯一值 ,requestID
ARGV[1] 就是對 transaction_create_key 進行過期時間設置
為了保證操作原子性,加鎖和解鎖操作都是使用lua腳本執行。
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; "
如果同一個客戶端再次加鎖成功,則使用hincrby自增加一。
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);" // 以MS為單位返回KEYS[1]的剩餘過期時間

解鎖時,先判斷可重複次數是否大於0,大於0則減一,否則刪除鍵值,釋放鎖資源。
KEYS[1] 就是加鎖的key,比如 transaction_create_key
ARGV[3] 就是加鎖的唯一值 ,requestID
ARGV[2] 就是對 transaction_create_key 進行過期時間設置
HINCRBY 執行之後的返回值是 執行 HINCRBY 命令之後,哈希表中欄位的值。
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}

問題三
上面的加鎖方法是加鎖後立即返回加鎖結果,如果加鎖失敗的情況下,總不可能一直輪詢嘗試加鎖,直到加鎖成功為止,這樣太過耗費性能。所以需要利用發布訂閱的機制進行優化。
具體步驟如下:
此處的堵塞隊列可以使用 java 並發安全的堵塞隊列,堵塞隊列通知機制原理是 condition通知不會有執行緒的忙等待。
- 當加鎖失敗後,訂閱鎖釋放的消息,自身進入阻塞狀態。
- 當持有鎖的客戶端釋放鎖的時候,發布鎖釋放的消息。
- 當進入阻塞等待的其他客戶端收到鎖釋放的消息後,解除阻塞等待狀態,再次嘗試加鎖。
{{uploading-image-747980.png(uploading...)}}
問題四
如果使用的是 Redis-cluster集群,則執行緒A先在master加鎖成功之後, master掛了,此時還沒同步到 slave,slave切換為 master,會導致 執行緒B在 新的master加鎖成功, 也就是 執行緒A 執行緒B 同時認為都佔有鎖,若業務處理是冪等的沒啥問題,但是在有些情況下會導致各種臟數據產生。
總結:
問題一 解決了死鎖的問題,即使執行緒A奔潰了,鎖超時也會被釋放,執行緒B 也會得到鎖。並且引入了守護執行緒解決了執行緒A還沒執行完畢,鎖就釋放了,進而破壞了臨界區操作原子性的問題。
問題二 解決了了可重複鎖的問題,就是保證該段被分散式鎖邏輯實現的程式碼,在相同的執行緒進入繼續執行時還能繼續執行。
問題三 解決了執行緒B獲取不到鎖 堵塞效率低的問題。

