資料密集型應用儲存與檢索設計

邴越發表於2023-03-30

本文內容翻譯自《資料密集型應用系統設計》(大名鼎鼎的 DDIA)。

 高分好書

什麼是「資料密集型應用系統」?

當資料(資料量、資料複雜度、資料變化速度)是一個應用的主要挑戰,那麼可以把這個應用稱為資料密集型的。與之相對的是計算密集型——處理器速度是主要瓶頸。

其實我們平時遇到的大部分系統都是資料密集型的——應用程式碼訪問記憶體、硬碟、資料庫、訊息佇列中的資料,經過業務邏輯處理,再返回給使用者。

 

 

 

 

 

 

查詢型別

Online analytical processing和Online analytical processing。

查詢型別主要分為兩大類:

引擎型別

請求數量

資料量

瓶頸

儲存格式

使用者

場景舉例

產品舉例

OLTP

相對頻繁,側重線上交易

總體和單次查詢都相對較小

Disk Seek

多用行存

比較普遍,一般應用用的比較多

銀行交易

MySQL

OLAP

相對較少,側重離線分析

總體和單次查詢都相對巨大

Disk Bandwidth

列存逐漸流行

多為商業使用者

商業分析

ClickHouse

其中,OLTP 側,常用的儲存引擎又有兩種流派:

流派

主要特點

基本思想

代表

log-structured 流

只允許追加,所有修改都表現為檔案的追加和檔案整體增刪

變隨機寫為順序寫

Bitcask、LevelDB、RocksDB、Cassandra、Lucene

update-in-place 流

以頁(page)為粒度對磁碟資料進行修改

面向頁、查詢樹

B 族樹,所有主流關係型資料庫和一些非關係型資料庫

此外,針對 OLTP, 還探索了常見的建索引的方法,以及一種特殊的資料庫 —— 全記憶體資料庫。

對於資料倉儲,本章分析了它與 OLTP 的主要不同之處。資料倉儲主要側重於聚合查詢,需要掃描很大量的資料,此時,索引就相對不太有用。需要考慮的是儲存成本、頻寬最佳化等,由此引出列式儲存。

 

驅動資料庫的底層資料結構

本節由一個 shell 指令碼出發,到一個相當簡單但可用的儲存引擎 Bitcask,然後引出 LSM-tree,他們都屬於日誌流範疇。之後轉向儲存引擎另一流派 ——B 族樹,之後對其做了簡單對比。最後探討了儲存中離不開的結構 —— 索引。

首先來看,世界上 “最簡單” 的資料庫,由兩個 Bash 函式構成:

1

2

3

4

5

6

7

8

#!/bin/bash

db_set () {

echo "$1,$2" >> database

}

 

db_get () {

grep "^$1," database | sed -e "s/^$1,//" | tail -n 1

}

 

這兩個函式實現了一個基於字串的 KV 儲存(只支援 get/set,不支援 delete):

1

2

3

4

$ db_set 123456 '{"name":"London","attractions":["Big Ben","London Eye"]}'

$ db_set 42 '{"name":"San Francisco","attractions":["Golden Gate Bridge"]}'

$ db_get 42

{"name":"San Francisco","attractions":["Golden Gate Bridge"]}

 

來分析下它為什麼 work,也反映了日誌結構儲存的最基本原理:

  1. set:在檔案末尾追加一個 KV 對。
  2. get:匹配所有 Key,返回最後(也即最新)一條 KV 對中的 Value。

可以看出:寫很快,但是讀需要全文逐行掃描,會慢很多。典型的以讀換寫。為了加快讀,我們需要構建索引:一種允許基於某些欄位查詢的額外資料結構。

索引從原資料中構建,只為加快查詢。因此索引會耗費一定額外空間,和插入時間(每次插入要更新索引),即,重新以空間和寫換讀取。

這便是資料庫儲存引擎設計和選擇時最常見的權衡(trade off)

  1. 恰當的儲存格式能加快寫(日誌結構),但是會讓讀取很慢;也可以加快讀(查詢樹、B 族樹),但會讓寫入較慢。
  2. 為了彌補讀效能,可以構建索引。但是會犧牲寫入效能和耗費額外空間。

儲存格式一般不好動,但是索引構建與否,一般交予使用者選擇。

 

雜湊索引

本節主要基於最基礎的 KV 索引。

依上小節的例子,所有資料順序追加到磁碟上。為了加快查詢,我們在記憶體中構建一個雜湊索引:

  1. Key 是查詢 Key
  2. Value 是 KV 條目的起始位置和

 

 

 

看來很簡單,但這正是 Bitcask 的基本設計,但關鍵是,他 Work(在小資料量時,即所有 key 都能存到記憶體中時):能提供很高的讀寫效能:

  1. 寫:檔案追加寫。
  2. 讀:一次記憶體查詢,一次磁碟 seek;如果資料已經被快取,則 seek 也可以省掉。

 

 

如果你的 key 集合很小(意味著能全放記憶體),但是每個 key 更新很頻繁,那麼 Bitcask 便是你的菜。舉個例子:頻繁更新的影片播放量,key 是影片 url,value 是影片播放量。

 

但有個很重要問題,單個檔案越來越大,磁碟空間不夠怎麼辦?
在檔案到達一定尺寸後,就新建一個檔案,將原檔案變為只讀。同時為了回收多個 key 多次寫入的造成的空間浪費,可以將只讀檔案進行緊縮( compact ),將舊檔案進行重寫,擠出 “水分”(被覆寫的資料)以進行垃圾回收。

 

 

當然,如果我們想讓其工業可用,還有很多問題需要解決:

  1. 檔案格式。對於日誌來說,CSV 不是一種緊湊的資料格式,有很多空間浪費。比如,可以用 length + record bytes 。
  2. 記錄刪除。之前只支援 put\get,但實際還需要支援 delete。但日誌結構又不支援更新,怎麼辦呢?一般是寫一個特殊標記(比如墓碑記錄,tombstone)以表示該記錄已刪除。之後 compact 時真正刪除即可。
  3. 當機恢復。在機器重啟時,記憶體中的雜湊索引將會丟失。當然,可以全盤掃描以重建,但通常一個小最佳化是,對於每個 segment file, 將其索引條目和資料檔案一塊持久化,重啟時只需載入索引條目即可。
  4. 記錄寫壞、少寫。系統任何時候都有可能當機,由此會造成記錄寫壞、少寫。為了識別錯誤記錄,我們需要增加一些校驗欄位,以識別並跳過這種資料。為了跳過寫了部分的資料,還要用一些特殊字元來標識記錄間的邊界。
  5. 併發控制。由於只有一個活動(追加)檔案,因此寫只有一個天然併發度。但其他的檔案都是不可變的(compact 時會讀取然後生成新的),因此讀取和緊縮可以併發執行。

乍一看,基於日誌的儲存結構存在折不少浪費:需要以追加進行更新和刪除。但日誌結構有幾個原地更新結構無法做的優點:

  1. 以順序寫代替隨機寫。對於磁碟和 SSD,順序寫都要比隨機寫快幾個數量級。
  2. 簡易的併發控制。由於大部分的檔案都是不可變(immutable)的,因此更容易做併發讀取和緊縮。也不用擔心原地更新會造成新老資料交替。
  3. 更少的內部碎片。每次緊縮會將垃圾完全擠出。但是原地更新就會在 page 中留下一些不可用空間。

當然,基於記憶體的雜湊索引也有其侷限:

  1. 所有 Key 必須放記憶體。一旦 Key 的資料量超過記憶體大小,這種方案便不再 work。當然你可以設計基於磁碟的雜湊表,但那又會帶來大量的隨機寫。
  2. 不支援範圍查詢。由於 key 是無序的,要進行範圍查詢必須全表掃描。

後面講的 LSM-Tree 和 B+ 樹,都能部分規避上述問題。

  • 想想,會如何進行規避?

 

SSTables 和 LSM-Trees

這一節層層遞進,步步做引,從 SSTables 格式出發,牽出 LSM-Trees 全貌。

對於 KV 資料,前面的 BitCask 儲存結構是:

  1. 外存上日誌片段
  2. 記憶體中的雜湊表

其中外存上的資料是簡單追加寫而形成的,並沒有按照某個欄位有序。

假設加一個限制,讓這些檔案按 key 有序。我們稱這種格式為:SSTable(Sorted String Table)。

這種檔案格式有什麼優點呢?

高效的資料檔案合併。即有序檔案的歸併外排,順序讀,順序寫。不同檔案出現相同 Key 怎麼辦?

 

 

 

不需要在記憶體中儲存所有資料的索引。僅需要記錄下每個檔案界限(以區間表示:[startKey, endKey],當然實際會記錄的更細)即可。查詢某個 Key 時,去所有包含該 Key 的區間對應的檔案二分查詢即可。

 

 

 

 

分塊壓縮,節省空間,減少 IO。相鄰 Key 共享字首,既然每次都要批次取,那正好一組 key batch 到一塊,稱為 block,且只記錄 block 的索引。

構建和維護 SSTables

SSTables 格式聽起來很美好,但須知資料是亂序的來的,我們如何得到有序的資料檔案呢?

這可以拆解為兩個小問題:

  1. 如何構建。
  2. 如何維護。

構建 SSTable 檔案。將亂序資料在外存(磁碟 or SSD)中上整理為有序檔案,是比較難的。但是在記憶體就方便的多。於是一個大膽的想法就形成了:

  1. 在記憶體中維護一個有序結構(稱為 MemTable)。紅黑樹、AVL 樹、條表。
  2. 到達一定閾值之後全量 dump 到外存。

維護 SSTable 檔案。為什麼需要維護呢?首先要問,對於上述複合結構,我們怎麼進行查詢:

  1. 先去 MemTable 中查詢,如果命中則返回。
  2. 再去 SSTable 按時間順序由新到舊逐一查詢。

如果 SSTable 檔案越來越多,則查詢代價會越來越大。因此需要將多個 SSTable 檔案合併,以減少檔案數量,同時進行 GC,我們稱之為緊縮( Compaction)。

該方案的問題:如果出現當機,記憶體中的資料結構將會消失。 解決方法也很經典:WAL。

從 SSTables 到 LSM-Tree

將前面幾節的一些碎片有機的組織起來,便是時下流行的儲存引擎 LevelDB 和 RocksDB 後面的儲存結構:LSM-Tree:

 

 

 

 

這種資料結構是 Patrick O’Neil 等人,在 1996 年提出的:The Log-Structured Merge-Tree

Elasticsearch 和 Solr 的索引引擎 Lucene,也使用類似 LSM-Tree 儲存結構。但其資料模型不是 KV,但類似:word → document list。

效能最佳化

如果想讓一個引擎工程上可用,還會做大量的效能最佳化。對於 LSM-Tree 來說,包括:

最佳化 SSTable 的查詢。常用 Bloom Filter。該資料結構可以使用較少的記憶體為每個 SSTable 做一些指紋,起到一些初篩的作用。

層級化組織 SSTable。以控制 Compaction 的順序和時間。常見的有 size-tiered 和 leveled compaction。LevelDB 便是支援後者而得名。前者比較簡單粗暴,後者效能更好,也因此更為常見。

 

 

對於 RocksDB 來說,工程上的最佳化和使用上的最佳化就更多了。在其 Wiki 上隨便摘錄幾點:

  1. Column Family
  2. 字首壓縮和過濾
  3. 鍵值分離,BlobDB

但無論有多少變種和最佳化,LSM-Tree 的核心思想 —— 儲存一組合理組織、後臺合併的 SSTables —— 簡約而強大。可以方便的進行範圍遍歷,可以變大量隨機為少量順序。

B 族樹

雖然先講的 LSM-Tree,但是它要比 B+ 樹新的多。

B 樹於 1970 年被 R. Bayer and E. McCreight 提出後,便迅速流行了起來。現在幾乎所有的關係型資料中,它都是資料索引標準一般的實現。

與 LSM-Tree 一樣,它也支援高效的點查範圍查。但卻使用了完全不同的組織方式。

其特點有:

  1. 以頁(在磁碟上叫 page,在記憶體中叫 block,通常為 4k)為單位進行組織。
  2. 頁之間以頁 ID 來進行邏輯引用,從而組織成一顆磁碟上的樹。

 

 

查詢。從根節點出發,進行二分查詢,然後載入新的頁到記憶體中,繼續二分,直到命中或者到葉子節點。 查詢複雜度,樹的高度 —— O (lgn),影響樹高度的因素:分支因子(分叉數,通常是幾百個)。

 

 

插入 or 更新。和查詢過程一樣,定位到原 Key 所在頁,插入或者更新後,將頁完整寫回。如果頁剩餘空間不夠,則分裂後寫入。

分裂 or 合併。級聯分裂和合並。

一個記錄大於一個 page 怎麼辦?

  • 樹的節點是邏輯概念,page or block 是物理概念。一個邏輯節點可以對應多個物理 page。

讓 B 樹更可靠

B 樹不像 LSM-Tree ,會在原地修改資料檔案。

在樹結構調整時,可能會級聯修改很多 Page。比如葉子節點分裂後,就需要寫入兩個新的葉子節點,和一個父節點(更新葉子指標)。

  1. 增加預寫日誌(WAL),將所有修改操作記錄下來,預防當機時中斷樹結構調整而產生的混亂現場。
  2. 使用 latch 對樹結構進行併發控制。

B 樹的最佳化

B 樹出來了這麼久,因此有很多最佳化:

  1. 不使用 WAL,而在寫入時利用 Copy On Write 技術。同時,也方便了併發控制。如 LMDB、BoltDB。
  2. 對中間節點的 Key 做壓縮,保留足夠的路由資訊即可。以此,可以節省空間,增大分支因子。
  3. 為了最佳化範圍查詢,有的 B 族樹將葉子節點儲存時物理連續。但當資料不斷插入時,維護此有序性的代價非常大。
  4. 為葉子節點增加兄弟指標,以避免順序遍歷時的回溯。即 B+ 樹的做法,但遠不侷限於 B+ 樹。
  5. B 樹的變種,分形樹,從 LSM-tree 借鑑了一些思想以最佳化 seek。

B-Trees 和 LSM-Trees 對比

儲存引擎

B-Tree

LSM-Tree

備註

優勢

讀取更快

寫入更快

 

寫放大

1. 資料和 WAL

2. 更改資料時多次覆蓋整個 Page

1. 資料和 WAL

2. Compaction

SSD 不能過多擦除。因此 SSD 內部的韌體中也多用日誌結構來減少隨機小寫。

寫吞吐

相對較低:

1. 大量隨機寫。

相對較高:

1. 較低的寫放大(取決於資料和配置)

2. 順序寫入。

3. 更為緊湊。

 

壓縮率

1. 存在較多內部碎片。

1. 更加緊湊,沒有內部碎片。

2. 壓縮潛力更大(共享字首)。

但緊縮不及時會造成 LSM-Tree 存在很多垃圾

後臺流量

1. 更穩定可預測,不會受後臺 compaction 突發流量影響。

1. 寫吞吐過高,compaction 跟不上,會進一步加重讀放大。

2. 由於外存總頻寬有限,compaction 會影響讀寫吞吐。

3. 隨著資料越來越多,compaction 對正常寫影響越來越大。

RocksDB 寫入太過快會引起 write stall,即限制寫入,以期儘快 compaction 將資料下沉。

儲存放大

1. 有些 Page 沒有用滿

1. 同一個 Key 存多遍

 

併發控制

1. 同一個 Key 只存在一個地方

2. 樹結構容易加範圍鎖。

同一個 Key 會存多遍,一般使用 MVCC 進行控制。

 

相關文章