導讀:首先你將通過這篇文章了解到 Apache Druid 底層的數據存儲方式。其次將知道為什么 Apache Druid 兼具數據倉庫,全文檢索和時間序列的特點。最后將學習到一種優雅的底層數據文件結構。
今日格言:優秀的軟件,從模仿開始的原創。
了解過 Apache Druid 或之前看過本系列前期文章的同學應該都知道 Druid 兼具數據倉庫,全文檢索和時間序列的能力。那么為什么其可以具有這些能力,Druid 在實現這些能力時做了怎樣的設計和努力?
Druid 的底層數據存儲方式就是其可以實現這些能力的關鍵。本篇文章將為你詳細講解 Druid 底層文件 Segment 的組織方式。
帶着問題閱讀:
- Druid 的數據模型是怎樣的?
- Druid 維度列的三種存儲數據結構如何?各自的作用?
- Segment 文件標識組成部分?
- Segment 如何分片存儲數據?
- Segment 新老版本數據怎么生效?
Segment 文件
Druid 將數據存儲在 segment 文件中,segment 文件按時間分區。在基本配置中,將為每一個時間間隔創建一個 segment 文件,其中時間間隔可以通過granularitySpec
的segmentGranularity
參數配置。為了使 Druid 在繁重的查詢負載下正常運行,segment 的文件大小應該在建議的 300mb-700mb 范圍內。如果你的 segment 文件大於這個范圍,那么可以考慮修改時間間隔粒度或是對數據分區,並調整partitionSpec
的targetPartitonSize
參數(這個參數的默認值是 500 萬行)。
數據結構
下面將描述 segment 文件的內部數據結構,該結構本質上是列式的,每一列數據都放置在單獨的數據結構中。通過分別存儲每個列,Druid 可以通過僅掃描實際需要的那些列來減少查詢延遲。
Druid 共有三種基本列類型:時間戳列,維度列和指標列,如下圖所示:
timestamp
和metric
列很簡單:在底層,它們都是由 LZ4 壓縮的 interger 或 float 的數組。一旦查詢知道需要選擇的行,它就簡單的解壓縮這些行,取出相關的行,然后應用所需的聚合操作。與所有列一樣,如果查詢不需要某一列,則該列的數據會被跳過。
維度列
就有所不同,因為它們支持過濾和分組操作,所以每個維度都需要下列三種數據結構:
- 將值(始終被視為字符串)映射成整數 ID 的字典,
- 用 1 編碼的列值列表,以及
- 對於列中每一個不同的值,用一個bitmap指示哪些行包含該值。
為什么需要這三種數據結構?字典
僅將字符串映射成整數 id,以便可以緊湊的表示 2 和 3 中的值。3 中的
bitmap
也稱為反向索引,允許快速過濾操作(特別是,位圖便於快速進行 AND 和 OR 操作)。最后,group by和TopN需要 2 中的值列表
,換句話說,僅基於過濾器匯總的查詢無需查詢存儲在其中的維度值列表
。
為了具體了解這些數據結構,考慮上面示例中的“page”列,下圖說明了表示該維度的三個數據結構。
1: 編碼列值的字典
{
"Justin Bieber": 0,
"Ke$ha": 1
}
2: 列數據
[0,0,1,1]
3: Bitmaps - 每個列唯一值對應一個
value="Justin Bieber": [1,1,0,0]
value="Ke$ha": [0,0,1,1]
注意bitmap
和前兩種數據結構不同:前兩種在數據大小上呈線性增長(在最壞的情況下),而 bitmap 部分的大小則是數據大小和列基數的乘積。壓縮將在這里為我們提供幫助,因為我們知道,對於“列數據”中的每一行,只有一個位圖具有非零的條目。這意味着高基數列將具有極為稀疏的可壓縮高度位圖。Druid 使用特別適合位圖的壓縮算法來壓縮 bitmap,如roaring bitmap compressing
(有興趣的同學可以深入去了解一下)。
如果數據源使用多值列,則 segment 文件中的數據結構看起來會有所不同。假設在上面的示例中,第二行同時標記了“ Ke $ ha” 和 “ Justin Bieber”主題。在這種情況下,這三個數據結構現在看起來如下:
1: 編碼列值的字段
{
"Justin Bieber": 0,
"Ke$ha": 1
}
2: 列數據
[0,
[0,1], <--Row value of multi-value column can have array of values
1,
1]
3: Bitmaps - one for each unique value
value="Justin Bieber": [1,1,0,0]
value="Ke$ha": [0,1,1,1]
^
|
|
Multi-value column has multiple non-zero entries
注意列數據和Ke$ha
位圖中第二行的更改,如果一行的一個列有多個值,則其在“列數據“中的輸入是一組值。此外,在”列數據“中具有 n 個值的行在位圖中將具有 n 個非零值條目。
命名約定
segment 標識通常由數據源
,間隔開始時間
(ISO 8601 format),間隔結束時間
(ISO 8601 format)和版本號
構成。如果數據因為超出時間范圍被分片,則 segment 標識符還將包含分區號
。如下:
segment identifier=datasource_intervalStart_intervalEnd_version_partitionNum
Segment 文件組成
在底層,一個 segment 由下面幾個文件組成:
-
version.bin
4 個字節,以整數表示當前 segment 的版本。例如,對於 v9 segment,版本為 0x0, 0x0, 0x0, 0x9。 -
meta.smoosh
存儲關於其他 smooth 文件的元數據(文件名和偏移量)。 -
XXXXX.smooth
這些文件中存儲着一系列二進制數據。
這些
smoosh
文件代表一起被“ smooshed”的多個文件,分成多個文件可以減少必須打開的文件描述符的數量。它們的大小最大 2GB(以匹配 Java 中內存映射的 ByteBuffer 的限制)。這些smoosh
文件包含數據中每個列的單獨文件,以及index.drd
帶有有關該 segment 的額外元數據的文件。還有一個特殊的列,稱為
__time
,是該 segment 的時間列。
在代碼庫中,segment 具有內部格式版本。當前的 segment 格式版本為v9
。
列格式
每列存儲為兩部分:
- Jackson 序列化的 ColumnDescriptor
- 該列的其余二進制文件
ColumnDescriptor 本質上是一個對象。它由一些有關該列的元數據組成(它是什么類型,它是否是多值的,等等),然后是可以反序列化其余二進制數的序列化/反序列化 list。
分片數據
分片
對於同一數據源,在相同的時間間隔內可能存在多個 segment。這些 segment 形成一個block
間隔。根據shardSpec
來配置分片數據,僅當block
完成時,Druid 查詢才可能完成。也就是說,如果一個塊由 3 個 segment 組成,例如:
sampleData_2011-01-01T02:00:00:00Z_2011-01-01T03:00:00:00Z_v1_0
sampleData_2011-01-01T02:00:00:00Z_2011-01-01T03:00:00:00Z_v1_1
sampleData_2011-01-01T02:00:00:00Z_2011-01-01T03:00:00:00Z_v1_2
在對時間間隔的查詢2011-01-01T02:00:00:00Z_2011-01-01T03:00:00:00Z
完成之前,必須裝入所有 3 個 segment。
該規則的例外是使用線性分片規范。線性分片規范不會強制“完整性”,即使分片未加載到系統中,查詢也可以完成。例如,如果你的實時攝取創建了 3 個使用線性分片規范進行分片的 segment,並且系統中僅加載了兩個 segment,則查詢將僅返回這 2 個 segment 的結果。
模式變更
替換 segment
Druid 使用 datasource,interval,version 和 partition number 唯一地標識 segment。如果在一段時間內創建了多個 segment,則分區號僅在 segment ID 中可見。例如,如果你有一個一小時時間范圍的 segment,但是一個小時內的數據量超過單個 segment 所能容納的時間,則可以在同一小時內創建多個 segment。這些 segment 將共享相同的 datasource,interval 和 version,但 partition number 線性增加。
foo_2015-01-01/2015-01-02_v1_0
foo_2015-01-01/2015-01-02_v1_1
foo_2015-01-01/2015-01-02_v1_2
在上面的示例 segment 中,dataSource = foo,interval = 2015-01-01 / 2015-01-02,version = v1,partitionNum =0。如果在以后的某個時間點,你使用新的模式重新索引數據,新創建的 segment 將具有更高的版本 ID。
foo_2015-01-01/2015-01-02_v2_0
foo_2015-01-01/2015-01-02_v2_1
foo_2015-01-01/2015-01-02_v2_2
Druid 批量索引(基於 Hadoop 或基於 IndexTask 的索引)可確保每個間隔的原子更新。在我們的示例中,在將所有v2
segment2015-01-01/2015-01-02
都加載到 Druid 集群中之前,查詢僅使用v1
segment。一旦v2
加載了所有 segment 並可以查詢,所有查詢將忽略v1
segment 並切換到這些v2
segment。之后不久,v1
segment 將被集群卸載。
請注意,跨越多個 segment 間隔的更新僅是每個間隔內具有原子性。在整個更新過程中,它們不是原子的。例如,當你具有以下 segment:
foo_2015-01-01/2015-01-02_v1_0
foo_2015-01-02/2015-01-03_v1_1
foo_2015-01-03/2015-01-04_v1_2
在v2
構建完並替換掉v1
segment 這段時間期內,v2
segment 將被加載進集群之中。因此在完全加載v2
segment 之前,群集中可能同時存在v1
和v2
segment。
foo_2015-01-01/2015-01-02_v1_0
foo_2015-01-02/2015-01-03_v2_1
foo_2015-01-03/2015-01-04_v1_2
在這種情況下,查詢可能會同時出現v1
和和v2
segment。
segment 多個不同模式
同一數據源的 segment 可能具有不同的 schema。如果一個 segment 中存在一個字符串列(維),但另一個 segment 中不存在,則涉及這兩個 segment 的查詢仍然有效。缺少維的 segment 查詢將表現得好像維只有空值。同樣,如果一個 segment 包含一個數字列(指標),而另一部分則沒有,則對缺少該指標的 segment 的查詢通常會“做正確的事”。缺少該指標的聚合的行為就好像該指標缺失。
最后
一、文章開頭的問題,你是否已經有答案
- Druid 的數據模型是怎樣的?(時間戳列,維度列和指標列)
- Druid 維度列的三種存儲數據結構如何?各自的作用?(編碼映射表、列值列表、Bitmap)
- Segment 文件標識組成部分?(datasource,interval,version 和 partition numbe)
- Segment 如何分片存儲數據?
- Segment 新老版本數據怎么生效?
二、知識擴展
- 什么是列存儲?列存儲和行存儲的區別是什么?
- 你了解 Bitmap 數據結構嗎?
- 深入了解
roaring bitmap compressing
壓縮算法。 - Druid 是如何定位到一條數據的?詳細流程是怎樣的?
系列推薦
Mysql:小主鍵,大問題
Mysql大數據量問題與解決
你應該知道一些其他存儲——列式存儲
時間序列數據庫(TSDB)初識與選擇(InfluxDB、OpenTSDB、Druid、Elasticsearch對比)
十分鍾了解Apache Druid(集數據倉庫、時間序列、全文檢索於一體的存儲方案)
Apache Druid 底層存儲設計(列存儲與全文檢索)
Apache Druid 的集群設計與工作流程
*請持續關注,后期將為你拓展更多知識。對 Druid 感興趣的同學也可以回顧我之前的系列文章。
關注公眾號 MageByte,設置星標點「在看」是我們創造好文的動力。后台回復 “加群” 進入技術交流群獲更多技術成長。