三種分布式鎖


多線程情況下對共享資源的操作需要加鎖,避免數據被寫亂,在分布式系統中,這個問題也是存在的,此時就需要一個分布式鎖服務。常見的分布式鎖實現一般是基於DB、Redis、zookeeper。下面筆者會按照順序分析下這3種分布式鎖的設計與實現,想直接看分布式鎖總結的小伙伴可直接翻到文檔末尾處。

分布式鎖的實現由多種方式,但是不管怎樣,分布式鎖一般要有以下特點:

  • 排他性:任意時刻,只能有一個client能獲取到鎖
  • 容錯性:分布式鎖服務一般要滿足AP,也就是說,只要分布式鎖服務集群節點大部分存活,client就可以進行加鎖解鎖操作
  • 避免死鎖:分布式鎖一定能得到釋放,即使client在釋放之前崩潰或者網絡不可達

除了以上特點之外,分布式鎖最好也能滿足可重入、高性能、阻塞鎖特性(AQS這種,能夠及時從阻塞狀態喚醒)等,下面就話不多說,趕緊上(開往分布式鎖的設計與實現的)車~

DB鎖

在數據庫新建一張表用於控制並發控制,表結構可以如下所示:

CREATE TABLE `lock_table` (
  `id` int(11) unsigned NOT NULL COMMENT '主鍵',
  `key_id` bigint(20) NOT NULL COMMENT '分布式key',
  `memo` varchar(43) NOT NULL DEFAULT '' COMMENT '可記錄操作內容',
  `update_time` datetime NOT NULL COMMENT '更新時間',
  PRIMARY KEY (`id`,`key_id`),
  UNIQUE KEY `key_id` (`key_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

key_id作為分布式key用來並發控制,memo可用來記錄一些操作內容(比如memo可用來支持重入特性,標記下當前加鎖的client和加鎖次數)。將key_id設置為唯一索引,保證了針對同一個key_id只有一個加鎖(數據插入)能成功。此時lock和unlock偽代碼如下:

def lock :
    exec sql: insert into lock_table(key_id, memo, update_time) values (key_id, memo, NOW())
    if result == true :
        return true
    else :
        return false

def unlock :
    exec sql: delete from lock_table where key_id = 'key_id' and memo = 'memo'

注意,偽代碼中的lock操作是非阻塞鎖,也就是tryLock,如果想實現阻塞(或者阻塞超時)加鎖,只修反復執行lock偽代碼直到加鎖成功為止即可。基於DB的分布式鎖其實有一個問題,那就是如果加鎖成功后,client端宕機或者由於網絡原因導致沒有解鎖,那么其他client就無法對該key_id進行加鎖並且無法釋放了。為了能夠讓鎖失效,需要在應用層加上定時任務,去刪除過期還未解鎖的記錄,比如刪除2分鍾前未解鎖的偽代碼如下:

def clear_timeout_lock :
    exec sql : delete from lock_table where update_time <  ADDTIME(NOW(),'-00:02:00')

因為單實例DB的TPS一般為幾百,所以基於DB的分布式性能上限一般也是1k以下,一般在並發量不大的場景下該分布式鎖是滿足需求的,不會出現性能問題。不過DB作為分布式鎖服務需要考慮單點問題,對於分布式系統來說是不允許出現單點的,一般通過數據庫的同步復制,以及使用vip切換Master就能解決這個問題。

以上DB分布式鎖是通過insert來實現的,如果加鎖的數據已經在數據庫中存在,那么用select xxx where key_id = xxx for udpate方式來做也是可以的。

Redis鎖

Redis鎖是通過以下命令對資源進行加鎖:

set key_id key_value NX PX expireTime

其中,set nx命令只會在key不存在時給key進行賦值,px用來設置key過期時間,key_value一般是隨機值,用來保證釋放鎖的安全性(釋放時會判斷是否是之前設置過的隨機值,只有是才釋放鎖)。由於資源設置了過期時間,一定時間后鎖會自動釋放。

set nx保證並發加鎖時只有一個client能設置成功(Redis內部是單線程,並且數據存在內存中,也就是說redis內部執行命令是不會有多線程同步問題的),此時的lock/unlock偽代碼如下:

def lock:
    if (redis.call('set', KEYS[1], ARGV[1], 'ex', ARGV[2], 'nx')) then
      return true
    end
      return false
      
def unlock:
    if (redis.call('get', KEYS[1]) == ARGV[1]) then
      redis.call('del', KEYS[1])
      return true
    end
      return false

分布式鎖服務中的一個問題

如果一個獲取到鎖的client因為某種原因導致沒能及時釋放鎖,並且redis因為超時釋放了鎖,另外一個client獲取到了鎖,此時情況如下圖所示:

 

那么如何解決這個問題呢,一種方案是引入鎖續約機制,也就是獲取鎖之后,釋放鎖之前,會定時進行鎖續約,比如以鎖超時時間的1/3為間隔周期進行鎖續約。

關於開源的redis的分布式鎖實現有很多,比較出名的有redisson、百度的dlock,關於分布式鎖,筆者也寫了一個簡易版的分布式鎖redis-lock,主要是增加了鎖續約和可同時針對多個key加鎖的機制。

對於高可用性,一般可以通過集群或者master-slave來解決,redis鎖優勢是性能出色,劣勢就是由於數據在內存中,一旦緩存服務宕機,鎖數據就丟失了。像redis自帶復制功能,可以對數據可靠性有一定的保證,但是由於復制也是異步完成的,因此依然可能出現master節點寫入鎖數據而未同步到slave節點的時候宕機,鎖數據丟失問題。

zookeeper分布式鎖

ZooKeeper是一個高可用的分布式協調服務,由雅虎創建,是Google Chubby的開源實現。ZooKeeper提供了一項基本的服務:分布式鎖服務。zookeeper重要的3個特征是:zab協議、node存儲模型和watcher機制。通過zab協議保證數據一致性,zookeeper集群部署保證可用性,node存儲在內存中,提高了數據操作性能,使用watcher機制,實現了通知機制(比如加鎖成功的client釋放鎖時可以通知到其他client)。

zookeeper node模型支持臨時節點特性,即client寫入的數據時臨時數據,當客戶端宕機時臨時數據會被刪除,這樣就不需要給鎖增加超時釋放機制了。當針對同一個path並發多個創建請求時,只有一個client能創建成功,這個特性用來實現分布式鎖。注意:如果client端沒有宕機,由於網絡原因導致zookeeper服務與client心跳失敗,那么zookeeper也會把臨時數據給刪除掉的,這時如果client還在操作共享數據,是有一定風險的。

基於zookeeper實現分布式鎖,相對於基於redis和DB的實現來說,使用上更容易,效率與穩定性較好。curator封裝了對zookeeper的api操作,同時也封裝了一些高級特性,如:Cache事件監聽、選舉、分布式鎖、分布式計數器、分布式Barrier等,使用curator進行分布式加鎖示例如下:

<!--引入依賴-->
<!--對zookeeper的底層api的一些封裝-->
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>2.12.0</version>
</dependency>
 
<!--封裝了一些高級特性,如:Cache事件監聽、選舉、分布式鎖、分布式計數器、分布式Barrier等-->
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>2.12.0</version>
</dependency>
public static void main(String[] args) throws Exception {
    String lockPath = "/curator_recipes_lock_path";
    CuratorFramework client = CuratorFrameworkFactory.builder().connectString("192.168.193.128:2181")
            .retryPolicy(new ExponentialBackoffRetry(1000, 3)).build();

    client.start();
    InterProcessMutex lock = new InterProcessMutex(client, lockPath);

    Runnable task = () -> {
        try {
            lock.acquire();
            try {
                System.out.println("zookeeper acquire success: " + Thread.currentThread().getName());
                Thread.sleep(1000);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.release();
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    };

    ExecutorService executor = Executors.newFixedThreadPool(10);
    for (int i = 0; i < 1000; i++) {
        executor.execute(task);
    }

    LockSupport.park();
}

總結

從上面介紹的3種分布式鎖的設計與實現中,我們可以看出每種實現都有各自的特點,針對潛在的問題有不同的解決方案,歸納如下:

  • 性能:redis > zookeeper > db。
  • 避免死鎖:DB通過應用層設置定時任務來刪除過期還未釋放的鎖,redis通過設置超時時間來解決,而zookeeper是通過臨時節點來解決。
  • 可用性:DB可通過數據庫同步復制,vip切換master來解決,redis可通過集群或者master-slave方式來解決,zookeeper本身自己是通過zab協議集群部署來解決的。注意,DB和redis的復制一般都是異步的,也就是說某些時刻分布式鎖發生故障可能存在數據不一致問題,而zookeeper本身通過zab協議保證集群內(至少n/2+1個)節點數據一致性。
  • 鎖喚醒:DB和redis分布式鎖一般不支持喚醒機制(也可以通過應用層自己做輪詢檢測鎖是否空閑,空閑就喚醒內部加鎖線程),zookeeper可通過本身的watcher/notify機制來做。

使用分布式鎖,安全性上和多線程(同一個進程內)加鎖是沒法比的,可能由於網絡原因,分布式鎖服務(因為超時或者認為client掛了)將加鎖資源給刪除了,如果client端繼續操作共享資源,此時是有隱患的。因此,對於分布式鎖,一個是盡量提高分布式鎖服務的可用性,另一個就是要部署同一內網,盡量降低網絡問題發生幾率。這樣來看,貌似分布式鎖服務不是“完美”的(PS:技術貌似也不好做到十全十美 :( ),那么開發人員該如何選擇分布式鎖呢?最好是結合自己的業務實際場景,來選擇不同的分布式鎖實現,一般來說,基於redis的分布式鎖服務應用較多。

 

參考資料

1、聊一聊分布式鎖的設計

2、https://github.com/luoxn28/redis-lock

 

 

作者:向南l
出處:https://www.cnblogs.com/xiangnanl
如果您覺得閱讀本文對您有幫助,請點擊一下“推薦”按鈕,您的“推薦”將是我最大的寫作動力!
本文版權歸作者和博客園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接,否則保留追究法律責任的權利。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM