[TOC]
分散式鎖實現彙總
很多時候我們需要保證同一時間一個方法只能被同一個執行緒呼叫,在單機環境中,Java中其實提供了很多併發處理相關的API,但是這些API在分散式場景中就無能為力了。也就是說單純的Java Api並不能提供分散式鎖的能力。
針對分散式鎖的實現目前有多種方案:
- 基於資料庫實現分散式鎖
- 基於快取(redis,memcached)實現分散式鎖
- 基於Zookeeper實現分散式鎖
基於資料庫實現分散式鎖
簡單實現
直接建一張表,裡面記錄鎖定的方法名
時間
即可。
需要加鎖時,就插入一條資料,釋放鎖時就刪除資料。
CREATE TABLE `methodLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '鎖定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '備註資訊',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '儲存資料時間,自動生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';複製程式碼
當我們想要鎖住某個方法時,執行以下SQL:
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)複製程式碼
因為我們對method_name做了唯一性約束,這裡如果有多個請求同時提交到資料庫的話,資料庫會保證只有一個操作可以成功,那麼我們就可以認為
操作成功的那個執行緒獲得了該方法的鎖,可以執行方法體內容。
當方法執行完畢之後,想要釋放鎖的話,需要執行以下Sql:
delete from methodLock where method_name ='method_name'複製程式碼
存在的問題
- 這把鎖強依賴資料庫的可用性,資料庫是一個單點,一旦資料庫掛掉,會導致業務系統不可用。
- 這把鎖沒有失效時間,一旦解鎖操作失敗,就會導致鎖記錄一直在資料庫中,其他執行緒無法再獲得到鎖。
- 這把鎖只能是非阻塞的,因為資料的insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的執行緒並不會進入排隊佇列,要想再次獲得鎖就要再次觸發獲得鎖操作。
- 這把鎖是非重入的,同一個執行緒在沒有釋放鎖之前無法再次獲得該鎖。因為資料中資料已經存在了。
解決辦法
- 單點問題可以用多資料庫例項,同時塞N個表,N/2+1個成功就任務鎖定成功
- 寫一個定時任務,隔一段時間清除一次過期的資料。
- 寫一個while迴圈,不斷的重試插入,直到成功。
- 在資料庫表中加個欄位,記錄當前獲得鎖的機器的主機資訊和執行緒資訊,那麼下次再獲取鎖的時候先查詢資料庫,如果當前機器的主機資訊和執行緒資訊在資料庫可以查到的話,直接把鎖分配給他就可以了。
總結
資料庫實現分散式鎖的優點: 直接藉助資料庫,容易理解。
資料庫實現分散式鎖的缺點: 會有各種各樣的問題,在解決問題的過程中會使整個方案變得越來越複雜。
運算元據庫需要一定的開銷,效能問題需要考慮。
基於快取實現分散式鎖
相比於用資料庫來實現分散式鎖,基於快取實現的分散式鎖的效能會更好一些。
目前有很多成熟的分散式產品,包括Redis、memcache、Tair等。
單點實現
步驟
- 獲取鎖的使用,使用setnx加鎖,將值設為當前的時間戳,再使用expire設定一個過期值。
- 獲取到鎖則執行同步程式碼塊,沒獲取則根據業務場景可以選擇自旋、休眠、或做一個等待佇列等擁有鎖程式來喚醒(類似Synchronize的同步佇列),在等待時使用ttl去檢查是否有過期值,如果沒有則使用expire設定一個。
- 執行完畢後,先根據value的值來判斷是不是自己的鎖,如果是的話則刪除,不是則表明自己的鎖已經過期,不需要刪除。(此時出現由於過期而導致的多程式同時擁有鎖的問題)
存在的問題
- 單點問題。如果單機redis掛掉了,那麼程式會跟著出錯
- 如果轉移使用 slave節點,複製不是同步複製,會出現多個程式獲取鎖的情況
code
public Object around(ProceedingJoinPoint joinPoint) {
try {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
DLock dLock = method.getAnnotation(DLock.class);
if (dLock != null) {
String lockedPrefix = buildLockedPrefix(dLock, method, joinPoint.getArgs());
long timeOut = dLock.timeOut();
int expireTime = dLock.expireTime();
long value = System.currentTimeMillis();
if (lock(lockedPrefix, timeOut, expireTime, value)) {
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
} finally {
unlock(lockedPrefix, value);
}
} else {
recheck(lockedPrefix, expireTime);
}
}
} catch (Exception e) {
logger.error("DLockAspect around error", e);
}
return null;
}
/**
* 檢查是否設定過超時
*
* @param lockedPrefix
* @param expireTime
*/
public void recheck(String lockedPrefix, int expireTime) {
try {
Result<Long> ttl = cacheFactory.getFactory().ttl(getLockedPrefix(lockedPrefix));
if (ttl.isSuccess() && ttl.getValue() == -1) {
Result<String> get = cacheFactory.getFactory().get(getLockedPrefix(lockedPrefix));
//沒有超時設定則設定超時
if (get.isSuccess() && !StringUtils.isEmpty(get.getValue())) {
long oldTime = Long.parseLong(get.getValue());
long newTime = expireTime * 1000 - (System.currentTimeMillis() - oldTime);
if (newTime < 0) {
//已過超時時間 設預設最小超時時間
cacheFactory.getFactory().expire(getLockedPrefix(lockedPrefix), MIX_EXPIRE_TIME);
} else {
//未超過 設定為剩餘超時時間
cacheFactory.getFactory().expire(getLockedPrefix(lockedPrefix), (int) newTime);
}
logger.info(lockedPrefix + "recheck:" + newTime);
}
}
logger.info(String.format("執行失敗lockedPrefix:%s count:%d", lockedPrefix, count++));
} catch (Exception e) {
logger.error("DLockAspect recheck error", e);
}
}
public boolean lock(String lockedPrefix, long timeOut, int expireTime, long value) {
long millisTime = System.currentTimeMillis();
try {
//在timeOut的時間範圍內不斷輪詢鎖
while (System.currentTimeMillis() - millisTime < timeOut * 1000) {
//鎖不存在的話,設定鎖並設定鎖過期時間,即加鎖
Result<Long> result = cacheFactory.getFactory().setnx(getLockedPrefix(lockedPrefix), String.valueOf(value));
if (result.isSuccess() && result.getValue() == 1) {
Result<Long> result1 = cacheFactory.getFactory().expire(getLockedPrefix(lockedPrefix), expireTime);
logger.info(lockedPrefix + "locked and expire " + result1.getValue());
return true;
}
//短暫休眠,避免可能的活鎖
Thread.sleep(100, RANDOM.nextInt(50000));
}
} catch (Exception e) {
logger.error("lock error " + getLockedPrefix(lockedPrefix), e);
}
return false;
}
public void unlock(String lockedPrefix, long value) {
try {
Result<String> result = cacheFactory.getFactory().get(getLockedPrefix(lockedPrefix));
String kvValue = result.getValue();
if (!StringUtils.isEmpty(kvValue) && kvValue.equals(String.valueOf(value))) {
cacheFactory.getFactory().del(getLockedPrefix(lockedPrefix));
}
logger.info(lockedPrefix + "unlock:" + kvValue + "----" + value);
} catch (Exception e) {
logger.error("unlock error" + getLockedPrefix(lockedPrefix), e);
}
}複製程式碼
RedLock
Redlock是Redis的作者antirez給出的叢集模式的Redis分散式鎖,它基於N個完全獨立的Redis節點(通常情況下N可以設定成5)。
步驟
- 獲取當前時間(毫秒數)。
- 按順序依次向N個Redis節點執行獲取鎖的操作。這個獲取操作跟前面基於單Redis節點的獲取鎖的過程相同,包含隨機字串my_random_value,也包含過期時間(比如PX 30000,即鎖的有效時間)。為了保證在某個Redis節點不可用的時候演算法能夠繼續執行,這個獲取鎖的操作還有一個超時時間(time out),它要遠小於鎖的有效時間(幾十毫秒量級)。客戶端在向某個Redis節點獲取鎖失敗以後,應該立即嘗試下一個Redis節點。這裡的失敗,應該包含任何型別的失敗,比如該Redis節點不可用,或者該Redis節點上的鎖已經被其它客戶端持有(注:Redlock原文中這裡只提到了Redis節點不可用的情況,但也應該包含其它的失敗情況)。
- 計算整個獲取鎖的過程總共消耗了多長時間,計算方法是用當前時間減去第1步記錄的時間。如果客戶端從大多數Redis節點(>= N/2+1)成功獲取到了鎖,並且獲取鎖總共消耗的時間沒有超過鎖的有效時間(lock validity time),那麼這時客戶端才認為最終獲取鎖成功;否則,認為最終獲取鎖失敗。
- 如果最終獲取鎖成功了,那麼這個鎖的有效時間應該重新計算,它等於最初的鎖的有效時間減去第3步計算出來的獲取鎖消耗的時間。
- 如果最終獲取鎖失敗了(可能由於獲取到鎖的Redis節點個數少於N/2+1,或者整個獲取鎖的過程消耗的時間超過了鎖的最初有效時間),那麼客戶端應該立即向所有Redis節點發起釋放鎖的操作。
優化
客戶端1成功鎖住了A, B, C,獲取鎖成功(但D和E沒有鎖住);節點C崩潰重啟了,但客戶端1在C上加的鎖沒有持久化下來,丟失了;節點C重啟後,客戶端2鎖住了C, D, E,獲取鎖成功。客戶端1和客戶端2同時獲得了鎖(針對同一資源)。
這個問題可以延遲節點的恢復時間,時間長度應大於等於一個鎖的過期時間。
存在的問題
- 時鐘發生跳躍。這種情況發生時,直接導致所有的鎖都超時,新的執行緒可以成功的獲取鎖,導致多執行緒同時處理。
關於RedLock的更多內容可以看:
一個比較好的實現:
Zookeeper鎖
步驟
- 每個客戶端對某個方法加鎖時,在zookeeper上的與該方法對應的指定節點的目錄下,生成一個唯一的瞬時有序節點。
- 判斷是否獲取鎖的方式很簡單,只需要判斷有序節點中序號最小的一個。
- 當釋放鎖的時候,只需將這個瞬時節點刪除即可。同時,其可以避免服務當機導致的鎖無法釋放,而產生的死鎖問題。
優點
無單點問題。ZK是叢集部署的,只要叢集中有半數以上的機器存活,就可以對外提供服務。
持有鎖任意長的時間,可自動釋放鎖。使用Zookeeper可以有效的解決鎖無法釋放的問題,因為在建立鎖的時候,客戶端會在ZK中建立一個臨時節點,一旦客戶端獲取到鎖之後突然掛掉(Session連線斷開),那麼這個臨時節點就會自動刪除掉。其他客戶端就可以再次獲得鎖。這避免了基於Redis的鎖對於有效時間(lock validity time)到底設定多長的兩難問題。實際上,基於ZooKeeper的鎖是依靠Session(心跳)來維持鎖的持有狀態的,而Redis不支援Sesion。
可阻塞。使用Zookeeper可以實現阻塞的鎖,客戶端可以通過在ZK中建立順序節點,並且在節點上繫結監聽器,一旦節點有變化,Zookeeper會通知客戶端,客戶端可以檢查自己建立的節點是不是當前所有節點中序號最小的,如果是,那麼自己就獲取到鎖,便可以執行業務邏輯了。
可重入。客戶端在建立節點的時候,把當前客戶端的主機資訊和執行緒資訊直接寫入到節點中,下次想要獲取鎖的時候和當前最小的節點中的資料比對一下就可以了。如果和自己的資訊一樣,那麼自己直接獲取到鎖,如果不一樣就再建立一個臨時的順序節點,參與排隊。
問題
- 這種做法可能引發
羊群效應
,從而降低鎖的效能。 - 效能不如快取。因為每次在建立鎖和釋放鎖的過程中,都要動態建立、銷燬瞬時節點來實現鎖功能。ZK中建立和刪除節點只能通過Leader伺服器來執行,然後將資料同不到所有的Follower機器上。
一個比較好的實現:
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
try {
return interProcessMutex.acquire(timeout, unit);
} catch (Exception e) {
e.printStackTrace();
}
return true;
}
public boolean unlock() {
try {
interProcessMutex.release();
} catch (Throwable e) {
log.error(e.getMessage(), e);
} finally {
executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);
}
return true;
}複製程式碼
acquire方法使用者獲取鎖,release方法用於釋放鎖。
總結
使用Zookeeper實現分散式鎖的優點: 有效的解決單點問題,不可重入問題,非阻塞問題以及鎖無法釋放的問題。實現起來較為簡單。
使用Zookeeper實現分散式鎖的缺點 : 效能上不如使用快取實現分散式鎖。 需要對ZK的原理有所瞭解。
三種方案的比較
從理解的難易程度角度(從低到高): 資料庫 > 快取 > Zookeeper
從實現的複雜性角度(從低到高): Zookeeper >= 快取 > 資料庫
從效能角度(從高到低): 快取 > Zookeeper >= 資料庫
從可靠性角度(從高到低): Zookeeper > 快取 > 資料庫