前言
我們在開發(fā)應用時,如果需要對一個共享變量進行多線程同步訪問的時候,我們可以使用Java多線程的各個技能點來處理,保證完美運行無BUG。
但是這里的都只是單機應用,即在同一個JVM中;然后隨著業(yè)務發(fā)展、微服務化,一個應用需要部署到多臺服務器上然后做負載均衡,大概的架構圖如下:

在上圖可以看到,變量A在JVM1、JVM2、JVM3三個JVM內存中(這個變量A主要體現(xiàn)是在一個類中的一個成員變量,是一個有狀態(tài)的對象),如果我們不加任何控制的話,變量A同進都會在JVM分配一塊內存,三個請求發(fā)過來同時對這個變量進行操作,顯然結果不是我們想要的。
如果我們業(yè)務中存在這樣的場景的話,就需要找到一種方法來解決。
為了保證一個方法或屬性在高并發(fā)的情況下同一時間只能被同一個線程執(zhí)行,在傳統(tǒng)單機部署的情況下,可以使用Java并發(fā)處理相關的API(如ReentrantLock或Synchronized)進行互斥控制。但是,隨之業(yè)務發(fā)展的需要,原單機部署的系統(tǒng)演化成分布式集群系統(tǒng)后,由于分布式系統(tǒng)多線程、多進程并且分布在不同的機器上,這將原來的單機部署情況下的并發(fā)控制鎖策略失效,單純的Java API并不能提供分布式鎖的能力。
為了解決這個問題,就需要一種跨JVM的互斥機制來控制共享資源的訪問,這就是分布式鎖要解決的問題!
分布式鎖應該具備哪些條件
- 在分布式系統(tǒng)環(huán)境下,一個方法在同一時間只能被一個機器的一個線程執(zhí)行;
- 高可用、高性能的獲取鎖與釋放鎖;
- 具備可重入特性;
- 具備鎖失效機制、防止死鎖;
- 具備非阻塞鎖特性,即沒有獲取到鎖直接返回獲取鎖失??;
分布式鎖的實現(xiàn)方式
目前幾乎所有大型網(wǎng)站及應用都是分布式部署,分布式場景中的數(shù)據(jù)一致性問題一直是一個比較重要的話題,分布式的CAP理論告訴我們任何一個分布式系統(tǒng)都無法同時滿足一致性(Consistency)、可用性(Availability)和分區(qū)容錯性(Partition tolerance),最多只能同時滿足兩項。
一般情況下,都需要犧牲強一致性來換取系統(tǒng)的高可用性,系統(tǒng)往往只需要保證最終一致性,只要這個最終時間是在用戶可以接受的范圍內即可。
在很多時候,為了保證數(shù)據(jù)的最終一致性,需要很多的技術方案來支持,比如分布式事務、分布式鎖等。有的時候,我們需要保證一信方法在同一時間內只能被同一個線程執(zhí)行。
而分布式鎖的具體實現(xiàn)方案有如下三種:
基于數(shù)據(jù)庫實現(xiàn);
基于緩存(Redis等)實現(xiàn);
基于Zookeeper實現(xiàn);
以上盡管有三種方案,但是我們需要根據(jù)不同的業(yè)務進行選型。
基于數(shù)據(jù)庫的實現(xiàn)方式
基于數(shù)據(jù)庫的實現(xiàn)方式的思想核心為:
在數(shù)據(jù)庫中創(chuàng)建一個表,表中包含方法名等字段,并在方法名字段上創(chuàng)建唯一索引,想要執(zhí)行某個方法,就使用這個方法名向表中插入數(shù)據(jù),成功插入則獲取鎖,執(zhí)行完成后刪除對應的行數(shù)據(jù)釋放鎖。
一、創(chuàng)建一個表
DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT
COMMENT '主鍵',
`method_name` VARCHAR(64) NOT NULL
COMMENT '鎖定的方法名',
`desc` VARCHAR(255) NOT NULL
COMMENT '備注信息',
`update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
)
ENGINE = InnoDB
AUTO_INCREMENT = 3
DEFAULT CHARSET = utf8
COMMENT = '鎖定中的方法';
二、想要執(zhí)行某個方法,就使用這個方法名向表中插入數(shù)據(jù)
INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '測試的methodName');
由于我們對method_name做了唯一性約束,如果有多個請求同時提交插入操作時,數(shù)據(jù)庫能確保只有一個操作可以成功,那么我們就可以認為操作成功的那個線程獲得了該方法的鎖,可以執(zhí)行方法體中的內容。
三、執(zhí)行完成后,刪除對應的行數(shù)據(jù)釋放鎖
delete from method_lock where method_name ='methodName';
這里只是基于數(shù)據(jù)庫實現(xiàn)的一種方法(比較粗的一種)。
但是對于分布式鎖應該具備的條件來說,還有一些問題需要解決及優(yōu)化:
- 因為是基于數(shù)據(jù)庫實現(xiàn)的,數(shù)據(jù)庫的可用性和性能將直接影響分布式鎖的可用性及性能。所以,數(shù)據(jù)庫需要雙機部署、數(shù)據(jù)同步、主備切換;
- 它不具備可重入的特性,因為同一個線程在釋放鎖之前,行數(shù)據(jù)一直存在,無法再次成功插入數(shù)據(jù)。所以,需要在表中新增一列,用于記錄當前獲取到鎖的機器和線程信息,在再次獲取鎖的時候,先查詢表中機器和線程信息是否和當前機器線程相同,若相同則直接獲取鎖。
- 沒有鎖失效機制,因為有可能出現(xiàn)成功插入數(shù)據(jù)后,服務器宕機了,對應的數(shù)據(jù)沒有被刪除,當服務恢復后一直獲取不到鎖,所以,需要在表中新增一列,用于記錄失效時間,并且需要有定時任務清除這些失效的數(shù)據(jù);
- 不具備阻塞鎖特性,獲取不到鎖直接返回失敗,所以需要優(yōu)化獲取邏輯,循環(huán)多次去獲?。?/li>
- 依賴數(shù)據(jù)庫需要一定的資源開銷,性能問題需要考慮;
基于緩存(Redis)的實現(xiàn)方式
使用Redis實現(xiàn)分布式鎖的理由:
- Redis具有很高的性能;
- Redis的命令對此支持較好,實現(xiàn)起來很方便;
Redis命令介紹:
SETNX
// 當且僅當key不存在時,set一個key為val的字符串,返回1;
// 若key存在,則什么都不做,返回0。
SETNX key val;
expire
// 為key設置一個超時時間,單位為second,超過這個時間鎖會自動釋放,避免死鎖。
expire key timeout;
delete
// 刪除key
delete key;
我們通過Redis實現(xiàn)分布式鎖時,主要通過上面的這三個命令。
通過Redis實現(xiàn)分布式的核心思想為:
- 獲取鎖的時候,使用setnx加鎖,并使用expire命令為鎖添加一個超時時間,超過該時間自動釋放鎖,鎖的value值為一個隨機生成的UUID,通過這個value值,在釋放鎖的時候進行判斷。
- 獲取鎖的時候還設置一個獲取的超時時間,若超過這個時間則放棄獲取鎖。
3.釋放鎖的時候,通過UUID判斷是不是當前持有的鎖,若時該鎖,則執(zhí)行delete進行鎖釋放。
具體實現(xiàn)代碼如下:
public class DistributedLock {
private final JedisPool jedisPool;
private final static String KEY_PREF = "lock:"; // 鎖的前綴
public DistributedLock(JedisPool jedisPool) {
this.jedisPool = jedisPool;
}
/**
* 加鎖
*
* @param lockName String 鎖的名稱(key)
* @param acquireTimeout long 獲取超時時間
* @param timeout long 鎖的超時時間
* @return 鎖標識
*/
public String lockWithTimeout(String lockName, long acquireTimeout, long timeout) {
Jedis conn = null;
try {
// 獲取連接
conn = jedisPool.getResource();
// 隨機生成一個value
String identifier = UUID.randomUUID().toString();
// 鎖名,即 key值
String lockKey = KEY_PREF + lockName;
// 超時時間, 上鎖后超過此時間則自動釋放鎖
int lockExpire = (int) (timeout / 1000);
// 獲取鎖的超時時間,超過這個時間則放棄獲取鎖
long end = System.currentTimeMillis() + acquireTimeout;
while (System.currentTimeMillis() < end) {
if (conn.setnx(lockKey, identifier) == 1) {
conn.expire(lockKey, lockExpire);
// 返回value值,用于釋放鎖時間確認
return identifier;
}
// 返回-1代表key沒有設置超時時間,為key設置一個超時時間
if (conn.ttl(lockKey) == -1) {
conn.expire(lockKey, lockExpire);
}
try {
Thread.sleep(10);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
} catch (JedisException e) {
e.printStackTrace();
} finally {
if (conn != null) {
conn.close();
}
}
return null;
}
/**
* 釋放鎖
*
* @param lockName String 鎖key
* @param identifier String 釋放鎖的標識
* @return boolean
*/
public boolean releaseLock(String lockName, String identifier) {
Jedis conn = null;
String lockKey = KEY_PREF + lockName;
boolean retFlag = false;
try {
conn = jedisPool.getResource();
while (true) {
// 監(jiān)視lock, 準備開始事務
conn.watch(lockKey);
// 通過前面返回的value值判斷是不是該鎖,若時該鎖,則刪除釋放鎖
if (identifier.equals(conn.get(lockKey))) {
Transaction transaction = conn.multi();
transaction.del(lockKey);
List<Object> results = transaction.exec();
if (results == null) continue;
retFlag = true;
}
conn.unwatch();
break;
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (conn != null) {
conn.close();
}
}
return retFlag;
}
}
基于 Zookeeper 實現(xiàn)分布式鎖
基于Zookeeper臨時有序節(jié)點同樣可以實現(xiàn)分布式鎖。Zookeeper分布式鎖應用了臨時順序節(jié)點(在創(chuàng)建節(jié)點時,Zookeeper根據(jù)創(chuàng)建的時間順序給該節(jié)點名稱進行編號)。
具體實現(xiàn)步驟:
獲取鎖

首先,在Zookeeper當中創(chuàng)建一個持久節(jié)點ParentLock。當?shù)谝粋€客戶端想要獲得鎖時,需要在ParentLock這個節(jié)點下面創(chuàng)建一個臨時順序節(jié)點 Lock1。

之后,Client1查找ParentLock下面所有的臨時順序節(jié)點并排序,判斷自己所創(chuàng)建的節(jié)點Lock1是不是順序最靠前的一個。如果是第一個節(jié)點,則成功獲得鎖。

這時候,如果再有一個客戶端 Client2 前來獲取鎖,則在ParentLock下載再創(chuàng)建一個臨時順序節(jié)點Lock2。Client2查找ParentLock下面所有的臨時順序節(jié)點并排序,判斷自己所創(chuàng)建的節(jié)點Lock2是不是順序最靠前的一個,結果發(fā)現(xiàn)節(jié)點Lock2并不是最小的。

于是,Client2向排序僅比它靠前的節(jié)點Lock1注冊Watcher,用于監(jiān)聽Lock1節(jié)點是否存在。這意味著Client2搶鎖失敗,進入了等待狀態(tài)。

這時候,如果又有一個客戶端Client3前來獲取鎖,則在ParentLock下載再創(chuàng)建一個臨時順序節(jié)點Lock3。
Client3查找ParentLock下面所有的臨時順序節(jié)點并排序,判斷自己所創(chuàng)建的節(jié)點Lock3是不是順序最靠前的一個,結果同樣發(fā)現(xiàn)節(jié)點Lock3并不是最小的。

于是,Client3向排序僅比它靠前的節(jié)點Lock2注冊Watcher,用于監(jiān)聽Lock2節(jié)點是否存在。這意味著Client3同樣搶鎖失敗,進入了等待狀態(tài)。
這樣一來,Client1得到了鎖,Client2監(jiān)聽了Lock1,Client3監(jiān)聽了Lock2。這恰恰形成了一個等待隊列,很像是Java當中ReentrantLock所依賴的 AQS 。
釋放鎖
釋放鎖分為兩種情況:
1.任務完成,客戶端顯示釋放
當任務完成時,Client1會顯示調用刪除節(jié)點Lock1的指令。

2.任務執(zhí)行過程中,客戶端崩潰
獲得鎖的Client1在任務執(zhí)行過程中,如果Duang的一聲崩潰,則會斷開與Zookeeper服務端的鏈接。根據(jù)臨時節(jié)點的特性,相關聯(lián)的節(jié)點Lock1會隨之自動刪除。

由于Client2一直監(jiān)聽著Lock1的存在狀態(tài),當Lock1節(jié)點被刪除,Client2會立刻收到通知。這時候Client2會再次查詢ParentLock下面的所有節(jié)點,確認自己創(chuàng)建的節(jié)點Lock2是不是目前最小的節(jié)點。如果是最小,則Client2順理成章獲得了鎖。

同理,如果Client2也因為任務完成或者節(jié)點崩潰而刪除了節(jié)點Lock2,那么Client3就會接到通知。

最終,Client3成功得到了鎖。
基于 Zookeeper 實現(xiàn)分布式鎖優(yōu)缺點:
優(yōu)點
具備高可用、可重入、阻塞鎖特性、可解決失效死鎖問題。
缺點
因為需要頻繁的創(chuàng)建和刪除節(jié)點,性能上不如Redis方式。因為每次在創(chuàng)建鎖和釋放鎖的過程中,都要動態(tài)創(chuàng)建、銷毀瞬時節(jié)點來實現(xiàn)鎖功能。ZK中創(chuàng)建和刪除節(jié)點只能通過Leader服務器來執(zhí)行,然后將數(shù)據(jù)同不到所有的Follower機器上。
PS: 可以直接使用zookeeper第三方庫Curator客戶端,這個客戶端中封裝了一個可重入的鎖服務。Curator提供的InterProcessMutex是分布式鎖的實現(xiàn)。acquire方法用戶獲取鎖,release方法用于釋放鎖。
https://github.com/apache/curator/
參考鏈接:
http://m.itdecent.cn/p/9055ca856aaf
https://blog.csdn.net/wuzhiwei549/article/details/80692278