詳解分佈式鎖的三種實現方式

前言

分佈式鎖的背景

我們在開發應用的時候,如果需要對某一個共享變量進行多線程同步訪問的時候,可以使用我們學到的Java多線程的18般武藝進行處理,並且可以完美的運行,毫無Bug。

但注意這是單機應用,也就是所有的請求都會分配到當前服務器的JVM內部,然後映射爲操作系統的線程進行處理!而這個共享變量只是在這個JVM內部的一塊內存空間。爲了保證一個方法或屬性在高併發情況下的同一時間只能被同一個線程執行,在傳統單體應用單機部署的情況下,可以使用Java併發處理相關的API(如ReentrantLock或Synchronized)進行互斥控制。在單機環境中,Java中提供了很多併發處理相關的API。

但是,隨着業務發展的需要,原單體單機部署的系統被演化成分佈式集羣系統後,由於分佈式系統多線程、多進程並且分佈在不同機器上,這將使原單機部署情況下的併發控制鎖策略失效,單純的Java API並不能提供分佈式鎖的能力。爲了解決這個問題就需要一種跨JVM的互斥機制來控制共享資源的訪問,這就是分佈式鎖要解決的問題。

對分佈式鎖的要求

  1. 分佈式互斥性:在分佈式系統環境下,一個同步資源在同一時間只能被一個機器的一個線程執行;
  2. 高可用:高可用的獲取鎖與釋放鎖;
  3. 高性能:高性能的獲取鎖與釋放鎖;
  4. 可重入性:具備可重入特性;
  5. 可失效性:具備鎖失效機制,防止死鎖;
  6. 非阻塞性:具備非阻塞鎖特性,即沒有獲取到鎖將直接返回獲取鎖失敗。

分佈式鎖的實現方向

目前幾乎很多大型網站及應用都是分佈式部署的,分佈式場景中的數據一致性問題一直是一個比較重要的話題。

分佈式的CAP理論告訴我們,任何一個分佈式系統都無法同時滿足一致性(Consistency)、可用性(Availability)和分區容錯性(Partition tolerance),最多隻能同時滿足兩項。所以,很多系統在設計之初就要對這三者做出取捨。

在分佈式場景下,CAP理論是很多架構設計的指導思想。CAP思想下有兩個分支CP與AP:

  • CP模型強調不管什麼情況下,都要求各服務之間的數據一致;CP模型仍然保持原有的一致性要求,保證了業務資源串行競爭,更加適合於金融交易場景的強數據要求。
  • AP模型強調高可用下的數據最終一致性。基於此,AP模式可以做到更高的併發性能。

這也導致了分佈式鎖的實現也分爲了CP型和AP型兩種方向。雖然傳統含義的鎖要求強一致性CP模型,但AP模型分佈式鎖也並非沒有用武之地,其使用取決於業務場景對髒數據的最大容忍度。

1 基於數據庫實現分佈式鎖

1.1 數據庫分佈式鎖的設計實現

基於數據庫的實現方式的核心思想是:在數據庫中創建一個表,表中包含lock_name等字段,並在lock_name字段上創建唯一索引,多個客戶端同時向表中插入數據,成功插入則獲取鎖,執行完成後刪除對應的行數據釋放鎖。

CREATE TABLE `lock_record` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `lock_name` varchar(50) DEFAULT NULL COMMENT '鎖名稱',
  `expire_time` bigint(20) DEFAULT NULL COMMENT '過期時間',
  `version` int(11) DEFAULT NULL COMMENT '版本號',
  `lock_owner` varchar(100) DEFAULT NULL COMMENT '鎖擁有者',
  PRIMARY KEY (`id`),
  UNIQUE KEY `lock_name` (`lock_name`)
)

因爲我們對lock_name做了唯一性約束,所以這裏如果有多個請求同時提交到數據庫的話,數據庫會保證只有一個INSERT操作可以成功,鎖的互斥性得到了保證。

注意:這只是使用基於數據庫的一種方法,使用數據庫實現分佈式鎖還有很多其他的玩法。

1.2 數據庫分佈式鎖的優化

使用基於數據庫的這種實現方式很簡單,但是對於分佈式鎖應該具備的條件來說,它有一些問題需要解決及優化:

  1. 因爲是基於數據庫實現的,數據庫的可用性和性能將直接影響分佈式鎖的可用性及性能,

    • 數據庫需要雙機部署、數據同步、主備切換;
  2. 不具備可重入的特性,因爲同一個線程在釋放鎖之前,行數據一直存在,無法再次成功插入數據。

    • 需要在表中新增一列,用於記錄當前獲取到鎖的機器和線程信息,在再次獲取鎖的時候,先查詢表中機器和線程信息是否和當前機器和線程相同,若相同則直接獲取鎖;
    • 或者仿照zk對可重入鎖的實現,使用一個map記錄當前獲取的鎖對象。
  3. 沒有鎖失效機制,因爲有可能出現成功插入數據後,服務器宕機了,對應的數據沒有被刪除,當服務恢復後一直獲取不到鎖。

    • 需要在表中新增一列,用於記錄失效時間,並且需要有定時任務清除這些失效的數據,或者代碼中判斷失效時間,再使用樂觀鎖機制去互斥性的爭鎖。

基於數據庫實現分佈式鎖,在實施的過程中會遇到各種不同的問題,爲了解決這些問題,實現方式將會越來越複雜;依賴數據庫需要一定的資源開銷,性能問題需要考慮。

一般來說,基於數據庫實現分佈式鎖,不是一個最好的選擇。

1.3 數據庫分佈式鎖的邏輯實現

1.3.1 獲取鎖邏輯

// 定義一個map,用來做可重入,模仿zk的可重入實現。
private final ConcurrentMap<Thread, LockData> threadData;
/**
* @param lockName 鎖名稱
* @param lockTime 鎖時間
* @return
*/
public boolean acquire(String lockName, Long lockTime) {
    Thread currentThread = Thread.currentThread();
    LockData lockData = threadData.get(currentThread);
    // 重入
    if (lockData != null) {
        lockData.lockCount.incrementAndGet();
        return true;
    }
    // 先檢查是否被其他客戶端鎖上了
    LockRecordDTO lockRecord = lockRecordMapperExt.selectByLockName(lockName);
    if (lockRecord == null) {// 鎖還不存在
        String lockOwner = generatorOwner();
        // 嘗試獲取鎖
        boolean acquired = tryAcquireIfLockNotExist(lockName, lockTime, lockOwner);
        if (acquired) {// 獲取到了
            startExtendExpireTimeTask(lockName, lockOwner, lockTime);
            lockData = new LockData(currentThread, lockName, lockOwner);
            // 放入map
            threadData.put(currentThread, lockData);
        }
        return acquired;
    }
    // 鎖已經存在了,檢查它的過期時間
    long lockExpireTime = lockRecord.getExpireTime();
    // 如果已經過期,那麼再次爭鎖,只存在於上一次獲取鎖的線程沒有正確釋放鎖時
    if (lockExpireTime < System.currentTimeMillis()) {
        // 當上一次獲取鎖的線程沒有正確釋放鎖時,其他線程獲取鎖時會走到這裏
        String lockOwner = generatorOwner();
        boolean acquired = tryAcquireIfLockExist(lockRecord, lockTime, lockOwner);
        if (acquired) {
            lockData = new LockData(currentThread, lockName, lockOwner);
            threadData.put(currentThread, lockData);
        }
        return acquired;
    }
    return false;
}

/**
* 嘗試獲得鎖,數據庫表有設置唯一鍵約束,只有插入成功的線程纔可以獲取鎖
*
* @param lockName  鎖名稱
* @param lockTime  鎖的過期時間
* @param lockOwner 鎖的擁有者
* @return
*/
private boolean tryAcquireIfLockNotExist(String lockName, long lockTime, String lockOwner) {
    try {
        LockRecordDTO lockRecord = new LockRecordDTO();
        lockRecord.setLockName(lockName);
        Long expireTime = System.currentTimeMillis() + lockTime;
        lockRecord.setExpireTime(expireTime);
        lockRecord.setLockOwner(lockOwner);
        lockRecord.setVersion(0);
        int insertCount = lockRecordMapperExt.insert(lockRecord);
        return insertCount == 1;
    } catch (Exception exp) {
        return false;
    }
}

/**
* 當上一次獲取鎖的線程沒有正確釋放鎖時,下一次其他線程獲取鎖時會調用本方法
* 這時候也不用刪除鎖了,直接再利用就好,此時就是用樂觀鎖來控制互斥性了
* 當多個線程競爭獲取鎖時,有樂觀鎖控制,只有更新成功的線程纔會獲的鎖
*
* @param lockRecord 鎖記錄,裏面保存了上一次獲取鎖的擁有者信息
* @param lockTime   鎖過期時間
* @param lockOwner  鎖的擁有者
* @return
*/
private boolean tryAcquireIfLockExist(LockRecordDTO lockRecord, long lockTime, String lockOwner) {
    try {
        // 獲取鎖時,如果數據庫中有記錄且超時時間小於當前時間,說明持有鎖的客戶端崩潰退出了,
        // 沒有正確釋放鎖,纔會導致表中有過期的記錄。
        // 這時,併發的獲取鎖時,只有更新成功的線程纔可以獲取鎖。
        Long expireTime = System.currentTimeMillis() + lockTime;
        lockRecord.setExpireTime(expireTime);
        lockRecord.setLockOwner(lockOwner);
        int updateCount = lockRecordMapperExt.updateExpireTime(lockRecord);
        return updateCount == 1;
    } catch (Exception exp) {
        return false;
    }
}

對應樂觀鎖更新sql如下:

<update id="updateExpireTime" parameterType="com.iwill.db.model.LockRecordDTO">
    update lock_record
    set expire_time = #{expireTime},
     version = version + 1
    where lock_name = #{lockName} and version = #{version}
</update>

1.3.2 釋放鎖邏輯

釋放鎖時,只有持有鎖的線程纔可以釋放鎖,代碼如下:

/**
* 釋放鎖
* 實現參考zookeeper的鎖釋放機制
*/
public void release() {
    Thread currentThread = Thread.currentThread();
    LockData lockData = threadData.get(currentThread);
    if (lockData == null) {
        throw new RuntimeException("current thread do not own lock");
    }
    int newLockCount = lockData.lockCount.decrementAndGet();
    if (newLockCount > 0) {
        return;
    }
    if (newLockCount < 0) {
        throw new RuntimeException("Lock count has gone negative for lock :" + lockData.lockName);
    }
    try {
        lockRecordMapperExt.deleteByOwner(lockData.lockName, lockData.owner);
    } finally {
        threadData.remove(currentThread);
    }
}

對應的底層sql如下:

<delete id="deleteByOwner" parameterType="java.util.Map">
    delete from lock_record where lock_name = #{lockName} and lock_owner = #{lockOwner}
</delete>

2 基於Redis實現分佈式鎖

Redis具有很高的併發性能,並且Redis命令對分佈式鎖的支持較好,實現起來比較方便。這使得使用Redis實現分佈式鎖成爲了一種主流的實現方案。

2.1 Redis分佈式鎖的設計實現

Redis分佈式鎖分爲單節點和多節點兩種類型,我們分開論述。

2.1.1 單節點下的設計實現

單節點指的是一個Redis節點,即多個jvm對應一個Redis實例。主要是使用set命令加上NX PX選項(又叫做setNX命令)來實現Redis單機的分佈式鎖。

  1. 獲取鎖的時候,使用setnx加鎖,並使用expire命令爲鎖添加一個超時時間,超過該時間則自動釋放鎖,鎖的value值爲一個隨機生成的隨機數,通過此在釋放鎖的時候進行判斷。
  2. 獲取鎖的時候還設置一個獲取的超時時間,若超過這個時間則放棄獲取鎖。
  3. 釋放鎖的時候,通過隨機數判斷是不是該鎖,若是該鎖,則執行delete進行鎖釋放。

setnx加鎖語句如下:

SET key_name random_value NX PX 30000

早期版本的set命令沒有PX選項,導致執行完set命令後,還要在執行一次expire命令,現在二者可以合二爲一了。

  • setNX命令可以設置一個key,並且只有在這個key不存的情況下才能設置成功(由NX參數控制)。
  • 同時,對這個key設置了一個30000毫秒的過期時間(由PX參數控制)。避免客戶端加鎖後宕機無法釋放鎖從而引發死鎖的情況。
  • 這個key對應的值被設置爲一個隨機的值,但是必須保證這個隨機的值在所有的客戶端上都是唯一的。

隨機數的唯一性很重要,它被用來在釋放鎖時進行比較、判斷,也可以作爲鎖重入的依據,即如果value是自己設置的隨機數,那麼就表示自己現在持有鎖,可重入。

避免釋放一個由其他客戶端創建的鎖是非常重要的

例如:有個客戶端A獲取了鎖,但是執行了比較長時間的業務邏輯,以至於超過了鎖的生命週期(TTL)而讓鎖自動釋放掉了。

客戶端B之後嘗試獲取鎖,因爲之前的鎖過期了,所以客戶端B成功獲取到了。

不久後,客戶端A執行完業務邏輯,再去釋放這個鎖的時候,如果沒有檢驗隨機數,那麼客戶端A會將其誤認爲當前的鎖是自己之前持有的鎖,再進行刪除的話就會發生問題。

所以只使用單獨的刪除命令會誤刪除已被其他客戶端獲取的鎖。

加入隨機數就是爲了防止這個問題:

  1. 客戶端A執行setNX的時候,key=lock對應的value是random_A,一段時間後,鎖過期失效。
  2. 客戶端B執行setNX的時候,key=lock對應的value是random_B。
  3. 客戶端A執行完業務邏輯,準備刪除鎖,此時他判斷了一下key=lock的value,發現是random_B,那顯然當前的鎖不是自己加的鎖,不能刪除。

用一段腳本執行以下語義:只有在key存在並且key對應的value值與隨機數的值相等時才能被刪除

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

隨機數的選擇有很多,最簡單的方法就是取unix的當前系統時間,轉換成毫秒形式,然後粘連到客戶端ID的後面作爲唯一隨機數,雖然這種方式不是很安全,但是能滿足大部分需求了。

2.1.2 多節點的分佈式鎖實現——Redlock算法

單節點下的分佈式鎖實現不可避免會出現單點問題,即單機Redis掛了就沒有災備了。正因爲如此,Redis作者antirez基於分佈式環境下提出了一種更高級的多節點分佈式鎖的實現方式:Redlock。

antirez提出的Redlock算法大概是這樣的:

  • 在Redis的分佈式環境中,我們假設有N個Redis master。這些節點完全互相獨立,不存在主從複製或者其他集羣協調機制。

  • 我們確保將在N個實例上使用與在Redis單實例下相同方法獲取和釋放鎖。現在我們假設有5個Redis master節點,同時我們需要在5臺服務器上面運行這些Redis實例,這樣保證他們不會同時都宕掉。

爲了取到鎖,客戶端應該執行以下操作:

  1. 獲取當前Unix時間,以毫秒爲單位,記爲start time。

  2. 嘗試對5個實例依次使用setNX命令,它們相同的key和具有唯一性的value(例如UUID)獲取鎖。

  3. 當向Redis請求獲取鎖時,客戶端應該設置一個connect timeout時間和read timeout時間,這個超時時間應該遠遠小於鎖的過期時間。這樣單個redis宕機,不會導致客戶端一直等在那。如果服務器端沒有在規定時間內響應,客戶端應該儘快嘗試去另外一個Redis實例請求獲取鎖。

  4. 客戶端使用當前時間current time減去start time,就得到獲取鎖使用的時間,記爲work time。當且僅當從大多數((N/2)+1)的Redis節點都取到鎖,並且使用的時間小於鎖失效時間時,鎖纔算獲取成功。

  5. 如果取到了鎖,key的真正有效時間即爲work time的時間。

  6. 如果因爲某些原因,獲取鎖失敗(沒有在至少(N/2)+1個Redis實例取到鎖或者取鎖時間已經超過了有效時間),客戶端應該在所有的Redis實例上進行解鎖,即便某些Redis實例根本就沒有加鎖成功,這樣可以防止某些節點其實已經set成功,但是客戶端沒有得到響應而導致接下來的一段時間不能被重新獲取鎖。

  7. 獲取鎖失敗後,需要稍等一段時間再重試,避免發生多個客戶端同時申請鎖的情況。最好的情況是一個客戶端幾乎同時地向多個Redis節點發起鎖申請。

2.2 Redission封裝的Redlock邏輯實現

Redisson是一個在Redis的基礎上實現的Java駐內存數據網格,相較於暴露底層操作的Jedis,Redisson提供了一系列的分佈式的Java常用對象,還提供了許多分佈式服務

Redisson已經有對redlock算法封裝,接下來對其用法進行簡單介紹,並對核心源碼進行分析(假設5個redis實例)

<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
 <groupId>org.redisson</groupId>
 <artifactId>redisson</artifactId>
 <version>3.3.2</version>
</dependency>

我們來看一下Redission封裝的Redlock算法實現的分佈式鎖用法,非常簡單,跟重入鎖(ReentrantLock)有點類似

Config config = new Config();
config.useSentinelServers().addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389")
 .setMasterName("masterName")
 .setPassword("password").setDatabase(0);

RedissonClient redissonClient = Redisson.create(config);

// 還可以getFairLock(), getReadWriteLock()
RLock redLock = redissonClient.getLock("REDLOCK_KEY");
boolean isLock;
try {
    isLock = redLock.tryLock();

    // 500ms拿不到鎖, 就認爲獲取鎖失敗。10000ms即10s是鎖失效時間。
    isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);

    if (isLock) {
        //TODO if get lock success, do something;
    }
} catch (Exception e) {

} finally {
    // 無論如何, 最後都要解鎖
    redLock.unlock();
}

2.3 Redis分佈式鎖的弱正確性

Redis實現的分佈式鎖,是AP模式的分佈式鎖,它強調併發的性能,而非數據的強一致性。在極端情況下,它甚至無法保證絕對的互斥性。或者說,無法保證分佈式鎖的絕對正確性。

如下這些極端場景,都可能導致Redis的分佈式鎖不正確。

2.3.1 主從同步場景

Redis集羣中leader與slave之間的數據複製是採用異步的方式(因爲需要滿足高性能要求),即,leader將客戶端發送的寫請求記錄下來後,就給客戶端返回響應,後續該leader的slave節點就會從該leader節點複製數據。

那麼就會存在這麼一種可能性:leader接收了客戶端的寫請求,也給客戶端響應了,但是該數據還沒來得及複製到它對應的slave節點中,leader就宕機了。

注意,客戶端不會給slave發送獲取鎖的請求,Redlock算法要求多節點之間是完全獨立的,主從關係不可以存在。slave只是leader的災備。

此時從slave節點中重新選舉出來的leader也不包含之前leader最後寫的數據了,這時,客戶端來獲取同樣的鎖就可以獲取到,這樣就會在同一時刻,兩個客戶端持有鎖。

2.3.1 崩潰恢復場景

我們一般會開啓AOF來做持久化,假設Redis集羣中的某個master節點突然斷電,導致setNX命令沒來得及被AOF刷回磁盤就直接丟失了,導致重啓後的該節點不存在鎖數據。

這種情況幾乎難以避免,除非我們將AOF策略設置爲fsnyc = always,但這會損傷性能。

在Redlock官方文檔中也提到了這個情況,Redlock 官方本身也是知道Redlock算法不是完全可靠的,官方爲了解決這種問題建議使用延時啓動。

當一個節點重啓之後,我們規定在max TTL期間它是不可用的,這樣它就不會干擾原本已經申請到的鎖,等到它crash前的那部分鎖都過期了,環境不存在歷史鎖了,那麼再把這個節點加進來正常工作。

2.3.3 不可靠的時間

在不同的Redis節點上,它們的本地時間不是精確相同的,考慮到Redis使用 get time of day獲取時間而不是單調的時鐘,故而會受到系統時間的影響,可能會突然前進或者後退一段時間,這會導致一個key更快或更慢地過期。

在極端情況下,不精確一致的時間可能會帶來如下的問題:

  1. client1想ABCDE五個節點發送獲取鎖的請求,從ABC三個節點處申請到鎖,DE由於網絡原因請求沒有到達。
  2. 這之後,C節點的時鐘往前推了,導致lock提前過期。
  3. 此時client2也向五個節點獲取鎖,並在CDE處獲得了鎖,AB由於lock還未過期,導致set失敗。
  4. 此時client1和client2都獲得了鎖。

2.3.4 程序停頓

我們在假設一下程序停頓的情況:

  1. client1從ABCDE處獲得了鎖
  2. 當獲得鎖的response還沒到達client1時,client1進入GC停頓
  3. 停頓期間鎖已經過期了
  4. client2在ABCDE處獲得了鎖
  5. client1在GC完成後,也收到了獲得鎖的response,此時兩個 client 又拿到了同一把鎖

所以Redis實現的分佈式鎖不可用在對強一致性有嚴格要求的場景,如金融領域等。

2.4 總結

Martin Kleppmann在文章《How to do distributed locking》中認爲Redlock實在不是一個好的選擇,對於需求性能的分佈式鎖應用它太重了且成本高;對於需求正確性的應用來說它不夠安全。

因爲它對高危的時鐘或者說其他上述列舉的情況進行了不可靠的假設,如果你的應用只需要高性能的分佈式鎖不要求多高的正確性,那麼單節點Redis夠了;

如果你的應用想要保住正確性,那麼不建議Redlock,建議使用一個合適的一致性協調系統,例如Zookeeper,可能會更好。

3 基於Zookeeper實現分佈式鎖

ZooKeeper是一個爲分佈式應用提供一致性服務的開源組件,它內部是一個分層的文件系統目錄樹結構,規定同一個目錄下只能有一個唯一文件名。

作爲基於AP模式實現的zk來說,它天然的適合用來實現AP模型的分佈式鎖,zk能夠保證分佈式鎖的正確性,但因爲需要頻繁的創建和刪除節點,性能上不如Redis優秀。

3.1 zk分佈式鎖的設計實現

Zookeeper實現分佈式鎖的原理就是:

  1. 創建一個目錄mylock;

  2. 線程A想獲取鎖就在/lock-path目錄下創建臨時順序節點;

  3. 獲取/lock-path目錄下所有的子節點,然後獲取比自己小的兄弟節點,如果不存在,則說明當前線程順序號最小,獲得鎖;

  4. 線程B獲取所有節點,判斷自己不是最小節點,設置監聽比自己次小的節點;

  5. 線程A處理完,刪除自己的節點,線程B監聽到變更事件,判斷自己是不是最小的節點,如果是則獲得鎖。

流程如下:

可以看到,zk的實現原理,天然是公平鎖的原理,即客戶端在獲取鎖時,就創建了臨時會話順序節點,那麼它的順序就固定了。而不像Redis和數據庫實現的那樣,是非公平的。

zk分佈式鎖的可失效性,是根據臨時節點這個特性來的,如果某個客戶端獲取了鎖,在執行業務代碼期間宕機了,zk服務端的心跳檢測到客戶端失聯,第一步就會刪除這個客戶端創建的臨時節點。

3.2 Curator封裝的zk分佈式鎖邏輯實現

Curator是Netflix公司開源的一套Zookeeper客戶端框架。瞭解過Zookeeper原生API都會清楚其複雜度,Curator幫助我們在其基礎上進行封裝、實現一些開發細節,包括接連重連、反覆註冊Watcher和NodeExistsException等。目前已經作爲Apache的頂級項目出現,是最流行的Zookeeper客戶端之一。

Curator提供的InterProcessMutex是分佈式鎖的實現,acquire方法用於獲取鎖,release方法用於釋放鎖。

3.2.1 使用Curator分佈式鎖

應用依賴:

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>4.0.0</version>
</dependency>

客戶端注入:

@Configuration
public class CuratorBean {
    @Bean
    public CuratorFramework curatorFramework() {
        RetryPolicy retryPolicy = new RetryNTimes(3, 1000);
        CuratorFramework client = CuratorFrameworkFactory.newClient("localhost:2181", retryPolicy);
        return client;
    }
}

使用案例:

package com.iwill.zookeeper.service;

import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.utils.CloseableUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
public class CuratorClient implements InitializingBean, DisposableBean {

    @Autowired
    private CuratorFramework client;

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    public void execute(String lockPath, BusinessService businessService) throws Exception {
        // 根據lockPath生產一個分佈式鎖的handler
        InterProcessMutex lock = new InterProcessMutex(client, lockPath);
        try {
            // 嘗試獲取鎖
            boolean acquireLockSuccess = lock.acquire(200, TimeUnit.MILLISECONDS);
            if (!acquireLockSuccess) {
                logger.warn("acquire lock fail , thread id : " + Thread.currentThread().getId());
                return;
            }
            logger.info("acquire lock success ,thread id : " + Thread.currentThread().getId());
            // 執行業務邏輯
            businessService.handle();
        } catch (Exception exp) {
            logger.error("execute throw exp", exp);
        } finally {
            if (lock.isOwnedByCurrentThread()) {
                // 釋放鎖
                lock.release();
            }
        }
    }
}

3.2.2 Curator分佈式鎖的可重入實現

跟蹤獲取鎖的代碼進入到org.apache.curator.framework.recipes.locks.InterProcessMutex#internalLock,代碼如下:

private final ConcurrentMap<Thread, InterProcessMutex.LockData> threadData;

private boolean internalLock(long time, TimeUnit unit) throws Exception
{
    /*
        Note on concurrency: a given lockData instance
        can be only acted on by a single thread so locking isn't necessary
    */
    Thread currentThread = Thread.currentThread();

    LockData lockData = threadData.get(currentThread);
    if ( lockData != null )
    {
        // re-entering
        // 重入
        lockData.lockCount.incrementAndGet();
        return true;
    }

    String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
    if ( lockPath != null )
    {
        LockData newLockData = new LockData(currentThread, lockPath);
        threadData.put(currentThread, newLockData);
        return true;
    }
    return false;
}

可以看見zookeeper的鎖是可重入的,即同一個線程可以多次獲取鎖,只有第一次真正的去創建臨時會話順序節點,後面的獲取鎖都是對重入次數加1。

相應的,在釋放鎖的時候,前面都是對鎖的重入次數減1,只有最後一次纔是真正的去刪除節點。代碼見:


@Override
public void release() throws Exception
{
    /*
        Note on concurrency: a given lockData instance
        can be only acted on by a single thread so locking isn't necessary
        */

    Thread currentThread = Thread.currentThread();
    LockData lockData = threadData.get(currentThread);
    if ( lockData == null )
    {
        throw new IllegalMonitorStateException("You do not own the lock: " + basePath);
    }

    int newLockCount = lockData.lockCount.decrementAndGet();
    if ( newLockCount > 0 )
    {
        return;
    }
    if ( newLockCount < 0 )
    {
        throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + basePath);
    }
    try
    {
        internals.releaseLock(lockData.lockPath);
    }
    finally
    {
        threadData.remove(currentThread);
    }
}

2.3 zk分佈式鎖的強正確性

zk分佈式鎖的正確性保證,是基於如下機制的:

2.3.1 zk的客戶端故障檢測機制

正常情況下,客戶端會在會話的有效期內,會向服務器端發送PING請求,來進行心跳檢查,說明自己還是存活的。

服務器端接收到客戶端的請求後,會進行對應的客戶端的會話激活,延長該會話的存活期。如果有會話一直沒有激活,那麼說明該客戶端出問題了,服務器端的會話超時檢測任務就會檢查出那些一直沒有被激活的與客戶端的會話,然後進行清理,清理中有一步就是刪除臨時會話節點(包括臨時會話順序節點)。

這就保證了zookeeper分佈鎖的容錯性,不會因爲客戶端的意外退出,導致鎖一直不釋放,其他客戶端獲取不到鎖。

2.3.2 zk的強一致性機制

zk服務器集羣一般由一個leader節點和其他的follower節點組成,數據的讀寫都是在leader節點上進行。

當一個寫請求過來時,leader節點會發起一個proposal,待大多數follower節點都返回ack之後,再發起commit,待大多數follower節點都對這個proposal進行commit了,leader纔會對客戶端返回請求成功;

如果之後leader掛掉了,那麼由於zookeeper集羣的leader選舉算法採用zab協議保證數據最新的follower節點當選爲新的leader,所以,新的leader節點上都會有原來leader節點上提交的所有數據。

這樣就保證了客戶端請求數據的一致性了。不過要注意的是,zk選舉期間,zk服務不可用。

更詳細的細節,請見本站博客《ZAB協議分析》

綜上所述,zookeeper分佈式鎖保證了鎖的容錯性、一致性。但是會產生空閒節點(/lock-path),並且有些時候不可用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章