Zookeeper源碼分析之Curator如何實現ZK分佈式鎖

摘要:今天要講的這個問題,我會從三個角度去分析。

第一個角度:爲什麼會出現分佈式鎖以及它的出現能解決哪種場景下的問題?

第二個角度:爲什麼zookeeper能實現分佈式鎖,他的優勢又在哪?

第三個角度:走進源碼一探究竟,知其然知其所以然,原理是什麼?

好了,廢話不多說,直接進入今天的正題吧!

一.爲何出現分佈式鎖以及能解決的場景

  • 爲何會出現分佈式鎖?

分佈式鎖的概念:在分佈式系統中維持有序的對共享資源進行操作,通過互斥來保持一致性的一種鎖機制。

在原始的單體單機部署下如何能保證在高併發情況下對共享數據操作保證一致性,即同一時間內如何確保只能被一個線程執行,我們很容易想到的是通過ReentrantLcok或synchronized鎖機制對共享資源進行互斥操作。但是,隨着業務發展的需要,原始的單體架構慢慢的演進成了分佈式架構,由於分佈式系統是部署在多臺機器上的,從而衍生成爲了多進程下多線程的訪問。多個進程下仍能進行同時訪問,這將使原單機部署情況下的併發控制鎖策略失效,分佈式鎖就是爲了解決跨JVM的互斥機制來控制共享資源的訪問纔出現的。

可能這麼說比較抽象,舉個簡單的例子吧。現在有一個電商秒殺系統,當用戶進行下單操作時,首先要確保當前商品的庫存是否充足,確認充足纔會進行一個訂單欲佔的操作。假設現在商品A只有最後一件庫存了,此時系統同時進來了2個下單請求,在查詢訂單時同時執行到了如下這段代碼。

//查詢庫存是否充足
public int queryGoodsNum(String orderId) {
    int nums = goodsMapper.queryGoodsNums(orderId);
    if (nums == 0) {
        sout("該商品庫存不足!");
    }
    return nums;
}

都查詢到了該商品只有最後一件,那麼接下來就不用我多說了吧,肯定會創建2筆訂單,導致出現超賣的現象。如何解決呢?可能大家第一時間想到的是對查詢庫存,減庫存,預佔訂單進行加鎖操作,讓多線程串行執行來確保庫存的一致性。沒錯,單原始的單體單機架構下,確實能保證數據的一致性。可能隨着用戶量的劇增,一臺機器可能扛不住這麼多的用戶訪問量,急需架構升級成分佈式。假如原先的訂單系統衍生成了2臺機器,多個下單請求分別落在了2臺機器上,那麼問題來了,2臺機器同時進行用戶下單操作,恰巧都在同一個時間點進行了庫存查詢的操作,都查詢到是1,那麼同樣會導致超賣的問題。所以,像這類跨進程的共享資源訪問控制就需要涉及到分佈式鎖來解決。

  • 分佈式鎖能具體解決哪些應用場景?

記住幾個關鍵詞:分佈式架構、跨JVM、共享資源互斥訪問。

例如上面所說,電商系統防止超買超賣的問題就很典型。

二.Zookeeper爲何能實現分佈式鎖

  • 爲什麼Zookeeper能實現分佈式鎖

簡單的介紹下,Zookeeper是由雅虎研究院開發的一款分佈式協調框架,後捐贈給Apache。這是官網的地址:http://zookeeper.apache.org/,有興趣可以去了解了解。是一個經典的分佈式數據一致性解決方案,致力於爲分佈式應用提供一個高性能、高可用,且具有嚴格順序訪問控制能力的分佈式協調服務。也有人認爲他是一款數據庫,因爲能像redis存儲數據,它是一種樹形結構,可以創建節點,節點類型分爲持久化節點,持久化有序節點、臨時節點、臨時有序節點。

Zookeeper爲什麼能實現分佈式鎖,似乎已經有了答案,我們從以下幾點來分析:

  1. 保持獨佔。Zookeeper的是一種類似於文件系統的樹形結構,節點特性是唯一的,例如/lock/zk-001,倘若有多個客戶端同時過來創建/lock/zk-001節點,那麼有且僅有一個客戶端能創建成功。換句話說,倘若把Zookeeper上的一個節點看做是一把鎖,那麼成功創建的客戶端則能保持獨佔;
  2. 控制時序。Zookeeper中有一種臨時有序節點,每個來嘗試獲取鎖的客戶端,都會在Zookeeper的根目錄下創建一個臨時有序節點,例如有四個客戶端過來嘗試獲取鎖,則會在Zookeeper的/lock節點下創建四個臨時有序節點,/lock/zk-001、/lock/zk-002、/lock/zk-003、/lock/zk-004,Zookeeper的/lock節點維護一個序列,序號最小的節點獲取鎖成功。
  3. 監聽機制。Watcher機制能原子性的監聽Zookeeper上節點的增刪改操作,每個節點設置一個監聽,監聽自己的前一個節點即可,例如/lock/zk-003監聽/lock/zk-002,倘若/lock/zk-002節點被刪除,則/lock/zk-003獲取到鎖。

  • Zookeeper實現分佈式鎖的邏輯梳理

基於Zookeeper以上所述的幾點非常重要的特性,我們就能通過Zookeeper來實現數據的強一致性。

  1. 首先創建一個根目錄/lock,便於後續客戶端過來獲取鎖的一個時序訪問。
  2. 多個客戶端過來搶佔鎖時,先在/lock根目錄下,創建對應的臨時節點。
  3. 根目錄對子節點維持一個有序隊列,從小到大排序,最小的節點佔有鎖,其餘的節點分別監聽上一個節點。
  4. 噹噹前持有該鎖的客戶端完成對數據的操作後,刪除該節點,監聽該節點的節點收到通知後,獲取到鎖。

大致邏輯就是這樣,爲了更清晰點,我畫了一個流程圖,如下:

三.走進源碼探其究竟

  • 導入zookeeper有關jar包
<dependency>
	<groupId>org.apache.curator</groupId>
	<artifactId>curator-framework</artifactId>
	<version>2.13.0</version>
</dependency>

<!--封裝了一些zookeeper操作的一些高級特性-->
<dependency>
	<groupId>org.apache.curator</groupId>
	<artifactId>curator-recipes</artifactId>
	<version>2.13.0</version>
</dependency>
  • 示例代碼演示如何獲取鎖和釋放鎖

下面先寫一個Zookeeper的基礎操作類,其中包括基本的節點的增刪改查。

public class ZkConnectionUtil {

    private static final String CONN_STR = "192.168.2.105:2181";

    /**
     * 獲取zk連接
     * @return
     */
    public static CuratorFramework getConnection() {

        CuratorFramework curatorFramework = CuratorFrameworkFactory.builder()
                .connectString(CONN_STR)
                .sessionTimeoutMs(500000)
                .retryPolicy(new ExponentialBackoffRetry(1000, 3)).build();

        return curatorFramework;
    }
}

然後寫一個測試類,假設有4個線程來獲取鎖,根節點爲/locks。

public class ZkOperator {

    public static void main(String[] args) {
        //創建連接
        CuratorFramework curator = ZkConnectionUtil.getConnection();
        curator.start();

        InterProcessMutex lock = new InterProcessMutex(curator, "/locks");

        for (int i=0;i<4;i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println("線程:"+Thread.currentThread().getName()+"嘗試獲取鎖!");
                    try {
                        lock.acquire();
                        System.out.println("線程:"+Thread.currentThread().getName()+"獲取鎖成功!");
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    try {
                        Thread.sleep(20000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    try {
                        lock.release();
                        System.out.println("線程:"+Thread.currentThread().getName()+"釋放鎖成功!");
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }, "thread-"+i);

            thread.start();
        }
    }

}

打開Zookeeper服務,並使用客戶端進行連接。

連接成功後,我們來測試一下。

當我們查看/locks目錄下時,確實生成了4個臨時有序節點。

當程序執行完成之後,所有的節點全都被刪除了。之前的邏輯也得到了驗證,有興趣的同學可以自己debug調試看看,這裏就不過多贅述了,接下來讓我們看看源碼吧。

  • 進入源碼

這裏使用的是curator包下的InterProcessMutex類,它實現的互斥鎖。當程序執行到locks.acquire()時,點進去看看。

//獲取鎖的主方法,此方法還有一個重載方法,可以設置獲取鎖的超時時間
public void acquire() throws Exception
{
	if ( !internalLock(-1, null) )
	{
		throw new IOException("Lost connection while trying to acquire lock: " + basePath);
	}
}

private boolean internalLock(long time, TimeUnit unit) throws Exception
{
	Thread currentThread = Thread.currentThread();
	//會有一個LockData的數據結構,用來存儲當前持有的鎖的線程
	LockData lockData = threadData.get(currentThread);
	if ( lockData != null )
	{
		// re-entering
		lockData.lockCount.incrementAndGet();
		return true;
	}
	//如果當前線程沒有持有鎖,則嘗試去獲取鎖
	String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
	if ( lockPath != null )
	{	
		//獲取成功後,存入LockData中,便於下次重複獲取時直接返回
		LockData newLockData = new LockData(currentThread, lockPath);
		threadData.put(currentThread, newLockData);
		return true;
	}

	return false;
}

大概說一下這是什麼意思吧,內部維護了一個持有該鎖的map,噹噹前線程獲取鎖時,先判斷當前線程是否持有該鎖,持有的話重入的次數加一,返回true,表示當前線程持有該鎖。

private final ConcurrentMap<Thread, LockData> threadData = Maps.newConcurrentMap();

如果當前線程沒有持有鎖,那麼此時會去嘗試獲取鎖,進入internals.attemptLock(time, unit, getLockNodeBytes())方法。

String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception
{
	String          ourPath = null;
	boolean         hasTheLock = false;
	boolean         isDone = false;
	//循環獲取鎖
	while ( !isDone )
	{
		isDone = true;
		//嘗試創建鎖,實際上是去/locks節點下創建臨時有序節點
		ourPath = driver.createsTheLock(client, path, localLockNodeBytes);
		//獲取鎖,判斷當前創建好的臨時有序節點是否是最小的節點
		hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
	}
	//當獲取到鎖後會跳出循環
	if ( hasTheLock )
	{
		return ourPath;
	}

	return null;
}

大致看一下這段代碼,其實很簡單,就是不斷循環,去獲取鎖,讓我們繼續跟進去

driver.createsTheLock(client, path, localLockNodeBytes)。

//創建鎖,實際上是創建一個臨時有序節點
public String createsTheLock(CuratorFramework client, String path, byte[] lockNodeBytes) throws Exception
{
	String ourPath;
	//當前根據是否需要存儲值來進行創建,默認lockNodeBytes爲null
	if ( lockNodeBytes != null )
	{
		ourPath = client.create().creatingParentContainersIfNeeded().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path, lockNodeBytes);
	}
	else
	{
		//返回的ourPath也是Zookeeper爲我們創建的,例如/locks/acdc-locks-0000001,最後的數字爲遞增
		ourPath = client.create().creatingParentContainersIfNeeded().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path);
	}	
	return ourPath;
}

這一段代碼一目瞭然,就是在根目錄下創建了一個臨時有序節點而已。ourPath="/lock/_c_b523dfef-cb03-4b59-af58-8c7727e3334e-lock-0000000032";然後將當前節點的路徑返回。繼續往下看

//顧名思義循環獲取鎖,成功返回true,點進去看看
hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception
{
	  .......
		while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock )
		{
		   //獲取子節點列表按照序號從小到大排序
			List<String>        children = getSortedChildren();
			String              sequenceNodeName = ourPath.substring(basePath.length() + 1); // +1 to include the slash
			//判斷自己是否是當前最小序號節點
			PredicateResults    predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases);
			if ( predicateResults.getsTheLock() )
			{
				//成功獲取鎖
				haveTheLock = true;
			}
			else
			{
			   //拿到前一個節點
				String  previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();
			 //如果沒有拿到鎖,調用wait,等待前一個節點刪除時,通過回調notifyAll喚醒當前線程
				synchronized(this)
				{
					try 
					{
					   //設置監聽器,getData會判讀前一個節點是否存在,不存在就會拋出異常從而不會設置監聽器
						client.getData().usingWatcher(watcher).forPath(previousSequencePath);
						//如果設置了millisToWait,等一段時間,到了時間刪除自己跳出循環
						if ( millisToWait != null )
						{
							millisToWait -= (System.currentTimeMillis() - startMillis);
							startMillis = System.currentTimeMillis();
							if ( millisToWait <= 0 )
							{
								doDelete = true;    // timed out - delete our node
								break;
							}
							//等待一段時間
							wait(millisToWait);
						}
						else
						{
							//一直等待下去
							wait();
						}
					}
					catch ( KeeperException.NoNodeException e ) 
					{
					  //getData發現前一個子節點被刪除,拋出異常
					}
				}
			}
		}
	}
	.....
}

大致理一下這段代碼的意思,此處有一個while循環,用來循環獲取鎖操作,getSortedChildren()會返回一個當前根目錄下所有節點從小到大的一個排序集合。

public static List<String> getSortedChildren(CuratorFramework client, String basePath, final String lockName, final LockInternalsSorter sorter) throws Exception
{
	List<String> children = client.getChildren().forPath(basePath);
	List<String> sortedList = Lists.newArrayList(children);
	Collections.sort
	(
		sortedList,
		new Comparator<String>()
		{
			@Override
			public int compare(String lhs, String rhs)
			{
				return sorter.fixForSorting(lhs, lockName).compareTo(sorter.fixForSorting(rhs, lockName));
			}
		}
	);
	return sortedList;
}

然後去嘗試獲取鎖,driver.getsTheLock(client, children, sequenceNodeName, maxLeases)進入這個方法看看。

public PredicateResults getsTheLock(CuratorFramework client, List<String> children, String sequenceNodeName, int maxLeases) throws Exception
{
	int             ourIndex = children.indexOf(sequenceNodeName);
	validateOurIndex(sequenceNodeName, ourIndex);

	boolean         getsTheLock = ourIndex < maxLeases;
	String          pathToWatch = getsTheLock ? null : children.get(ourIndex - maxLeases);

	return new PredicateResults(pathToWatch, getsTheLock);
}

這段代碼的意思是,首先獲取當前創建的臨時節點在list中的下標,而maxLeases=1,表示當前只能有一個節點持有鎖,如果當前下標小於1,表示當前的臨時節點是最小的,那麼理所當然會獲取到鎖;如果不小於1,說明它還不是最小的,那麼會返回上一個節點的路徑,爲後面監聽做準備。

client.getData().usingWatcher(watcher).forPath(previousSequencePath);

這一行代碼就是給上一個節點設置watcher監聽,倘若之後節點發生變化,會第一時間得到通知。

好了,源碼大致也分析完了,你會發現其實代碼邏輯並不難,比起晦澀難懂的Spring源碼來說,這簡直就是小菜一碟。

當然,實現分佈式鎖的方式不僅僅是Zookeeper,還有數據庫的樂觀鎖,redis的setnx原子操作都能實現。有興趣的朋友自己去學習學習!

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章