分散式鎖解決併發的三種實現方式
分散式鎖解決併發的三種實現方式
在很多場景中,我們為了保證資料的最終一致性,需要很多的技術方案來支援,比如分散式事務、分散式鎖等。有的時候,我們需要保證一個方法在同
一時間內只能被同一個執行緒執行。在單機環境中,Java中其實提供了很多併發處理相關的API,但是這些API在分散式場景中就無能為力了。也就是說單
純的Java Api並不能提供分散式鎖的能力。所以針對分散式鎖的實現目前有多種方案:
分散式鎖一般有三種實現方式:1. 資料庫鎖;2. 基於Redis的分散式鎖;3. 基於ZooKeeper的分散式鎖。
分散式鎖應該是怎麼樣的
- 互斥性 可以保證在分散式部署的應用叢集中,同一個方法在同一時間只能被一臺機器上的一個執行緒執行。
- 這把鎖要是一把可重入鎖(避免死鎖)
- 不會發生死鎖:有一個客戶端在持有鎖的過程中崩潰而沒有解鎖,也能保證其他客戶端能夠加鎖
- 這把鎖最好是一把阻塞鎖(根據業務需求考慮要不要這條)
- 有高可用的獲取鎖和釋放鎖功能
- 獲取鎖和釋放鎖的效能要好
資料庫鎖
基於資料庫表
要實現分散式鎖,最簡單的方式可能就是直接建立一張鎖表,然後通過操作該表中的資料來實現了。
當我們要鎖住某個方法或資源時,我們就在該表中增加一條記錄,想要釋放鎖的時候就刪除這條記錄。
-
CREATE TABLE `methodLock` (
-
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
-
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的方法名',
-
`desc` varchar(1024) NOT NULL DEFAULT '備註資訊',
-
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '儲存資料時間,自動生成',
-
PRIMARY KEY (`id`),
-
UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';
當我們想要鎖住某個方法時,執行以下SQL:
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)
因為我們對method_name做了唯一性約束,這裡如果有多個請求同時提交到資料庫的話,資料庫會保證只有一個操作可以成功,那麼我們就可以認為
操作成功的那個執行緒獲得了該方法的鎖,可以執行方法體內容。
當方法執行完畢之後,想要釋放鎖的話,需要執行以下Sql:
delete from methodLock where method_name ='method_name'
上面這種簡單的實現有以下幾個問題:
1、這把鎖強依賴資料庫的可用性,資料庫是一個單點,一旦資料庫掛掉,會導致業務系統不可用。
2、這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在資料庫中,其他執行緒無法再獲得到鎖。
3、這把鎖只能是非阻塞的,因為資料的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的執行緒並不會進入排隊佇列,要想再次獲得鎖就要再次觸發獲得鎖操作。
4、這把鎖是非重入的,同一個執行緒在沒有釋放鎖之前無法再次獲得該鎖。因為資料中資料已經存在了。
當然,我們也可以有其他方式解決上面的問題。
- 資料庫是單點?搞兩個資料庫,資料之前雙向同步。一旦掛掉快速切換到備庫上。
- 沒有失效時間?只要做一個定時任務,每隔一定時間把資料庫中的超時資料清理一遍。
- 非阻塞的?搞一個while迴圈,直到insert成功再返回成功。
- 非重入的?在資料庫表中加個欄位,記錄當前獲得鎖的機器的主機資訊和執行緒資訊,那麼下次再獲取鎖的時候先查詢資料庫,如果當前機器的主機資訊和執行緒資訊在資料庫可以查到的話,直接把鎖分配給他就可以了。
基於資料庫的排它鎖
除了可以通過增刪操作資料表中的記錄以外,其實還可以藉助資料庫中自帶的鎖來實現分散式的鎖。
我們還用剛剛建立的那張資料庫表。可以通過資料庫的排他鎖來實現分散式鎖。
在查詢語句後面增加for update,資料庫會在查詢過程中給資料庫表增加排他鎖。當某條記錄被加上排他鎖之後,其他執行緒無法再在該行記錄上增加排他鎖。
我們可以認為獲得排它鎖的執行緒即可獲得分散式鎖,當獲取到鎖之後,可以執行方法的業務邏輯,執行完方法之後,再通過以下方法解鎖:
-
public void unlock(){
-
connection.commit();
-
}
通過connection.commit()操作來釋放鎖。
這種方法可以有效的解決上面提到的無法釋放鎖和阻塞鎖的問題。
- 阻塞鎖? for update語句會在執行成功後立即返回,在執行失敗時一直處於阻塞狀態,直到成功。
- 鎖定之後服務宕機,無法釋放?使用這種方式,服務宕機之後資料庫會自己把鎖釋放掉。
但是還是無法直接解決資料庫單點和可重入問題。
總結:
總結一下使用資料庫來實現分散式鎖的方式,這兩種方式都是依賴資料庫的一張表,一種是通過表中的記錄的存在情況確定當前是否有鎖存在,另外一種是通過資料庫的排他鎖來實現分散式鎖。
資料庫實現分散式鎖的優點: 直接藉助資料庫,容易理解。
資料庫實現分散式鎖的缺點: 會有各種各樣的問題,在解決問題的過程中會使整個方案變得越來越複雜。
操作資料庫需要一定的開銷,效能問題需要考慮。
樂觀鎖
樂觀鎖假設認為資料一般情況下不會造成衝突,只有在進行資料的提交更新時,才會檢測資料的衝突情況,如果發現衝突了,則返回錯誤資訊
實現方式:
時間戳(timestamp)記錄機制實現:給資料庫表增加一個時間戳欄位型別的欄位,當讀取資料時,將timestamp欄位的值一同讀出,資料每更新一次,timestamp也同步更新。當對資料做提交更新操作時,檢查當前資料庫中資料的時間戳和自己更新前取到的時間戳進行對比,若相等,則更新,否則認為是失效資料。
若出現更新衝突,則需要上層邏輯修改,啟動重試機制
同樣也可以使用version的方式。
效能對比
(1) 悲觀鎖實現方式是獨佔資料,其它執行緒需要等待,不會出現修改的衝突,能夠保證資料的一致性,但是依賴資料庫的實現,且線上程較多時出現等待造成效率降低的問題。一般情況下,對於資料很敏感且讀取頻率較低的場景,可以採用悲觀鎖的方式
(2) 樂觀鎖可以多執行緒同時讀取資料,若出現衝突,也可以依賴上層邏輯修改,能夠保證高併發下的讀取,適用於讀取頻率很高而修改頻率較少的場景
(3) 由於庫存回寫資料屬於敏感資料且讀取頻率適中,所以建議使用悲觀鎖優化
基於redis的分散式鎖
相比較於基於資料庫實現分散式鎖的方案來說,基於快取來實現在效能方面會表現的更好一點。而且很多快取是可以叢集部署的,可以解決單點問題。
首先,為了確保分散式鎖可用,我們至少要確保鎖的實現同時滿足以下四個條件:
- 互斥性。在任意時刻,只有一個客戶端能持有鎖。
- 不會發生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖。
- 具有容錯性。只要大部分的Redis節點正常執行,客戶端就可以加鎖和解鎖。
- 解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。
可以看到,我們加鎖就一行程式碼:jedis.set(String key, String value, String nxxx, String expx, int time)
,這個set()方法一共有五個形參:
-
第一個為key,我們使用key來當鎖,因為key是唯一的。
-
第二個為value,我們傳的是requestId,很多童鞋可能不明白,有key作為鎖不就夠了嗎,為什麼還要用到value?原因就是我們在上面講到可靠性時,分散式鎖要滿足第四個條件解鈴還須繫鈴人,通過給value賦值為requestId,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據。requestId可以使用
UUID.randomUUID().toString()
方法生成。 -
第三個為nxxx,這個引數我們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經存在,則不做任何操作;
-
第四個為expx,這個引數我們傳的是PX,意思是我們要給這個key加一個過期的設定,具體時間由第五個引數決定。
-
第五個為time,與第四個引數相呼應,代表key的過期時間。
總的來說,執行上面的set()方法就只會導致兩種結果:1. 當前沒有鎖(key不存在),那麼就進行加鎖操作,並對鎖設定個有效期,同時value表示加鎖的客戶端。2. 已有鎖存在,不做任何操作。
心細的童鞋就會發現了,我們的加鎖程式碼滿足我們可靠性裡描述的三個條件。首先,set()加入了NX引數,可以保證如果已有key存在,則函式不會呼叫成功,也就是隻有一個客戶端能持有鎖,滿足互斥性。其次,由於我們對鎖設定了過期時間,即使鎖的持有者後續發生崩潰而沒有解鎖,鎖也會因為到了過期時間而自動解鎖(即key被刪除),不會發生死鎖。最後,因為我們將value賦值為requestId,代表加鎖的客戶端請求標識,那麼在客戶端在解鎖的時候就可以進行校驗是否是同一個客戶端。由於我們只考慮Redis單機部署的場景,所以容錯性我們暫不考慮。
錯誤例項:
使用jedis.setnx()
和jedis.expire()
組合實現加鎖
-
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
-
Long result = jedis.setnx(lockKey, requestId);
-
if (result == 1) {
-
// 若在這裡程式突然崩潰,則無法設定過期時間,將發生死鎖
-
jedis.expire(lockKey, expireTime);
-
}
-
}
setnx()方法作用就是SET IF NOT EXIST,expire()方法就是給鎖加一個過期時間。乍一看好像和前面的set()方法結果一樣,然而由於這是兩條Redis命令,不具有原子性,如果程式在執行完setnx()之後突然崩潰,導致鎖沒有設定過期時間。那麼將會發生死鎖。網上之所以有人這樣實現,是因為低版本的jedis並不支援多引數的set()方法。
解鎖:
首先獲取鎖對應的value值,檢查是否與requestId相等,如果相等則刪除鎖(解鎖)
總結:
可以使用快取來代替資料庫來實現分散式鎖,這個可以提供更好的效能,同時,很多快取服務都是叢集部署的,可以避免單點問題。並且很多快取服務都提供了可以用來實現分散式鎖的方法,比如redis的setnx方法等。並且,這些快取服務也都提供了對資料的過期自動刪除的支援,可以直接設定超時時間來控制鎖的釋放。
使用快取實現分散式鎖的優點
效能好,實現起來較為方便。
使用快取實現分散式鎖的缺點
通過超時時間來控制鎖的失效時間並不是十分的靠譜。
基於Zookeeper實現分散式鎖
基於zookeeper臨時有序節點可以實現的分散式鎖。大致思想即為:每個客戶端對某個方法加鎖時,在zookeeper上的與該方法對應的指定節點的目錄下,生成一個唯一的
瞬時有序節點。 判斷是否獲取鎖的方式很簡單,只需要判斷有序節點中序號最小的一個。 當釋放鎖的時候,只需將這個瞬時節點刪除即可。同時,其可以避免服務宕機導
致的鎖無法釋放,而產生的死鎖問題。
來看下Zookeeper能不能解決前面提到的問題。
- 鎖無法釋放?使用Zookeeper可以有效的解決鎖無法釋放的問題,因為在建立鎖的時候,客戶端會在ZK中建立一個臨時節點,一旦客戶端獲取到鎖之後突然掛掉(
Session連線斷開),那麼這個臨時節點就會自動刪除掉。其他客戶端就可以再次獲得鎖。
- 非阻塞鎖?使用Zookeeper可以實現阻塞的鎖,客戶端可以通過在ZK中建立順序節點,並且在節點上繫結監聽器,一旦節點有變化,Zookeeper會通知客戶端,客戶
端可以檢查自己建立的節點是不是當前所有節點中序號最小的,如果是,那麼自己就獲取到鎖,便可以執行業務邏輯了。
- 不可重入?使用Zookeeper也可以有效的解決不可重入的問題,客戶端在建立節點的時候,把當前客戶端的主機資訊和執行緒資訊直接寫入到節點中,下次想要獲取鎖的
時候和當前最小的節點中的資料比對一下就可以了。如果和自己的資訊一樣,那麼自己直接獲取到鎖,如果不一樣就再建立一個臨時的順序節點,參與排隊。
- 單點問題?使用Zookeeper可以有效的解決單點問題,ZK是叢集部署的,只要叢集中有半數以上的機器存活,就可以對外提供服務。
可以直接使用zookeeper第三方庫Curator客戶端,這個客戶端中封裝了一個可重入的鎖服務。
Zookeeper實現的分散式鎖其實存在一個缺點,那就是效能上可能並沒有快取服務那麼高。
因為每次在建立鎖和釋放鎖的過程中,都要動態建立、銷燬瞬時節點來實現鎖功能。ZK中建立和刪除節點只能通過Leader伺服器來執行,然後將資料同不到所有的Follower機器上。
使用Zookeeper實現分散式鎖的優點: 有效的解決單點問題,不可重入問題,非阻塞問題以及鎖無法釋放的問題。實現起來較為簡單。
使用Zookeeper實現分散式鎖的缺點 : 效能上不如使用快取實現分散式鎖。 需要對ZK的原理有所瞭解。
三種方案的比較
從理解的難易程度角度(從低到高): 資料庫 > 快取 > Zookeeper
從實現的複雜性角度(從低到高): Zookeeper >= 快取 > 資料庫
從效能角度(從高到低): 快取 > Zookeeper >= 資料庫
從可靠性角度(從高到低): Zookeeper > 快取 > 資料庫
參考:
http://wudashan.cn/2017/10/23/Redis-Distributed-Lock-Implement/
http://www.hollischuang.com/archives/1716