不知諸位還是否記得上次我們說的《沙灘 - 腳印》那個例子,在Zookeeper中,實現分散式鎖原理也差不多。如果你不知道,快回頭先看看分散式鎖之Redis實現
如果您對zookeeper還不熟悉,需要先去了解相關背景知識。
一、Zookeeper特性
在開始之前,我們重溫一下zookeeper中的一些概念性知識。
1、資料節點
zookeeper的檢視結構和檔案系統類似,儲存於記憶體。其中, 每個節點稱為資料節點znode。每個znode可以儲存資料,也可以掛載子節點。
比如在Dubbo中,我們將服務的資訊註冊到zookeeper中。就是由一個個的節點和子節點組成。
[zk: localhost:2181(CONNECTED) 1] ls /dubbo/com.viewscenes.netsupervisor.service.InfoUserService
[consumers, configurators, routers, providers]
[zk: localhost:2181(CONNECTED) 2]
複製程式碼
或者,我們將它理解為Windows系統中的資料夾,意思差不多的。
2、Watcher
Watcher(事件監聽器),我們可以註冊watcher監控znode的變化。比如znode刪除、資料發生變化、子節點發生變化等。當觸發這些事件時,客戶端就會得到通知。 Watcher機制是Zookeeper實現分散式協調服務的重要特性。
3、節點型別
在zookeeper中,資料節點有著不同的型別。
- 持久節點(PERSISTENT)
- 持久順序節點(PERSISTENT_SEQUENTIAL)
- 臨時節點(EPHEMERAL)
- 臨時順序節點(EPHEMERAL_SEQUENTIAL)
持久節點,一旦被建立,除非主動進行刪除操作,否則這個節點會一直保持。 臨時節點,與客戶端session繫結,一旦session失效,節點就會被自動刪除。
有個重要的資訊是,對於持久節點和臨時節點,同一個znode下,節點的名稱是唯一的! 就像在windows中,我們不可能在同一目錄,建立兩個相同名字的資料夾。記住它,這個實現分散式鎖的基礎。
二、實現
基於上面zookeeper的特性,對於分散式鎖,我們就可以梳理出這樣一條思路。
1、加鎖,申請建立臨時節點。 2、建立成功,則加鎖成功;完成自己的業務邏輯後,刪除此節點,釋放鎖。 3、建立失敗,則證明節點已存在,當前鎖被別人持有;註冊watcher,監聽資料節點的變化。 4、監聽到資料節點被刪除後,證明鎖已被釋放。重複步驟1,再次嘗試加鎖。
1、初始化
在構造方法中,我們先將zookeeper的客戶端和鎖的節點路徑設定一下。
public class ZookeeperLock implements Lock {
Logger logger = LoggerFactory.getLogger(this.getClass());
private String root_path = "/zookeeper";
private String current_path;
private ZooKeeper zooKeeper;
public ZookeeperLock(String lock_name,ZooKeeper zooKeeper){
current_path = root_path+"/"+lock_name;
this.zooKeeper = zooKeeper;
}
}
複製程式碼
2、加鎖
上面我們說了,加鎖就是建立節點的過程。程式碼也很簡單。
public class ZookeeperLock implements Lock {
/**
* 加鎖
* 失敗後呼叫waitForRelease方法等待。
*/
public void lock() {
if (tryLock()){
logger.info("獲取鎖成功!");
}else {
waitForRelease();
}
}
/**
* 嘗試加鎖
* 建立臨時節點,建立成功則加鎖成功,返回true
* @return
*/
public boolean tryLock() {
try {
zooKeeper.create(current_path, "1".getBytes(),
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
logger.info("建立節點成功!");
return true;
} catch (Exception e) {
logger.error("建立節點失敗!{}",e.getMessage());
}
return false;
}
}
複製程式碼
3、等待鎖釋放
waitForRelease就是等待鎖釋放的方法。這裡先判斷一次鎖的資料節點是否還存在,如果不存在,再次呼叫lock方法嘗試加鎖;如果存在,則通過countDownLatch來等待,直到觸發NodeDeleted事件。
public class ZookeeperLock implements Lock {
/**
* 註冊Watcher 監聽znode節點刪除事件
*/
private void waitForRelease(){
CountDownLatch countDownLatch = new CountDownLatch(1);
Watcher watcher = watchedEvent -> {
Watcher.Event.EventType type = watchedEvent.getType();
//觸發NodeDeleted事件,跳過await方法
if (Watcher.Event.EventType.NodeDeleted.equals(type)){
countDownLatch.countDown();
}
};
try {
//判斷當前鎖的資料節點是否存在
Stat exists = zooKeeper.exists(current_path, watcher);
if (exists==null){
lock();
}else {
countDownLatch.await();
lock();
}
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
複製程式碼
4、釋放鎖
我們把鎖的資料節點刪除之後,就釋放了鎖。
public class ZookeeperLock implements Lock {
public void unlock() {
try {
zooKeeper.delete(current_path,-1);
} catch (Exception e) {
logger.error("刪除節點失敗:{}",e.getMessage());
}
logger.info("釋放鎖成功!");
}
}
複製程式碼
5、測試
public class ZkTest1 {
static final Logger logger = LoggerFactory.getLogger(ZkTest1.class);
static final int thread_count = 100;
static int num = 0;
public static void main(String[] args) throws IOException, InterruptedException {
CountDownLatch c1 = new CountDownLatch(1);
ZooKeeper zooKeeper = new ZooKeeper("192.168.139.131:2181", 99999, watchedEvent -> {
if (Watcher.Event.KeeperState.SyncConnected == watchedEvent.getState()) {
c1.countDown();
}
});
c1.await();
long start = System.currentTimeMillis();
ExecutorService executorService = Executors.newFixedThreadPool(thread_count);
CountDownLatch countDownLatch = new CountDownLatch(thread_count);
for (int i=0;i<thread_count;i++){
executorService.execute(() -> {
Lock lock = new ZookeeperLock("lock",zooKeeper);
try {
lock.lock();
num++;
}finally {
if (lock!=null){
lock.unlock();
}
}
countDownLatch.countDown();
});
}
countDownLatch.await();
long end = System.currentTimeMillis();
logger.info("執行緒數量為:{},統計NUM數值為:{},共耗時:{}",thread_count,num,(end-start));
executorService.shutdown();
}
}
複製程式碼
大家可以執行測試一下,重點可以看看輸出的日誌,來幫助我們理解整個程式碼流程。這種方式有很大的效能問題,當請求數高了之後,會變得非常非常慢。
15:07:51.713 [main] INFO - 執行緒數量為:100,統計NUM數值為:100,共耗時:1625
15:07:25.056 [main] INFO - 執行緒數量為:1000,統計NUM數值為:1000,共耗時:125062
複製程式碼
如上,在筆者虛擬機器環境中的測試結果。問題出在哪呢?
三、改進版實現
我們說上面的程式碼有較大的效能問題,事實上造成這種問題的原因,有個專業名詞:驚群效應。
驚群問題是電腦科學中,當許多程式等待一個事件,事件發生後這些程式被喚醒,但只有一個程式能獲得CPU執行權,其他程式又得被阻塞,這造成了嚴重的系統上下文切換代價。
就好比,一隻凶惡的大灰狼,跑進羊群。雖然一次只會有一隻羊被吃掉,但可愛的羊兒們為了保住自己的小命,都會四處奔逃。沒有被吃掉的羊兒,繼續埋頭吃草...直到下一隻狼的到來,歷史總是驚人的相似。
回到我們上面的程式碼中,多個執行緒都會註冊Watcher事件,等待鎖釋放;當觸發資料節點刪除事件,執行緒被喚醒,然後爭先搶後的嘗試加鎖。只有一個執行緒加鎖成功,其餘的執行緒繼續阻塞,等待喚醒...
我們怎麼改進這個問題呢?
1、臨時順序節點
上面我們說zookeeper資料節點分為4個型別,其中有一個就是臨時順序節點。
首先,它是一個臨時節點;其次,它的節點名稱是有順序的。比如,我們在/lock節點建立幾個臨時順序節點,它看起來是這樣的:
[zk: localhost:2181(CONNECTED) 2] create -s -e /lock/test 1234
Created /lock/test0000000230
[zk: localhost:2181(CONNECTED) 3] create -s -e /lock/test 1234
Created /lock/test0000000231
[zk: localhost:2181(CONNECTED) 4] create -s -e /lock/test 1234
Created /lock/test0000000232
[zk: localhost:2181(CONNECTED) 5] create -s -e /lock/test 1234
Created /lock/test0000000233
[zk: localhost:2181(CONNECTED) 6] create -s -e /lock/test 1234
Created /lock/test0000000234
[zk: localhost:2181(CONNECTED) 7] create -s -e /lock/test 1234
Created /lock/test0000000235
[zk: localhost:2181(CONNECTED) 8] ls /lock
[test0000000230, test0000000231, test0000000232, test0000000233, test0000000234, test0000000235]
[zk: localhost:2181(CONNECTED) 9]
複製程式碼
可以看到,我們建立的/lock/test節點,都被加上了一個自增的序號。比如test0000000230、test0000000231
這樣。這個自增序號是zookeeper內部機制保證的,我們暫且不用管它怎麼生成。
2、實現原理
基於zookeeper臨時順序節點的特性,針對驚群效應,業界又改進了一種實現方法。它的思路是這樣的:
1、加鎖,在/lock鎖節點下建立臨時順序節點並返回,比如:test0000000235 2、獲取/lock節點下全部子節點,並排序。 3、判斷當前執行緒建立的節點,是否在全部子節點中順序最小。 4、如果是,則代表獲取鎖成功;完成自己的業務邏輯後釋放鎖。 5、如果不是最小,找到比自己小一位的節點,比如test0000000234;對它進行監聽。 6、當上一個節點刪除後,證明前面的客戶端已釋放鎖;然後嘗試去加鎖,重複以上步驟。
3、實現
初始化
public class ZkClientLock implements Lock {
private Logger logger = LoggerFactory.getLogger(ZkClientLock.class);
private String lock_path = "/lock";
private ZkClient client;
private CountDownLatch countDownLatch;
private String beforePath;// 當前請求的節點前一個節點
private String currentPath;// 當前請求的節點
public ZkClientLock(ZkClient client){
this.client = client;
}
}
複製程式碼
加鎖
我們看加鎖的過程,主要是判斷自己是否處在全部子節點的第一位,是的話加鎖成功;否則找到自己的上一個節點,呼叫waitForRelease方法等待鎖釋放。
public class ZkClientLock implements Lock {
/**
* 加鎖
* 如未成功獲取鎖,則等待鎖釋放後再次嘗試加鎖
*/
public void lock() {
if (tryLock()){
logger.info("獲取鎖成功");
}else {
waitForRelease();
lock();
}
}
/**
* 當前節點排在全部子節點的第一位,則加鎖成功
* 否則找出前一個節點
* @return
*/
public boolean tryLock() {
if (currentPath==null || currentPath.length()==0){
currentPath = client.createEphemeralSequential(lock_path+"/","lock");
logger.info("當前鎖路徑為:{}",currentPath);
}
//獲取全部子節點並排序
List<String> children = client.getChildren(lock_path);
Collections.sort(children);
//當前節點如果排在第一位,返回成功
if (currentPath.equals(lock_path+"/"+children.get(0))){
return true;
}else {
//找出上一個節點
String sequenceNodeName = currentPath.substring(lock_path.length()+1);
int i = Collections.binarySearch(children, sequenceNodeName);
beforePath = lock_path + '/' + children.get(i - 1);
}
return false;
}
}
複製程式碼
等待鎖釋放
這裡就是對上一個節點進行監聽,節點被刪除後,方法返回。
public class ZkClientLock implements Lock {
/**
* 等待鎖釋放
* 對上一個節點進行監聽,節點刪除後返回
*/
private void waitForRelease(){
IZkDataListener listener = new IZkDataListener() {
public void handleDataDeleted(String dataPath) throws Exception {
if (countDownLatch != null) {
countDownLatch.countDown();
}
}
public void handleDataChange(String dataPath, Object data) throws Exception {}
};
this.client.subscribeDataChanges(beforePath, listener);
if (this.client.exists(beforePath)) {
countDownLatch = new CountDownLatch(1);
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
this.client.unsubscribeDataChanges(beforePath, listener);
}
}
複製程式碼
釋放鎖
public void unlock() {
client.delete(currentPath);
logger.info("釋放鎖成功");
}
複製程式碼
這種方式改進之後,效能會得到大幅度提升。同樣的測試方法,得到結果如下:
15:19:19.327 [main] INFO - 執行緒數量為:100,統計NUM數值為:100,共耗時:636
15:18:58.728 [main] INFO - 執行緒數量為:1000,統計NUM數值為:1000,共耗時:5398
複製程式碼
四、Curator
我們上面寫的兩個鎖實現,並不能用於生產環境中。還需要考慮很多細節才行,比如鎖的可重入性、鎖超時。 Curator是什麼,想必不用再介紹。如果在生產環境中,使用到zookeeper分散式鎖,筆者推薦使用開源元件,除非自己寫的比人家的要好,哈哈。 首先,引入它的Maven座標。
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>4.0.1</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.0.1</version>
</dependency>
複製程式碼
1、用法
Curator幫我們封裝了zookeeper分散式鎖的實現邏輯,用起來非常簡單。
首先,連線到zookeeper客戶端。然後建立一個互斥鎖的例項,呼叫即可。
public static void main(String[] args) throws Exception {
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client =
CuratorFrameworkFactory.newClient(
"192.168.139.131:2181",
999999,
3000,
retryPolicy);
client.start();
InterProcessMutex lock = new InterProcessMutex(client, "/lock");
//加鎖
lock.acquire();
//解鎖
lock.release();
}
複製程式碼
用同樣的測試方法,得到結果如下:
15:43:34.306 [main] INFO - 執行緒數量為:100,統計NUM數值為:100,共耗時:606
15:43:02.427 [main] INFO - 執行緒數量為:1000,統計NUM數值為:1000,共耗時:6785
複製程式碼
2、InterProcessMutex
我們先看下InterProcessMutex類有哪些屬性。
public class InterProcessMutex implements InterProcessLock, Revocable<InterProcessMutex> {
//內部鎖的例項物件
private final LockInternals internals;
//鎖的基本路徑
private final String basePath;
//執行緒和鎖的對映
private final ConcurrentMap<Thread, InterProcessMutex.LockData> threadData;
//鎖的名稱字首
private static final String LOCK_NAME = "lock-";
}
複製程式碼
這裡的重點是threadData,它是一個ConcurrentMap物件,儲存著當前執行緒和鎖的對映關係,是實現可重入鎖的基礎。我們再看下LockData這個類。
private static class LockData {
//當前執行緒
final Thread owningThread;
//當前鎖的節點
final String lockPath;
//鎖的次數
final AtomicInteger lockCount;
private LockData(Thread owningThread, String lockPath) {
this.lockCount = new AtomicInteger(1);
this.owningThread = owningThread;
this.lockPath = lockPath;
}
}
複製程式碼
接著再看InterProcessMutex的構造方法。
public InterProcessMutex(CuratorFramework client, String path) {
this(client, path, new StandardLockInternalsDriver());
}
複製程式碼
StandardLockInternalsDriver是鎖的驅動類,它主要就是建立鎖的節點和判斷當前節點是不是處於第1 位。接著看,就是初始化一些屬性。
InterProcessMutex(CuratorFramework client, String path, String lockName,
int maxLeases, LockInternalsDriver driver) {
this.threadData = Maps.newConcurrentMap();
this.basePath = PathUtils.validatePath(path);
this.internals = new LockInternals(client, driver, path, lockName, maxLeases);
}
複製程式碼
3、加鎖
加鎖的方式有兩種,有超時時間的和不帶超時時間的。
lock.acquire();
lock.acquire(5000, TimeUnit.SECONDS);
複製程式碼
不過沒關係, 它們都會呼叫到internalLock
方法。
public class InterProcessMutex implements InterProcessLock, Revocable<InterProcessMutex> {
//加鎖
private boolean internalLock(long time, TimeUnit unit) throws Exception {
//當前執行緒
Thread currentThread = Thread.currentThread();
InterProcessMutex.LockData lockData = this.threadData.get(currentThread);
//當前執行緒有鎖的資訊,鎖次數加1,返回
if (lockData != null) {
lockData.lockCount.incrementAndGet();
return true;
} else {
//嘗試加鎖,返回當前鎖的節點路徑
String lockPath = this.internals.attemptLock(time, unit, this.getLockNodeBytes());
if (lockPath != null) {
//將當前執行緒和鎖的關係快取到lockData
InterProcessMutex.LockData newLockData =
new InterProcessMutex.LockData(currentThread, lockPath);
this.threadData.put(currentThread, newLockData);
return true;
} else {
return false;
}
}
}
}
複製程式碼
以上程式碼我們分為兩部分來看。
- 獲取當前執行緒,從threadData快取中獲取鎖的相關資料lockData。
- 如果,lockData不為空,說明當前執行緒是一個重入鎖,則鎖次數加1,返回。
- lockData為空就嘗試去加鎖。返回當前鎖的節點路徑,建立LockData 例項,放入到threadData快取中。
然後我們接著看attemptLock
方法,它是如何嘗試加鎖的。
public class LockInternals {
//嘗試加鎖
//如果加鎖成功,則返回當前鎖的節點路徑
String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception {
//方法執行開始時間
long startMillis = System.currentTimeMillis();
//鎖超時時間
Long millisToWait = unit != null ? unit.toMillis(time) : null;
//鎖節點資料
byte[] localLockNodeBytes = this.revocable.get() != null ? new byte[0] : lockNodeBytes;
int retryCount = 0;
//是否已經持有鎖
boolean hasTheLock = false;
boolean isDone = false;
while(!isDone) {
isDone = true;
try {
//建立鎖
ourPath = this.driver.createsTheLock(this.client, this.path, localLockNodeBytes);
//加鎖
hasTheLock = this.internalLockLoop(startMillis, millisToWait, ourPath);
} catch (NoNodeException var14) {
if (!this.client.getZookeeperClient().getRetryPolicy().
allowRetry(retryCount++, System.currentTimeMillis() - startMillis,
RetryLoop.getDefaultRetrySleeper())) {
throw var14;
}
isDone = false;
}
}
return hasTheLock ? ourPath : null;
}
}
複製程式碼
以上程式碼看著多,其實也不復雜,如果已經持有鎖,就返回鎖的節點路徑。我們重點看兩個地方。
- 建立鎖
建立鎖就是在zookeeper中建立臨時順序節點,返回鎖的節點名稱。
public class StandardLockInternalsDriver implements LockInternalsDriver {
//建立鎖
public String createsTheLock(CuratorFramework client,
String path, byte[] lockNodeBytes) throws Exception {
String ourPath;
if (lockNodeBytes != null) {
//lockNodeBytes預設為空
} else {
ourPath = (String)((ACLBackgroundPathAndBytesable)client.create().
creatingParentContainersIfNeeded().
withProtection().
withMode(CreateMode.EPHEMERAL_SEQUENTIAL)).
forPath(path);
}
return ourPath;
}
}
複製程式碼
建立後鎖的節點路徑 = UUID + lock + zookeeper自增序列。它看起來是這樣的:
_c_d5815293-f5b1-484d-b789-9f11df69149c-lock-0000006168
- 迴圈加鎖
建立鎖的節點之後,不斷的迴圈去加鎖,直到加鎖成功或者鎖超時退出。
public class LockInternals {
private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception {
//是否已經持有鎖
boolean haveTheLock = false;
boolean doDelete = false;
try {
if (this.revocable.get() != null) {
((BackgroundPathable)this.client.getData().usingWatcher(this.revocableWatcher)).forPath(ourPath);
}
//如果沒有持有鎖,就一直迴圈
while(this.client.getState() == CuratorFrameworkState.STARTED && !haveTheLock) {
//獲取所有子節點並排序
List<String> children = this.getSortedChildren();
//獲取當前鎖的自增序列
String sequenceNodeName = ourPath.substring(this.basePath.length() + 1);
//判斷當前鎖是否處於第1位
PredicateResults predicateResults = this.driver.getsTheLock(this.client,
children, sequenceNodeName, this.maxLeases);
//處於第1位,返回true
if (predicateResults.getsTheLock()) {
haveTheLock = true;
} else {
//獲取上一個序列路徑
String previousSequencePath = this.basePath + "/" + predicateResults.getPathToWatch();
synchronized(this) {
try {
//對上一個序列路徑進行監聽,並等待
((BackgroundPathable)this.client.getData().usingWatcher(this.watcher)).forPath(previousSequencePath);
if (millisToWait == null) {
this.wait();
} else {
millisToWait = millisToWait - (System.currentTimeMillis() - startMillis);
startMillis = System.currentTimeMillis();
if (millisToWait > 0L) {
this.wait(millisToWait);
} else {
doDelete = true;
break;
}
}
} catch (NoNodeException var19) {
}
}
}
}
} catch (Exception var21) {
ThreadUtils.checkInterrupted(var21);
doDelete = true;
throw var21;
} finally {
if (doDelete) {
this.deleteOurPath(ourPath);
}
}
return haveTheLock;
}
}
複製程式碼
以上程式碼比較長,但邏輯比較清晰。我們重點看while迴圈內的程式碼。
- 獲取所有子節點並排序。
- 獲取當前鎖的節點名稱,判斷是否處於全部子節點的第1位。
- 如果是,則返回true,退出迴圈。
- 如果不是,則對上一個序列節點進行監聽,並等待。
- 如果沒有鎖超時時間,則一直等待;反之等待millisToWait時間後,退出迴圈,並刪除當前鎖的節點,返回false。
4、解鎖
既然是可重入鎖,解鎖的時候必然先判斷鎖的重入次數。當次數為0時,刪除zookeeper中的鎖節點資訊等。
public class InterProcessMutex implements InterProcessLock, Revocable<InterProcessMutex> {
//解鎖
public void release() throws Exception {
//獲取當前執行緒的鎖相關資訊lockData
Thread currentThread = Thread.currentThread();
InterProcessMutex.LockData lockData = this.threadData.get(currentThread);
if (lockData == null) {
throw new IllegalMonitorStateException("You do not own the lock: " + this.basePath);
} else {
//鎖次數遞減1
int newLockCount = lockData.lockCount.decrementAndGet();
if (newLockCount <= 0) {
if (newLockCount < 0) {
throw new IllegalMonitorStateException("....");
} else {
try {
//移除Watch、刪除鎖節點、移除執行緒和鎖的對映關係
this.client.removeWatchers();
this.revocable.set((Object)null);
this.deleteOurPath(lockPath);
} finally {
this.threadData.remove(currentThread);
}
}
}
}
}
}
複製程式碼