通俗講解分佈式鎖:場景和使用方法

前言

對於鎖大家肯定不會陌生,比如 synchronized 關鍵字 和 ReentrantLock 可重入鎖,一般我們用其在多線程環境中控制對資源的並發訪問。但是隨着業務的發展,分佈式的概念逐漸出現在我們系統中,我們在開發的過程中經常需要進行多個系統之間的交互,於是上面的加鎖方法就會失去作用。於是在分佈式鎖就自然而然的誕生了,接下來我們來聊一聊分佈式鎖實現的幾種方式。

分佈式鎖的使用場景

  • 效率性:使用分佈式鎖可以避免不同節點重複相同的工作。

  • 正確性:分佈式鎖可以避免破壞正確性的發生,如果兩個節點在同一條數據上面操作,比如多個節點機器對同一個訂單操作不同的流程有可能會導致該筆訂單最後狀態出現錯誤,造成損失。

分佈式鎖的幾種特性

  • 互斥性:和我們本地鎖一樣互斥性是最基本,但是分佈式鎖需要保證在不同節點的不同線程的互斥。

  • 可重入性:同一個節點上的同一個線程如果獲取了鎖之後那麼也可以再次獲取這個鎖。

  • 鎖超時:和本地鎖一樣支持鎖超時,防止死鎖。

  • 高效,高可用:加鎖和解鎖需要高效,同時也需要保證高可用防止分佈式鎖失效,可以增加降級。

  • 支持阻塞和非阻塞:和ReentrantLock一樣支持lock和trylock以及tryLock(long timeOut)。

  • 支持公平鎖和非公平鎖(可選):公平鎖的意思是按照請求加鎖的順序獲得鎖,非公平鎖就相反是無序的。

分佈式鎖的幾種實現方式

分佈式鎖有以下幾個方式:

  • MySql
  • Zk
  • Redis
  • 一些自研的分佈式鎖(Chubby)

一、基於 Mysql 實現分佈式鎖

1、首先,我們需要創建一個鎖表:

CREATE TABLE `resource_lock` (
    `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
    `resource_name` varchar(128) NOT NULL DEFAULT '' COMMENT '資源名稱',
    'node_info' varchar(128) DEFAULT '0' COMMENT '節點信息',
    'count' int(11) NOT NULL DEFAULT '0' COMMENT  '鎖的次數,統計可重入鎖',
    'desc' varchar(128) DEFAULT NULL COMMENT '額外的描述信息',
    `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
    `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
    PRIMARY KEY ('id'),
    UNIQUE KEY 'un_resource_name' ('resource_name')
) ENGINE=InnoDB DEFAULT CHARSET = utf8mb4;

2、lock

先進行查詢,如果有值,那麼需要比較 node_info 是否一致,這裡的 node_info 可以用機器 IP 和線程名字來表示,如果一致那麼就加可重入鎖 count 的值,如果不一致那麼就返回 false 。如果沒有值那麼直接插入一條數據。偽代碼如下:

// 添加事務,原子性
@Transaction
public void lock() {
    if (select * from resource_lock where resource_name = 'xxx' for update;) {
        // 判斷節點信息是否一致
        if (currentNodeInfo == resultNodeInfo) {
            // 保住鎖的可重入性
            update resource_lock set count = count + 1 where resource_name = 'xxx';
            return true;
        } else {
            return false;
        }
    } else {
        // 插入新數據
        insert into resourceLock;
        return true;
    }
}

3、tryLock

偽代碼如下:

public boolean tryLock(long timeOut) {
    long stTime = System.currentTimeMillis();
    long endTimeOut = stTime + timeOut;

    while (endTimeOut > stTime) {
        if (mysqlLock.lock()) {
            return true;
        }

        // 休眠3s後重試
        LockSupport.parkNanos(1000 * 1000 * 1000 * 1);
        stTime = System.currentTimeMillis();
    }
    return false;
}

4、unlock

偽代碼如下:

@Transaction
public boolean unlock() {
    // 查詢是否有數據
    if (select * from resource_lock where resource_name = 'xxx' for update;) {
        // count為1那麼可以刪除,如果大於1那麼需要減去1。
        if (count > 1) {
            update count = count - 1;
        } else {
            delete;
        }
    } else {
        return false;
    }
}

5、定時清理因為機器宕機導致的鎖未被釋放的問題

啟動一個定時任務,當這個鎖遠超過任務的執行時間,沒有被釋放我們就可以認定是節點掛了然後將其直接釋放。

二、基於單Redis節點的分佈式鎖

首先,Redis客戶端為了獲取鎖,向Redis節點發送如下命令:

SET resource_name my_random_value NX PX 30000

上面的命令如果執行成功,則客戶端成功獲取到了鎖,接下來就可以訪問共享資源了;而如果上面的命令執行失敗,則說明獲取鎖失敗。

注意,在上面的SET命令中:

  • my_random_value是由客戶端生成的一個隨機字符串,它要保證在足夠長的一段時間內在所有客戶端的所有獲取鎖的請求中都是唯一的。
  • NX表示只有當resource_name對應的key值不存在的時候才能SET成功。這保證了只有第一個請求的客戶端才能獲得鎖,而其它客戶端在鎖被釋放之前都無法獲得鎖。
  • PX 30000表示這個鎖有一個30秒的自動過期時間。當然,這裡30秒只是一個例子,客戶端可以選擇合適的過期時間。

最後,當客戶端完成了對共享資源的操作之後,執行下面的Redis Lua腳本來釋放鎖:

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

這段Lua腳本在執行的時候要把前面的my_random_value作為 ARGV[1] 的值傳進去,把 resource_name 作為 KEYS[1] 的值傳進去。

至此,基於單Redis節點的分佈式鎖的算法就描述完了。

關鍵點總結

第一點:過期時間

首先第一個問題,這個鎖必須要設置一個過期時間。否則的話,當一個客戶端獲取鎖成功之後,假如它崩潰了,或者由於發生了網絡分割(network partition)導致它再也無法和Redis節點通信了,那麼它就會一直持有這個鎖,而其它客戶端永遠無法獲得鎖了,而且把這個過期時間稱為鎖的有效時間(lock validity time)。獲得鎖的客戶端必須在這個時間之內完成對共享資源的訪問。

第二點:獲取鎖

第二個問題,第一步獲取鎖的操作,網上不少文章把它實現成了兩個Redis命令:

SETNX resource_name my_random_value
EXPIRE resource_name 30

雖然這兩個命令和前面算法描述中的一個SET命令執行效果相同,但卻不是原子的。如果客戶端在執行完SETNX後崩潰了,那麼就沒有機會執行EXPIRE了,導致它一直持有這個鎖。

第三點:my_random_value

第三個問題,設置一個隨機字符串 my_random_value 是很有必要的,它保證了一個客戶端釋放的鎖必須是自己持有的那個鎖。

假如獲取鎖時SET的不是一個隨機字符串,而是一個固定值,那麼可能會發生下面的執行序列:

  • 客戶端1獲取鎖成功。
  • 客戶端1在某個操作上阻塞了很長時間。
  • 過期時間到了,鎖自動釋放了。
  • 客戶端2獲取到了對應同一個資源的鎖。
  • 客戶端1從阻塞中恢復過來,釋放掉了客戶端2持有的鎖。
  • 之後,客戶端2在訪問共享資源的時候,就沒有鎖為它提供保護了。
第四點:Lua腳本

第四個問題,釋放鎖的操作必須使用Lua腳本來實現。釋放鎖其實包含三步操作:獲取、判斷和刪除,用Lua腳本來實現能保證這三步的原子性。

否則,如果把這三步操作放到客戶端邏輯中去執行的話,就有可能發生與前面第三個問題類似的執行序列:

  • 客戶端1獲取鎖成功。
  • 客戶端1訪問共享資源。
  • 客戶端1為了釋放鎖,先執行’GET’操作獲取隨機字符串的值。
  • 客戶端1判斷隨機字符串的值,與預期的值相等。
  • 客戶端1由於某個原因阻塞住了很長時間。
  • 過期時間到了,鎖自動釋放了。
  • 客戶端2獲取到了對應同一個資源的鎖。
  • 客戶端1從阻塞中恢復過來,執行DEL操縱,釋放掉了客戶端2持有的鎖。

實際上,在上述第三個問題和第四個問題的分析中,如果不是客戶端阻塞住了,而是出現了大的網絡延遲,也有可能導致類似的執行序列發生。

這四個問題,只要實現分佈式鎖的時候加以注意,就都能夠被正確處理。

但除此之外,還有一個問題,是由 failover(故障轉移) 引起的,卻是基於單Redis節點的分佈式鎖無法解決的。正是這個問題催生了Redlock的出現。

多個Redis節點的情況下會產生的問題

這個問題是這樣的。假如Redis節點宕機了,那麼所有客戶端就都無法獲得鎖了,服務變得不可用。為了提高可用性,我們可以給這個Redis節點掛一個Slave,當Master節點不可用的時候,系統自動切到Slave上(failover)。但由於Redis的主從複製(replication)是異步的,這可能導致在failover過程中喪失鎖的安全性。

例如下面的執行序列:

  • 客戶端1從Master獲取了鎖。
  • Master宕機了,存儲鎖的key還沒有來得及同步到Slave上。
  • Slave升級為Master。
  • 客戶端2從新的Master獲取到了對應同一個資源的鎖。

於是,客戶端1和客戶端2同時持有了同一個資源的鎖。鎖的安全性被打破。

三、分佈式鎖 Redlock

前面介紹的基於單Redis節點的分佈式鎖在failover的時候會產生解決不了的安全性問題,因此antirez提出了新的分佈式鎖的算法Redlock,它基於N個完全獨立的Redis節點(通常情況下N可以設置成5)。

運行Redlock算法的客戶端依次執行下面各個步驟,來完成獲取鎖的操作:

1、獲取當前時間(毫秒數)。

2、按順序依次向N個Redis節點執行獲取鎖的操作。這個獲取操作跟前面基於單Redis節點的獲取鎖的過程相同,包含隨機字符串my_random_value,也包含過期時間(比如PX 30000,即鎖的有效時間)。

為了保證在某個Redis節點不可用的時候算法能夠繼續運行,這個獲取鎖的操作還有一個超時時間(time out),它要遠小於鎖的有效時間(幾十毫秒量級)。客戶端在向某個Redis節點獲取鎖失敗以後,應該立即嘗試下一個Redis節點。

這裡的失敗,應該包含任何類型的失敗,比如該Redis節點不可用,或者該Redis節點上的鎖已經被其它客戶端持有(註:Redlock原文中這裡只提到了Redis節點不可用的情況,但也應該包含其它的失敗情況)。

3、計算整個獲取鎖的過程總共消耗了多長時間,計算方法是用當前時間減去第1步記錄的時間。如果客戶端從大多數Redis節點(>= N/2+1)成功獲取到了鎖,並且獲取鎖總共消耗的時間沒有超過鎖的有效時間(lock validity time),那麼這時客戶端才認為最終獲取鎖成功;否則,認為最終獲取鎖失敗。

4、如果最終獲取鎖成功了,那麼這個鎖的有效時間應該重新計算,它等於最初的鎖的有效時間減去第3步計算出來的獲取鎖消耗的時間。

5、如果最終獲取鎖失敗了(可能由於獲取到鎖的Redis節點個數少於N/2+1,或者整個獲取鎖的過程消耗的時間超過了鎖的最初有效時間),那麼客戶端應該立即向所有Redis節點發起釋放鎖的操作(即前面介紹的Redis Lua腳本)。

上面描述的只是獲取鎖的過程,而釋放鎖的過程比較簡單:客戶端向所有Redis節點發起釋放鎖的操作,不管這些節點當時在獲取鎖的時候成功與否。

由於N個Redis節點中的大多數能正常工作就能保證Redlock正常工作,因此理論上它的可用性更高。我們前面討論的單Redis節點的分佈式鎖在failover的時候鎖失效的問題,在Redlock中不存在了,但如果有節點發生崩潰重啟,還是會對鎖的安全性有影響的。具體的影響程度跟Redis對數據的持久化程度有關。

節點崩潰可能導致的問題

假設一共有5個Redis節點:A, B, C, D, E。設想發生了如下的事件序列:

1、客戶端1成功鎖住了A, B, C,獲取鎖成功(但D和E沒有鎖住)。

2、節點C崩潰重啟了,但客戶端1在C上加的鎖沒有持久化下來,丟失了。

3、節點C重啟後,客戶端2鎖住了C, D, E,獲取鎖成功。

4、這樣,客戶端1和客戶端2同時獲得了鎖(針對同一資源)。

在默認情況下,Redis的AOF持久化方式是每秒寫一次磁盤(即執行fsync),因此最壞情況下可能丟失1秒的數據。為了儘可能不丟數據,Redis允許設置成每次修改數據都進行fsync,但這會降低性能。當然,即使執行了fsync也仍然有可能丟失數據(這取決於系統而不是Redis的實現)。

所以,上面分析的由於節點重啟引發的鎖失效問題,總是有可能出現的。為了應對這一問題,antirez又提出了延遲重啟(delayed restarts)的概念。

也就是說,一個節點崩潰後,先不立即重啟它,而是等待一段時間再重啟,這段時間應該大於鎖的有效時間(lock validity time)。這樣的話,這個節點在重啟前所參與的鎖都會過期,它在重啟後就不會對現有的鎖造成影響。

客戶端應該向所有Redis節點發起釋放鎖的操作?

在最後釋放鎖的時候,antirez在算法描述中特彆強調,客戶端應該向所有Redis節點發起釋放鎖的操作。也就是說,即使當時向某個節點獲取鎖沒有成功,在釋放鎖的時候也不應該漏掉這個節點。這是為什麼呢?

設想這樣一種情況,客戶端發給某個Redis節點的獲取鎖的請求成功到達了該Redis節點,這個節點也成功執行了SET操作,但是它返回給客戶端的響應包卻丟失了。這在客戶端看來,獲取鎖的請求由於超時而失敗了,但在Redis這邊看來,加鎖已經成功了。

因此,釋放鎖的時候,客戶端也應該對當時獲取鎖失敗的那些Redis節點同樣發起請求。實際上,這種情況在異步通信模型中是有可能發生的:客戶端向服務器通信是正常的,但反方向卻是有問題的。

四、基於zk實現分佈式鎖

ZooKeeper是以Paxos算法為基礎分佈式應用程序協調服務。Zk的數據節點和文件目錄類似,所以我們可以用此特性實現分佈式鎖。

基本實現步驟如下:

1、客戶端嘗試創建一個znode節點,比如/lock。那麼第一個客戶端就創建成功了,相當於拿到了鎖;而其它的客戶端會創建失敗(znode已存在),獲取鎖失敗。

2、持有鎖的客戶端訪問共享資源完成後,將znode刪掉,這樣其它客戶端接下來就能來獲取鎖了。

注意:這裡的znode應該被創建成ephemeral的(臨時節點)。這是znode的一個特性,它保證如果創建znode的那個客戶端崩潰了,那麼相應的znode會被自動刪除。這保證了鎖一定會被釋放。

可能存在的問題

看起來這個鎖相當完美,沒有Redlock過期時間的問題,而且能在需要的時候讓鎖自動釋放。但其實也存在這其中也存在問題。

ZooKeeper是怎麼檢測出某個客戶端已經崩潰了呢?

實際上,每個客戶端都與ZooKeeper的某台服務器維護着一個Session,這個Session依賴定期的心跳(heartbeat)來維持。如果ZooKeeper長時間收不到客戶端的心跳(這個時間稱為Sesion的過期時間),那麼它就認為Session過期了,通過這個Session所創建的所有的ephemeral類型的znode節點都會被自動刪除。

假如按照下面的順序執行:

1、客戶端1創建了znode節點/lock,獲得了鎖。

2、客戶端1進入了長時間的GC pause。

3、客戶端1連接到ZooKeeper的Session過期了。znode節點/lock被自動刪除。

4、客戶端2創建了znode節點/lock,從而獲得了鎖。

5、客戶端1從GC pause中恢復過來,它仍然認為自己持有鎖。

由上面的執行順序,可以發現最後客戶端1和客戶端2都認為自己持有了鎖,衝突了。所以說,用ZooKeeper實現的分佈式鎖也不一定就是安全的,該有的問題它還是有。

zk的watch機制

ZooKeeper有個很特殊的機制–watch機制。這個機制可以這樣來使用,比如當客戶端試圖創建 /lock 節點的時候,發現它已經存在了,這時候創建失敗,但客戶端不一定就此對外宣告獲取鎖失敗。

客戶端可以進入一種等待狀態,等待當/lock節點被刪除的時候,ZooKeeper通過watch機制通知它,這樣它就可以繼續完成創建操作(獲取鎖)。這可以讓分佈式鎖在客戶端用起來就像一個本地的鎖一樣:加鎖失敗就阻塞住,直到獲取到鎖為止。

參考文章

  • //juejin.cn/post/6844903688088059912
  • Redlock的算法://redis.io/topics/distlock
  • //github.com/redisson/redisson
  • linux的同步IO操作函數: sync、fsync與fdatasync://my.oschina.net/u/1377774/blog/529847
  • //mp.weixin.qq.com/s/JTsJCDuasgIJ0j95K8Ay8w
Tags: