淺談分佈式鎖

該文首發《虛懷若谷》個人博客,轉載前請務必署名,轉載請標明出處。

古之善爲道者,微妙玄通,深不可識。夫唯不可識,故強爲之容:

豫兮若冬涉川,猶兮若畏四鄰,儼兮其若客,渙兮若冰之釋,敦兮其若樸,曠兮其若谷,混兮其若濁。

孰能濁以靜之徐清?孰能安以動之徐生?

保此道不欲盈。夫唯不盈,故能敝而新成。

請關注我的微信公衆號:下雨就像彈鋼琴,Thanks♪(・ω・)ノ
微信二維碼

爲什麼要有分佈式鎖

隨着架構系統的演進,由純真的單機架構到容器化編排的分佈式架構,可以說是一個大型互聯網企業發展的必然走向。在網站初創時,應用數量和用戶較少,可以把 Tomcat 和Mysql 部署在同一臺機器上。隨着用戶數量增多,訪問量增大,併發升高,Tomcat 和 MySQL 競爭資源,此時,單機已經扛不住了,需要把 Tomcat 和 MySQL 分離在不同的機器上,用於提升單臺機器的處理能力。業務從來沒有減少,產品越做越大。應用也越來越複雜,原來的大應用,拆分成多個小應用,加入各級緩存,做了反向代理負責均衡,最後墜入分庫分表的深淵。

微服務漸漸代替了龐大冗雜的服務,每個小服務,各司其職。這時候是不是就不存在資源競爭的問題了呢?答案毋庸置疑,在架構的演進過程中,無時無刻都存在着資源競爭的問題。

說起資源競爭的問題,是不是想起了在計算機科學中的一個經典問題——哲學家就餐,也就是在並行計算中多線程同步( Synchronization )時產生的問題?哲學家就餐問題用來解釋死鎖和資源耗盡的問題,我們不做詳細的討論,感興趣的同學可以搜索資料瞭解。既然存在資源競爭的問題,解決的方案必然是對資源加鎖,對於鎖大家肯定不陌生,在 Java 中synchronized 關鍵字和 ReentrantLock 可重入鎖在我們的代碼或者一些開源代碼中隨處可見的,一般用於在本地多線程環境中控制對資源的併發訪問。但是隨着微服務架構的蓬勃興起,分佈式的快速發展,本地加鎖已經不能滿足我們的業務需求,如果還通過本地加鎖的方式鎖定資源,在分佈式環境中是無用的。於是人們爲了在分佈式環境中也能實現本地鎖的效果,也是紛紛各出其招。

Martin Kleppmann 是英國劍橋大學的分佈式系統的研究員,之前和 Redis 之父 Antirez 進行過關於 RedLock(紅鎖,後續有講到)是否安全的激烈討論。Martin 認爲一般我們使用分佈式鎖有兩個場景:

  • 效率:使用分佈式鎖可以避免不同節點重複相同的工作,這些工作會浪費資源。比如用戶付了錢之後有可能不同節點會發出多封短信。
  • 正確性:加分佈式鎖同樣可以避免破壞正確性的發生,如果兩個節點在同一條數據上面操作,比如多個節點機器對同一個訂單操作不同的流程有可能會導致該筆訂單最後狀態出現錯誤,造成損失。

分佈式鎖的特點

在瞭解分佈式鎖之前,我們首先要了解操作系統級別的鎖(特指 Linux 系統)和 Java 編髮編程時遇到的鎖。對 Linux 鎖和 Java鎖有大概的瞭解後,我們深入分析分佈式鎖的實現機制。如果還想深入瞭解 Linux 的鎖相關的信息,可查閱參考文章。

linux 鎖的特點

在現代操作系統裏,同一時間可能有多個內核執行命令在執行,因此內核其實像多進程多線程編程一樣也需要一些同步機制來同步各執行單元對共享數據的訪問。尤其是在多核CPU 系統上,更需要一些同步機制來同步不同處理器上的執行單元對共享的數據的訪問。在主流的 Linux 內核中包含了幾乎所有現代的操作系統具有的同步機制,這些同步機制包括:

  • Atomic(原子操作): 所謂原子操作,就是該操作絕不會在執行完畢前被任何其他任務或事件打斷,也就說,它的最小的執行單位,不可能有比它更小的執行單位。
  • Mutex(互斥量): 互斥鎖主要用於實現內核中的互斥訪問功能。內核互斥鎖是在原子 API 之上實現的,但這對於內核用戶是不可見的。對它的訪問必須遵循一些規則:同一時間只能有一個任務持有互斥鎖,而且只有這個任務可以對互斥鎖進行解鎖。互斥鎖不能進行遞歸鎖定或解鎖。一個互斥鎖對象必須通過其API初始化,而不能使用memset或複製初始化。一個任務在持有互斥鎖的時候是不能結束的。互斥鎖所使用的內存區域是不能被釋放的。使用中的互斥鎖是不能被重新初始化的。並且互斥鎖不能用於中斷上下文。但是互斥鎖比當前的內核信號量選項更快,並且更加緊湊,因此如果它們滿足您的需求,那麼它們將是您明智的選擇。但是,對於互斥鎖而言,如果資源已經被佔用,其它的資源申請進程只能進入 sleep 狀態。
  • Spinlock(自旋鎖): 自旋鎖與互斥鎖有點類似,只是自旋鎖不會引起調用者睡眠,如果自旋鎖已經被別的執行單元保持,調用者就一直循環在那裏看是否該自旋鎖的保持者已經釋放了鎖,”自旋”一詞就是因此而得名。由於自旋鎖使用者一般保持鎖時間非常短,因此選擇自旋而不是睡眠是非常必要的,自旋鎖的效率遠高於互斥鎖。
  • rwlock(讀寫鎖): 讀寫鎖實際是一種特殊的自旋鎖,它把對共享資源的訪問者劃分成讀者和寫者,讀者只對共享資源進行讀訪問,寫者則需要對共享資源進行寫操作。這種鎖相對於自旋鎖而言,能提高併發性,因爲在多處理器系統中,它允許同時有多個讀者來訪問共享資源,最大可能的讀者數爲實際的邏輯 CPU 數。寫者是排他性的,一個讀寫鎖同時只能有一個寫者或多個讀者(與CPU數相關),但不能同時既有讀者又有寫者。
  • semaphore(信號量): 信號量在創建時需要設置一個初始值,表示同時可以有幾個任務可以訪問該信號量保護的共享資源,初始值爲1就變成互斥鎖(Mutex),即同時只能有一個任務可以訪問信號量保護的共享資源。一個任務要想訪問共享資源,首先必須得到信號量,獲取信號量的操作將把信號量的值減1,若當前信號量的值爲負數,表明無法獲得信號量,該任務必須掛起在該信號量的等待隊列等待該信號量可用;若當前信號量的值爲非負數,表示可以獲得信號量,因而可以立刻訪問被該信號量保護的共享資源。當任務訪問完被信號量保護的共享資源後,必須釋放信號量,釋放信號量通過把信號量的值加1實現,如果信號量的值爲非正數,表明有任務等待當前信號量,因此它也喚醒所有等待該信號量的任務。
  • rw_semaphore(讀寫信號量): 讀寫信號量對訪問者進行了細分,或者爲讀者,或者爲寫者,讀者在保持讀寫信號量期間只能對該讀寫信號量保護的共享資源進行讀訪問,如果一個任務除了需要讀,可能還需要寫,那麼它必須被歸類爲寫者,它在對共享資源訪問之前必須先獲得寫者身份,寫者在發現自己不需要寫訪問的情況下可以降級爲讀者。
  • 條件變量
  • seqlock(順序鎖): 順序鎖也是對讀寫鎖的一種優化,對於順序鎖,讀者絕不會被寫者阻塞,也就說,讀者可以在寫者對被順序鎖保護的共享資源進行寫操作時仍然可以繼續讀,而不必等待寫者完成寫操作,寫者也不需要等待所有讀者完成讀操作纔去進行寫操作。但是,寫者與寫者之間仍然是互斥的,即如果有寫者在進行寫操作,其他寫者必須自旋在那裏,直到寫者釋放了順序鎖。
  • BKL(大內核鎖): 大內核鎖本質上也是自旋鎖,但是它又不同於自旋鎖,自旋鎖是不可以遞歸獲得鎖的,因爲那樣會導致死鎖。但大內核鎖可以遞歸獲得鎖。大內核鎖用於保護整個內核,而自旋鎖用於保護非常特定的某一共享資源。進程保持大內核鎖時可以發生調度,具體實現是:在執行 schedule 時,schedule 將檢查進程是否擁有大內核鎖,如果有,它將被釋放,以致於其它的進程能夠獲得該鎖,而當輪到該進程運行時,再讓它重新獲得大內核鎖。注意在保持自旋鎖期間是不允許發生調度的。
  • brlock(大讀者鎖): 大讀者鎖是讀寫鎖的高性能版,讀者可以非常快地獲得鎖,但寫者獲得鎖的開銷比較大。大讀者鎖只存在於 2.4 內核中,在 2.6 中已經沒有這種鎖(提醒讀者特別注意)。它們的使用與讀寫鎖的使用類似,只是所有的大讀者鎖都是事先已經定義好的。這種鎖適合於讀多寫少的情況,它在這種情況下遠好於讀寫鎖。
  • RCU(Read-Copy Update): 顧名思義就是讀-拷貝修改,它是基於其原理命名的。對於被RCU保護的共享數據結構,讀者不需要獲得任何鎖就可以訪問它,但寫者在訪問它時首先拷貝一個副本,然後對副本進行修改,最後使用一個回調(callback)機制在適當的時機把指向原來數據的指針重新指向新的被修改的數據。這個時機就是所有引用該數據的CPU都退出對共享數據的操作。

Java鎖的特點

在很多書寫Java併發的文章中,我們經常看到有這些鎖的概念。這些概念中,並不全指鎖的狀態,有的是指所得特性,有的是指所得設計。本文僅僅簡要敘述鎖的概念,不過多涉及Java鎖的實現,這部分內容放在《Javaer不得不說的 Java “鎖”事》一文中。

  • 公平鎖 / 非公平鎖:按照多線程申請資源是否按照順序來獲取鎖。
  • 可重入鎖 / 不可重入鎖:廣義上的可重入鎖指的是可重複可遞歸調用的鎖,在外層使用鎖之後,在內層仍然可以使用,並且不發生死鎖(前提得是同一個對象或者 class),這樣的鎖就叫做可重入鎖,否則就叫不可重入鎖。 ReentrantLock 和 synchronized都是可重入鎖。
  • 獨享鎖 / 共享鎖:
    • 獨享鎖該鎖每一次只能被一個線程所持有。
    • 共享鎖該鎖可被多個線程共有,典型的就是ReentrantReadWriteLock裏的讀鎖,它的讀鎖是可以被共享的,但是它的寫鎖確每次只能被獨佔。
  • 互斥鎖 / 讀寫鎖:
    • 在訪問共享資源之前對進行加鎖操作,在訪問完成之後進行解鎖操作。加鎖後,任何其他試圖再次加鎖的線程會被阻塞,直到當前進程解鎖。這是互斥鎖。
    • 讀寫鎖既是互斥鎖,又是共享鎖,read 模式是共享,write 是互斥(排它鎖)的。
  • 樂觀鎖 / 悲觀鎖:
    • 悲觀鎖總是假設最壞的情況,每次去拿數據的時候都認爲別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞直到它拿到鎖, Java中 synchronized 和 ReentrantLock 等獨佔鎖就是悲觀鎖思想的實現。
    • 樂觀鎖總是假設最好的情況,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號機制和CAS算法實現。樂觀鎖適用於多讀的應用類型,這樣可以提高吞吐量,像數據庫提供的類似於 write_condition 機制,其實都是提供的樂觀鎖。在 Java 中 java.util.concurrent.atomic 包下面的原子變量類就是使用了樂觀鎖的一種實現方式CAS實現的。
  • 分段鎖:分段鎖其實是一種鎖的設計,並不是具體的一種鎖,對於 ConcurrentHashMap 而言,其併發的實現就是通過分段鎖的形式來實現高效的併發操作。
  • 偏向鎖 / 輕量級鎖 / 重量級鎖:
    • 偏向鎖是指一段同步代碼一直被一個線程所訪問,那麼該線程會自動獲取鎖。降低獲取鎖的代價。
    • 輕量級鎖是指當鎖是偏向鎖的時候,被另一個線程所訪問,偏向鎖就會升級爲輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,提高性能。
    • 重量級鎖是指當鎖爲輕量級鎖的時候,另一個線程雖然是自旋,但自旋不會一直持續下去,當自旋一定次數的時候,還沒有獲取到鎖,就會進入阻塞,該鎖膨脹爲重量級鎖。重量級鎖會讓其他申請的線程進入阻塞,性能降低。
  • 自旋鎖:是指當一個線程在獲取鎖的時候,如果鎖已經被其它線程獲取,那麼該線程將循環等待,然後不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖纔會退出循環。

分佈式鎖的特點

對系統內核鎖和Java鎖有初步的瞭解之後,我們總結髮現,所必需的要有以下特點:

  • 互斥性: 互斥性是最基本的特徵,分佈式鎖需要保證在不同節點的不同線程的互斥。
  • 可重入性: 同一個節點上的同一個線程如果獲取了鎖之後那麼也可以再次獲取這個鎖。
  • 鎖超時: 和本地鎖一樣支持鎖超時,防止死鎖。
  • 高效,高可用: 加鎖和解鎖需要高效,同時也需要保證高可用防止分佈式鎖失效,可以增加降級。
  • 支持阻塞和非阻塞: 和 ReentrantLock 一樣支持 lock 和 trylock 以及 tryLock(long timeOut)。
  • 支持公平鎖和非公平鎖(可選):公平鎖的意思是按照請求加鎖的順序獲得鎖,非公平鎖就相反是無序的。這個一般來說實現的比較少。

常見分佈式鎖

一般實現分佈式鎖有以下幾個方式:

  • MySql
  • Zk
  • Redis
  • Etcd
  • 自研分佈式鎖:如谷歌的Chubby。

下面就 MySQL 和 zk curator 客戶端加鎖的實現方式逐一列舉,關於 Redis、Zk 原生客戶端、etcd 等其他方式的分佈式鎖的實現原理,放在後面的章節。

MySQL分佈式鎖

MySQL實現分佈式鎖相對簡單,創建一張鎖資源表。

CREATE TABLE resource_lock (
	`id` BIGINT(20) UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
	`resource_name` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '資源名稱',
	`node_info` VARCHAR(128) NULL DEFAULT NULL COMMENT '',
	`count` INT(10) NOT NULL DEFAULT '0' COMMENT '',
	`description` VARCHAR(128) NULL DEFAULT NULL COMMENT '',
	`gmt_create` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '',
	`gmt_modify` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '',
	UNIQUE KEY `uk_resource` (`resource_name`)
) ENGINE = InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '資源加鎖表';

前面分佈式鎖所說的 lock(),trylock(long timeout),trylock() 這幾個方法可以用下面的僞代碼實現。

lock()

lock一般是阻塞式的獲取鎖,阻塞知道獲取到鎖或者異常結束,那麼我們可以寫一個死循環來執行其操作:

public void lock() {
	while(true) {
		if (mysqlLock.lock(resoureName)) {
			return;
		}
		// 休眠3ms後重試
		LockSuprot.parkNanos(1000*1000*3);
	}
}

mysqlLock.lcok() 內部是一個SQL,爲了達到可重入鎖的效果那麼我們應該先進行查詢,如果有值,那麼需要比較 node_info 是否一致,這裏的 node_info 可以用機器 IP 和線程名字來表示,如果一致那麼就加可重入鎖 count 的值,如果不一致那麼就返回false。如果沒有值那麼直接插入一條數據。僞代碼如下:

public class MysqlLock {
	@Resource
	private MysqlLockMapper mysqlLockMapper;
	private NodeInfo nodeInfo;

	public MysqlLock(NodeInfo nodeInfo) {
		this.nodeInfo = nodeInfo;
	}

	@Transcation
	public boolean lock(String resourceName) {
		MyResource result = mysqlLockMapper.existsResource(resourceName);
		if (result != null) {
			if (Objects.equeals(nodeInfo, result.getNodeInfo())) {
				mysqlLockMapper.updateResourceCount(resourceName);
				return true;
			} else {
				return false;
			}
		} else {
			mysqlLockMapper.insertResource(resourceName, nodeInfo);
		}
	}
}

需要注意的是這一段代碼需要加事務,必須要保證這一系列操作的原子性。

上面代碼對應的三條 Mybatis 版的SQL語句如下:

-- mysqlLockMapper.existsResource

select * from resource_lock where resource_name = ${resourceName} for update

-- mysqlLockMapper.updateResourceCount

update resource_lock set count = count + 1 where resource_name = ${resourceName}

-- mysqlLockMapper.insertResource

insert into resource_lock(`resource_name`,`node_info`,`count`,`description`)
values(#{resourceName}, ${nodeInfo}, 1, '')

trylock()

tryLock() 是非阻塞獲取鎖,如果獲取不到那麼就會馬上返回,代碼可以如下:

public boolean tryLock() {
	return mysqlLock.lock(resourceName);
}

trylock(long millsecs)

public boolean trylock(long millsecs) {
	// 記錄超時時間
	long deadline = System.currentTimeMillis() + millsecs;
	while(true) {
		if (mysqlLock.tryLock()) {
			return true;
		}
		deadline = deadline - millsecs;

		// 避免網絡延遲引起加鎖失敗,增加自旋超時閾值,可設置爲300ms
		if (deadline <= spinTimeoutThreshold) {
			return false;
		}
		if (millsecs <= 0) {
			return false;
		}
	}
}

mysqlLock.lock 和上面一樣,但是要注意的是 select … for update 這個是阻塞的獲取行鎖,如果同一個資源併發量較大還是有可能會退化成阻塞的獲取鎖。

unlock()

public boolean unlock() {
	MyResource result = mysqlLockMapper.existsResource(resourceName);
	if (result != null) {
		if (Objects.equeals(nodeInfo, result.getNodeInfo())) {
			if (result.getCount() > 1) {
				// count - 1 
				mysqlLockMapper.decrementResource(resourceName);
			} else {
				mysqlLockMapper.deleteResource(resourceName);
			}
		} else {
			return false;
		}
	} else {
		return false;
	}
}

上面新增兩條 Mybatis 版本的SQL語句:

-- mysqlLockMapper.decrementResource(resourceName)

update resource_lock set count = count - 1 where resource_name = ${resourceName}

-- mysqlLockMapper.deleteResource(resourceName)

delete from resource_lock where resource_name = ${resourceName}

鎖超時

我們注意到,鎖的釋放是通過 delete 語句刪除資源鎖的,如果加鎖的客戶端由於某些原因掛掉了,鎖就一直存在。這時,我們可以通過定時任務,在加鎖的時候添加任務到任務系統,也可以通過定時任務檢查釋放鎖。

ZK客戶端Curator分佈式鎖

ZooKeeper也是我們常見的實現分佈式鎖方法,ZooKeeper 是以 Paxos 算法爲基礎分佈式應用程序協調服務。Zk 的數據節點和文件目錄類似,所以我們可以用此特性實現分佈式鎖。我們以某個資源爲目錄,然後這個目錄下面的節點就是我們需要獲取鎖的客戶端,未獲取到鎖的客戶端註冊需要註冊 Watcher 到上一個客戶端,可以用下圖表示。

/lock是我們用於加鎖的目錄,/resource_name是我們鎖定的資源,其下面的節點按照我們加鎖的順序排列。

Curator 封裝了 Zookeeper 底層的 API,使我們更加容易方便的對 Zookeeper 進行操作,並且它封裝了分佈式鎖的功能,這樣我們就不需要再自己實現了。

Curator 實現了可重入鎖(InterProcessMutex),也實現了不可重入鎖(InterProcessSemaphoreMutex)。在可重入鎖中還實現了讀寫鎖。

Curator-Recipes實現了五種分佈式鎖:

下面就分佈式可重入鎖舉例。

可重入鎖InterProcessMutex

InterProcessMutex 是 Curator 實現的可重入鎖,創建 InterProcessMutex 實例
InterProcessMutex 提供了兩個構造方法,傳入一個 CuratorFramework 實例和一個要使用的節點路徑,InterProcessMutex 還允許傳入一個自定義的驅動類,默認是使用 StandardLockInternalsDriver。

public InterProcessMutex(CuratorFramework client, String path);
public InterProcessMutex(CuratorFramework client, String path, LockInternalsDriver driver);

獲取鎖

使用 acquire 方法獲取鎖, acquire 方法有兩種:

public void acquire() throws Exception;

獲取鎖,一直阻塞到獲取到鎖爲止。獲取鎖的線程在獲取鎖後仍然可以調用 acquire() 獲取鎖(可重入)。 鎖獲取使用完後,調用了幾次 acquire(),就得調用幾次 release() 釋放。

public boolean acquire(long time, TimeUnit unit) throws Exception;

與 acquire()類似,等待 time * unit 時間獲取鎖,如果仍然沒有獲取鎖,則直接返回 false。

  • 共享資源
public class FakeLimitedResource {

    //總共250張火車票
    private Integer ticket = 250;

    public void use() throws InterruptedException {
        try {
            System.out.println("火車票還剩"+(--ticket)+"張!");
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}
  • 使用鎖操作資源
public class ExampleClientThatLocks {

    /** 鎖 */
    private final InterProcessMutex lock;
    /** 共享資源 */
    private final FakeLimitedResource resource;
    /** 客戶端名稱 */
    private final String clientName;

    public ExampleClientThatLocks(CuratorFramework client, String lockPath, FakeLimitedResource resource, String clientName) {
        this.resource = resource;
        this.clientName = clientName;
        lock = new InterProcessMutex(client, lockPath);
    }

    public void doWork(long time, TimeUnit unit) throws Exception {
        if ( !lock.acquire(time, unit) ) {
            throw new IllegalStateException(clientName + " could not acquire the lock");
        }
        try {
            System.out.println(clientName + " has the lock");
            //操作資源
            resource.use();
        } finally {
            System.out.println(clientName + " releasing the lock");
            lock.release(); //總是在Final塊中釋放鎖。
        }
    }
}

  • 客戶端
public class LockingExample {
    private static final int QTY = 5; // 併發操作線程數
    private static final int REPETITIONS = QTY * 10; // 資源總量
    private static final String CONNECTION_STRING = "127.0.0.1:2181";
    private static final String PATH = "/locks";

    public static void main(String[] args) throws Exception {

        //FakeLimitedResource模擬某些外部資源,這些外部資源一次只能由一個進程訪問
        final FakeLimitedResource resource = new FakeLimitedResource();

        ExecutorService service = Executors.newFixedThreadPool(QTY);
        try {
            for ( int i = 0; i < QTY; ++i ){
                final int index = i;
                Callable<Void>  task = new Callable<Void>() {
                    @Override
                    public Void call() throws Exception {
                        CuratorFramework client = CuratorFrameworkFactory.newClient(CONNECTION_STRING, new ExponentialBackoffRetry(1000, 3,Integer.MAX_VALUE));
                        try {
                            client.start();
                            ExampleClientThatLocks example = new ExampleClientThatLocks(client, PATH, resource, "Client " + index);
                            for ( int j = 0; j < REPETITIONS; ++j ) {
                                example.doWork(10, TimeUnit.SECONDS);
                            }
                        }catch ( InterruptedException e ){
                            Thread.currentThread().interrupt();
                        }catch ( Exception e ){
                            e.printStackTrace();
                        }finally{
                            CloseableUtils.closeQuietly(client);
                        }
                        return null;
                    }
                };
                service.submit(task);
            }

            service.shutdown();
            service.awaitTermination(10, TimeUnit.MINUTES);
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

起五個線程,即五個窗口賣票,五個客戶端分別有50張票可以賣,先是嘗試獲取鎖,操作資源後,釋放鎖。

加鎖的流程具體如下:

  1. 首先進行可重入的判定:這裏的可重入鎖記錄在 ConcurrentMap,threadData 這個 Map 裏面,如果threadData.get(currentThread)是有值的那麼就證明是可重入鎖,然後記錄就會加1。我們之前的 Mysql 其實也可以通過這種方法去優化,可以不需要 count 字段的值,將這個維護在本地可以提高性能。
  2. 然後在我們的資源目錄下創建一個節點:比如這裏創建一個 /0000000002 這個節點,這個節點需要設置爲 EPHEMERAL_SEQUENTIAL 也就是臨時節點並且有序。
  3. 獲取當前目錄下所有子節點,判斷自己的節點是否位於子節點第一個。
  4. 如果是第一個,則獲取到鎖,那麼可以返回。
  5. 如果不是第一個,則證明前面已經有人獲取到鎖了,那麼需要獲取自己節點的前一個節點。/0000000002 的前一個節點是 /0000000001,我們獲取到這個節點之後,再上面註冊Watcher(這裏的 watcher 其實調用的是 object.notifyAll(),用來解除阻塞)。
  6. object.wait(timeout) 或 object.wait() :進行阻塞等待這裏和我們第5步的watcher相對應。

釋放鎖

線程通過 acquire() 獲取鎖時,可通過 release()進行釋放,如果該線程多次調用了 acquire() 獲取鎖,則如果只調用一次 release() 該鎖仍然會被該線程持有。

note:同一個線程中InterProcessMutex實例是可重用的,也就是不需要在每次獲取鎖的時候都new一個InterProcessMutex實例,用同一個實例就好。

解鎖的具體流程:

  1. 首先進行可重入鎖的判定: 如果有可重入鎖只需要次數減 1 即可,減1之後加鎖次數爲 0 的話繼續下面步驟,不爲 0 直接返回。
  2. 刪除當前節點。
  3. 刪除 threadDataMap 裏面的可重入鎖的數據。

讀寫鎖

Curator提供了讀寫鎖,其實現類是 InterProcessReadWriteLock,這裏的每個節點都會加上前綴:

private static final String READ_LOCK_NAME  = "__READ__";
private static final String WRITE_LOCK_NAME = "__WRIT__";

根據不同的前綴區分是讀鎖還是寫鎖,對於讀鎖,如果發現前面有寫鎖,那麼需要將 watcher 註冊到和自己最近的寫鎖。寫鎖的邏輯和我們之前分析的依然保持不變。

鎖超時

Zookeeper不需要配置鎖超時,由於我們設置節點是臨時節點,我們的每個機器維護着一個ZK的session,通過這個session,ZK可以判斷機器是否宕機。如果我們的機器掛掉的話,那麼這個臨時節點對應的就會被刪除,所以我們不需要關心鎖超時。

分佈式鎖的安全問題

  • 長時間的GC pause: 做Java開發的同學肯定對 GC 不陌生,在 GC 的時候會發生STW(stop-the-world),例如CMS垃圾回收器,會有兩個階段進行 STW 防止引用繼續進行變化。Martin反駁Redlock的文章《How to do distributed locking》中對此有詳細的解釋,下面此圖來源此文:

    client1 獲取了鎖並且設置了鎖的超時時間,但是 client1 之後出現了 STW,這個 STW 時間比較長,導致分佈式鎖進行了釋放,client2 獲取到了鎖,這個時候 client1 恢復了鎖,那麼就會出現 client1,client2 同時獲取到鎖,這個時候分佈式鎖不安全問題就出現了。這個其實不僅僅侷限於 RedLock,對於我們的 ZK,Mysql 一樣的有同樣的問題。

  • 時鐘發生跳躍:對於Redis服務器如果其時間發生了跳躍,那麼肯定會影響我們鎖的過期時間,那麼我們的鎖過期時間就不是我們預期的了,也會出現 client1 和 client2 獲取到同一把鎖,那麼也會出現不安全,這個對於 Mysql 也會出現。但是 ZK 由於沒有設置過期時間,那麼發生跳躍也不會受影響。

  • 長時間的網絡I/O:這個問題和我們的 GC 的 STW 很像,也就是我們這個獲取了鎖之後我們進行網絡調用,其調用時間可能比我們鎖的過期時間都還長,那麼也會出現不安全的問題,這個 Mysql 也會有,ZK 也會出現這個問題。

GC的STW

對於這個問題可以看見基本所有的都會出現問題,Martin 給出了一個解法,對於 ZK 這種他會生成一個自增的序列,那麼我們真正進行對資源操作的時候,需要判斷當前序列是否是最新,有點類似於我們樂觀鎖。當然這個解法Redis作者進行了反駁,你既然都能生成一個自增的序列了那麼你完全不需要加鎖了,也就是可以按照類似於Mysql樂觀鎖的解法去做。

時鐘發生跳躍

Martin 覺得 RedLock 不安全很大的原因也是因爲時鐘的跳躍,因爲鎖過期強依賴於時間,但是 ZK 不需要依賴時間,依賴每個節點的 Session。Redis作者也給出瞭解答:對於時間跳躍分爲人爲調整和 NTP 自動調整。

  • 人爲調整:人爲調整影響的那麼完全可以人爲不調整,這個是處於可控的。
  • NTP自動調整: 這個可以通過一定的優化,把跳躍時間控制的可控範圍內,雖然會跳躍,但是是完全可以接受的。

##長時間的網絡I/O

這一塊不是他們討論的重點,我自己覺得,對於這個問題的優化可以控制網絡調用的超時時間,把所有網絡調用的超時時間相加,那麼我們鎖過期時間其實應該大於這個時間,當然也可以通過優化網絡調用比如串行改成並行,異步化等。可以參考下面兩篇文章:
並行化-你的高併發大殺器異步化-你的高併發大殺器

參考文章

  1. Linux內核中的各種鎖
  2. linux幾種鎖的分析與比較
  3. 鎖的種類與特點
  4. 聊聊分佈式鎖
  5. zookeeper開源客戶端Curator典型應用場景之-分佈式鎖
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章