概述
所謂分布式鎖,就是在分布式網絡環境中對本地鎖機制的升級,制造分布式環境下的臨界區。保證操作的原子性。 一句話概之就是保證多台服務器在執行某一段代碼時保證只有一台服務器執行。
為什么需要分布式鎖呢 ? 單機多線程環境是JVM鎖就搞定了。但是現在的微服務架構是跨多進程的,需要保證進程級別的互斥性,所以需要分布式鎖保證,既然是進程級別就需要依賴外部獨立的系統。
不管是正常工作中,還是各大面試場景中都使用爛了,但是真要把這個東西徹底搞清楚還不是那么簡單的,所以本地進行詳細的闡述。
目前本人使用場景主要是, 在常用緩存讀取不到再從DB讀取數據,完了之后把結果set到緩存的時候,對於一些大事務DB操作負載很重,要是不限制,完全並發。則會導致DB負載過大,導致數據庫連接數被打滿,進而引起 RT 明顯增大,進而出現各種穩定性問題。所以必須加以限制保證查詢DB 和set 緩存的原子性,只有一個線程能操作成功,其余線程等待該線程操作完畢之后,直接從緩存讀取即可。此舉會大大提高業務穩定性和性能。
Redis實現分布式鎖
完成分布式鎖必須滿足的條件核心三原則:
- 互斥性。在任何時刻,保證只有一個客戶端持有鎖。
- 不能出現死鎖。如果在一個客戶端持有鎖的期間,這個客戶端崩潰了,也要保證后續的其他客戶端可以上鎖。
- 保證上鎖和解鎖都是同一個客戶端。
一般分布式鎖的手段如下:
- 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);
根據以上思路最終操作流程大概如下:
最后判斷是自己的鎖+ 釋放鎖必須是原子的, 使用 LUA腳本包裹起來。
問題一 超時時間設置
但是上述有個問題就是超時時間設置多大 ? 太小了沒等業務執行完畢就把鎖釋放了,起不到獨占的效果。太大了當線程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通知不會有線程的忙等待。
- 當加鎖失敗后,訂閱鎖釋放的消息,自身進入阻塞狀態。
- 當持有鎖的客戶端釋放鎖的時候,發布鎖釋放的消息。
- 當進入阻塞等待的其他客戶端收到鎖釋放的消息后,解除阻塞等待狀態,再次嘗試加鎖。
問題四 Redis-cluster集群
如果使用的是 Redis-cluster集群,則線程A先在master加鎖成功之后, master掛了,此時還沒同步到 slave,slave切換為 master,會導致 線程B在 新的master加鎖成功, 也就是 線程A 線程B 同時認為都占有鎖,若業務處理是冪等的沒啥問題,但是在有些情況下會導致各種臟數據產生。
以上 在java 技術棧中已有現成的實現 Redisson
zookeeper實現分布式鎖
大體流程如下:
- 線程1 線程2 都嘗試創建ZK 臨時節點,例如 /lock
- 假設線程1先到達,則加鎖成功,線程2加鎖失敗
- 線程1操作共享資源
- 線程1 刪除 /lock 釋放鎖
ZK 實現鎖的優勢,不用像Redis考慮過期的問題;因為線程掛了之后就自動刪除臨時節點,避免了死鎖問題;
Zookeeper實現鎖優點: - 不需要考慮鎖的過期時間
劣勢: - 性能不如Redis高
- 部署和運維成本高
- 客戶端和ZK 長時間失聯(比如 GC ) 鎖被釋放問題
總結:
問題一 解決了死鎖的問題,即使線程A奔潰了,鎖超時也會被釋放,線程B 也會得到鎖。並且引入了守護線程解決了線程A還沒執行完畢,鎖就釋放了,進而破壞了臨界區操作原子性的問題。
問題二 解決了了可重復鎖的問題,就是保證該段被分布式鎖邏輯實現的代碼,在相同的線程進入繼續執行時還能繼續執行。
問題三 解決了線程B獲取不到鎖 堵塞效率低的問題。
死鎖:設置過期時間
過期時間評估不好,鎖提前過期:守護線程,自動續期
鎖被別人釋放:鎖寫入唯一標識,釋放鎖先檢查標識,再釋放