深入理解分布式鎖:原理、應用與挑戰(zhàn)
前言
在單機環(huán)境中,我們主要通過線程間的加鎖機制來確保同一時間只有一個線程能夠訪問某個共享資源或執(zhí)行某個關(guān)鍵代碼塊,從而防止各種并發(fā)修改異常。例如,在Java中提供了synchronized/Lock。但是在分布式環(huán)境中,這種線程間的鎖機制已經(jīng)不起作用了,因為系統(tǒng)會被部署在不同機器上,這些資源已經(jīng)不是在線程間共享了,而是進程之間共享資源。為了解決這個問題,分布式鎖應運而生。本文將詳細解析分布式鎖的原理、應用與挑戰(zhàn),以幫助讀者更好地理解和應用分布式鎖。
分布式鎖的原理
首先,從最原始的鎖定義來看,鎖是一種同步機制,主要用于協(xié)調(diào)并發(fā)訪問共享資源的行為。分布式鎖也符合這個定義,只不過運行環(huán)境從單機變?yōu)榉植际江h(huán)境。它們的核心操作都可以分為以下三個步驟:
1. 獲?。涸谠L問共享資源前,先獲取一個鎖
2. 占有:獲取成功的進程或線程可以訪問共享資源,其他進程或線程則需要等待鎖釋放后才能進行訪問
3. 釋放:釋放鎖
同時,分布式鎖也具備一般鎖的以下特性:
1. 互斥性:這是鎖的核心特性,確保在任意時刻,同一個鎖只能被一個進程或線程所持有。這種特性對于確保資源的獨占訪問和防止并發(fā)沖突至關(guān)重要。
2. 一致性:加鎖和釋放鎖的過程應盡量由同一個線程或進程完成,以確保鎖狀態(tài)的一致性,防止因鎖狀態(tài)不一致而導致的錯誤或混亂。
3. 可重入性:這意味著已經(jīng)持有鎖的線程或進程可以再次獲得同一個鎖,這在某些情況下是有用的,例如遞歸函數(shù)中的鎖操作。
還有分布式鎖的特性問題:
4. 鎖租期問題:在分布式鎖的場景中,為避免死鎖或無法正常釋放,鎖通常設(shè)置有效時間。當有效時間過期但業(yè)務還在執(zhí)行時,需要通過特定的機制(如watchdog)來續(xù)租,確保鎖的持有者能夠繼續(xù)完成其操作。
5. 性能:避免鎖成為分布式系統(tǒng)的瓶頸。
分布式鎖的主流實現(xiàn)方案
常見的分布式鎖實現(xiàn)方案可以分為以下三大類:基于數(shù)據(jù)庫(比如MySQL),基于緩存(比如 Redis)和基于分布式一致性協(xié)調(diào)服務組件(比如 ZooKeeper、etcd)
基于數(shù)據(jù)庫的分布式鎖(以MySQL為例)
要實現(xiàn)一套基于數(shù)據(jù)庫的分布式鎖,最簡單的方式可能就是直接創(chuàng)建一張鎖表,然后通過操作該表中的數(shù)據(jù)來實現(xiàn)分布式鎖。
為了更好的演示,我們先創(chuàng)建一張數(shù)據(jù)庫表,例如:
CREATE TABLE `database_lock` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`resource` int(11) NOT NULL COMMENT '鎖定的資源',
`desc` varchar(128) NOT NULL DEFAULT '' COMMENT '描述',
`create_time` datetime COMMENT '創(chuàng)建時間',
`update_time` datetime COMMENT '更新時間'
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分布式鎖表';
記錄鎖
1. 獲取鎖:
當想要獲取鎖時,可以插入一條數(shù)據(jù):
INSERT INTO `database_lock` (resource, desc, create_time, update_time) VALUES (1,'lock',now(), now());
由于表中對resource設(shè)置了唯一索引,也就存在唯一性約束,這樣如果有多個請求同時提交到數(shù)據(jù)庫的話,數(shù)據(jù)庫可以保證只有一個操作成功,那么我們就可以認為操作成功的請求獲得了鎖。
2. 占有鎖:
成功獲取鎖后,就可以繼續(xù)操作共享資源了。
3. 釋放鎖:
當需要釋放鎖時,可以刪除這條數(shù)據(jù):
DELETE FROM database_lock WHERE resource = 1;
以上實現(xiàn)方式非常簡單,但是以下幾點需要特別注意:
1. 這種鎖沒有失效時間,一旦釋放鎖的操作失敗就會導致鎖記錄一直存在數(shù)據(jù)庫中,鎖無法釋放,其他線程無法獲得鎖。這個缺陷也很好解決,比如可以增加一個定時任務定時清理未正常釋放的鎖記錄。
2. 這種鎖的可靠性依賴于數(shù)據(jù)庫。可以設(shè)置備庫,避免單點,進一步提升可靠性。
3. 這種鎖時非阻塞的,因為插入數(shù)據(jù)失敗后會立即報錯,想要獲得鎖就需要再次操作。如果需要阻塞式的,可以通過For循環(huán)、while循環(huán)模擬,直至成功再返回。
4. 這種鎖時非可重入的,因為同一個線程在沒有釋放鎖之前無法再次獲得鎖,因為數(shù)據(jù)庫中已經(jīng)存在同一份記錄了。想要實現(xiàn)可重入,可以在數(shù)據(jù)庫中添加一些鎖的唯一標識字段,比如 主機信息、線程信息等,那么再次獲取鎖的時候可以先查詢數(shù)據(jù),如果當前的主機信息和線程信息等能被查詢到的話,可以直接分配鎖。
樂觀鎖
如果數(shù)據(jù)的更新在大多數(shù)情況下是不會產(chǎn)生沖突的,那么只在數(shù)據(jù)庫更新操作提交的時候?qū)?shù)據(jù)作沖突檢測,如果檢測的結(jié)果與預期一致,則獲得鎖,如果出現(xiàn)了與預期數(shù)據(jù)不一致的情況,則丟棄本次更新。
樂觀鎖大多數(shù)是基于版本控制實現(xiàn)的。即給數(shù)據(jù)增加一個版本標識,比如通過為數(shù)據(jù)庫表添加一個"version"字段來實現(xiàn)。
為了更好的理解數(shù)據(jù)庫樂觀鎖在實際項目中的使用,這里就列舉一個典型的電商庫存更新的例子。電商平臺中,當用戶提單的時候就會對庫存進行操作(庫存減1代表已經(jīng)賣出了一件)。我們將這個庫存模型用下面的一張表optimistic_lock來表述:
CREATE TABLE `optimistic_lock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource` int NOT NULL COMMENT '鎖定的資源',
`version` int NOT NULL COMMENT '鎖的版本信息',
`create_time` datetime COMMENT '創(chuàng)建時間',
`update_time` datetime COMMENT '更新時間',
`delete_time` datetime COMMENT '刪除時間',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分布式鎖表-樂觀鎖';
其中:resource表示具體操作的資源,在這里也就是特指庫存;version表示版本號。
在使用樂觀鎖之前要確保表中有相應的數(shù)據(jù),比如:
INSERT INTO optimistic_lock (resource, version, create_at, update_at) VALUES(20, 10, now(), now());
如果只有一個線程進行操作,數(shù)據(jù)庫本身就能保證操作的正確性。主要步驟如下:
1. 獲取資源信息:SELECT resource FROM optimistic_lock WHERE id = 1
2. 執(zhí)行業(yè)務邏輯
3. 提交數(shù)據(jù):UPDATE optimistic_lock SET resource = resource -1 WHERE id = 1
但是當有兩個用戶同時購買一件商品時,庫存實際操作應該是庫存(resource)減2,但是由于有高并發(fā)的存在,第一個用戶請求執(zhí)行之后(執(zhí)行了1、2,但是還沒有完成3),第二個用戶在購買相同的商品(執(zhí)行1),此時查詢出的庫存并沒有完成減1的動作,那么最終會導致2個線程購買的商品卻出現(xiàn)庫存只減1的情況,最終導致庫存異常。
在引入了version版本控制之后,具體的操作就會演變成如下步驟:
1. 獲取資源信息: SELECT resource, version as oldVersion FROM optimistic_lock WHERE id = 1
2. 執(zhí)行業(yè)務邏輯
3. 更新資源:UPDATE optimistic_lock SET resource = resource -1, version = version + 1 WHERE id = 1 AND version = oldVersion
另外,借助更新時間戳(update_at)也可以實現(xiàn)樂觀鎖,和采用version字段的方式相似:更新操作執(zhí)行前先獲取并記錄當前的更新時間,在提交更新時,檢測當前更新時間是否與更新開始時獲取的更新時間戳相等。
由于在檢測數(shù)據(jù)沖突時并不依賴唯一索引,不會影響請求的性能,在并發(fā)量較小的時候只有少部分請求會失敗,適用于競爭較少的場景。缺點是當應用并發(fā)量高的時候,version值在頻繁變化,則會導致大量請求失敗,影響系統(tǒng)的可用性。另外,我們通過上述sql語句還可以看到,數(shù)據(jù)庫鎖都是作用于同一行數(shù)據(jù)記錄上,這就會導致熱點數(shù)據(jù),在一些特殊場景,如大促、秒殺等活動的時候,大量的請求同時請求同一條記錄的行鎖,會對數(shù)據(jù)庫產(chǎn)生很大的寫壓力。所以綜合數(shù)據(jù)庫樂觀鎖的優(yōu)缺點,可以看出樂觀鎖比較適合并發(fā)量不高,寫操作不頻繁的場景。
悲觀鎖
我們還可以借助數(shù)據(jù)庫中自帶的鎖來實現(xiàn)分布式鎖。例如在查詢語句后面增加FOR UPDATE,數(shù)據(jù)庫會在查詢過程中給數(shù)據(jù)庫表增加悲觀鎖,也稱排他鎖。當某條記錄被加上悲觀鎖之后,其它線程也就無法再該行上增加悲觀鎖。
悲觀鎖,與樂觀鎖相反,總是假設(shè)最壞的情況,它認為數(shù)據(jù)的更新在大多數(shù)情況下是會產(chǎn)生沖突的。
在使用悲觀鎖的同時,我們需要注意一下鎖的級別。MySQL InnoDB引擎在加鎖的時候,只有明確地指定主鍵(或唯一索引)的才會執(zhí)行行鎖 (只鎖住被選取的數(shù)據(jù))。 在使用悲觀鎖時,我們必須關(guān)閉MySQL數(shù)據(jù)庫的自動提交屬性(參考下面的示例),因為MySQL默認使用autocommit模式,也就是說,當你執(zhí)行一個更新操作后,MySQL會立刻將結(jié)果進行提交。
mysql> SET AUTOCOMMIT = 0;
Query OK, 0 rows affected (0.00 sec)
這樣在使用FOR UPDATE獲得鎖之后可以執(zhí)行相應的業(yè)務邏輯,執(zhí)行完之后再使用COMMIT來釋放鎖。
下面通過前面的database_lock表來具體表述一下用法。假設(shè)有一線程A需要獲得鎖并執(zhí)行相應的操作,那么它的具體步驟如下:
1. 獲取鎖:SELECT * FROM database_lock WHERE id = 1 FOR UPDATE;。
2. 執(zhí)行業(yè)務邏輯。
3. 釋放鎖:COMMIT。
如果另一個線程B在線程A釋放鎖之前執(zhí)行步驟1,那么它會被阻塞,直至線程A釋放鎖之后才能繼續(xù)。注意,如果線程A長時間未釋放鎖,那么線程B會報錯,參考如下(lock wait time可以通過innodb_lock_wait_timeout來進行配置):
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
注意事項:
1. 上面的示例中演示了指定主鍵并且能查詢到數(shù)據(jù)的過程(觸發(fā)行鎖),如果查不到數(shù)據(jù)那么也就無從“鎖”起了。 2. 如果未指定主鍵(或者唯一索引)且能查詢到數(shù)據(jù),那么就會觸發(fā)表鎖或間隙鎖,比如步驟1改為執(zhí)行:
SELECT * FROM database_lock WHERE desc='lock' FOR UPDATE;
或者主鍵不明確也會觸發(fā)表鎖,又比如步驟1改為執(zhí)行:
SELECT * FROM database_lock WHERE id>0 FOR UPDATE;
在悲觀鎖中,每一次行數(shù)據(jù)的訪問都是獨占的,只有當正在訪問該行數(shù)據(jù)的請求事務提交以后,其他請求才能依次訪問該數(shù)據(jù),否則將阻塞等待鎖的獲取。悲觀鎖可以嚴格保證數(shù)據(jù)訪問的安全。但是缺點也明顯,即每次請求都會額外產(chǎn)生加鎖的開銷且未獲取到鎖的請求將會阻塞等待鎖的獲取,在高并發(fā)環(huán)境下,容易造成大量請求阻塞,影響系統(tǒng)性能。另外,悲觀鎖使用不當還可能產(chǎn)生死鎖的情況。
小結(jié)
基于以上討論,借助與數(shù)據(jù)庫自身的能力(唯一索引,數(shù)據(jù)庫排他鎖),基于數(shù)據(jù)庫實現(xiàn)分布式鎖還是挺簡單的。下面對其實用性其進行簡單分析:
優(yōu)點:
?實現(xiàn)簡單,容易理解,不需要額外的第三方中間件。
?通過數(shù)據(jù)庫的事務特性可以確保鎖的原子性、互斥性。
不足:
?性能相對較低,特別是在高并發(fā)場景下,頻繁的數(shù)據(jù)庫操作可能導致性能瓶頸。
?需要自己考慮鎖超時等問題,實現(xiàn)起來較為繁瑣。
?依賴本地事務,不支持集群部署,不能保證高可用。
基于Redis實現(xiàn)的分布式鎖
方案一:SETNX+EXPIRE
這種是最簡單的實現(xiàn)方式,先通過setNX或取到鎖,然后通過expire命令添加超時時間。這種方式存在一個很大的問題:這兩個命令不是原子操作,需要和redis交互兩次,客戶端可能會在第一個命令執(zhí)行完之后掛掉,導致沒有設(shè)置超時時間,鎖無法正常失效。于是產(chǎn)生了以下優(yōu)化方案。
方案二:SETNX+VALUE
這種方式的value值中保存的是客戶端計算出的過期時間,通過setnx命令一次性寫入redis中
public boolean getLock(String key,Long expireTime) {
long now = System.currentTimeMills();
//絕對超時時間
long expireTime = now + expireTime;
String expiresStr = String.valueOf(expireTime);
// 加鎖成功
if ( jedis.setnx(key, expiresStr)==1) {
return true;
}
// 檢查鎖是否過期,獲取鎖的value
String currentValueStr = jedis.get(key);
// 如果記錄的過期時間小于系統(tǒng)時間,則表示已過期
if (currentValueStr != null && Long.parseLong(currentValueStr) < now) {
// 鎖已過期,獲取上一個鎖的過期時間,并設(shè)置現(xiàn)在鎖的過期時間
String oldValueStr = jedis.getSet(key, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考慮多線程并發(fā)的情況,只有一個線程的設(shè)置值和當前值相同,它才可以加鎖
return true;
}
}
//其他情況,均返回加鎖失敗
return false;
}
這種方式通過value將超時時間賦值,解決了第一種方案的兩次操作不能保證原子性的問題。但是這種方式也有問題:
1. 在鎖過期時,如果多個線程同時來加鎖,可能會導致多個線程都加鎖成功(不滿足互斥性);
2. 在多個線程都加鎖成功后,因為鎖中沒有加鎖線程的標識,會導致多個線程都可以解鎖(不滿足一致性);
3. 超時時間是在客戶端計算的,不同的客戶端的時鐘可能會存在差異,導致在加鎖客戶端沒有超時的鎖,在另一個客戶端已經(jīng)超時(基于客戶端時鐘,不滿足一致性)。
方案三:使用Lua腳本
同樣是為了解決第一種方案中的原子性問題,我們可以采用Lua腳本,來保證SETNX+EXPIRE操作的原子性。
if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then
redis.call('expire',KEYS[1],ARGV[2])
else
return 0
end;
在Java代碼中,使用jedis.eval()執(zhí)行加鎖。
public boolean getLock(String key, String value, long expireTime) {
String lua_scripts = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +
"redis.call('expire', KEYS[1], ARGV[2]) " +
"return 1 " +
"else " +
"return 0 " +
"end";
List<String> keys = Collections.singletonList(key);
List<String> argv = Arrays.asList(value, String.valueOf(expireTime));
Long result = (Long) jedis.eval(lua_scripts, keys, argv);
return result != null && result == 1;
}
這種方式可以完全避免在加鎖后中斷設(shè)置不上超時時間的問題。也不會存在有時鐘不一致的問題,和高并發(fā)情況下多個線程都加上鎖的問題。但是這種方式就一定沒有問題了嗎?答案是否定的??紤]以下場景:
當服務A加鎖成功后,正在執(zhí)行業(yè)務的過程中,鎖過期啦,這時服務A是沒有感知的;
接著服務B這時來獲取鎖,成功獲取到了;
緊接著,服務A處理完業(yè)務了,來釋放鎖,成功釋放掉了,而服務B這時還以為它的鎖還在,在執(zhí)行代碼。
全亂套了有沒有?以為自己加鎖了,其實你沒加;
以為自己解鎖成功了,其實解的是別人的鎖;
這種方案的問題主要是因為兩點:鎖過期釋放,業(yè)務沒處理完;鎖沒有唯一身份標識。
備注:從Redis 2.6.12版本開始支持setNx同時設(shè)置超時時間
如果你想要在設(shè)置key的同時為其設(shè)置過期時間,并希望這是一個原子操作,你可以考慮使用Redis的 SET 命令,如下所示:
SET mykey "myvalue" NX EX 10 # 設(shè)置mykey的值為myvalue,僅當mykey不存在時,并設(shè)置過期時間為10秒
方案四:SET NX PX EX + 唯一標識
對于誤刪鎖的問題,我們可以在加鎖時,由客戶端生成一個唯一ID作為value設(shè)置在鎖中,在刪除鎖時先進行身份判斷,再刪除;加鎖邏輯如下:
public boolean getLock(String key,String uniId,Long expireTime) {
//加鎖
return jedis.set(key, uniId, "NX", "EX", expireTime) == 1;
}
// 解鎖
public boolean releaseLock(String key,String uniId) {
// 因為get和del操作并不是原子的,所以使用lua腳本
String lua_script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end;";
List<String> keys = Collections.singletonList(key);
List<String> argv = Arrays.asList(uuiId);
Object result = jedis.eval(lua_scripts, keys, argv);
return result !=null && result.equals(1L);
}
這種方式解決了鎖被誤刪的問題,但是同樣存在鎖超時失效,但是業(yè)務還未處理完的問題。
方案五:Redission框架
那么對于鎖過期失效,業(yè)務未處理完畢的問題,該如何處理呢?
我們可以在加鎖成功后,啟動一個守護線程,在守護線程中隔一段時間就對鎖的超時時間再續(xù)長一點,直到業(yè)務處理完成后再釋放鎖,防止鎖在業(yè)務處理完畢之前提前釋放。而Redission框架就是使用的這種機制來解決的這個問題。
1. 當一個線程去獲取鎖,在加鎖成功的情況下,那么它已經(jīng)通過Lua腳本將數(shù)據(jù)保存在了redis中;
2. 然后在加鎖成功的同時,啟動Watch Dog看門狗,每隔10秒檢查是否還持有鎖,如果是則將鎖超時時間延長。
3. 如果一開始就獲取鎖失敗,則會一直循環(huán)獲取。
方案六:RedLock
以上的這些方案,都只是在Redis單機模式下討論的方案,如果Redis是采用集群模式,還會存在一些問題,比如:
在集群模式下,一般Master節(jié)點會將數(shù)據(jù)同步到Salve節(jié)點,如果我們先在Master節(jié)點上加鎖成功,在同步到Salve節(jié)點之前,這個Master節(jié)點掛了,然后另一臺Salve節(jié)點升級為Master節(jié)點,這時這個節(jié)點上并沒有我們的加鎖數(shù)據(jù);
此時另一個客戶端線程來獲取相同的鎖,它就會獲取成功,這時在我們的應用中將會有兩個線程同時獲取到這個鎖,這個鎖也就不安全了。
為了解決這個問題,Redis的作者提出了一種高級的分布式鎖算法,叫:RedLock,即:Redis Distributed Lock, Redis分布式鎖。
RedLock的核心原理:
?在Redis集群中選出多個Master節(jié)點,保證這些Master節(jié)點不會同時宕機;
?并且各個Master節(jié)點之間相互獨立,數(shù)據(jù)不同步;
?使用與Redis單實例相同的方法來加鎖和解鎖。
那么RedLock到底是如何來保證在有節(jié)點宕機的情況下,還能安全的呢?
1.假設(shè)集群中有N臺Master節(jié)點,首先,獲取當前時間戳;
2.客戶端按照順序使用相同的key,value依次獲取鎖,并且獲取時間要比鎖超時時間足夠??;比如超時時間5s,那么獲取鎖時間最多1s,超過1s則放棄,繼續(xù)獲取下一個;
3.客戶端通過獲取所有能獲取的鎖之后減去第一步的時間戳,這個時間差要小于鎖超時時間,并且要至少有N/2 + 1臺節(jié)點獲取成功,才表示鎖獲取成功,否則獲取失敗;
4.如果成功獲取鎖,則鎖的有效時間是原本超時時間減去第三步的時間差;
5.如果獲取鎖失敗,則要解鎖所有的節(jié)點,不管該節(jié)點加鎖時是否成功,防止有漏網(wǎng)之魚。
Redssion庫對RedLock方案已經(jīng)做了實現(xiàn),如果你的Redis是集群部署,可以看看使用方法。
參考文檔:https://redis.io/topics/distlock
小結(jié)
優(yōu)點:
?實現(xiàn)簡單,性能較高。
?可以利用Redis的集群特性實現(xiàn)高可用性和可擴展性。
?有現(xiàn)成的第三方包和工具支持,實現(xiàn)起來相對簡單。
缺點:
?如果Redis節(jié)點故障,可能導致鎖失效或死鎖。
?RedLock算法雖然提高了容錯性,但增加了實現(xiàn)的復雜性和開銷。
基于Zookeeper等實現(xiàn)的分布式鎖
zookeeper 鎖相關(guān)基礎(chǔ)知識
zk 一般由多個節(jié)點構(gòu)成(單數(shù)),采用 zab 一致性協(xié)議。因此可以將 zk 看成一個單點結(jié)構(gòu),對其修改數(shù)據(jù)其內(nèi)部自動將所有節(jié)點數(shù)據(jù)進行修改而后才提供查詢服務。zk 的數(shù)據(jù)以目錄樹的形式,每個目錄稱為 znode,znode 中可存儲數(shù)據(jù)(一般不超過 1M),還可以在其中增加子節(jié)點。
znode節(jié)點有三種類型。序列化節(jié)點,每在該節(jié)點下增加一個節(jié)點自動給該節(jié)點的名稱上添加序號并且自增1。臨時節(jié)點,一旦創(chuàng)建這個 znode 的客戶端與服務器失去聯(lián)系,這個 znode 也將自動刪除。最后就是普通節(jié)點。
Watch 機制,client 可以監(jiān)控每個節(jié)點的變化,當產(chǎn)生變化時 client 會接受到一個事件通知。
zk 基本鎖
原理:利用臨時節(jié)點與 watch 機制。每個鎖占用一個普通節(jié)點 /lock,當需要獲取鎖時在 /lock 目錄下創(chuàng)建一個臨時節(jié)點,創(chuàng)建成功則表示獲取鎖成功,失敗則 watch/lock 節(jié)點,有刪除操作后再去爭鎖。臨時節(jié)點好處在于當進程掛掉后能自動上鎖的節(jié)點自動刪除即取消鎖。
缺點:所有取鎖失敗的進程都監(jiān)聽父節(jié)點,很容易發(fā)生羊群效應,即當釋放鎖后所有等待進程一起來創(chuàng)建節(jié)點,并發(fā)量很大,增加zk集群壓力。
zk 鎖優(yōu)化
原理:上鎖改為創(chuàng)建臨時有序節(jié)點,每個上鎖的節(jié)點均能創(chuàng)建節(jié)點成功,只是其序號不同。只有序號最小的可以擁有鎖,如果這個節(jié)點序號不是最小的則 watch 序號比本身小的前一個節(jié)點 (公平鎖)。
步驟:
?在 /lock 節(jié)點下創(chuàng)建一個有序臨時節(jié)點 (EPHEMERAL_SEQUENTIAL)。
?判斷創(chuàng)建的節(jié)點序號是否最小,如果是最小則獲取鎖成功。不是則獲取鎖失敗,然后 watch 序號比本身小的前一個節(jié)點。
?當取鎖失敗,設(shè)置 watch 后則等待 watch 事件到來后,再次判斷是否序號最小。
?取鎖成功則執(zhí)行代碼,最后釋放鎖(刪除該節(jié)點)。
參考代碼:
@Slf4j
public class DistributedLock implements Lock, Watcher{
/**
* zk客戶端
*/
private ZooKeeper zk;
/**
* 根目錄
*/
private final String root = "/locks";
/**
* 鎖名稱
*/
private final String lockName;
/**
* 等待前一個鎖
*/
private String waitNode;
/**
* 當前鎖
*/
private String myZnode;
/**
* 計數(shù)器
*/
private CountDownLatch latch;
/**
* 會話超時時間
*/
private final int sessionTimeout = 30000;
/**
* 異常列表
*/
private final List<Exception> exception = new ArrayList<>();
/**
* 創(chuàng)建分布式鎖
* @param config 服務器配置
* @param lockName 競爭資源標志,lockName中不能包含單詞lock
*/
public DistributedLock(String config, String lockName){
this.lockName = lockName;
// 創(chuàng)建與服務器的連接
try {
zk = new ZooKeeper(config, sessionTimeout, this);
Stat stat = zk.exists(root, false);
if(stat == null){
// 創(chuàng)建根節(jié)點
zk.create(root, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.PERSISTENT);
}
} catch (IOException | KeeperException | InterruptedException e) {
exception.add(e);
}
}
/**
* zookeeper節(jié)點的監(jiān)視器
*/
@Override
public void process(WatchedEvent event) {
if(this.latch != null) {
this.latch.countDown();
}
}
@Override
public void lock() {
if(!exception.isEmpty()){
throw new LockException(exception.get(0));
}
try {
if(this.tryLock()){
log.info("Thread " + Thread.currentThread().getId() + " " +myZnode + " get lock true");
} else{
//等待鎖
waitForLock(waitNode, sessionTimeout);
}
} catch (KeeperException | InterruptedException e) {
throw new LockException(e);
}
}
@Override
public boolean tryLock() {
try {
String splitStr = "_lock_";
if(lockName.contains(splitStr)) {
throw new LockException("lockName can not contains \\u000B");
}
//創(chuàng)建臨時有序子節(jié)點
myZnode = zk.create(root + "/" + lockName + splitStr, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.EPHEMERAL_SEQUENTIAL);
log.info(myZnode + " is created ");
//取出所有子節(jié)點
List<String> subNodes = zk.getChildren(root, false);
//取出所有l(wèi)ockName的鎖
List<String> lockObjNodes = new ArrayList<String>();
for (String node : subNodes) {
String _node = node.split(splitStr)[0];
if(_node.equals(lockName)){
lockObjNodes.add(node);
}
}
Collections.sort(lockObjNodes);
log.info("myZnode={} minZnode={}", myZnode, lockObjNodes.get(0));
if(myZnode.equals(root+"/"+lockObjNodes.get(0))){
//如果是最小的節(jié)點,則表示取得鎖
return true;
}
//如果不是最小的節(jié)點,找到比自己小1的節(jié)點
String subMyZnode = myZnode.substring(myZnode.lastIndexOf("/") + 1);
waitNode = lockObjNodes.get(Collections.binarySearch(lockObjNodes, subMyZnode) - 1);
} catch (KeeperException | InterruptedException e) {
throw new LockException(e);
}
return false;
}
@Override
public boolean tryLock(long time,@NonNull TimeUnit unit) {
try {
if(this.tryLock()){
return true;
}
return waitForLock(waitNode,time);
} catch (Exception e) {
log.error("tryLock exception:", e);
}
return false;
}
/**
* @param lower 監(jiān)視節(jié)點
* @param waitTime 等待超時時間
* @return 是否獲得鎖
* @throws InterruptedException
* @throws KeeperException
*/
private boolean waitForLock(String lower, long waitTime) throws InterruptedException, KeeperException {
Stat stat = zk.exists(root + "/" + lower,true);
//判斷比自己小一個數(shù)的節(jié)點是否存在,如果不存在則無需等待鎖,同時注冊監(jiān)聽
if(stat != null){
log.info("Thread " + Thread.currentThread().getId() + " waiting for " + root + "/" + lower);
this.latch = new CountDownLatch(1);
this.latch.await(waitTime, TimeUnit.MILLISECONDS);
this.latch = null;
}
return true;
}
/**
* 解鎖方法
* @throws InterruptedException 線程中斷異常
* @throws KeeperException ZooKeeper異常
*/
@Override
public void unlock() {
try {
log.info("unlock " + myZnode);
zk.delete(myZnode,-1);
myZnode = null;
zk.close();
} catch (InterruptedException | KeeperException e) {
log.error("unlock exception:", e);
}
}
@Override
public void lockInterruptibly() throws InterruptedException {
this.lock();
}
@Override
public Condition newCondition() {
return null;
}
/**
* 自定義鎖異常
*/
public static class LockException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* @param e 異常
*/
public LockException(String e){
super(e);
}
/**
* @param e 異常
*/
public LockException(Exception e){
super(e);
}
}
}
小結(jié)
優(yōu)點:
?有效的解決單點問題,不可重入問題,非阻塞問題以及鎖無法釋放的問題。實現(xiàn)起來較為簡單。
?具有良好的順序性和公平性,可以有效的避免死鎖和競爭問題。
?支持高可用,容錯性較好,通過zookeeper集群可以確保鎖的可靠性和強一致性。
?有現(xiàn)成的第三方包和工具支持,實現(xiàn)起來相對簡單。
不足:
?性能相對較低,ZK中創(chuàng)建和刪除節(jié)點只能通過 Leader 服務器來執(zhí)行,然后將數(shù)據(jù)同步到所有的 Follower 機器上。
?需要維護ZooKeeper集群,增加了系統(tǒng)的復雜性和維護成本。
?在高并發(fā)場景下,頻繁的鎖操作可能導致ZooKeeper集群成為性能瓶頸。
分布式鎖的應用
分布式鎖的應用場景
分布式鎖在分布式系統(tǒng)中有著廣泛的應用,主要體現(xiàn)在以下幾個方面:
1.共享資源競爭:當多個進程或線程嘗試同時訪問或修改共享資源時,為了避免數(shù)據(jù)沖突和不一致,可以使用分布式鎖來確保同一時刻只有一個節(jié)點可以訪問資源。這在多機器或多節(jié)點的分布式系統(tǒng)中尤為重要,因為傳統(tǒng)的單機并發(fā)控制策略可能不再適用。
2.效率性:使用分布式鎖可以避免不同節(jié)點或進程重復執(zhí)行相同的任務或操作。例如,在任務調(diào)度系統(tǒng)中,如果多個節(jié)點都嘗試執(zhí)行同一任務,通過使用分布式鎖,可以確保只有一個節(jié)點執(zhí)行該任務,從而提高系統(tǒng)的整體效率。
3.特殊業(yè)務場景:在電商業(yè)務中,分布式鎖常用于處理高并發(fā)場景下的資源競爭問題。例如,在扣減庫存或防止流量過載時,通過分布式鎖可以確保操作的原子性和一致性。此外,秒殺搶購、優(yōu)惠券領(lǐng)取等場景也常利用分布式鎖來確保數(shù)據(jù)的一致性。
4.微服務架構(gòu):在微服務架構(gòu)的系統(tǒng)中,分布式鎖發(fā)揮著至關(guān)重要的作用。特別是在金融支付系統(tǒng)等對一致性要求極高的場景中,分布式鎖被廣泛應用于實現(xiàn)各種特殊需求,確保操作的原子性、數(shù)據(jù)的準確性和一致性。
總的來說,分布式鎖的主要應用場景涉及需要確保數(shù)據(jù)一致性、防止數(shù)據(jù)沖突和提高系統(tǒng)效率的場景。通過使用分布式鎖,可以在分布式系統(tǒng)中實現(xiàn)更精細化的控制和協(xié)調(diào),確保系統(tǒng)的穩(wěn)定性和可靠性。
選型分析
根據(jù)以上實現(xiàn)原理的分析,選擇哪種分布式鎖方案取決于具體的應用場景和需求。對于簡單的應用場景和對性能要求不高的系統(tǒng),基于MySQL的分布式鎖可能是一個不錯的選擇。對于高并發(fā)、高性能要求的系統(tǒng),基于Redis的分布式鎖可能更合適。而如果需要確保鎖的公平性和一致性,并且對性能要求不是特別高,那么基于ZooKeeper的分布式鎖可能是一個更好的選擇。在實際應用中,還需要根據(jù)系統(tǒng)的具體情況和需求進行權(quán)衡和選擇。
關(guān)于布式鎖互斥性的進一步討論
經(jīng)過以探討,我們可以得出一個結(jié)論:基于單機模式的MySQL、Redis以及ZooKeeper集群,均能夠嚴格實現(xiàn)分布式鎖,從而確保鎖的互斥性。這里之所以強調(diào)鎖的互斥性,是因為它確保了同一時刻僅有一個進程或線程能夠訪問特定的共享資源,從而避免了數(shù)據(jù)沖突和不一致性的發(fā)生。
然而,當我們轉(zhuǎn)向MySQL主從模式或Redis主從模式時,情況便發(fā)生了變化。這些模式在保障鎖的互斥性方面存在明顯的不足。要深入探究這一現(xiàn)象的根源,我們不得不提及分布式領(lǐng)域中的一個關(guān)鍵理論——CAP理論。
從鎖的定義和特性出發(fā),我們知道,在獲取鎖的過程中,需要一個全局可見的標識。當一個進程或線程成功獲取鎖后,該標識會被設(shè)置并變得全局可見,這樣其他線程就無法突破鎖的互斥性限制,確保鎖的互斥性得到維護。而這一切的前提,便是數(shù)據(jù)必須保持一致性。
然而,主從模式更傾向于保障可用性和分區(qū)容忍性,即AP模型,這在一定程度上犧牲了數(shù)據(jù)的一致性。相比之下,ZooKeeper集群則采用了CP模型,即保證一致性和分區(qū)容忍性。因此,在分布式環(huán)境下,ZooKeeper集群能夠確保數(shù)據(jù)的一致性,從而確保鎖的互斥性得到嚴格保障。
綜上所述,在分布式系統(tǒng)中,確保鎖的互斥性至關(guān)重要。我們在選擇和設(shè)計分布式鎖時,必須充分考慮其互斥性保障能力,并結(jié)合實際場景和需求,選擇最合適的實現(xiàn)方案。當業(yè)務場景需要高可靠性的分布式鎖時,ZooKeeper集群因其出色的數(shù)據(jù)一致性保障能力,自然成為了一個更加值得考慮的優(yōu)秀選擇。
分布式鎖的挑戰(zhàn)
雖然分布式鎖為分布式系統(tǒng)帶來了諸多好處,但在實際應用中也面臨一些挑戰(zhàn):
1.性能問題:分布式鎖的獲取和釋放需要通過網(wǎng)絡(luò)通信,這可能會引入額外的性能開銷。在高并發(fā)場景下,如果大量進程或線程爭用同一個鎖,可能導致性能瓶頸。
2.可靠性問題:分布式鎖的可靠性受到網(wǎng)絡(luò)、硬件、軟件等多方面因素的影響。如果鎖服務出現(xiàn)故障或網(wǎng)絡(luò)中斷,可能導致死鎖或數(shù)據(jù)不一致等問題。
3.可擴展性問題:隨著分布式系統(tǒng)的規(guī)模不斷擴大,如何確保分布式鎖的可擴展性成為一個重要問題。需要設(shè)計合理的分布式鎖策略,以適應不同規(guī)模和需求的系統(tǒng)。
本文主要討論了分布式鎖的原理和不同的實現(xiàn)方案,有基于數(shù)據(jù)庫,Redis和ZooKeeper三種選擇,并且各有優(yōu)缺點。項目開發(fā)過程中根據(jù)自己實際的業(yè)務場景,選擇適合自己項目的方案。
文章中難免會有不足之處,希望讀者能給予寶貴的意見和建議。謝謝!
參考文檔
https://cloud.tencent.com/developer/article/1909596
https://zhuanlan.zhihu.com/p/42056183