1)基于數據庫做分布式鎖--樂觀鎖(基于版本號)和悲觀鎖(基于排它鎖)
2)基于redis做分布式鎖:setnx(key,當前時間+過期時間)和Redlock機制
3)基于zookeeper做分布式鎖:臨時有序節點來實現的分布式鎖,Curator
4)基于 Consul 做分布式鎖
基于數據庫(MySQL)的分布式鎖方案,一般分為3類:基于表記錄、樂觀鎖和悲觀鎖。
該方法是最簡單的,就是直接創建一張鎖表。當我們想要獲得鎖的時候,就可以在該鎖表中增加一條記錄,想要釋放鎖的時候就刪除鎖表的這條記錄。
總結:
1.這種鎖沒有失效時間,一旦釋放鎖操作失敗就會導致鎖記錄一直在數據庫中,其它線程無法獲得鎖。這個缺陷也很好解決,比如可以做一個定時任務去定時清理。
2.這種鎖的可靠性依賴于數據庫。建議設置備庫,避免單點,進一步提高可靠性。
3.這種鎖是非阻塞的。因為插入數據失敗之后會直接報錯,想要獲得鎖就需要再次操作。如果需要阻塞式的,可以弄個for循環、while循環之類的,直至INSERT成功再返回。
4.這種鎖是非可重入的。因為數據庫中鎖表的一份記錄就是一把鎖,想要實現可重入鎖,可以在數據庫中添加一些字段,比如獲得鎖的主機信息、線程信息等,那么在再次獲得鎖的時候可以先查詢數據,如果當前的主機信息和線程信息等能被查到的話,可以直接把鎖分配給它。
樂觀鎖大多數是基于數據版本(version)的記錄機制實現的。通過對數據庫表添加一個 “version”字段來實現的。讀數據時會將此版本號一同讀出,之后更新數據時會對此版本號加1。在更新過程中,會對版本號進行比較,如果是一致的,沒有發生改變,則會成功執行更新操作;如果版本號不一致,則執行不會更新。
當然借助更新時間戳(updated_at)也可以實現樂觀鎖,和采用version字段的方式相似:更新操作執行前先記錄當前的更新時間,在提交更新時,檢測當前更新時間是否與更新開始時獲取的更新時間戳相等。
樂觀鎖的優點:由于在檢測數據沖突時并不依賴數據庫本身的鎖機制,不會影響請求的性能,當產生并發且并發量較小的時候只有少部分請求會失敗。
樂觀鎖的缺點:需要對表的設計增加額外的字段,增加了數據庫的冗余。另外,當應用并發量高的時候,version值在頻繁變化,會對數據庫產生很大的寫壓力。并且也會導致大量請求失敗,影響系統的可用性。所以數據庫樂觀鎖比較適合并發量不高,并且寫操作不頻繁的場景。
悲觀鎖是數據庫中自帶的。在查詢語句后面增加FOR UPDATE,數據庫會在查詢過程中給數據庫表增加悲觀鎖,也稱排他鎖。悲觀鎖就會比較悲觀,總是假設最壞的情況,它認為數據的更新在大多數情況下是會產生沖突的。
在使用悲觀鎖的同時,我們需要注意一下鎖的級別。MySQL InnoDB在加鎖的時候,只有明確地指定主鍵(或索引)的才會執行行鎖 (只鎖住被選取的數據),否則將會執行表鎖(將整個數據表單給鎖住)。
在使用悲觀鎖時,我們必須關閉MySQL數據庫的自動提交屬性(參考下面的示例),因為MySQL默認使用autocommit(自動提交)模式。這樣在使用FOR UPDATE獲得鎖之后可以執行相應的業務邏輯,執行完之后再使用COMMIT來釋放鎖。
悲觀鎖優點:可以嚴格保證數據訪問的安全。
悲觀鎖缺點:即每次請求都會額外產生加鎖的開銷且未獲取到鎖的請求將會阻塞等待鎖的獲取,在高并發環境下,容易造成大量請求阻塞,影響系統可用性。另外,悲觀鎖使用不當還可能產生死鎖的情況。
ZooKeeper是一個分布式的,開放源碼的分布式應用程序協調服務,Zookeeper在本質上就像一個文件管理系統。其用類似文件路徑的方式管理來監聽多個節點(Znode),同時判斷當前每個節點上機器的狀態(是否宕機、是否斷開連接等),從而達到分布式協同的操作。
ZooKeeper 可以根據有序節點+watch實現,實現思路,如:為每個線程生成一個有序的臨時節點,為確保有序性,在排序一次全部節點,獲取全部節點,每個線程判斷自己是否最小,如果是的話,獲得鎖,執行操作,操作完刪除自身節點。如果不是第一個的節點則監聽它的前一個節點,當它的前一個節點被刪除時,則它會獲得鎖,以此類推。
1. Redis分布式鎖需要不斷去嘗試獲取鎖,比較消耗性能。而ZooKeeper分布式鎖,獲取不到鎖會注冊個監聽器,不需要不斷主動嘗試獲取鎖因此性能開銷較小;
2. 如果是Redis獲取鎖的那個客戶端bug了或者掛了,那么只能等待超時時間之后才能釋放鎖;而ZooKeeper的話,因為創建的是臨時znode,只要客戶端掛了,znode就沒了,此時就自動釋放鎖;
SETNX 是SET IF NOT EXISTS的簡寫.日常命令格式是SETNX key value,如果 key不存在,則SETNX成功返回1,如果這個key已經存在了,則返回0。
偽代碼實現如下:假設某電商網站的某商品做秒殺活動,key可以設置為key_resource_id,value設置任意值
if(jedis.setnx(key_resource_id,lock_value) == 1){ //加鎖 ? ??
? ? expire(key_resource_id,100);//設置過期時間 ? ??
? ? try { ? ? ? ??
? ? ? ? do something ?//業務請求 ? ??
? ? }catch(){ ??
? ? }finally { ? ? ? ?
? ? ? ? jedis.del(key_resource_id); //釋放鎖 ? ??
? ? }
}
缺點:setnx和expire兩個命令分開了,不是原子操作。如果執行完setnx加鎖,正要執行expire設置過期時間時,進程crash或者要重啟維護了,那么這個鎖就永遠釋放不了,別的線程永遠獲取不到鎖啦。
為了解決發生異常鎖得不到釋放的場景,可以把過期時間放到setnx的value值里面。如果加鎖失敗,再拿出value值校驗一下即可。加鎖代碼如下:
long expires = System.currentTimeMillis() + expireTime; //系統時間+設置的過期時間
String expiresStr = String.valueOf(expires);
// 如果當前鎖不存在,返回加鎖成功
if (jedis.setnx(key_resource_id, expiresStr) == 1) {
return true;
}
// 如果鎖已經存在,獲取鎖的過期時間
String currentValueStr = jedis.get(key_resource_id);
// 如果獲取到的過期時間,小于系統當前時間,表示已經過期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// 鎖已過期,獲取上一個鎖的過期時間,并設置現在鎖的過期時間(不了解redis的getSet命令的小伙伴,可以去官網看下哈)
String oldValueStr = jedis.getSet(key_resource_id, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考慮多線程并發的情況,只有一個線程的設置值和當前值相同,它才可以加鎖
return true;
}
}
//其他情況,均返回加鎖失敗
return false;
}
缺點:
1.過期時間是客戶端自己生成的(System.currentTimeMillis()是當前系統的時間),必須要求分布式環境下,每個客戶端的時間必須同步。
2.如果鎖過期的時候,并發多個客戶端同時請求過來,都執行jedis.getSet(),最終只能有一個客戶端加鎖成功,因此某個客戶端加的鎖可能被別的客戶端所覆蓋。
3.該鎖沒有保存持有者的唯一標識,可能被別的客戶端釋放/解鎖。
SET key value[EX seconds][PX milliseconds][NX|XX]
● EX seconds :設定key的過期時間,時間單位是秒。
● PX milliseconds: 設定key的過期時間,單位為毫秒。
● NX :表示key不存在的時候,才能set成功,也即保證只有第一個客戶端請求才能獲得鎖,而其他客戶端請求只能等其釋放鎖,才能獲取。
● XX: 僅當key存在時設置值;
if(jedis.set(key_resource_id,lock_value,"NX","EX",100s)==1){//加鎖
try{
dosomething
//業務處理
}catch(){
}finally {
jedis.del(key_resource_id); //釋放鎖
}
}
缺點:
問題一:鎖過期釋放了,業務還沒執行完。假設線程a獲取鎖成功,一直在執行臨界區的代碼。但是100s過去后,它還沒執行完。但是,這時候鎖已經過期了,此時線程b又請求過來。顯然線程b就可以獲得鎖成功,也開始執行臨界區的代碼。那么問題就來了,臨界區的業務代碼都不是嚴格串行執行的啦。
問題二:鎖被別的線程誤刪。假設線程a執行完后,去釋放鎖。但是它不知道當前的鎖可能是線程b持有的(線程a去釋放鎖時,有可能過期時間已經到了,此時線程b進來占有了鎖)。那線程a就把線程b的鎖釋放掉了,但是線程b臨界區業務代碼可能都還沒執行完呢。
既然鎖可能被別的線程誤刪,那我們給value值設置一個標記當前線程唯一的隨機數,在刪除的時候,校驗一下:
if(jedis.set(key_resource_id,uni_request_id,"NX","EX",100s)==1){
//加鎖
try {
do something //業務處理
}catch(){
}finally {
//判斷是不是當前線程加的鎖,是才釋放
if (uni_request_id.equals(jedis.get(key_resource_id))) {
jedis.del(lockKey); //釋放鎖
}
}
}
方案四中還是可能存在鎖過期釋放,業務沒執行完的問題。開源框架Redisson解決了這個問題。先來看下Redisson底層原理圖:
只要線程一加鎖成功,就會啟動一個watch dog看門狗,它是一個后臺線程,會每隔10秒檢查一下,如果線程1還持有鎖,那么就會不斷的延長鎖key的生存時間。因此Redisson解決了鎖過期釋放,業務沒執行完問題。
前面五種方案都是基于單機版的,其實Redis一般都是集群部署的。為了解決這個問題,Redis作者 antirez提出一種高級的分布式鎖算法:Redlock。