天天看點

面向應用的反範式化模組化

作者:天穆

一、基礎:資料分布

(一)擴充性:scale up & scale out

分布式系統裡常見的擴充性問題有兩種:scale up 和 scale out。拿資料存儲舉例,如果一塊盤存不下,換一個更大的盤,從1t到4t到8t到更大的硬碟,但是這種方式很容易觸及到系統的容量上限。

因為不可能把一塊盤做的非常大,是以現在業界最常用的擴充性的方式是通過scale out方式,一塊硬碟不夠,用更多的盤,當一台機器盤的數量達到上限,用更多的機器組成叢集,如果叢集不夠了,用更多的叢集組成聯邦。再往前一步可以用一個機房,用更多機房,甚至全球分布,擴充整個系統的容量。

面向應用的反範式化模組化

(二)基本問題:資料分布政策

做scale out的時候必然會面臨一個問題,當存儲資料的節點或盤變多了以後,必須要解決資料怎麼在硬碟和機器上分布問題,也就是資料分布政策。

了解資料分布政策,可以從讀、寫兩個方面開始,比如寫的時候一個請求或者要寫一行資料,要寫到哪個機器上、寫到哪個盤上;從讀的角度來講,一個請求讀取資料,不可能通路整個叢集的所有盤或者所有機器,這樣讀取這行資料太慢了,是以必須有很好的算法或者是分布政策,能夠讓讀、寫的請求能夠一次到達目标。最多可以有多一條的方式,但是最終期待的是一條就能擷取。

讨論具體的分布政策之前,要明白設計分布政策的目标,第一是:負載均衡;第二是:線性擴充。

負載均衡:是希望在寫的時候,能夠均勻的寫到每一台機器上、每一塊盤上,整體是均勻的,不會某些盤或者某些機器成為寫熱點,也不會因為某些機器寫的太多,水位很高,其他機器都空着。從讀的角度來講也一樣,希望讀能夠很均勻的分布在整個叢集上,不會導緻熱點和傾斜。

達到負載均衡以後,還要有線性擴充,比如叢集擴縮容、盤壞了要下盤、還要加新的盤上來,這個時候希望無論機器怎麼擴容,盤怎麼增減,整個系統仍然處于負載均衡的狀态。隻要保證這一點,當系統盤增加了或者機器增加的時候,整個系統仍然能夠處于線性擴容的關系,機器多了能夠存的資料就多了,能承載的吞吐也變多了,是整個資料分布的目标。

總結:

負載均衡:

  • 寫:均勻的寫到叢集的每一台機器上,每一塊盤上;
  • 讀:均勻的從叢集的每台機器上、每塊盤上讀資料。

線性擴充:

  • 機器擴縮容,磁盤上下線,系統始終/最終處于負載均衡狀态;
  • 系統容量、吞吐與系統資源成正比(線性關系)。
面向應用的反範式化模組化

(三)兩種分布政策

目前業界有兩種比較典型的分布政策,一種是順序分布,一種是Hash分布。

順序分布:根據使用者定義元件,讓資料從最小的主鍵開始,依次往後排,如圖例所示:user_id和ts是聯合的主鍵,先按user_id 1、2、3、4、5排序,排完之後,再按ts進行排序。順序分布是把整個表做拆分,例如:把user_id 等于1的分到一個Region裡面,user_idt等于2、3的分到一個Region裡面。

面向應用的反範式化模組化

Hash公布:需要有一個Hash算法,選一個分區鍵,經過算法得到所在機器的名字。常見的一種算法就是取模“分區 = user_id % 機器數”,可以拿user_id模上這個機器數,得到所在的機器。如圖例所示,假設有3台機器,“%3 = 0”在第一台機器上,“%3 = 1”在第二台機器上,以此類推,是一種基于規則的分布。

面向應用的反範式化模組化

1)順序分布:目前比較典型的産品有hbase,tidb。

順序分布的缺點:

  • 第一,是比較依賴主鍵的值,如果user_id分布不均勻,因為通常user_id是順序配置設定的,比如有1、2、3、4、5、6、7、8、9、10,user_id更大的時候,熱度會比較高,user_id小的時候,熱度會比較低。會産生一種問題,越往表的尾部越熱,頭部的可能就會冷一點,會産生資料傾斜以及通路傾斜,需要通過額外設計或人工介入調整。
  • 第二,相同字首的資料也可能會分開,比如上圖所示的“user_id = 3” 的資料,可能會被分到兩個Region上,當通路等于3的所有資料時,必然會涉及到兩次Region。
  • 第三,因為有強大的幹預能力,需要很複雜的路由表機制。

順序分布優點:

  • 第一,一個Region包含哪些資料,通過路由表決定,比如HBase的meta表,tidb裡面是PD。Region可以靈活分布,比如讓user_id=1的資料,在Region裡面拆分,也可以把user_id=1、2、3合并。
  • 第二, Region在哪台機器可以人工指定,比如可以讓Region 1單獨分一台機器,Region 1、3共享另外一台機器,在生産上,尤其是在有資料熱點場景下,有人工介入幹預能力。
面向應用的反範式化模組化

2)Hash分布:是基于規則的分布,選取分區鍵,user_id根據分布算法或者Hash算法,得到所在的機器。比較典型的代表産品有cassandra、dynamodb。跟傳統關系資料庫裡面的分庫分表非常類似,因為沒有外部依賴,是以比較簡單。

缺點

第一,是在做擴縮容的時候,需要對很多的資料進行搬遷,是以需要一緻性hash方案。

第二,是分區無法靈活調整,因為是基于規則的,當資料基于分區鍵算好分區之後,所在的機器就确定了,不能靈活調整。

第三,有資料傾斜問題,比如有超大分區,比如user_id=1是個超大的使用者,記錄非常多,會産生熱點的問題,user_id=1的所有的資料強制分布在某一台機器上,資料特别多的話,這台機器很快會達到上限。

面向應用的反範式化模組化

(四)Hash分布:分區鍵的選擇

如圖所示,基于直覺的方式是選user_id作為分區鍵,為什麼不能用把TS也放進去?

面向應用的反範式化模組化

假設把TS放進去,user_id和TS一起算Hash,勢必會産生一種情況,就是user_id = 3的資料,可能分布在整個叢集的不同位置,做查詢的時候where user_id=3,等于3的所有資料會面臨查很多分區。而且 user_id=3下面的TS,沒法知道有多少,是一個不可預測的值,這時涉及到跨分區的查詢,這種查詢會退化成全面表掃描,是不能接受的。

選擇分區鍵要結合查詢的場景,選擇合适的分區鍵,盡量避免或者一定要避免跨分區的查詢。比如where user_id>3,這種是沒辦法直接高效的定位查詢,一定要掃全表;但是where user_id in (3, 6, 9, ...)這種,是可以拆分成多個請求逐個查詢,因為是可枚舉的。

二、Cassandra的資料模型

(一)Partition Key,Clustering key

Cassandra資料模型裡ts叫聚類鍵,user_id叫分區鍵,分區鍵和聚類鍵加一起,構成表的主鍵,主鍵要求唯一性。比如下圖所示的表裡面,user_id和TS放到一起一定要全局唯一,如果400有兩個,就是沖突的資料。

對于分區鍵和聚類鍵,可以有很多個,可以很多個Key作為分區鍵,也可以有很多Key作為聚類鍵。除了主鍵之外,Cassandra裡面還有非主鍵,或者叫屬性列或者叫資料列,比如location存具體資料,不參與資料排序。

面向應用的反範式化模組化

(二)聯合主鍵與字首比對

key比較多的場景稱為聯合主鍵或,聯合主鍵如何排序以及查詢?如圖例所示的場景,分區鍵是city,有兩個聚類鍵,一個是last_name,一個是first_name。因為分區間鍵不參與排序,當我們做Hash分布的時候,分區鍵在整個表裡面随機分布,但是在某一個特定的分區鍵下面,clustering key是順序分布的。圖例中是按last_name字首排序, p排在前面,w排在後面,在last_name相同的時候,再排下一個列, potter相同的時候, Harry排在前面,James排在後面,是這種排序規則。

面向應用的反範式化模組化

因為是這樣排的,是以在查的時候,要從左到右依次去查,有以下幾種情況:

1.where city = 'hangzhou' and last_name = 'Potter',字首掃描;

2. where city = 'hangzhou' and last_name = 'Potter' and first_name = 'James',單行讀;

這兩種可以很高效的完成,因為查詢的掃描範圍和結果集一樣大,有一些場景不能很好的支援,如:

3. where city = 'hangzhou' and first_name = 'Harry',跳過了last_name列,直接查first_name,這種查詢first_name不能夠用于圈定掃描範圍,會變成一個filter,直接對每一行資料過濾,查詢的掃描範圍是city = 'hangzhou'的所有資料,為每一行資料基于first_name = 'Harry'做過濾,假設'hangzhou'是一個很大的Partition Key,資料量很多,這個查詢會非常低效。

4. where city >= 'hangzhou',當city >= 'hangzhou'的時候,就是一個跨分區鍵的查詢,也不能被支援。

5.where city = 'hangzhou' and last_name >='P' and first_name = 'James',first_name進入filter。

6.where city = 'hangzhou' and last_name >='P' and first_name = 'Ron';

這兩個查詢從表上看,James排在前面,Ron排在後面,但是事實上last_name是範圍查詢,first_name字段變成filter來掃,而不是用來縮小查詢範圍,是以說5和6兩個語句的掃描範圍一樣。

面向應用的反範式化模組化

(三)邏輯分區:一組具有相同字首的行

一個Partition Key的值代表一個分區,但本質上來講,并不是實體上的分區,比如一塊盤、一個機器,有實體的實體跟其對應,但是分區不會有一個檔案或者實體跟其對應,分區是一種邏輯概念。

在這裡面把分區定義成是一組具有相同字首的行,字首是Partition Key,如下圖所示,Partition Key等于杭州,杭州這兩行資料就是一個分區,等于上海的就是另外一個分區,這種就是叫邏輯分區。在實體上沒有一個有力度的實體跟它對應,是以它的數量可以無窮大,這裡的city是一個字元串,可以有無窮多的資料組合,city分區鍵可以無窮無盡的分區。

面向應用的反範式化模組化
  • 分區鍵:值域可能非常大(比如long),分區鍵的每一個值,都代表了一個"分區";
  • "分區"的數量可能會非常大;
  • "分區"的本質:一組具有相同字首的行,"字首"即分區鍵的值;
  • 所有的分區都是"邏輯分區";
  • 線性擴充。

線性擴充:是指分區根據一緻性Hash算法劃分到某一個機器上,一台機器可以服務很多分區,機器數量增加之後,能夠承載的分區數量也會相應的增加,能夠獲得線性擴充能力。除非産生了一些巨大的分區,這些分區把一些機器占滿了,這種情況下線性擴充能力是受限的。

三、範式與反範式設計

(一)範式化與反範式化

範式化:是傳統關系型資料庫要求的概念,資料庫剛出現的時候,盤都比較貴,存儲空間都比較貴,資料庫的表設計必須要滿足降低資料備援度的原則,需要範式化的設計,減少資料備援度。

另外需要增加資料的一緻性校驗,比如有很多表,一些表來存買家,一些表存賣家,一些表來存訂單,通過主鍵和外鍵之間的關系進行關聯,通過外建描述資料的完整性,也是範式化設計的一部分。這種通常是用于關系資料庫的設計,而且能夠很好的解決複雜業務的設計,通過一整套的方法論,業務模型進行抽象。

在NoSQL系統裡面,強調反範式化的設計,通過增加備援度換取更好的性能。帶來的一個問題就是資料備援,存儲空間開銷上升,但是現在存儲越來越便宜了,成本并沒有上升很多。

範式化(Normalization)

  • 目的:

➢ 降低資料備援度;

➢ 增加資料的完整性(如外鍵)。

• 通常用于關系型資料庫的設計

反範式化(Denormalization)

  • 增加備援度,用空間換時間;
  • 資料在多個地方都有,存在一緻性問題。

(二)示例

下圖所示,是一個部門和部門下的雇員之間的表設計,比如有個department表,存 depId和名字,還有一個user的表,來存每一個人和userId,要描述一個部門還有哪些人的時候,需要把這兩張表關聯起來。記錄表的depId和userId之間的關系,當查一個部門有哪些人的時候,要先掃這個部門的人員表,得到這個部門的userId資訊,比如查depId=2,得到的userId是1和2,這時轉user表拿到1和2兩個ID的使用者名,同時拿depId=2的depName,才能擷取depName是Math,一次查詢,需要有三張表,這是範式化設計。

面向應用的反範式化模組化

反範式化設計,就用一張表來代替。如下圖所示,depName和userName直接存在一起,查詢一次搞定。缺點是名字重複存在,depName内容也重複存在,資料備援度增加。另外,當修改名字的時候,要改很多地方。

面向應用的反範式化模組化

(三)反範式化優缺點

優點:

  • 多個表的資料統一到一張表裡;
  • JOIN不是必須的(大部分NoSQL也不支援join),查詢更高效;
  • 采用寬表設計,從業務設計來講業務更簡潔,查詢更簡潔,整個業務模式會更清晰,SQL會更簡單,維護性會更好;
  • 當業務出現問題的時候,調查問題的效率得到相應的提升。

缺點:

  • 備援存儲,空間開銷增加。但是因為現在存儲變便宜了,是以說成本沒有增加。
  • 資料備援之後,帶來的一緻性的問題,比如隻有一張表,Math存了兩次,但是假設當有很多張表的時候,都有Math字段,會面臨在多張表之間處理一緻性問題。

(四)原則

反範式化設計的基本原則是:

  • 根據讀寫模式來設計表,設計主鍵;
  • 使用分區鍵來規劃資料分布:一次查詢需要的資料,盡可能在一個分區裡;
  • 使用聚類鍵來保證資料在分區内的唯一性,并控制結果集中的資料的排序(ASC/DESC);
  • 設計好主鍵以後,使用非主鍵列來記錄額外資訊。這個時候非主鍵包含了很多業務字段,比如訂單存儲,希望其包含訂單金額、訂單ID、買家名字、賣家資訊、商品資訊等,是一張大寬表,可以通過一次或者是少量的查詢,得到需要的所有資料,避免join,提升整個系統的查詢性能。
  • 反範式化設計:将原本需要通過join得到的資料,都包含進來。

四、典型場景分析

(一)典型場景一:物流詳情

場景描述:

  • 電商物流訂單,每個訂單會經曆多輪中轉最後達到使用者手中。每一次中轉會産生一個事件,比如已攬收、裝車、到達xx中轉站、派送中、已簽收。
  • 需要記錄全網所有物流訂單的狀态變化,為使用者提供訂變更記錄的查詢能力。
  • 訂單資料量極大,可能有上百億;體量不能影響讀寫性能。

場景抽象:

  • 寫:記錄一個訂單的一次狀态變更。
  • 讀:讀取一個訂單最近N條記錄;讀取一個訂單的全部記錄。

如下圖所示:表中有兩列主鍵,orderId指訂單的ID,是分區鍵;gmtCreated指事件産生的時間,是聚類鍵;非主鍵列detail指的是一次事件的資訊,比如已攬收或到達的狀态,是資料列。

面向應用的反範式化模組化

1)物流詳情:高表設計

"高表"設計:

  • 行不斷增加,一行描述一個訂單的一個事件。
  • 一個訂單的所有資料,由連續的一組行來描述(一個邏輯分區)。查一個訂單的所有資料時,事實是查一組具有相同字首的行,就是查一個分區的資料。

優缺點:

  • 單個分區鍵下的key數量可以很多;
  • 過多的資料将導緻寬分區的産生,應避免;
  • 無論資料量多大,單次next()的RT可控:流式ResultSet。

高表設計可以避免很大的行産生,因為所有的變化都産生在行裡面,不是産生在列裡面。可以很好的解決orderId的問題,如果某一個訂單資料量特别多的時候,會産生寬分區,需要避免。正常做法是增加次元,拆開分到不同的分區裡面。

高表設計無論資料量多大,單次讀下一行資料的時間不變,有流式ResultSet能力,一次加載一部分資料。

2)物流詳情:寬表設計(不推薦)

寬表設計:

  • 用一行來描述一個訂單的所有事件,每一列是一個事件,用事件的發生事件作列名;
  • 也可将所有事件encode到一個列裡。
面向應用的反範式化模組化

寬表設計,用一行來描述一個訂單的所有事件,每一次事件通過一列來描述。

如上圖所示,把時間作為列名,每一個列記錄了一個訂單的某一次事件。也可以把後面的列合到一起,變成一個列。

  • 單行讀;讀一個訂單的所有的資料時,隻做單行讀,業務會更簡單。
  • 無法預知列名,列數量,每一行的列都可能不一樣,強依賴schema-free能力;
  • 隻能讀所有資料,不容易實作topN讀取;
  • 超大行風險:個别行的列特别多;會影響性能。

是以在物流詳情場景下,不推薦寬表設計,建議用高表設計。

(二)典型場景二:時序類---監控系統

如下圖所示,是CPU監控,對整個叢集的多台機器做 CPU名額的監控,CPU名額有user、system、idle等不同類型,還有很多主機如host,一台機器的某一個CPU user名額有很多點位,比如這裡面192.168.1.1機器在CPU type裡面産生了兩個點,一個是30,一個是40,這個是時間線。

面向應用的反範式化模組化

這個表裡面列出來的是監控系統裡面需要的資料,在這個資料場景下,怎麼選擇分區鍵、聚類鍵以及監控資料的存儲,可以有以下幾種選擇:

分區鍵怎麼選:

  • metric;
  • metric + host;
  • metric + host + type;
  • metric + type + host。

監控資料怎麼存?

  • 一行一個點;
  • 一行存所有點;
  • 一行存有限個點:如1分鐘/1小時内産生的點。

1)分區鍵:隻使用metric

隻使用metric作為分區鍵,意味着分區隻有 CPU,如果加入網絡、磁盤,每一個metric是一個分區,意味着所有被監控的對象,所有機器的所有資料,都在一個分區下面,很容易觸發單分區限制。

面向應用的反範式化模組化

因為有變量和不變量的問題, CPU名額本身是不變量,即使未來新增名額,通常也是低頻事件。但被監控的機器是變量,會不斷的增加,可能數量巨大(比如物流訂單的數量)

  • 單分區限制:所有機器的名額都聚集在一個分區裡,被監控的機器可能無限增長,但單機的承受能力不會線性增長。
  • 業務側:識别變量和不變量
  • cpu名額本身是不變量,即使未來新增名額,通常也是低頻事件;
  • 被監控的機器是變量,會不斷的增加,可能數量巨大(比如物流訂單的數量)。

2)分區鍵:metric + host

metric + host政策可以很好的控制了單分區的資料量,不會出現寬分區。因為除了 host以外,沒有其他次元大幅度的變化量,如下圖所示,type和TS都不會太大變化,TS本質上是作為host下面的一個子集存在,比如在做一個查詢的時候,要查某一台機器的某一段時間範圍的 CPU名額,肯定希望這台機器的資料都排在一起,用一個查詢搞定,是以TS不能夠放在分區鍵裡面。

面向應用的反範式化模組化

這個設計的缺點是,并發讀寫同一個機器的cpu名額,請求都路由給同一台機器,不利于并發。

優缺點總結:

  • 很好的控制了單分區的資料量,不會出現寬分區;
  • 單機的所有類别的cpu名額都在一個分區裡;
  • 并發讀寫同一個機器的cpu名額,請求都路由給同一台機器,不利于并發。

3)分區鍵:metric + host + type

metric + host + type政策,把名額類别也加到分區鍵裡面,可以很好的适配并發查詢模式,提高整個叢集的吞吐。因為 metric + host + type整體作為分區鍵,隻有三個全相等的時候,才會分在一個分區裡面。

面向應用的反範式化模組化

另外一種方式是host和type交換位置,其對采用一緻性hash的cassandra來說,沒有差別。但是對于順序分布來講,可能會有一點差別,因為改變了key的值域範圍,可能導緻值變少了,這個時候會産生聚合效應,可能導緻一些潛在的問題。總結:

  • 同一台機器的不同cpu類别的名額在不同的分區裡,很好的支援并發通路;
  • host和type的順序
  • 對采用一緻性hash的cassandra來說,沒有差別。

4)優化:type合并至metric中

metric + host + type政策還有另一個優化,type合并至metric中,如下圖所示,是時序資料庫模組化時的特點,type在時序資料庫裡面叫 tag,标簽的意思,可以有很多标簽,比如IP是一種标簽,所在機房可能也是一個标簽,甚至可以有業務的标簽, CPU的 type也是一種标簽,這種場景下,可以把标簽合到 metric中。

面向應用的反範式化模組化

因為type是可枚舉的,隻有幾種,不會增加也不會減少。合并過去之後,減少了列,存儲的列變少以後,可以提高性能,減少開銷,僅資料量較大時,體量小的時候看不出來。

還有一種場景,儲存的是程序ID,這種時候沒辦法合并,比如監控每一個程序的網絡流量,這個時候程序ID沒法合到 Metric裡面,因為程序ID不可預估,而且不可枚舉。

面向應用的反範式化模組化

5)資料點位的存儲:寬表 or 高表

資料點位的存儲指的是每個時間點metric的值,依然可以有高表和寬表的設計。如下圖所示,高表設計,把TS放到Clustering key裡面,一行存儲一個資料點。高表設計因為一行隻有一列,容易擴充多值監控,對于像經緯度,一個點位有兩個值,在高表設計裡面很容易擴充。

面向應用的反範式化模組化

寬表設計,一行存儲這個某個機器的某個名額的所有點,這種寬表設計還是會存在單行上線的問題,列多了以後會有性能問題。

融合設計,一行記錄有限個點,比如存1分鐘采集的所有的點或者1小時的點,結合高表和寬表的設計,粒度選擇合适的時候,得到最優的性能,而且能夠配合整個系統内部機制,如cache,bloomfilter等。

當然這些例子,在時序場景下面比較簡單,能夠解決簡單業務的時序設計問題,對于業界的實際資料庫來講,但生産使用時有很多考量因素。

  • 高表設計:一行存儲一個資料點,如上表所示;
  • 容易擴充多值監控:如經緯度;
  • 寬表設計:一行存儲這個某個機器的某個名額的所有點;
  • 單行上限;
  • 融合設計:一行記錄有限個點:如1分鐘,1小時内采集到的所有點;
  • 粒度選擇:由名額的采集頻率決定,以控制單行的列數;
  • 适當的控制行數,可以配合一些内部優化機制:如cache,bloomfilter等;

注意:時序模組化的原理很簡單,但生産使用時有很多考量因素,各TSDB都有不同的側重點。應根據業務實際需要選擇合适的模型,沒有銀彈

(三)常見誤區

1)常見誤區一:分頁查詢

常見的分頁查詢誤區,從Mexico

[MOU23]

過來的使用者很容易遇到一個問題拆請求,如下圖所示,做一個大表的掃描,user id=3的資料可能非常多,為了避免一次傳回太多的資料,需要對請求進行拆分。

面向應用的反範式化模組化

比如按TS進行分頁,先掃500的,再掃下500,再掃下500,一次一次掃。在 MySQL裡面這樣做是合理的,因為RPC一次傳回所有記錄。但是在Cassandra裡面沒必要,因為Cassandra用的是一種流式ResultSet方式,在系統設計層面,已經考慮到了不斷往下next的情況,已經做了請求拆分。比如第一次next的時候,會新加載500行的資料,等到這500行資料消化完了,再下一次next,會加載下500行資料,如此往複,直到所有結果集傳回。

流式ResultSet:

  • 為了避免單次RPC傳回過多資料導緻RT過高,CQL driver會自動對請求進行拆分;
  • 第一次next()調用會從服務端load N行資料,之後的N-1次next()隻從記憶體消費資料;
  • 下一次next()會再加載N行資料到用戶端,如此往複,直到所有結果集傳回。

參見:

https://docs.datastax.com/en/developer/java-driver/3.2/manual/paging/

結論:不要為了拆分大請求而進行分頁。

2)常見誤區二:修改主鍵

  • 場景1:修改主鍵的schema,在MySQL裡面可以,但在Cassandra裡面不允許,隻能重建立表。
  • 場景2:修改主鍵的值,本身就是錯誤的說法。考慮java的map的key, key能修改嗎?修改key的邏輯就是删除老key,寫入新key。從資料庫角度來講,沒有修改主建操作,隻有删除、添加這兩種操作,非主鍵可以修改。