Prometheus 存儲層的演進

Prometheus 是當(dāng)下最流行的監(jiān)控平臺之一,它的主要職責(zé)是從各個目標(biāo)節(jié)點中采集監(jiān)控數(shù)據(jù),后持久化到本地的時序數(shù)據(jù)庫中,并向外部提供便捷的查詢接口。本文嘗試探討 Prometheus 存儲層的演進過程,信息源主要來自于 Prometheus 團隊在歷屆 PromConf 上的分享。
時序數(shù)據(jù)庫是 Promtheus 監(jiān)控平臺的一部分,在了解其存儲層的演化過程之前,我們需要先了解時序數(shù)據(jù)庫及其要解決的根本問題。
TSDB
時序數(shù)據(jù)庫 (Time Series Database, TSDB) 是數(shù)據(jù)庫大家庭中的一員,專門存儲隨時間變化的數(shù)據(jù),如股票價格、傳感器數(shù)據(jù)、機器狀態(tài)監(jiān)控等等。時序 (Time Series) 指的是某個變量隨時間變化的所有歷史,而樣本 (Sample) 指的是歷史中該變量的瞬時值:

每個樣本由時序標(biāo)識、時間戳和數(shù)值 3 部分構(gòu)成,其所屬的時序就由一系列樣本構(gòu)成。由于時間是連續(xù)的,我們不可能、也沒有必要記錄時序在每個時刻的數(shù)值,因此采樣間隔 (Interval) 也是時序的重要組成部分。采樣間隔越小、樣本總量越大、捕獲細節(jié)越多;采樣間隔越大、樣本總量越小、遺漏細節(jié)越多。以服務(wù)器機器監(jiān)控為例,通常采樣間隔為 15 秒。
數(shù)據(jù)的高效查詢離不開索引,對于時序數(shù)據(jù)而言,唯一的、天然的索引就是時間 (戳)。因此通常時序數(shù)據(jù)庫的存儲層相比于關(guān)系型數(shù)據(jù)庫要簡單得多。仔細思考,你可能會發(fā)現(xiàn)時序數(shù)據(jù)在某種程度上就是鍵值數(shù)據(jù)的一個子集,因此鍵值數(shù)據(jù)庫天然地可以作為時序數(shù)據(jù)的載體。通常一個時序數(shù)據(jù)庫能容納百萬量級以上的時序數(shù)據(jù),要從其中搜索到其中少量的幾個時序也非易事,因此對時序本身建立高效的索引也很重要。
The Fundamental Problem of TSDBs
TSDB 要解決的基本問題,可以概括為下圖:

研究過存儲引擎結(jié)構(gòu)和性能優(yōu)化的工程師都會知道:
許多數(shù)據(jù)庫的奇技淫巧都是在解決內(nèi)存與磁盤的讀寫模式、性能的不匹配問題
時序數(shù)據(jù)庫也是數(shù)據(jù)庫的一種,只要它想持久化,自然不能例外。但與鍵值數(shù)據(jù)庫相比,時序數(shù)據(jù)庫存儲的數(shù)據(jù)有更特殊的讀寫特征,Bj?rn Rabenstein 將稱其為:
Vertical writes, horizontal(-ish) reads
垂直寫,水平讀
圖中每條橫線就是一個時序,每個時序由按照 (準(zhǔn)) 固定間隔采集的樣本數(shù)據(jù)構(gòu)成,通常在時序數(shù)據(jù)庫中會有很多活躍時序,因此數(shù)據(jù)寫入可以用一個垂直的窄方框表示,即每個時序都要寫入新的樣本數(shù)據(jù);用戶在查詢時,通常會觀察某個、某幾個時序在某個時間段內(nèi)的變化趨勢,或?qū)ζ溥M行聚合計算,因此數(shù)據(jù)讀取可以用一個水平的方框表示。是謂 “垂直寫、水平讀”。
Storage Layer of Prometheus
Prometheus 是為云原生環(huán)境中的數(shù)據(jù)監(jiān)控而生,在其設(shè)計過程中至少需要考慮以下兩個方面:
1、在云原生環(huán)境中,實例可能隨時出現(xiàn)、消失,因此時序也可能隨時出現(xiàn)或消失,即系統(tǒng)中存在大量時序,其中部分處于活躍狀態(tài),這會在多方面帶來挑戰(zhàn):
如何存儲大量時序避免資源浪費 如何定位被查詢的少數(shù)幾個時序
2、監(jiān)控系統(tǒng)本身應(yīng)該盡量少地依賴外部服務(wù),否則外部服務(wù)失效將引發(fā)監(jiān)控系統(tǒng)失效
對于第 2 點,Prometheus 團隊選擇放棄集群,使用單機架構(gòu),并且在單機系統(tǒng)中使用本地 TSDB 做數(shù)據(jù)持久化,完全不依賴外部服務(wù);第 1 點是需要存儲、索引、查詢引擎層合作解決的問題,在下文中我們將進一步分析存儲層在其中的作用。Prometheus 存儲層的演進可以分成 3 個階段:
1st Generation: Prototype 2nd Generation: Prometheus V1 3rd Generation: Prometheus V2
注意:本節(jié)只關(guān)注 Prometheus 時序數(shù)據(jù)的存儲,不涉及索引、WAL 等其它數(shù)據(jù)的存儲。
Data Model
盡管數(shù)據(jù)模型是存儲層之上的抽象,理論上它不應(yīng)該影響存儲層的設(shè)計。但理解數(shù)據(jù)模型能夠幫助我們更快地理解存儲層。
在 Prometheus 中,每個時序?qū)嶋H上由多個標(biāo)簽 (labels) 標(biāo)識,如:
api_http_requests_total{path="/users",status=200,method="GET",instance="10.111.201.26"}
該時序的名字為 api_http_requests_total,標(biāo)簽為 path、status、method 和 instance,只有時序名字和標(biāo)簽鍵值完全相同的時序才是同一個時序。事實上,時序名字就是一個隱藏標(biāo)簽:
{name="api_http_requests_total",path="/users",status=200,method="GET",
instance="10.111.201.26"}
對于用戶來說,標(biāo)簽之間不存在先后順序,用戶可能關(guān)注:
所有 api 調(diào)用的 status 某個 path 調(diào)用的成功率、QPS 某個實例、某個 path 調(diào)用的成功率 …
1st Generation: Prototype
在 Prototype 階段,Prometheus 直接利用開源的鍵值數(shù)據(jù)庫 (LevelDB) 作為本地持久化存儲,并采用與 BigTable 推薦的時序數(shù)據(jù)方案[1] 類似的 schema 設(shè)計:

將時序名稱、標(biāo)簽 (固定順序)、時間戳拼接成每個樣本的鍵,于是同一個時序的數(shù)據(jù)就能夠連續(xù)存儲在鍵值數(shù)據(jù)庫中,提高范圍查詢的效率。但從圖中可以看出,這種方式存儲的鍵很長,盡管鍵值數(shù)據(jù)庫內(nèi)部會對數(shù)據(jù)進行壓縮,但是在內(nèi)存中這樣存儲數(shù)據(jù)很浪費空間,這無法滿足項目的設(shè)計要求。Prometheus 希望在內(nèi)存中壓縮數(shù)據(jù),使得內(nèi)存中可以容納更多活躍的時序數(shù)據(jù),同時在磁盤中也能按類似的方式壓縮編碼,提高效率。時序數(shù)據(jù)比通用鍵值數(shù)據(jù)有更顯著的特征。即使鍵值數(shù)據(jù)庫能夠壓縮數(shù)據(jù),但針對時序數(shù)據(jù)的特征,使用特殊的壓縮算法能夠取得更好的壓縮率。因此在 Prototype 階段,使用三方鍵值數(shù)據(jù)庫的方案最終流產(chǎn)。
2nd Generation: Prometheus V1
Compression
Why Compression
假設(shè)監(jiān)控系統(tǒng)的需求如下:
500 萬活躍時序 30 秒采樣間隔 1 個月數(shù)據(jù)留存
那么經(jīng)過計算可以得到具體的存儲要求:
平均每秒采集 166000 個樣本 存儲樣本總量為 4320 億個樣本
假設(shè)沒有任何壓縮,不算時序標(biāo)識,每個樣本需要 16 個字節(jié)存儲空間 (時間戳 8 個字節(jié)、數(shù)值 8 個字節(jié)),整個系統(tǒng)的存儲總量為 7TB,假設(shè)數(shù)據(jù)需要留存 6 個月,則總量為 42 TB,那么如果能找到一種有效的方式壓縮數(shù)據(jù),就能在單機的內(nèi)存和磁盤中存放更多、更長的時序數(shù)據(jù)。
Chunked Storage Abstraction
上文提到 TSDB 的根本問題是 “垂直寫,水平讀”,每次采樣都會需要為每個活躍時序?qū)懭胍粭l樣本數(shù)據(jù),但如果每次為每個時序?qū)懭?16 個字節(jié)到 HDD/SSD 中,顯然這對塊存儲設(shè)備十分不友好,效率低下。因此 Prometheus V2 將數(shù)據(jù)按固定長度切割相同大小的分段 (Chunks),方便壓縮、批量讀寫。
訪問時序數(shù)據(jù)時,Prometheus 使用 3 層抽象,如下圖所示:

應(yīng)用層使用 Series Iterator 順序訪問時序中的樣本,而 Series Iterator 底下由一個個 Chunk Iterator 拼接而成,每個 Chunk Iterator 負責(zé)將壓縮編碼的時序數(shù)據(jù)解碼返回。這樣做的好處是,每個 Chunk 甚至可以使用完全不同的方式編碼,方便開發(fā)團隊嘗試不同的編碼方案。
Timestamp Compression: Double Delta
由于通常數(shù)據(jù)采樣間隔是固定值,因此前后時間戳的差值幾乎固定,如 15s,30s。但如果我們更近一步,只存儲差值的差值,那么幾乎不用再為新的時間戳花費額外的空間,這便是所謂的 “Double Delta“。本質(zhì)上,如果未來所有的采集時間戳都可以精準(zhǔn)預(yù)測,那么每個新時間戳的信息熵為 0 bit。但現(xiàn)實并不完美,網(wǎng)絡(luò)可能延遲、中斷,實例可能遇到 GC、重啟,采樣間隔隨時有可能波動:

但這種波動的幅度有限,Prometheus 采用了和 FB 的內(nèi)存時序數(shù)據(jù)庫 Gorilla 類似的方式編碼時間戳,詳情可以參考 Gorilla) 以及 Bj?rn Rabenstein 在 PromConn 2016 的演講 ppt[2] ,細節(jié)比較瑣碎,這里不贅述。
Value Compression
Prometheus 和 Gorilla 中的每個樣本值都是 float64 類型。Gorilla 利用 float64 的二進制表示 (IEEE754) 將前后兩個樣本值 XOR 來尋找壓縮的空間,能獲得 1.37 bytes/sample 的壓縮能力。Prometheus V2 采用的方式比較簡單:
如果可能的話,使用整型 (8/16/32 位) 存儲,否則用 float32,最后實在不行就直接存儲 float64 如果數(shù)值增長得很規(guī)律,則不使用額外的空間存儲
以上做法給 Prometheus V1 帶來了 3.3 bytes/sample 的壓縮能力。相比于為完全存儲于內(nèi)存中的 Gorilla 相比,這樣的壓縮能力對于 Prometheus 已經(jīng)夠用,但在 V2 中,Prometheus 也融合了 Gorilla 采用的壓縮技術(shù)。
Chunk Encoding
Prometheus V1 將每個時序分割成大小為 1KB 的 chunks,如下圖所示:

在內(nèi)存中保留著最近寫入的 chunk,其中 head chunk 正在接收新的樣本。每當(dāng)一個 head chunk 寫滿 1KB 時,會立即被凍結(jié),我們稱之為完整的 chunk,從此刻開始該 chunk 中的數(shù)據(jù)就是不可變的 (immutable) ,同時生成一個新的 head chunk 負責(zé)消化新的請求。每個完整的 chunk 會被盡快地持久化到磁盤中。內(nèi)存中保存著每個時序最近被寫入或被訪問的 chunks,當(dāng) chunks 數(shù)量過多時,存儲引擎會將超過的 chunks 通過 LRU 策略清出。
在 Prometheus V1 中,每個時序都會被存儲到在一個獨占的文件中,這也意味著大量的時序?qū)a(chǎn)生大量的文件。存儲引擎會定期地去檢查磁盤中的時序文件,是否已經(jīng)有 chunk 數(shù)據(jù)超過保留時間,如果有則將其刪除 (復(fù)制后刪除)。
Prometheus 的查詢引擎的查詢過程必須完全在內(nèi)存中進行。因此在執(zhí)行之前,存儲引擎需要將不在內(nèi)存中的 chunks 預(yù)加載到內(nèi)存中:

如果在內(nèi)存中的 chunks 持久化之前系統(tǒng)發(fā)生崩潰,則會產(chǎn)生數(shù)據(jù)丟失。為了減少數(shù)據(jù)丟失,Prometheus V1 還使用了額外的 checkpoint 文件,用于存儲各個時序中尚未寫入磁盤的 chunks:

Prometheus V1 vs. Gorilla
正因為 Prometheus V1 與 Gorilla 的設(shè)計理念、需求有所不同,我們可以通過對比二者來理解其設(shè)計過程中使用不同決策的原因。

3rd Generation: Prometheus V2
The Main Problem With 2nd Generation
Prometheus V1 中,每個時序數(shù)據(jù)對應(yīng)一個磁盤文件的方式給系統(tǒng)帶來了比較大的麻煩:
由于在云原生環(huán)境下,會不斷產(chǎn)生新的時序、廢棄舊的時序 (Series Churn),因此實際上存儲層需要的文件數(shù)量遠遠高于活躍的時序數(shù)量。任其發(fā)展遲早會將文件系統(tǒng)的 inodes 消耗殆盡。而且一旦發(fā)生,恢復(fù)系統(tǒng)將異常麻煩。不僅如此,在新舊時序大量更迭時,由于舊時序數(shù)據(jù)尚未從內(nèi)存中清出,系統(tǒng)的內(nèi)存消耗量也會飆升,造成 OOM。 即便使用 chunks 來批量讀寫數(shù)據(jù),從整體上看,系統(tǒng)每秒鐘仍要向磁盤寫入數(shù)千個 chunks,造成 I/O 壓力;如果通過增大每批寫入的量來減少 I/O 次數(shù),又將造成內(nèi)存的壓力。 同時將所有時序文件保持打開狀態(tài)很不合理,需要消耗大量的資源。如果在查詢前后打開、關(guān)閉文件,又會增加查詢的時延。 當(dāng)數(shù)據(jù)超過留存時間時需要刪除相關(guān)的 chunks,這意味著每隔一段時間就要對數(shù)百萬的文件執(zhí)行一次刪除數(shù)據(jù)操作,這個過程可能需要持續(xù)數(shù)小時。 通過周期性地將未持久化的 chunks 寫入 checkpoint 文件理論上確實可以減少數(shù)據(jù)丟失,但是如果執(zhí)行數(shù)據(jù)恢復(fù)需要很長時間,那么實際上又錯過了新的數(shù)據(jù),還不如不恢復(fù)。
因此 Prometheus 的第三代存儲引擎,主要改變就是放棄 “一個時序?qū)?yīng)一個文件” 的設(shè)計理念。
Macro Design
第三代存儲引擎在磁盤中的文件結(jié)構(gòu)如下圖所示:

根目錄下,順序排列著編了號的 blocks,每個 block 中包含 index 和 chunk 文件夾,后者里面包含編了號的 chunks,每個 chunk 包含許多不同時序的樣本數(shù)據(jù)。其中 index 文件中的信息可以幫我我們快速鎖定時序的標(biāo)簽及其可能的取值,進而找到相關(guān)的時序和持有該時序樣本數(shù)據(jù)的 chunks。值得注意的是,最新的 block 文件夾中還包含一個 wal 文件夾,后者將承擔(dān)故障恢復(fù)的職責(zé)。
Many Little Databases
第三代存儲引擎將所有時序數(shù)據(jù)按時間分片,即在時間維度上將數(shù)據(jù)劃分成互不重疊的 blocks,如下圖所示:

每個 block 實際上就是一個小型數(shù)據(jù)庫,內(nèi)部存儲著該時間窗口內(nèi)的所有時序數(shù)據(jù),因此它需要擁有自己的 index 和 chunks。除了最新的、正在接收新鮮數(shù)據(jù)的 block 之外,其它 blocks 都是不可變的。由于新數(shù)據(jù)的寫入都在內(nèi)存中,數(shù)據(jù)的寫效率較高:

為了防止數(shù)據(jù)丟失,所有新采集的數(shù)據(jù)都會被寫入到 WAL 日志中,在系統(tǒng)恢復(fù)時能快速地將其中的數(shù)據(jù)恢復(fù)到內(nèi)存中。在查詢時,我們需要將查詢發(fā)送到不同的 block 中,再將結(jié)果聚合。
按時間將數(shù)據(jù)分片賦予了存儲引擎新的能力:
當(dāng)查詢某個時間范圍內(nèi)的數(shù)據(jù),我們可以直接忽略在時間范圍外的 blocks 寫完一個 block 后,我們可以將輕易地其持久化到磁盤中,因為只涉及到少量幾個文件的寫入 新的數(shù)據(jù),也是最常被查詢的數(shù)據(jù)會處在內(nèi)存中,提高查詢效率 (第二代同樣支持) 每個 chunk 不再是固定的 1KB 大小,我們可以選擇任意合適的大小,選擇合適的壓縮方式 刪除超過留存時間的數(shù)據(jù)變得異常簡單,直接刪除整個文件夾即可
mmap
第三代引擎將數(shù)百萬的小文件合并成少量大文件,也讓 mmap 成為可能。利用 mmap 將文件 I/O 、緩存管理交給操作系統(tǒng),降低 OOM 發(fā)生的頻率。
Compaction
在 Macro Design 中,我們將所有時序數(shù)據(jù)按時間切割成許多 blocks,當(dāng)新寫滿的 block 持久化到磁盤后,相應(yīng)的 WAL 文件也會被清除。寫入數(shù)據(jù)時,我們希望每個 block 不要太大,比如 2 小時左右,來避免在內(nèi)存中積累過多的數(shù)據(jù)。讀取數(shù)據(jù)時,若查詢涉及到多個時間段,就需要對許多個 block 分別執(zhí)行查詢,然后再合并結(jié)果。假如需要查詢一周的數(shù)據(jù),那么這個查詢將涉及到 80 多個 blocks,降低數(shù)據(jù)讀取的效率。
為了既能寫得快,又能讀得快,我們就得引入 compaction,后者將一個或多個 blocks 中的數(shù)據(jù)合并成一個更大的 block,在合并的過程中會自動丟棄被刪除的數(shù)據(jù)、合并多個版本的數(shù)據(jù)、重新結(jié)構(gòu)化 chunks 來優(yōu)化查詢效率,如下圖所示:

Retention
當(dāng)數(shù)據(jù)超過留存時間時,刪除舊數(shù)據(jù)非常容易:

直接刪除在邊界之外的 block 文件夾即可。如果邊界在某個 block 之內(nèi),則暫時將它留存,知道邊界超出為止。當(dāng)然,在 Compaction 中,我們會將舊的 blocks 合并成更大的 block;在 Retention 時,我們又希望能夠粒度更小。所以 Compaction 與 Retention 的策略之間存在著一定的互斥關(guān)系。Prometheus 的系統(tǒng)參數(shù)可以對單個 block 的大小作出限制,來尋找二者之間的平衡。
看到這里,相信你已經(jīng)發(fā)現(xiàn)了,這不就是 LSM Tree **嗎?**每個 block 就是按時間排序的 SSTable,內(nèi)存中的 block 就是 MemTable。
Compression
第三代存儲引擎融合了 Gorilla 的 XOR float encoding 方案,將壓縮能力提升到 1-2 bytes/sample。具體方案可以概括為:按順序采用以下第一條適用的策略
Zero encoding:如果完全可預(yù)測,則無需額外空間 Integer double-delta encoding:如果是整型,可以利用 double-delta 原理,將不等的前后間隔分成 6/13/20/33 bits 幾種,來優(yōu)化空間使用 XOR float encoding:參考 Gorilla Direct encoding:直接存 float64
平均下來能取得 1.28 bytes/sample 的壓縮能力。
References
PromCon 2017: Storing 16 Bytes at Scale - Fabian Reinartz[3], slides[4] Writing a Time Series Database from Scratch[5] PromCon 2016: The Prometheus Time Series Database - Bj?rn Rabenstein[6], slides[7] Percona Live Open Source Database Conference 2017: Life of a PromQL query[8] Prometheus 1.8 doc: storage[9] Prometheus 2.16 doc: storage[10] Google Cloud: Schema Design for Time Series Data[11]
腳注
BigTable 推薦的時序數(shù)據(jù)方案: https://link.zhihu.com/?target=https%3A//cloud.google.com/bigtable/docs/schema-design-time-series%3Fhl%3Den%23server_metrics
[2]ppt: https://link.zhihu.com/?target=https%3A//docs.google.com/presentation/d/1TMvzwdaS8Vw9MtscI9ehDyiMngII8iB_Z5D4QW4U4ho/edit%23slide%3Did.g15afea0287_0_16
[3]PromCon 2017: Storing 16 Bytes at Scale - Fabian Reinartz: https://link.zhihu.com/?target=https%3A//www.youtube.com/watch%3Fv%3Db_pEevMAC3I%26feature%3Dyoutu.be
[4]slides: https://link.zhihu.com/?target=https%3A//promcon.io/2017-munich/slides/storing-16-bytes-at-scale.pdf
[5]Writing a Time Series Database from Scratch: https://link.zhihu.com/?target=https%3A//fabxc.org/tsdb/
[6]PromCon 2016: The Prometheus Time Series Database - Bj?rn Rabenstein: https://link.zhihu.com/?target=https%3A//www.youtube.com/watch%3Fv%3DHbnGSNEjhUc
[7]slides: https://link.zhihu.com/?target=https%3A//docs.google.com/presentation/d/1TMvzwdaS8Vw9MtscI9ehDyiMngII8iB_Z5D4QW4U4ho/edit%23slide%3Did.g59e2f6081_1_0
[8]Percona Live Open Source Database Conference 2017: Life of a PromQL query: https://link.zhihu.com/?target=https%3A//www.youtube.com/watch%3Fv%3DevPYwNzoltU%26t%3D782s
[9]Prometheus 1.8 doc: storage: https://link.zhihu.com/?target=https%3A//prometheus.io/docs/prometheus/1.8/storage/
[10]Prometheus 2.16 doc: storage: https://link.zhihu.com/?target=https%3A//prometheus.io/docs/prometheus/latest/storage/
[11]Google Cloud: Schema Design for Time Series Data: https://link.zhihu.com/?target=https%3A//cloud.google.com/bigtable/docs/schema-design-time-series%3Fhl%3Den%23server_metrics
原文鏈接:https://zhuanlan.zhihu.com/p/155719693


你可能還喜歡
點擊下方圖片即可閱讀

云原生是一種信仰 ??
關(guān)注公眾號
后臺回復(fù)?k8s?獲取史上最方便快捷的 Kubernetes 高可用部署工具,只需一條命令,連 ssh 都不需要!


點擊 "閱讀原文" 獲取更好的閱讀體驗!
發(fā)現(xiàn)朋友圈變“安靜”了嗎?


