前言
我們在開發(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)方式
目前幾乎所有大型網站及應用都是分布式部署,分布式場景中的數(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)多次去獲取函似;
- 依賴數(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é)點目錄下,生成一個唯一的瞬時有序節(jié)點敦间。
判斷是否獲取鎖的方式很簡單瓶逃,只需要判斷有序節(jié)點中序號最小的一個。如果獲取到比自己小的兄弟節(jié)點不存在廓块,則說明當前線程順序號最小厢绝,獲得鎖。
如果判斷自己不是那最小的一個節(jié)點带猴,則設置監(jiān)聽比自己次小的節(jié)點昔汉;
如果已處理完成,則刪除自己的節(jié)點拴清。
優(yōu)點
具備高可用靶病、可重入、阻塞鎖特性口予、可解決失效死鎖問題娄周。
缺點
因為需要頻繁的創(chuàng)建和刪除節(jié)點,性能上不如Redis方式沪停。
PS
在這里有一個很好用的Zookeeper客戶端開源庫Apache Curator
三種方案的比較
從理解的難易程度(從低到高)
數(shù)據(jù)庫 > 緩存 > Zookeeper
從實現(xiàn)的復雜性角度(從低到高)
Zookeeper >= 緩存 > 數(shù)據(jù)庫
從性能角度(從高到低)
緩存 > Zookeeper >= 數(shù)據(jù)庫
從可靠性角度(從高到低)
Zookeeper > 緩存 > 數(shù)據(jù)庫