分布式鎖
之前看程序員小灰的公眾號,通過漫畫的形式講解了分布式鎖的內(nèi)容。
后來想到公司的項目里,也利用到了分布式鎖,但是分布式鎖的具體代碼實現(xiàn)和在項目中的應用并不是自己寫的,具體情況還不是太懂。尋思,決定利用這個熱度來看看公司的分布式鎖實現(xiàn),向大牛們學習學習。
說到鎖,在生活中鎖通常是用來鎖門的,鎖車的。在java中,鎖是用來同步的,鎖是用來保證數(shù)據(jù)的一致性的。兩者雖不同,但最終都是以安全為結果來考慮的。
分布式鎖和我們java基礎中學習到的synchronized略有不同,在synchronized中我們的鎖是個對象,如果一個線程拿到了該鎖,別的線程就只能等待了。
分布式中的鎖,通常不會是一個對象,而是一個唯一的數(shù)據(jù),可以是商戶的訂單號,商戶的編碼,或者是一條數(shù)據(jù)的唯一主鍵等等。
synchronized主要對于單個應用中,多線程的同步;
而分布式鎖對應的是多個應用,每個應用中都可能會處理相同的數(shù)據(jù),所以需要對對個應用的數(shù)據(jù)進行同步,保證數(shù)據(jù)的一致性;
拋開具體代碼實現(xiàn),我認為兩種鎖的最大不同。
分布式鎖的具體實現(xiàn)
1.redis
向redis中添加一個key,添加的操作是原子性操作,key不存在才能添加成功;
2.zookeeper
具體實現(xiàn),還沒了解過,等有了解在做詳細解答;
下面我們就來看看redis怎么實現(xiàn)分布式鎖;
redis實現(xiàn)分布式鎖
說的簡單些,redis來實現(xiàn)分布式鎖的原理就是將程序中一個唯一的key寫入redis中,當有其他分布式應用要訪問時候此key時,就去redis中讀取,讀取到了則說明此數(shù)據(jù)正在被處理,讀取不到則說明可以進行處理;
但是,想將分布式鎖處理的妥當,還真不是一件輕松地事情,繼續(xù)往后看。
在redis實現(xiàn)的分布式鎖中,我們需要強調(diào)以下幾點,只有保證了以下幾點,才可說是確保了鎖的實現(xiàn):
互斥,在任何時刻,對于同一條數(shù)據(jù),只有一臺應用可以獲取到分布式鎖;
不能發(fā)生死鎖,一臺服務器掛了,程序沒有執(zhí)行完,但是redis中的鎖卻永久存在了,那么已加鎖未執(zhí)行完的數(shù)據(jù),就永遠得不到處理了,直到人工發(fā)現(xiàn),或者監(jiān)控發(fā)現(xià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
編譯成功后,進入到redis-4.0.10目錄中,再進入到src目錄下:
執(zhí)行./redis-server命令,redis程序啟動;
新啟動一個新的窗口,進入到redis-4.0.10/src目錄下執(zhí)行,./redis-cli命令進入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服務器中插入一個可做唯一條件的的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ā)生什么樣的問題?如果當程序執(zhí)行完成jedis.setnx后,key和val被設置到了redis中,此時應用程序異常,過期時間還未設置,那么此key將永久保留在redis中,不會被刪除。之所以這么實現(xiàn),是因為當時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);當key不存在時,向redis中插入數(shù)據(jù),設置過期時間,這就相當于上鎖了;這里面需要注意的一點就是,val的值。我們將唯一的值當做key,那么val怎么搞?
在分布式系統(tǒng)環(huán)境下,val的值可以設置成該機器的唯一標識,例如時間+請求號。為什么這么說,當一個服務器向redis加鎖時候,我們需要確定這個key是來自于哪臺服務器,在解鎖時需要校驗是不是解鎖的請求來自于同一個服務器;
set(final String key, final String value, final String nxxx,final String expx, final int time) 方法:
(1)key,我們使用key來當鎖,key是唯一的。
(2)value,我們傳的是“時間+請求號”,通過給value賦值我們在解鎖的時候就會傳遞同樣的數(shù)據(jù)進行解鎖。不至于出現(xiàn)不同的服務器對key進行解鎖。為什么說,不允許出現(xiàn)不同的服務器對一個key進行解鎖?我們后面講解。
(3)nxxx,NX意思為SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經(jīng)存在,則不做任何操作;
(4)expx,PX意思是給這個key加一個過期設置,具體時間由第五個參數(shù)決定。
(5)time,代表key的過期時間,單位毫秒。
說完了上鎖,接下來說說解鎖:
解鎖,就是將key刪除,你可能會覺得調(diào)用jedis刪除方法就行了唄,事實并不是如此;
public void deleteLock(Jedis jedis, String key){
jedis.del(key);
}
我們前面說了,在分布式環(huán)境中,哪臺服務器加的鎖,在解鎖時候,還讓那臺服務器來解鎖。不能出現(xiàn)A服務器加鎖,而B服務器解鎖的情況;而上面的代碼就會出現(xiàn)這種情況。
當A服務器將一個key設置超時時間為5秒鐘,獲取到鎖執(zhí)行業(yè)務邏輯,但是呢,5秒鐘沒有執(zhí)行完,此時key由于到了過期時間而被刪除了。正好B服務器進行了獲取鎖操作,發(fā)現(xiàn)key沒有上鎖,進而加鎖開始執(zhí)行業(yè)務邏輯。過了1秒后,A服務器執(zhí)行完畢,執(zhí)行釋放所操作,del(key),將B服務器上的鎖給刪除了。A、B服務器對同一個可以執(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服務器加鎖,而B服務器解鎖的情況。
答案:是。
由于判斷和del()操作不是原子性的,那么就會存在判斷后,讓其他服務器刪除的情況;
例如:A服務器加鎖,執(zhí)行業(yè)務邏輯,很快執(zhí)行完畢,進行解鎖操作,解鎖判斷,OK,準備進行del()操作,此時CPU切換到執(zhí)行別的操作了,或者JVM虛擬機進行垃圾回收操作。這時候,key到了過期時間,B服務器執(zhí)行獲取到鎖,執(zhí)行業(yè)務邏輯,還沒執(zhí)行完成,A服務器復活,執(zhí)行del()操作,刪除key;此時,A服務器上的鎖,超時而被刪除,B服務器加鎖,A服務器將其刪除;
終極大招,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));
總體代碼結構為:
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è)務邏輯
}catch (Exception e){
}finally {
jedis.eval(luaScript,Arrays.asList(key),Arrays.asList(val));
}
}
通過lua腳本,解決了 解鈴還須系鈴人 的問題,但是并沒有解決由于A服務器執(zhí)行時間過長,導致鎖失效,從而使得B服務器獲取到了鎖,對同一個key執(zhí)行了相同的邏輯。
筆者想到了兩種方式。首先,需要確認的是,以上的情況發(fā)生概率很低,如果你的系統(tǒng)并發(fā)量不大,業(yè)務邏輯不復雜的話,基本上很難遇到這個誤刪除的問題,或者A、B服務器都對同一個key執(zhí)行業(yè)務邏輯的問題。
第一個解決辦法,我給他起名叫“懶政”,意思是重復執(zhí)行就重復執(zhí)行吧,不影響數(shù)據(jù)的一致性就行。但是,此解決辦法有個前提條件,不影響數(shù)據(jù)的最終一致性。比如說,在加鎖的業(yè)務邏輯中有一個遠程調(diào)用的接口,此接口不是冪等性的,你調(diào)用幾次,此接口就接受幾次你的數(shù)據(jù),那么這種情況下就不能使用該方法。還有就是說,在業(yè)務邏輯中有一個update數(shù)據(jù)庫更新操作,sql為 update set amount = amount - 10 where id = 1 and amount >=0,很常見的金額更新操作,但是如果對id為1的數(shù)據(jù)連續(xù)執(zhí)行2次,那金額就不對了,這個是個大問題,這種情況也不行。
第二個解決辦法是,守護線程,當A服務器設置的鎖要超時的時候,守護線程再對該鎖進行續(xù)命,加血,延長存活時間。
守護線程例子:
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();
}
}
}
當設置setDaemon為true時,當main方法執(zhí)行結束后,新啟動的線程也隨之結束,這就是守護線程,守護這main方法主線程,隨著main結束而結束;
具體實現(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;
}
//開啟守護線程:
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è)務邏輯
}catch (Exception e){
}finally {
jedis.eval(luaScript,Arrays.asList(key),Arrays.asList(val));
}
}
與之前的一樣,首先我們來對key進行加鎖,設置超時間。獲取到鎖后,開啟守護線程,再對key進行超時間設置,增加其壽命,接下來進行睡眠,如果在睡眠后還能繼續(xù)執(zhí)行,則說明此業(yè)務邏輯執(zhí)行還未結束,再次對key進行壽命延長。
如果業(yè)務線程結束,那么守護線程也隨之結束。
至此,如上便是redis實現(xiàn)分布式鎖的邏輯。