分布式鎖
之前看程序員小灰的公眾號烙懦,通過漫畫的形式講解了分布式鎖的內(nèi)容器躏。
后來想到公司的項目里,也利用到了分布式鎖多矮,但是分布式鎖的具體代碼實現(xiàn)和在項目中的應(yīng)用并不是自己寫的缓淹,具體情況還不是太懂。尋思塔逃,決定利用這個熱度來看看公司的分布式鎖實現(xiàn)讯壶,向大牛們學(xué)習(xí)學(xué)習(xí)。
說到鎖患雏,在生活中鎖通常是用來鎖門的鹏溯,鎖車的。在java中淹仑,鎖是用來同步的丙挽,鎖是用來保證數(shù)據(jù)的一致性的肺孵。兩者雖不同,但最終都是以安全為結(jié)果來考慮的颜阐。
分布式鎖和我們java基礎(chǔ)中學(xué)習(xí)到的synchronized略有不同平窘,在synchronized中我們的鎖是個對象,如果一個線程拿到了該鎖凳怨,別的線程就只能等待了瑰艘。
分布式中的鎖,通常不會是一個對象肤舞,而是一個唯一的數(shù)據(jù)紫新,可以是商戶的訂單號,商戶的編碼李剖,或者是一條數(shù)據(jù)的唯一主鍵等等芒率。
synchronized主要對于單個應(yīng)用中,多線程的同步篙顺;
而分布式鎖對應(yīng)的是多個應(yīng)用偶芍,每個應(yīng)用中都可能會處理相同的數(shù)據(jù),所以需要對對個應(yīng)用的數(shù)據(jù)進(jìn)行同步德玫,保證數(shù)據(jù)的一致性匪蟀;
拋開具體代碼實現(xiàn),我認(rèn)為兩種鎖的最大不同宰僧。
分布式鎖的具體實現(xiàn)
1.redis
向redis中添加一個key材彪,添加的操作是原子性操作,key不存在才能添加成功撒桨;
2.zookeeper
具體實現(xiàn)查刻,還沒了解過,等有了解在做詳細(xì)解答凤类;
下面我們就來看看redis怎么實現(xiàn)分布式鎖;
redis實現(xiàn)分布式鎖
說的簡單些普气,redis來實現(xiàn)分布式鎖的原理就是將程序中一個唯一的key寫入redis中谜疤,當(dāng)有其他分布式應(yīng)用要訪問時候此key時,就去redis中讀取现诀,讀取到了則說明此數(shù)據(jù)正在被處理夷磕,讀取不到則說明可以進(jìn)行處理;
但是仔沿,想將分布式鎖處理的妥當(dāng)坐桩,還真不是一件輕松地事情,繼續(xù)往后看封锉。
在redis實現(xiàn)的分布式鎖中绵跷,我們需要強(qiáng)調(diào)以下幾點膘螟,只有保證了以下幾點,才可說是確保了鎖的實現(xiàn):
互斥碾局,在任何時刻荆残,對于同一條數(shù)據(jù),只有一臺應(yīng)用可以獲取到分布式鎖净当;
不能發(fā)生死鎖内斯,一臺服務(wù)器掛了,程序沒有執(zhí)行完像啼,但是redis中的鎖卻永久存在了俘闯,那么已加鎖未執(zhí)行完的數(shù)據(jù),就永遠(yuǎn)得不到處理了忽冻,直到人工發(fā)現(xiàn)真朗,或者監(jiān)控發(fā)現(xiàn);
高可用性甚颂,可以保證程序的正常加鎖蜜猾,正常解鎖;
加鎖解鎖必須由同一臺服務(wù)器進(jìn)行振诬,不能出現(xiàn)你加的鎖蹭睡,別人給你解鎖了。
再開始具體代碼之前赶么,我們需要來創(chuàng)建測試環(huán)境肩豁;首先是,在你的電腦中安裝redis辫呻,具體安裝如下:
下載清钥,解壓,編譯:
$ wget http://download.redis.io/releases/redis-4.0.10.tar.gz
$ tar xzf redis-4.0.10.tar.gz
$ cd redis-4.0.10
$ make
編譯成功后放闺,進(jìn)入到redis-4.0.10目錄中祟昭,再進(jìn)入到src目錄下:
執(zhí)行./redis-server命令,redis程序啟動怖侦;
新啟動一個新的窗口篡悟,進(jìn)入到redis-4.0.10/src目錄下執(zhí)行,./redis-cli命令進(jìn)入redis客戶端匾寝;
創(chuàng)建項目工程搬葬,添加java端redis依賴:
最新的2.9.0版本:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
基本的環(huán)境創(chuàng)建完了,接下來我們就來編寫具體的代碼。
前面說了,redis分布式鎖實現(xiàn)就是像redis服務(wù)器中插入一個可做唯一條件的的key放吩。那么轧抗,我們來看看redis中Java的api抡锈;
我們使用的是redis在Java中的jedis框架疾忍。
在Jedis老版本中,多數(shù)實現(xiàn)使用的是setnx()方法和expire()方法實現(xiàn)企孩,而在jedis最新版本中使用的是set()方法锭碳;
例如:
public boolean setLock(Jedis jedis,String key,String val,int expireTime){
if (jedis.setnx(key, val) == 1) {
jedis.expire(key, expireTime / 1000);
return true;
}
return false;
}
上面的例子中,你覺得會發(fā)生什么樣的問題勿璃?如果當(dāng)程序執(zhí)行完成jedis.setnx后擒抛,key和val被設(shè)置到了redis中,此時應(yīng)用程序異常补疑,過期時間還未設(shè)置歧沪,那么此key將永久保留在redis中,不會被刪除莲组。之所以這么實現(xiàn)诊胞,是因為當(dāng)時jedis框架并不支持多參數(shù)的setnx()方法,setnx指令本身不支持傳入超時時間。
public boolean setLock(Jedis jedis,String key,String val,int expireTime){
String response = jedis.set(key, val, "NX", "PX",
expireTime);
return "OK".equals(response);
}
此方法為現(xiàn)在通用的實現(xiàn)锹杈;當(dāng)key不存在時撵孤,向redis中插入數(shù)據(jù),設(shè)置過期時間竭望,這就相當(dāng)于上鎖了邪码;這里面需要注意的一點就是,val的值咬清。我們將唯一的值當(dāng)做key闭专,那么val怎么搞?
在分布式系統(tǒng)環(huán)境下旧烧,val的值可以設(shè)置成該機(jī)器的唯一標(biāo)識影钉,例如時間+請求號。為什么這么說掘剪,當(dāng)一個服務(wù)器向redis加鎖時候平委,我們需要確定這個key是來自于哪臺服務(wù)器,在解鎖時需要校驗是不是解鎖的請求來自于同一個服務(wù)器夺谁;
set(final String key, final String value, final String nxxx,final String expx, final int time) 方法:
(1)key肆汹,我們使用key來當(dāng)鎖,key是唯一的予权。
(2)value,我們傳的是“時間+請求號”浪册,通過給value賦值我們在解鎖的時候就會傳遞同樣的數(shù)據(jù)進(jìn)行解鎖扫腺。不至于出現(xiàn)不同的服務(wù)器對key進(jìn)行解鎖。為什么說村象,不允許出現(xiàn)不同的服務(wù)器對一個key進(jìn)行解鎖笆环?我們后面講解攒至。
(3)nxxx,NX意思為SET IF NOT EXIST躁劣,即當(dāng)key不存在時迫吐,我們進(jìn)行set操作;若key已經(jīng)存在账忘,則不做任何操作志膀;
(4)expx,PX意思是給這個key加一個過期設(shè)置鳖擒,具體時間由第五個參數(shù)決定溉浙。
(5)time,代表key的過期時間蒋荚,單位毫秒戳稽。
說完了上鎖,接下來說說解鎖:
解鎖期升,就是將key刪除惊奇,你可能會覺得調(diào)用jedis刪除方法就行了唄,事實并不是如此播赁;
public void deleteLock(Jedis jedis, String key){
jedis.del(key);
}
我們前面說了颂郎,在分布式環(huán)境中,哪臺服務(wù)器加的鎖行拢,在解鎖時候祖秒,還讓那臺服務(wù)器來解鎖。不能出現(xiàn)A服務(wù)器加鎖舟奠,而B服務(wù)器解鎖的情況竭缝;而上面的代碼就會出現(xiàn)這種情況。
當(dāng)A服務(wù)器將一個key設(shè)置超時時間為5秒鐘沼瘫,獲取到鎖執(zhí)行業(yè)務(wù)邏輯抬纸,但是呢,5秒鐘沒有執(zhí)行完耿戚,此時key由于到了過期時間而被刪除了湿故。正好B服務(wù)器進(jìn)行了獲取鎖操作,發(fā)現(xiàn)key沒有上鎖膜蛔,進(jìn)而加鎖開始執(zhí)行業(yè)務(wù)邏輯坛猪。過了1秒后,A服務(wù)器執(zhí)行完畢皂股,執(zhí)行釋放所操作墅茉,del(key),將B服務(wù)器上的鎖給刪除了。A就斤、B服務(wù)器對同一個可以執(zhí)行了一樣的操作悍募;
實現(xiàn)如下:
public void deleteLock(Jedis jedis, String key, String value){
if (value.equals(jedis.get(key))) {
jedis.del(lockKey);
}
}
上面實現(xiàn),你覺得有問題嗎洋机?會不會出現(xiàn)A服務(wù)器加鎖坠宴,而B服務(wù)器解鎖的情況。
答案:是绷旗。
由于判斷和del()操作不是原子性的喜鼓,那么就會存在判斷后,讓其他服務(wù)器刪除的情況刁标;
例如:A服務(wù)器加鎖颠通,執(zhí)行業(yè)務(wù)邏輯,很快執(zhí)行完畢膀懈,進(jìn)行解鎖操作顿锰,解鎖判斷,OK启搂,準(zhǔn)備進(jìn)行del()操作硼控,此時CPU切換到執(zhí)行別的操作了,或者JVM虛擬機(jī)進(jìn)行垃圾回收操作胳赌。這時候牢撼,key到了過期時間,B服務(wù)器執(zhí)行獲取到鎖疑苫,執(zhí)行業(yè)務(wù)邏輯熏版,還沒執(zhí)行完成,A服務(wù)器復(fù)活捍掺,執(zhí)行del()操作撼短,刪除key;此時挺勿,A服務(wù)器上的鎖曲横,超時而被刪除,B服務(wù)器加鎖不瓶,A服務(wù)器將其刪除禾嫉;
終極大招,lua腳本實現(xiàn):
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
jedis.eval(luaScript,Arrays.asList(key),Arrays.asList(value));
總體代碼結(jié)構(gòu)為:
public void redisLock(Jedis jedis,String key,String val,int expireTime){
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
try{
String response = jedis.set(key, val, "NX", "PX", expireTime);
if(!"OK".equals(response)){
return;
}
//.....執(zhí)行業(yè)務(wù)邏輯
}catch (Exception e){
}finally {
jedis.eval(luaScript,Arrays.asList(key),Arrays.asList(val));
}
}
通過lua腳本蚊丐,解決了 解鈴還須系鈴人 的問題熙参,但是并沒有解決由于A服務(wù)器執(zhí)行時間過長,導(dǎo)致鎖失效麦备,從而使得B服務(wù)器獲取到了鎖尊惰,對同一個key執(zhí)行了相同的邏輯讲竿。
筆者想到了兩種方式。首先弄屡,需要確認(rèn)的是,以上的情況發(fā)生概率很低鞋诗,如果你的系統(tǒng)并發(fā)量不大膀捷,業(yè)務(wù)邏輯不復(fù)雜的話,基本上很難遇到這個誤刪除的問題削彬,或者A全庸、B服務(wù)器都對同一個key執(zhí)行業(yè)務(wù)邏輯的問題。
第一個解決辦法融痛,我給他起名叫“懶政”壶笼,意思是重復(fù)執(zhí)行就重復(fù)執(zhí)行吧,不影響數(shù)據(jù)的一致性就行雁刷。但是覆劈,此解決辦法有個前提條件,不影響數(shù)據(jù)的最終一致性沛励。比如說责语,在加鎖的業(yè)務(wù)邏輯中有一個遠(yuǎn)程調(diào)用的接口,此接口不是冪等性的目派,你調(diào)用幾次坤候,此接口就接受幾次你的數(shù)據(jù),那么這種情況下就不能使用該方法企蹭。還有就是說白筹,在業(yè)務(wù)邏輯中有一個update數(shù)據(jù)庫更新操作,sql為 update set amount = amount - 10 where id = 1 and amount >=0谅摄,很常見的金額更新操作徒河,但是如果對id為1的數(shù)據(jù)連續(xù)執(zhí)行2次,那金額就不對了螟凭,這個是個大問題虚青,這種情況也不行。
第二個解決辦法是螺男,守護(hù)線程棒厘,當(dāng)A服務(wù)器設(shè)置的鎖要超時的時候,守護(hù)線程再對該鎖進(jìn)行續(xù)命下隧,加血奢人,延長存活時間。
守護(hù)線程例子:
public class ThreadTest implements Runnable{
@Override
public void run() {
for (;;){
System.out.println("111111111");
}
}
public static void main(String[] agrs){
ThreadTest threadTest = new ThreadTest();
Thread thread = new Thread(threadTest);
//thread.setDaemon(true);
thread.start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
當(dāng)設(shè)置setDaemon為true時淆院,當(dāng)main方法執(zhí)行結(jié)束后何乎,新啟動的線程也隨之結(jié)束,這就是守護(hù)線程,守護(hù)這main方法主線程支救,隨著main結(jié)束而結(jié)束抢野;
具體實現(xiàn)如下:
public void redisLockAndDaemonThread(final Jedis jedis, String key, String val, int expireTime){
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
try{
String response = jedis.set(key, val, "NX", "PX", expireTime);
if(!"OK".equals(response)){
return;
}
//開啟守護(hù)線程:
final int tmpExpireTime = expireTime;
final String tmpKey = key;
Thread thread = new Thread(new Runnable(){
@Override
public void run() {
for(;;){
jedis.expire(tmpKey,tmpExpireTime);
try {
Thread.sleep(tmpExpireTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
thread.setDaemon(true);
thread.start();
//.....執(zhí)行業(yè)務(wù)邏輯
}catch (Exception e){
}finally {
jedis.eval(luaScript,Arrays.asList(key),Arrays.asList(val));
}
}
與之前的一樣,首先我們來對key進(jìn)行加鎖各墨,設(shè)置超時間指孤。獲取到鎖后,開啟守護(hù)線程贬堵,再對key進(jìn)行超時間設(shè)置恃轩,增加其壽命,接下來進(jìn)行睡眠黎做,如果在睡眠后還能繼續(xù)執(zhí)行叉跛,則說明此業(yè)務(wù)邏輯執(zhí)行還未結(jié)束,再次對key進(jìn)行壽命延長蒸殿。
如果業(yè)務(wù)線程結(jié)束筷厘,那么守護(hù)線程也隨之結(jié)束。
至此伟桅,如上便是redis實現(xiàn)分布式鎖的邏輯敞掘。