分布式鎖一般有三種實(shí)現(xiàn)方式:
- 基于數(shù)據(jù)庫(kù)的鎖;
- 基于Redis的分布式鎖;
- 基于ZooKeeper的分布式鎖。
本篇將介紹第二種方式,基于Redis實(shí)現(xiàn)分布式鎖。
使用分布式鎖要滿足的幾個(gè)條件
1.系統(tǒng)是一個(gè)分布式系統(tǒng)(關(guān)鍵是分布式,單機(jī)的可以使用ReentrantLock或者synchronized代碼塊來(lái)實(shí)現(xiàn))
2.共享資源(各個(gè)系統(tǒng)訪問(wèn)同一個(gè)資源,資源的載體可能是傳統(tǒng)關(guān)系型數(shù)據(jù)庫(kù)或者NoSQL)
3.同步訪問(wèn)(即有很多個(gè)進(jìn)程同事訪問(wèn)同一個(gè)共享資源。沒(méi)有同步訪問(wèn),誰(shuí)管你資源競(jìng)爭(zhēng)不競(jìng)爭(zhēng))
使用命令介紹
SETNX
SETNX key val
當(dāng)且僅當(dāng)key不存在時(shí),set一個(gè)key為val的字符串,返回1;若key存在,則什么都不做,返回0。
expire
expire key timeout
為key設(shè)置一個(gè)超時(shí)時(shí)間,單位為second,超過(guò)這個(gè)時(shí)間鎖會(huì)自動(dòng)釋放,避免死鎖。
delete
delete key
刪除key
實(shí)現(xiàn)過(guò)程
簡(jiǎn)單版本
實(shí)現(xiàn)思路:SETNX命令只有當(dāng)key不存在時(shí)才能設(shè)值成功,返回值為1;key存在設(shè)值失敗,返回0。
public class testLock {
public static void acquire(String lock){
while(jedis.setnx(lock, "") == 0){}
}
public static void release(String lock){
jedis.del(lock);
jedis.close();
}
}
在acquire方法內(nèi)部,循環(huán)設(shè)置某個(gè)key的值,直到設(shè)置成功。release方法中刪除這個(gè)key,代表釋放鎖。
存在的問(wèn)題
如果有多個(gè)客戶端競(jìng)爭(zhēng)同一個(gè)分布式鎖,如果三個(gè)客戶端中,有任意一個(gè)線程在調(diào)用acquire成功之后異常退出,沒(méi)有釋放鎖,另外兩個(gè)客戶端會(huì)死循環(huán)等待在SETNX命令上。
按照Redis文檔給出的一種解決方法,重新修改acquire方法:
public static void acquire(String lock){
//1.先嘗試用setnx命令獲取鎖,key=lock,value=當(dāng)前時(shí)間+要持有鎖的時(shí)間hold_time
while(jedis.setnx(lock, String.valueOf(System.currentTimeMillis() + hold_time)) == 0){
//2.如果獲取失敗,檢查lock對(duì)應(yīng)的值是否已超時(shí)
String expireTime = jedis.get(lock);
if(expireTime != null && Long.parseLong(expireTime) < System.currentTimeMillis()){
//3.如果已經(jīng)超時(shí)了,刪除lock,獲取鎖
jedis.del(lock);
jedis.setnx(lock, String.valueOf(System.currentTimeMillis() + hold_time))
break;
}
}
}
這樣就解決了死鎖的問(wèn)題,但是還有一個(gè)嚴(yán)重的問(wèn)題
C0操作超時(shí)了,但它還持有著鎖,C1和C2讀取lock.foo檢查時(shí)間戳,先后發(fā)現(xiàn)超時(shí)了。
C1 發(fā)送DEL lock.foo
C1 發(fā)送SETNX lock.foo 并且成功了。
C2 發(fā)送DEL lock.foo
C2 發(fā)送SETNX lock.foo 并且成功了。
這樣一來(lái),C1,C2都拿到了鎖!問(wèn)題大了!
針對(duì)這個(gè)問(wèn)題,
繼續(xù)修改acquire方法:
public static void acquire(String lock){
//1.先嘗試用setnx命令獲取鎖,key=lock,value=當(dāng)前時(shí)間+要持有鎖的時(shí)間hold_time
while(jedis.setnx(lock, String.valueOf(System.currentTimeMillis() + hold_time)) == 0){
//2.如果獲取失敗,檢查lock對(duì)應(yīng)的值是否已超時(shí)
String expireTime = jedis.get(lock);
if(expireTime != null && Long.parseLong(expireTime) < System.currentTimeMillis()){
//3.如果已經(jīng)超時(shí)了,使用getset命令,設(shè)置新的超時(shí)時(shí)間
String oldExpire = jedis.getSet(lock, String.valueOf(System.currentTimeMillis() + hold_time));
if(oldExpire != null && Long.parseLong(expireTime) < System.currentTimeMillis()){
//4.如果setget命令返回的值,依然是過(guò)期時(shí)間,認(rèn)為獲取鎖成功
break;
}
}
}
}
這樣就解決了上述的c1,c2的問(wèn)題,但這個(gè)版本依舊有兩個(gè)問(wèn)題沒(méi)有解決:
1.有效期時(shí)間戳覆蓋問(wèn)題:持有鎖的客戶端1異常退出,其余多個(gè)客戶端同時(shí)執(zhí)行setnx失敗,獲取expireTime,發(fā)現(xiàn)已經(jīng)小于currentTime,開始執(zhí)行g(shù)etset命令。假設(shè)客戶端2先執(zhí)行了getset,獲取鎖成功??蛻舳?在執(zhí)行g(shù)etset時(shí),返回的是客戶端2設(shè)置的未超時(shí)的時(shí)間戳,是一個(gè)未超時(shí)的時(shí)間,獲取鎖失敗??雌饋?lái)沒(méi)有問(wèn)題,但客戶端2持有的鎖的有效期時(shí)間戳已經(jīng)被客戶端3修改了。
2.超時(shí)問(wèn)題:如果客戶端2在持有鎖的期間,由于操作還沒(méi)有完成,但鎖已經(jīng)超時(shí)了。這時(shí)其它客戶端會(huì)拿到鎖,和超時(shí)的客戶端一起訪問(wèn)redis,不滿足互斥條件。
解決問(wèn)題
public class SimpleRedisLock {
public static long hold_time = 3000;
public static ThreadLocal<String> expireHolder = new ThreadLocal<>();
public static void acquire(String lock){
//1.先嘗試用setnx命令獲取鎖,key為參數(shù)lock,值為當(dāng)前時(shí)間+要持有鎖的時(shí)間hold_time
while(jedis.setnx(lock, String.valueOf(System.currentTimeMillis() + hold_time)) == 0){
//2.如果獲取失敗,先watch lock key
jedis.watch(lock);
//3.獲取當(dāng)前超時(shí)時(shí)間
String expireTime = jedis.get(lock);
if(expireTime != null && Long.parseLong(expireTime) < System.currentTimeMillis()){
//4.如果超時(shí)時(shí)間小于當(dāng)前時(shí)間,開事務(wù)準(zhǔn)備更新lock值
Transaction transaction = jedis.multi();
Response<String> response = transaction.getSet(lock, String.valueOf(System.currentTimeMillis() + hold_time));
//5.步驟2設(shè)置了watch,如果lock的值被其他線程修改,不是執(zhí)行事務(wù)中的命令
if(transaction.exec() != null){
String oldExpire = response.get();
if(oldExpire != null && Long.parseLong(expireTime) < System.currentTimeMillis()){
//6.如果setget命令返回的值依然是過(guò)期時(shí)間,認(rèn)為獲取鎖成功(加了watch之后,這里返回的應(yīng)該一直是超時(shí)時(shí)間)
break;
}
}
}else{
//如果key未超時(shí),解除watch
jedis.unwatch();
}
}
//設(shè)置客戶端超時(shí)時(shí)間
expireHolder.set(jedis.get(lock));
}
public static void release(String lock){
//比較客戶端超時(shí)時(shí)間與lock值,判斷是否還由自己持有鎖
if(jedis.get(lock).equals(expireHolder.get())){
jedis.del(lock);
}
jedis.close();
}
}
新的acquire方法,通過(guò)watch、redis事務(wù),保證只有一個(gè)客戶端能執(zhí)行g(shù)etset,并記錄了鎖超時(shí)時(shí)間,解決了問(wèn)題一的麻煩。
對(duì)于鎖超時(shí)導(dǎo)致的兩個(gè)客戶端同時(shí)訪問(wèn)資源,要么靠業(yè)務(wù)代碼保證鎖超時(shí)時(shí)間內(nèi)可以完成處理;要么在release時(shí)檢查是否超時(shí),如果超時(shí)回滾所有操作,但對(duì)不能回滾的,例如++操作就比較麻煩,或者放棄死鎖容錯(cuò)功能。
Redis分布式鎖的獲取鎖的問(wèn)題就到這里了,具體怎么使用還要看實(shí)際業(yè)務(wù)場(chǎng)景。