Prometheus 包含一個儲存在本地磁碟的時間序列資料庫,同時也支援與遠端儲存系統整合,比如grafana cloud
提供的免費雲端儲存API,只需將remote_write
介面資訊填寫在Prometheus配置檔案即可。
本文不涉及遠端儲存介面內容,主要介紹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
時序資料如何在Prometheus TSDB儲存?
上面我們簡單瞭解了時序資料,接下來我們展開Prometheus TSDB儲存(V3引擎)
Prometheus TSDB 概覽
在上圖中,Head 塊是TSDB的記憶體塊,灰色塊Block是磁碟上的持久塊。
首先傳入的樣本(t,v)進入 Head 塊,為了防止記憶體資料丟失先做一次預寫日誌 (WAL),並在記憶體中停留一段時間,然後重新整理到磁碟並進行記憶體對映(M-map)。當這些記憶體對映的塊或記憶體中的塊老化到某個時間點時,會作為持久塊Block儲存到磁碟。接下來多個Block在它們變舊時被合併,並在超過保留期限後被清理。
Head中樣本的生命週期
當一個樣本傳入時,它會被載入到Head中的active chunk(紅色塊),這是唯一一個可以主動寫入資料的單元,為了防止記憶體資料丟失還會做一次預寫日誌 (WAL)。
一旦active chunk被填滿時(超過2小時或120樣本),將舊的資料截斷為head_chunk1。
head_chunk1被重新整理到磁碟然後進行記憶體對映。active chunk繼續寫入資料、截斷資料、寫入到記憶體對映,如此反覆。
記憶體對映應該只載入最新的、最被頻繁使用的資料,所以Prometheus TSDB將就是舊資料重新整理到磁碟持久化儲存Block,如上1-4為舊資料被寫入到下圖的Block中。
此時我們再來看一下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
順序遍歷所有記錄,並且:
- 刪除不再在 Head 中的所有序列記錄。
- 丟棄所有 time 在
T
之前的樣本。 - 刪除
T
之前的所有 tombstone 記錄。 - 重寫剩餘的序列、樣本和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合併
我們可以從之前的圖中看到當記憶體對映中chunk跨越2小時(預設)後第一個Block就被建立了,當 Prometheus 建立了一堆Block時,我們需要定期對這些塊進行維護,以有效利用磁碟並保持查詢的效能。
Block合併的主要工作是將一個或多個現有塊(source blocks or parent blocks)寫入一個新塊,最後,刪除源塊並使用新的合併後的Block代替這些源塊。
為什麼需要對Block進行合併?
- 上面對tombstones介紹我們知道Prometheus在對資料的刪除操作會記錄在單獨檔案stombstone中,而資料仍保留在磁碟上。因此,當stombstone序列超過某些百分比時,需要從磁碟中刪除該資料。
- 如果樣本資料值波動非常小,相鄰兩個Block中的大部分資料是相同的。對這些Block做合併的話可以減少重複資料,從而節省磁碟空間。
- 當查詢命中大於1個Block時,必須合併每個塊的結果,這可能會產生一些額外的開銷。
- 如果有重疊的Block(在時間上重疊),查詢它們還要對Block之間的樣本進行重複資料刪除,合併這些重疊塊避免了重複資料刪除的需要。
如上圖示例所示,我們有一組順序的Block[1, 2, 3, 4]
。資料塊1,2,和3可以被合併形成的新的塊是[1, 4]
。或者成對壓縮為[1,3]。 所有的時間序列資料仍然存在,但是現在總體的資料塊更少。 這顯著降低了查詢成本。
Block是如何刪除的?
對於源資料的刪除Prometheus TSDB採用了一種簡單的方式:即刪除該目錄下不在我們保留時間視窗的塊。
如下圖所示,塊1可以安全地被刪除,而2必須保留到完全落在邊界之後
因為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(原文已失效)