Prometheus TSDB儲存原理

iqsing發表於2022-04-14

Prometheus 包含一個儲存在本地磁碟的時間序列資料庫,同時也支援與遠端儲存系統整合,比如grafana cloud 提供的免費雲端儲存API,只需將remote_write介面資訊填寫在Prometheus配置檔案即可。

image-20220412141006992

本文不涉及遠端儲存介面內容,主要介紹Prometheus 時序資料的本地儲存實現原理。

什麼是時序資料?


在學習Prometheus TSDB儲存原理之前,我們先來認識一下Prometheus TSDB、InfluxDB這類時序資料庫的時序資料指的是什麼?

時序資料通常以(key,value)的形式出現,在時間序列採集點上所對應值的集,即每個資料點都是一個由時間戳和值組成的元組。

identifier->(t0,v0),(t1,v1),(t2,v2)...

Prometheus TSDB的資料模型

<metric name>{<label name>=<label value>, ...} 

具體到某個例項中

requests_total{method="POST", handler="/messages"}  

在儲存時可以通過name label來標記metric name,再通過識別符號@來標識時間,這樣構成了一個完整的時序資料樣本。

 ----------------------------------------key-----------------------------------------------value---------
{__name__="requests_total",method="POST", handler="/messages"}   @1649483597.197             52

一個時間序列是一組時間上嚴格單調遞增的資料點序列,它可以通過metric來定址。抽象成二維平面來看,二維平面的橫軸代表單調遞增的時間,metrics 遍及整個縱軸。在提取樣本資料時只要給定時間視窗和metric就可以得到value

series

時序資料如何在Prometheus TSDB儲存?


上面我們簡單瞭解了時序資料,接下來我們展開Prometheus TSDB儲存(V3引擎)

Prometheus TSDB 概覽

image-20220413104124771

在上圖中,Head 塊是TSDB的記憶體塊,灰色塊Block是磁碟上的持久塊。

首先傳入的樣本(t,v)進入 Head 塊,為了防止記憶體資料丟失先做一次預寫日誌 (WAL),並在記憶體中停留一段時間,然後重新整理到磁碟並進行記憶體對映(M-map)。當這些記憶體對映的塊或記憶體中的塊老化到某個時間點時,會作為持久塊Block儲存到磁碟。接下來多個Block在它們變舊時被合併,並在超過保留期限後被清理。

Head中樣本的生命週期

image-20220413120050962

當一個樣本傳入時,它會被載入到Head中的active chunk(紅色塊),這是唯一一個可以主動寫入資料的單元,為了防止記憶體資料丟失還會做一次預寫日誌 (WAL)

image-20220413120803681

一旦active chunk被填滿時(超過2小時或120樣本),將舊的資料截斷為head_chunk1。

image-20220413121223066

head_chunk1被重新整理到磁碟然後進行記憶體對映。active chunk繼續寫入資料、截斷資料、寫入到記憶體對映,如此反覆。

image-20220413121732282

記憶體對映應該只載入最新的、最被頻繁使用的資料,所以Prometheus TSDB將就是舊資料重新整理到磁碟持久化儲存Block,如上1-4為舊資料被寫入到下圖的Block中。

image-20220413113035412

此時我們再來看一下Prometheus TSDB 資料目錄基本結構,好像更清晰了一些。

./data
├── 01BKGV7JBM69T2G1BGBGM6KB12    
│   └── meta.json
├── 01BKGTZQ1SYQJTR4PB43C8PD98   # block ID
│   ├── chunks  	   # Block中的chunk檔案
│   │   └── 000001     
│   ├── tombstones     # 資料刪除記錄檔案
│   ├── index          # 索引
│   └── meta.json	   # bolck元資訊
├── chunks_head		   # head記憶體對映
│   └── 000001		  
└── wal			       # 預寫日誌
    ├── 000000002	  
    └── checkpoint.00000001
        └── 00000000
WAL 中checkpoint的作用

我們需要定期刪除舊的 wal 資料,否則磁碟最終會被填滿,並且在TSDB重啟時 replay wal 事件時會佔用大量時間,所以wal中任何不再需要的資料,都需要被清理。而checkpoint會將wal 清理過後的資料做過濾寫成新的段。

如下有6個wal資料段

data
└── wal
    ├── 000000
    ├── 000001
    ├── 000002
    ├── 000003
    ├── 000004
    └── 000005

現在我們要清理時間點T之前的樣本資料,假設為前4個資料段:

檢查點操作將按000000 000001 000002 000003順序遍歷所有記錄,並且:

  1. 刪除不再在 Head 中的所有序列記錄。
  2. 丟棄所有 time 在T之前的樣本。
  3. 刪除T之前的所有 tombstone 記錄。
  4. 重寫剩餘的序列、樣本和tombstone記錄(與它們在 WAL 中出現的順序相同)。

checkpoint被命名為建立checkpoint的最後一個段號checkpoint.X

這樣我們得到了新的wal資料,當wal在replay時先找checkpoint,先從checkpoint中的資料段回放,然後是checkpoint.000003的下一個資料段000004

data
└── wal
    ├── checkpoint.000003
    |   ├── 000000
    |   └── 000001
    ├── 000004
    └── 000005
Block的持久化儲存

上面我們認識了wal和chunks_head的儲存構造,接下來是Block,什麼是持久化Block?在什麼時候建立?為啥要合併Block?

Block的目錄結構

├── 01BKGTZQ1SYQJTR4PB43C8PD98   # block ID
│   ├── chunks  	   # Block中的chunk檔案
│   │   └── 000001     
│   ├── tombstones     # 資料刪除記錄檔案
│   ├── index          # 索引
│   └── meta.json	   # bolck元資訊

磁碟上的Block是固定時間範圍內的chunk的集合,由它自己的索引組成。其中包含多個檔案的目錄。每個Block都有一個唯一的 ID(ULID),他這個ID是可排序的。當我們需要更新、修改Block中的一些樣本時,Prometheus TSDB只能重寫整個Block,並且新塊具有新的 ID(為了實現後面提到的索引)。如果需要刪除的話Prometheus TSDB通過tombstones 實現了在不觸及原始樣本的情況下進行清理。

tombstones 可以認為是一個刪除標記,它記載了我們在讀取序列期間要忽略哪些時間範圍。tombstones 是Block中唯一在寫入資料後用於儲存刪除請求所建立和修改的檔案。

tombstones中的記錄資料結構如下,分別對應需要忽略的序列、開始和結束時間。

┌────────────────────────┬─────────────────┬─────────────────┐
│ series ref <uvarint64> │ mint <varint64> │ maxt <varint64> │
└────────────────────────┴─────────────────┴─────────────────┘

meta.json

meta.json包含了整個Block的所有後設資料

{
    "ulid": "01EM6Q6A1YPX4G9TEB20J22B2R",
    "minTime": 1602237600000,
    "maxTime": 1602244800000,
    "stats": {
        "numSamples": 553673232,
        "numSeries": 1346066,
        "numChunks": 4440437
    },
    "compaction": {
        "level": 1,
        "sources": [
            "01EM65SHSX4VARXBBHBF0M0FDS",
            "01EM6GAJSYWSQQRDY782EA5ZPN"
        ]
    },
    "version": 1
}

記錄了人類可讀的chunks的開始和結束時間,樣本、序列、chunks數量以及合併資訊。version告訴Prometheus如何解析metadata

Block合併

image-20220413113035412

我們可以從之前的圖中看到當記憶體對映中chunk跨越2小時(預設)後第一個Block就被建立了,當 Prometheus 建立了一堆Block時,我們需要定期對這些塊進行維護,以有效利用磁碟並保持查詢的效能。

Block合併的主要工作是將一個或多個現有塊(source blocks or parent blocks)寫入一個新塊,最後,刪除源塊並使用新的合併後的Block代替這些源塊。

為什麼需要對Block進行合併?

  1. 上面對tombstones介紹我們知道Prometheus在對資料的刪除操作會記錄在單獨檔案stombstone中,而資料仍保留在磁碟上。因此,當stombstone序列超過某些百分比時,需要從磁碟中刪除該資料。
  2. 如果樣本資料值波動非常小,相鄰兩個Block中的大部分資料是相同的。對這些Block做合併的話可以減少重複資料,從而節省磁碟空間。
  3. 當查詢命中大於1個Block時,必須合併每個塊的結果,這可能會產生一些額外的開銷。
  4. 如果有重疊的Block(在時間上重疊),查詢它們還要對Block之間的樣本進行重複資料刪除,合併這些重疊塊避免了重複資料刪除的需要。
  5. image-20220414120529698

如上圖示例所示,我們有一組順序的Block[1, 2, 3, 4]。資料塊1,2,和3可以被合併形成的新的塊是[1, 4]。或者成對壓縮為[1,3]。 所有的時間序列資料仍然存在,但是現在總體的資料塊更少。 這顯著降低了查詢成本。

Block是如何刪除的?

對於源資料的刪除Prometheus TSDB採用了一種簡單的方式:即刪除該目錄下不在我們保留時間視窗的塊。

如下圖所示,塊1可以安全地被刪除,而2必須保留到完全落在邊界之後

image-20220413202322093

因為Block合併的存在,意味著獲取越舊的資料,資料塊可能就變得越大。 因此必須得有一個合併的上限,,這樣塊就不會增長到跨越整個資料庫。通常我們可以根據保留視窗設定百分比。

如何從大量的series中檢索出資料?


在Prometheus TSDB V3引擎中使用了倒排索引,倒排索引基於它們內容的子集提供對資料項的快速查詢,例如我們要找出所有帶有標籤app ="nginx"的序列,而無需遍歷每一個序列然後再檢查它是否包含該標籤。

首先我們給每個序列分配一個唯一ID,查詢ID的複雜度是O(1),然後給每個標籤建一個倒排ID表。比如包含app ="nginx"標籤的ID為1,11,111那麼標籤"nginx"的倒排序索引為[1,11,111],這樣一來如果n是我們的序列總數,m是查詢的結果大小,那麼使用倒排索引的查詢複雜度是O(m),也就是說查詢的複雜度由m的數量決定。但是在最壞的情況下,比如我們每個序列都有一個“nginx”的標籤,顯然此時的複雜度變為O(n)了,如果是個別標籤的話無可厚非,只能稍加等待了,但是現實並非如此。

標籤被關聯到數百萬序列是很常見的,並且往往每次查詢會檢索多個標籤,比如我們要查詢這樣一個序列app =“dev”AND app =“ops” 在最壞情況下複雜度是O(n2),接著更多標籤複雜度指數增長到O(n3)、O(n4)、O(n5)... 這是不可接受的。那咋辦呢?

如果我們將倒排表進行排序會怎麼樣?

"app=dev" -> [100,1500,20000,51166]
"app=ops" -> [2,4,8,10,50,100,20000]

他們的交集為[100,20000],要快速實現這一點,我們可以通過2個遊標從列表值較小的一端率先推進,當值相等時就是可以加入到結果集合當中。這樣的搜尋成本顯然更低,在k個倒排表搜尋的複雜度為O(k*n)而非最壞情況下O(n^k)

剩下就是維護這個索引,通過維護時間線與ID、標籤與倒排表的對映關係,可以保證查詢的高效率。


以上我們從較淺的層面瞭解一下Prometheus TSDB儲存相關的內容,本文仍然有很多細節沒有提及,比如wal如何做壓縮與回放,mmap的原理,TSDB儲存檔案的資料結構等等,如果你需要進一步學習可移步參考文章。通過部落格閱讀:iqsing.github.io


本文參考於:

Prometheus維護者Ganesh Vernekar的系列部落格Prometheus TSDB

Prometheus維護者Fabian的部落格文章Writing a Time Series Database from Scratch(原文已失效)

PromCon 2017: Storing 16 Bytes at Scale - Fabian Reinartz

相關文章