誰手握賬本?趣講 ZK 的記憶體模型

削微寒發表於2021-02-25

本文作者:HelloGitHub-老荀

Hi,這裡是 HelloGitHub 推出的 HelloZooKeeper 系列,免費開源、有趣、入門級的 ZooKeeper 教程,面向有程式設計基礎的新手。

本系列教程是從零開始講解 ZooKeeper,內容從最基礎的安裝使用到背後原理和原始碼的講解,整個系列希望通過有趣文字、詼諧的氣氛中讓 ZK 的知識“鑽”進你聰明的大腦。本教程是開放式:開源、協作,所以不管你是新手還是老司機,我們都希望你可以加入到本教程的貢獻中,一起讓這個教程變得更好

  • 新手:參與修改文中的錯字、病句、拼寫、排版等問題
  • 使用者:參與到內容的討論和問題解答、幫助其他人的事情
  • 老司機:參與到文章的編寫中,讓你的名字出現在作者一欄

專案地址:https://github.com/HelloGitHub-Team/HelloZooKeeper

今天讓我們繼續深入聊一聊 ZK 的記憶體模型吧~

一、記憶體模型

ZKr~老規矩,今天讓我們看看動物村又發生了什麼事情吧?

之前的故事我們提到辦事處的所有資料都是記錄在小紅本和小黃本中的,畢竟馬果果是辦事處的負責人,如果弄丟了,烏紗帽怕是要保不住了,所以馬果果現在睡覺都要抱著兩個賬本睡覺呢。

之前馬果果小F招進來的時候,剛來的小F就問馬果果:“馬老師,這個賬本要怎麼記錄啊,我怕我做不好”,馬果果微微一笑:“別怕,既然你已經是我的入室弟子了,傳內不傳外,我會教你的”,“謝謝馬老師!快教教我,怎麼做的吧”,於是馬果果就給小F上起課來...

我先拿一張之前的圖:

當時為了方便大家理解樹形結構和 chroot,所以特地這樣的畫了上面這個圖,實際上小紅本長這樣:

但是圖中我還是把資料給省略了(因為沒地畫了),大家需要知道的是這樣記有兩個點需要注意:

  • 每一個路徑的父級路徑必須存在,頂級的路徑(例:“/雞太美”)的父路徑就是 “/” 根路徑
  • 路徑和資料一一對應,在圖中可以理解就是記在右邊

但是光記錄路徑和資料你就太小看馬果果了,畢竟是薑還是老的辣,馬果果可是很高瞻遠矚的呢,我們挑一條記錄放大來看看具體的細節吧

除了資料,節點上還記錄著許可權,統計,子節點列表。怎麼樣?馬果果是不是很厲害。ZKr~


下面把這個記憶體模型用猿話翻譯一下:

整個記憶體物件在 ZK 中對應的物件其實就是 DataTree

  • 其實整個 ZK 的資料最終是存在一個雜湊表(ConcurrentHashMap)中,key 是路徑,而 value 則是對應的節點
  • 節點包含了之前圖中的:資料、子節點列表、許可權、統計
  • 路徑、資料比較簡單就不講了
  • 許可權相關之後另外開篇講,這裡知道 -1 代表不進行許可權校驗就行
  • 子節點列表,每一個節點都會維護一個子節點的列表,只記錄兒子節點。孫子節點及以下都不記錄
  • 統計資料是給客戶端查詢的,統計中的資料版本會被用在刪除以及更新時作為樂觀鎖的版本號使用

因為使用的是雜湊表,所以 ZK 查詢速度是很快的。而基本的那些增刪改查操作,其實就是操作的這個雜湊表,具體到每一個操作的流程我這裡就不贅述了,因為是很簡單的,只是要注意的是:

  • 父路徑必須存在,不存在就報錯
  • 當建立新路徑的時候,路徑和已存在的重複就報錯

二、回撥通知

上面的內容其實只說了小紅本是怎麼存的,但是馬果果還有另一本核心賬本:小黃本。

我們接下來一起看看小黃本,馬果果是怎麼玩的吧?

2.1 訂閱(辦事處視角)

辦事處現在如火如荼的氛圍,也離不開馬果果當時定下的訂閱功能,這樣有需求的村民就不用一次次跑來辦事處詢問了。我們這次以村民們訂閱大明星雞太美的更新為例子講解吧。

首先假設頭號粉絲坤坤先跑去辦事處,提出需要訂閱雞太美更新視訊的請求,小F首先檢視小紅本必須確保 /雞太美/更新視訊 存在,然後掏出了小黃本,記下了:

一次訂閱需要記兩條記錄,分別是:

  • 具體事務路徑對應村民
  • 村民對應具體事務路徑

如果有多個村民以及多個事務的話就會變成這樣:

然後坤坤就可以安心回家等通知了。

直到雞太美去辦事處上傳了最新的唱跳視訊,小F在小紅本中記錄了:

然後小F就會去小黃本中檢視有沒有 /雞太美/更新視訊 的訂閱,發現有三個村民:坤坤馬小云東東訂閱了此次事件,記住後就會把他們訂閱的記錄和對應的事務給刪除:

然後會逐個打電話給他們,並告訴他們:

  • 你們訂閱的 /雞太美/更新視訊 有事件發生了
  • 這次的事件是「資料更新」

到這裡辦事處負責的部分就結束了。

2.2 訂閱(村民視角)

接下來讓我們把視角切到訂閱的村民,我們就拿坤坤舉例,坤坤之前到辦事處訂閱完回家後,其實也沒閒著,也拿出來一個小本本記了下來:

之後接到了辦事處的電話通知,坤坤就會再次拿出這個小本本,根據當前辦事處通知的事件 /雞太美/更新視訊 找到小本本中需要做的事情:拉上窗簾、開啟電腦、帶上耳機、準備紙巾。記住了之後,就將他們從小本本上刪除了:

然後就嚴格按照當時記錄的內容一件件去執行。

直到這裡,整個從訂閱到通知的流程算是結束了。因為兩邊都刪除了記錄,所以之後如果坤坤想要再次收到雞太美更新視訊的通知,需要再一次前往辦事處登記一次。

但是你以為到這裡就完了嗎?

我們的馬果果是一個活到老學到老的武術大師,他自從學了計算機以後,智商突飛猛進,真的是文武雙全的人才啊!立馬發現剛剛的流程裡存在的幾個問題:

  1. 每次有村民過來登記訂閱事務的時候,小F需要一個一個的記錄,如果同一時間有多個村民登記的話,處於靠後位置的村民需要排隊,等前面的村民登記完才能被小F登記在案
  2. 在觸發通知的時候,小F在小黃本中找到目標事件的訂閱的之後,是一個個把要通知的村民從小黃本上刪除的,並且整個刪除的操作也和上一條登記的操作是衝突的,都需要排隊
  3. 在小黃本中記錄村民登記資料的時候,一次訂閱需要記兩條記錄,非常的佔地方,能不能找個節約點的辦法

經過縝密的思考後 ,馬果果找到了優化的辦法,並且準備傳授給小F,讓我們和小F一起跟著馬果果學習下到底是什麼辦法吧~

2.3 小黃本的改進之路

前排提醒:以下講解屬於進階內容,有那麼點硬核!請酌情食用

先假設坤坤馬小云東東三個人按順序過來訂閱雞太美的事件 /雞太美,小黃本是這樣記錄的:

每一個村民過來之後都會分配唯一的一個遞增的編號從 0 開始,編號和村民做好互相對映關係,之後在記錄下訂閱路徑和編號之間的關係就行。那麼這麼記有什麼好處呢?我們先從之前馬果果提到的第 3 點講:

  • 訂閱的路徑作為字串本身的佔用比較大,而且移除了原先的村民對應具體事務路徑對映關係
  • 數字本身佔用比較小,而且採用了馬果果新學的 BitSet 儲存方式(這個呆會說),每一個村民只佔用 1 個 bit 儲存,理論上同一個路徑訂閱的村民少於 64 個的話,只需要 8 個位元組就能存完

這兩點都變相解決了佔用記憶體的問題,儲存問題講完了,我們再講講剩下的兩個問題:

  • 1、現在如果村民前來登記訂閱事務的話,先讓小F檢視該村民對應的編號是否存在,不存在的話需要遞增當前編號並如圖中一樣新增編號和村民的對映關係,這個操作需要讓其他村民暫時等下。但是如果該村民是已經登記過的話,直接拿著編號去路徑和編號對映關係中加上這個編號即可,大致流程如下:

    只有分配的新的編號的時候會進行上鎖,其他時候如果同時來多個訂閱請求的話,使用的是讀鎖是可以併發的。

  • 2、而觸發通知的時候,只需要直接取出待通知路徑對應的所有編號即可,再拿著編號去查到對應的村民一個個通知即可

    村委會對這次的改進很滿意,馬果果也非常高興!此次改進只和辦事處有關,村民的處理方法還是和之前是一樣的。


故事講(chui)完了,現在用猿話翻譯一下。

2.4 改進前

改進前的版本中服務端使用了兩個雜湊表分別記錄了路徑和客戶端的對映以及客戶端和路徑的對映,兩個雜湊表都是一對多的關係。

訂閱

而客戶端嘗試訂閱某一個路徑的時候,只會在請求中告訴服務端,當前這個路徑需要訂閱,其實就是請求中的一個布林值。服務端獲取這個請求後,得知這個路徑需要訂閱就會把這個客戶端和路徑分別存在上面提到的兩個雜湊表中。

然後客戶端在服務端成功返回後,也會在本地做一個記錄,把路徑和具體要執行的回撥給對映起來,所以真正的回撥是放在客戶端執行的,千萬不要認為是服務端來遠端呼叫客戶端的程式碼噢!

觸發

服務端在處理完一些事務方法後,比如:setDatacreatedelete 等,都會去檢查下是否有回撥通知需要觸發,有的話取出需要通知的所有客戶端,並逐個對他們發起通知。

客戶端在收到通知後,也會從自己本地的記錄中,通過路徑取出具體的回撥物件,然後觸發該物件的回撥方法。

2.5 改進後

改進只牽涉到服務端,所以客戶端的邏輯不再贅述。

而且主要的改進並不牽涉到邏輯,只牽涉到了底層的資料結構,所以我們重點來講下這個新儲存方式的資料結構 BitSet

前方真高能,酌情休息下,再繼續閱讀

一個 BitSet 底層實際是一個 long 型別的陣列,預設初始化長度是 1,我們放大這個索引 0,看看是什麼樣子的吧。

這個資料型別最重要的就是 setget 方法,而 ZK 服務端保證了每次往 BitSet 中新增的數字是遞增的從 0 開始,所以我們從 0 開始逐個往其中新增數字。

0 的話就是把從右邊數第一位翻成 1

然後是 1

依此類推 2 到 5

所以到這裡大家能知道,BitSet 用一個 64 位長度的整型數字,分別用每一位的 0 或 1 來記錄資料,這樣長度只有 1 的陣列也能記錄 64 個數字,準確的說是 0 ~ 63,這 64 個數字。那麼你一定會問,如果要記錄 64 怎麼辦呢?在記錄 64 的時候,陣列會先擴容(通常是 2 倍)

然後把索引 1 的那個數字的最右邊的那一位翻成 1

看到這裡你應該能知道了為什麼 BitSet 能存下這麼多資料了吧,但是缺點也很明顯,只能存大於等於 0 的數字,所以 ZK 需要使用另外兩個雜湊表去對映數字和客戶端之間的關係。

現在再回頭看這個圖大家就很清楚了:

關於 BitSet這裡我留一個作業給大家,ZK 為什麼要維護一個從 0 開始自增的數字,如果跳著數字存,比如直接存 100、200 等等,會怎麼樣呢?

介紹了半天,還沒講這個改進的版本怎麼用呢。很遺憾的是,ZK 預設採用的仍然是改進前的處理方法,如果要修改服務端為改進後的方法,需要在服務端的環境變數中設定 export zookeeper.watchManagerName=org.apache.zookeeper.server.watch.WatchManagerOptimized

講到這裡你以為本章要結束了嗎?

2.6 一次訂閱終生受益

馬果果是一個追求極致使用者體驗的負責人!因為不少村民都向他抱怨過,通知後要重新過來訂閱也太麻煩了,他們一般關心的事情就那些,希望能不能不用反覆過來訂閱,馬果果聽取了人民群眾的意見,在辦事處處理訂閱事務時新增了一個流程,如果村民想要一次訂閱終身受益,請提前告知他們,小黃本上需要多記錄一些東西。假設還是坤坤馬小云過來訂閱 /雞太美/更新視訊/雞太美/跳舞 的話:

和之前不一樣的是這個額外的記錄是使用村民加上路徑作為 key 的,然後在觸發通知的時候,讓小F額外檢查下這個持久訂閱的記錄,如果當前村民和路徑存在的話,就不刪除原來的記錄!這樣這個訂閱記錄就會一直存在了,村民就不用每次通知完重新過來訂閱了!村民們聽後紛紛拍手叫好!馬果果心裡美滋滋,真是辦了一件實事啊!

不過很快啊,雞太美的頭號粉絲坤坤又來找馬果果了,表示自己感覺已經愛上了雞太美了,想訂閱和雞太美有關係的所有事務,但是雞太美現在是大明星了,各種事務很多很雜,如果他要去訂閱雞太美的每一個事務的話根本不可能,而且他也無法得知具體一共有哪些事務,所以想問馬果果能不能再辦件好事,只需要訂閱雞太美的頂層路徑就能接受到他下面所有其他路徑的通知。馬果果不愧是見過世面的人,很快就想到了一個辦法,之前新增的持久訂閱的記錄中,做一下區分不就行了,現在的記錄變成了這樣:

然後在通知的時候,檢查到當前路徑有持久遞迴訂閱的話,就把所有當前路徑的所有父級路徑都檢查遍是否有訂閱,有的話就通知一遍,那這樣,坤坤可以接受到所有的 /雞太美 下面的所有事件通知,坤坤心滿意足的回去了。馬果果搖了搖頭:唉,年輕人追星要理智啊!ZKr~

好了,言歸正傳,說說最後新加的持久訂閱和持久遞迴訂閱:

  • 這兩個訂閱模式名字我是直接通過原始碼中的列舉值直譯過來的:PERSISTENTPERSISTENT_RECURSIVE
  • 這兩個訂閱模式是 ZK 3.6.0 以上版本才支援的新特性
  • 客戶端必須通過新的介面 addWatch 才能新增這兩類的訂閱
  • 刪除持久的訂閱也需要另外呼叫介面 removeWatches,感興趣的同學自己花時間研究下吧
  • 同一個客戶端,同一個路徑下只能有一種型別(共三種:一次性、持久、持久遞迴)的訂閱,後註冊的類別會覆蓋之前註冊的類別
  • 令人遺憾的是這兩個新的訂閱模式和之前 2.5 提到的 export zookeeper.watchManagerName=org.apache.zookeeper.server.watch.WatchManagerOptimized 配置衝突,無法一起使用

三、總結

本章介紹了 ZK 的記憶體模型以及 Watcher 通知回撥機制及其原理,Watcher 回撥可以說是 ZK 最常用的功能了,大家在平時的業務開發中一定會經常用到,搞清楚原理也很有必要的。

下一章開始就要深入到 ZK 最核心最有特色的知識點:叢集!期待一下吧!

和上一期一樣,如果你有任何對文章中的疑問也可以是建議或者是對 ZK 原理部分的疑問,可以來我建立的話題中來討論,方便記錄和答疑:

地址:https://www.yuque.com/kaixin1002/yla8hz

最後點個贊再走吧!ZKr~

相關文章