分散式鎖之Zookeeper實現

清幽之地發表於2019-02-21

不知諸位還是否記得上次我們說的《沙灘 - 腳印》那個例子,在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,再次嘗試加鎖。

分散式鎖之Zookeeper實現

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、當上一個節點刪除後,證明前面的客戶端已釋放鎖;然後嘗試去加鎖,重複以上步驟。

分散式鎖之Zookeeper實現

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);
                    }

                }
            }
        }
    }
}
複製程式碼

相關文章