Prometheus存儲模型分析


Prometheus是時下最為流行的開源監控解決方案,我們可以很輕松地以Prometheus為核心快速構建一套包含監控指標的抓取,存儲,查詢以及告警的完整監控系統。單個的Prometheus實例就能實現每秒上百萬的采樣,同時支持對於采集數據的快速查詢,而對於Kubernetes這類抓取對象變更頻繁的環境,Prometheus也是最好的選擇。顯然,這些優秀特性的實現都離不開一個設計優良的時序數據庫的支撐。本文就將對Prometheus內置的時序數據庫tsdb的設計與實現進行剖析,從架構設計以及代碼層面理解它何以支持Prometheus強大的讀寫表現。

1. 時序數據概述

Prometheus讀寫的是時序數據,與一般的數據對象相比,時序數據有其特殊性,tsdb對此進行了大量針對性的設計與優化。因此理解時序數據是理解Prometheus存儲模型的第一步。通常,它由如下所示的標識和采樣數據兩部組成:

標識 -> {(t0, v0), (t1, v1), (t2, v2), (t3, v3)...}
  1. 標識用於區分各個不同的監控指標,在Prometheus中通常用指標名+一系列的label唯一地標識一個時間序列。如下為Prometheus抓取的一條時間序列,其中http_request_total為指標名,表示HTTP請求的總數,它有pathmethod兩個label,用於表示各種請求的路徑和方法。
http_request_total{path="/", method="GET"} -> {(t0, v1), (t1, v1)...}

事實上指標名最后也是作為一個特殊的label被存儲的,它的key為__name__,如下所示。最終Prometheus存儲在數據庫中的時間序列標識就是一堆label。我們將這堆label稱為series

{__name__="http_request_total", path="/", method="GET"}
  1. 采樣數據則由諸多的采樣點(Prometheus中稱為sample)構成,t0, t1, t2...表示樣本采集的時間,v0, v1, v2...則表示指標在采集時刻的值。采樣時間一般是單調遞增的並且相鄰sample的時間間隔往往相同,Prometheus中默認為15s。而且一般相鄰sample的指標值v並不會相差太多。基於采樣數據的上述特性,對它進行高效地壓縮存儲是完全可能的。Prometheus對於采樣數據壓縮算法的實現,參考了Facebook的時序數據庫Gorilla中的做法,通過該算法,16字節的sample平均只需要1.37個字節的存儲空間。

2. 架構設計

監控數據是一種時效性非常強的數據類型,它被查詢的熱度會隨着時間的流逝而不斷降低,而且對於監控指標的訪問通常會指定一個時間段,例如,最近十五分鍾,最近一小時,最近一天等等。一般來說,最近一個小時采集到的數據被訪問地是最為頻繁的,過去一天的數據也經常會被訪問用來了解某個指標整體的波動情況,而一個月乃至一年之前的數據被訪問的意義就不是很大了。

基於監控數據的上述特性,tsdb的設計就非常容易理解了,其整體架構如下:

arch

對於最新采集到的數據,Prometheus會直接將它們存放在內存中,從而加快數據的讀寫。但是內存的空間是有限的,而且隨着時間的推移,內存中較老的那部分數據被訪問的概率也逐漸降低。因此,默認情況下,每隔兩小時Prometheus就會將部分“老”數據持久化到磁盤,每一次持久化的數據都獨立存放在磁盤的一個Block中。例如上圖中的block0就存放了[t0, t1]時間段內Prometheus采集的所有監控數據。這樣做的好處很明顯,如果我們想要訪問某個指標在[t0, t2]范圍內的數據,那么只需要加載block0和block1中的數據並進行查找即可,這樣一來大大縮小了查找的范圍,從而提高了查詢的速度。

雖然最近采集的數據存放在內存中能夠提高讀寫效率,但是由於內存的易失性,一旦Prometheus崩潰(如果系統內存不足,Prometheus被OOM的概率並不算低)那么這部分數據就徹底丟失了。因此Prometheus在將采集到的數據真正寫入內存之前,會首先存入WAL(Write Ahead Log)中。因為WAL是存放在磁盤中的,相當於對內存中的監控數據做了一個完全的備份,即使Prometheus崩潰這部分的數據也不至於丟失。當Prometheus重啟之后,它首先會將WAL的內容加載到內存中,從而完美恢復到崩潰之前的狀態,接着再開始新數據的抓取。

3. 內存存儲結構

在Prometheus的內存中使用如下所示的memSeries結構存儲時間序列,一條時間序列對應一個memSeries結構:

memseries

可以看到,一個memSeries主要由三部分組成:

  1. lset:用以識別這個series的label集合
  2. ref:每接收到一個新的時間序列(即它的label集合與已有的時間序列都不同)Prometheus就會用一個唯一的整數標識它,如果有ref,我們就能輕易找到相應的series
  3. memChunks:每一個memChunk是一個時間段內該時間序列所有sample的集合。如果我們想要讀取[tx, ty](t1 < tx < t2, t2 < ty < t3 )時間范圍內該時間序列的數據,只需要對[t1, t3]范圍內的兩個memChunksample數據進行裁剪即可,從而提高了查詢的效率。每當采集到新的sample,Prometheus就會用Gorilla中類似的算法將它壓縮至最新的memChunk

但是ref僅僅是供Prometheus內部使用的,如果用戶要查詢某個具體的時間序列,通常會利用一堆的label用以唯一指定一個時間序列。那么如何通過一堆label最快地找到對應的series呢?哈希表顯然是最佳的方案。基於label計算一個哈希值,維護一張哈希值與memSeries的映射表,如果產生哈希碰撞的話,則直接用label進行匹配。因此,Prometheus有必要在內存中維護如下所示的兩張哈希表,從而無論利用ref還是label都能很快找到對應的memSeries

{
	series map[uint64]*memSeries // ref到memSeries的映射 hashes map[uint64][]*memSeries // labels的哈希值到memSeries的映射 } 

然而我們知道Golang中的map並不是並發安全的,而Prometheus中又有大量對於memSeries的增刪操作,如果在讀寫上述結構時簡單地用一把大鎖鎖住,顯然無法滿足性能要求。所以Prometheus用了如下數據結構將鎖的控制精細化:

const stripSize = 1 << 14 // 為表達直觀,已將Prometheus原生數據結構簡化 type stripeSeries struct { series [stripeSize]map[uint64]*memSeries hashes [stripeSize]map[uint64][]*memSeries locks [stripeSize]sync.RWMutex }

Prometheus將一整個大的哈希表進行了切片,切割成了16k個小的哈希表。如果想要利用ref找到對應的series,首先要將ref對16K取模,假設得到的值為x,找到對應的小哈希表series[x]。至於對小哈希表的操作,只需要鎖住模對應的locks[x],從而大大減小了讀寫memSeries時對鎖的搶占造成的損耗,提高了並發性能。對於基於label哈希值的讀寫,操作類似。

然而上述數據結構僅僅只能支持對於時間序列的精確查詢,必須嚴格指定每一個label的值從而能夠唯一地確定一條時間序列。但很多時候,模糊查詢才是更為常用的。例如,我們想知道訪問路徑為/的各類HTTP請求的數目(請求的方法可以為GETPOST等等),此時提交給Prometheus的查詢條件如下:

http_request_total{path="/"}

如果路徑/曾經接收了GET,POST以及DELETE三種方法的HTTP請求,那么此次查詢應該返回如下三條時間序列:

http_request_total{path="/", method="GET"} ....
http_request_total{path="/", method="POST"} ....
http_request_total{path="/", method="DELETE"} ....

Prometheus甚至支持在指定label時使用正則表達式:

http_request_total{method="GET|POST"}

上述查詢將返回所有包含label名為method,且值為GET或者POST的指標名為http_request_total的時間序列。

針對如此復雜的查詢需求,暴力地遍歷所有series進行匹配是行不通的。一個指標往往會包含諸多的label,每個label又可以有很多的取值。因此Prometheus中會存在大量的series,為了能快速匹配到符合要求的series,Prometheus引入了倒排索引,結構如下:

struct MemPostings struct { mtx sync.Mutex m map[string]map[string][]uint64 ordered bool }

當Prometheus抓取到一個新的series,假設它的ref為x,包含如下的label pair:

{__name__="http_request_total", path="/", method="GET"}

在初始化相應的memSeries並且更新了哈希表之后,還需要對倒排索引進行刷新:

MemPostings.m["__name__"]["http_request_total"]{..., x ,...} MemPostings.m["path"]["/"]{..., x ,...} MemPostings.m["method"]["GET"]{..., x, ...}

可以看到,倒排索引能夠將所有包含某個label pair的series都聚合在一起。如果要得到匹配多個label pair的series,只要將每個label pair包含的series做交集即可。對於查詢請求

http_request_total{path="/"}

的匹配過程如下:

MemPostings.["__name__"]["http_request_total"]{3, 4, 2, 1}
MemPostings.["path"]["/"]{5, 4, 1, 3}
{3, 4, 2, 1} x {5, 4, 1, 3} -> {1, 3, 4}

但是如果每個label pair包含的series足夠多,那么對多個label pair的series做交集也將是非常耗時的操作。那么能不能進一步優化呢?事實上,只要保持每個label pair里包含的series有序就可以了,這樣就能將復雜度從指數級瞬間下降到線性級。

MemPostings.["__name__"]["http_request_total"]{1, 2, 3, 4}
MemPostings.["path"]["/"]{1, 3, 4, 5}
{1, 2, 3, 4} x {1, 3, 4, 5} -> {1, 3, 4}

Prometheus內存中的存儲結構大致如上,Gorilla的壓縮算法提高了samples的存儲效率,而哈希表以及倒排索引的使用,則對Prometheus復雜的時序數據查詢提供了高效的支持。

WAL

Prometheus啟動時,往往需要在參數中指定存儲目錄,該目錄包含WAL以及用於持久化的Block,結構如下:

# ls 01DJQ428PCD7Z06M6GKHES65P2 01DJQAXZY8MPVWMD2M4YWQFD9T 01DJQAY7F9WT8EHT0M8540F0AJ lock wal

形如01DJQ428PCD7Z06M6GKHES65P2都是Block目錄,用於存放持久化之后的時序數據,這部分內容后文會有詳細的敘述,本節重點關注WAL目錄,它的內部結構如下:

[wal]# ls -lht total 596M -rw-r--r-- 1 65534 65534 86M Aug 20 19:32 00000012 drwxr-xr-x 2 65534 65534 4.0K Aug 20 19:00 checkpoint.000006 -rw-r--r-- 1 65534 65534 128M Aug 20 19:00 00000011 -rw-r--r-- 1 65534 65534 128M Aug 20 18:37 00000010 -rw-r--r-- 1 65534 65534 128M Aug 20 17:47 00000009 -rw-r--r-- 1 65534 65534 128M Aug 20 17:00 00000008 -rw-r--r-- 1 65534 65534 128M Aug 20 16:38 00000007

WAL目錄中包含了多個連續編號的且大小為128M的文件,Prometheus稱這樣的文件為Segment,其中存放的就是對內存中series以及sample數據的備份。另外還包含一個以checkpoint為前綴的子目錄,由於內存中的時序數據經常會做持久化處理,WAL中的數據也將因此出現冗余。所以每次在對內存數據進行持久化之后,Prometheus都會對部分編號靠后的Segment進行清理。但是我們並沒有辦法做到恰好將已經持久化的數據從Segment中剔除,也就是說被刪除的Segment中部分的數據依然可能是有用的。所以在清理Segment時,我們會將肯定無效的數據刪除,剩下的數據就存放在checkpoint中。而在Prometheus重啟時,應該首先加載checkpoint中的內容,再按序加載各個Segment的內容。

那么seriessampleSegment中是如何組織的呢?在將時序數據備份到WAL的過程中,由於涉及到磁盤文件Segment的寫入,批量操作顯然是最經濟的。對於批量寫入,Prometheus提供了一個名為Appender的接口如下:

type Appender interface { Add(l labels.Labels, t int64, v float64) (uint64, error) AddFast(ref uint64, t int64, v float64) error Commit() error Rollback() error }

每當需要寫入數據時,就要創建一個AppenderAppender是一個臨時結構,僅供一次批量操作使用。一個Appender類似於其他數據庫中事務的概念,通過Add()或者AddFast()添加的時序數據會臨時在Appender中進行緩存,只有在最后調用Commit()之后,這批數據才正式提交給Prometheus,同時寫入WAL。而如果最后調用的Rollback(),則這批數據的samples會被全部丟棄,但是通過Add()方法新增的series結構則依然會被保留。

seriessampleAppender中是分開存儲的,它們在Appender中的結構如下:

// headAppender是Appender的一種實現 type headAppender struct { ... series []RefSeries samples []RefSample } type RefSeries struct { Ref uint64 Labels labels.Labels } type RefSample struct { Ref uint64 T int64 V float64 series *memSeries }

當調用AppenderCommit()方法提交這些時序數據時,seriessamples這兩個切片會分別編碼,形成兩條Record,如下所示:

|RecordType|RecordContent|

RecordType可以取“RecordSample”或者“RecordSeries”,表示這條Record的類型

RecordContent則根據RecordType可以series或者samples編碼后的內容

最后,seriessamplesRecord的形式被批量寫入Segment文件中,默認當Segment超過128M時,會創建新的Segment文件。若Prometheus因為各種原因崩潰了,WAL里的各個Segment以及checkpoint里的內容就是在崩潰時刻Prometheus內存的映像。Prometheus在重啟時只要加載WAL中的內容就能完全"恢復現場"。

Block

雖然將時序數據存儲在內存中能夠最大化讀寫效率,但是時序數據的寫入是穩定而持續的,隨着時間的流逝,數據量會線性增長,而且相對較老的數據被訪問的概率也將逐漸下降。因此,定期將內存中的數據持久化到磁盤是合理的。每一個Block存儲了對應時間窗口內的所有數據,包括所有的seriessamples以及相關的索引結構。Block目錄的詳細內容如下:

[01DJNTVX7GZ2M1EKB4TM76APV8]# ls
chunks  index  meta.json  tombstones
[01DJNTVX7GZ2M1EKB4TM76APV8]# ls chunks/
000001

meta.json包含了當前Block的元數據信息,其內容如下:

# cat meta.json
{
    "ulid": "01DJNTVX7GZ2M1EKB4TM76APV8",
    "minTime": 1566237600000,
    "maxTime": 1566244800000,
    "stats": {
        "numSamples": 30432619,
        "numSeries": 65064,
        "numChunks": 255203
    },
    "compaction": {
        "level": 1,
        "sources": [
            "01DJNTVX7GZ2M1EKB4TM76APV8"
        ]
    },
    "version": 1
}

各字段的含義如下:

ulid:用於識別這個Block的編號,它與Block的目錄名一致

minTimemaxTime:表示這個Block存儲的數據的時間窗口

stats:表示這個Block包含的sampleseries以及chunks數目

compaction:這個Block的壓縮信息,因為隨着時間的流逝,多個Block也會壓縮合並形成更大的Block。level字段表示了壓縮的次數,剛從內存持久化的Block的level為1,每被聯合壓縮一次,子Block的level就會在父Block的基礎上加一,而sources字段則包含了構成當前這個Block的所有祖先Block的ulid。事實上,對於level >= 2的Block,還會有一個parent字段,包含了它的父Block的ulid以及時間窗口信息。

chunks是一個子目錄,包含了若干個從000001開始編號的文件,一般每個文件大小的上限為512M。文件中存儲的就是在時間窗口[minTime,maxTime]以內的所有samples,本質上就是對於內存中符合要求的memChunk的持久化。

tombstones用於存儲對於series的刪除記錄。如果刪除了某個時間序列,Prometheus並不會立即對它進行清理,而是會在tombstones做一次記錄,等到下一次Block壓縮合並的時候統一清理。

index文件存儲了索引相關的內容,雖然持久化后的數據被讀取的概率是比較低的,但是依然存在被讀取的可能。這樣一來,如何盡快地從Block中讀取時序數據就顯得尤為重要了,而快速讀取索引並且基於索引查找時序數據則是加快整體查詢效率的關鍵。為了達到這一目標,存儲索引信息的index文件在設計上就顯得比較復雜了。

┌────────────────────────────┬─────────────────────┐
│ magic(0xBAAAD700) <4b>     │ version(1) <1 byte> │
├────────────────────────────┴─────────────────────┤
│ ┌──────────────────────────────────────────────┐ │
│ │                 Symbol Table                 │ │
│ ├──────────────────────────────────────────────┤ │
│ │                    Series                    │ │
│ ├──────────────────────────────────────────────┤ │
│ │                 Label Index 1                │ │
│ ├──────────────────────────────────────────────┤ │
│ │                      ...                     │ │
│ ├──────────────────────────────────────────────┤ │
│ │                 Label Index N                │ │
│ ├──────────────────────────────────────────────┤ │
│ │                   Postings 1                 │ │
│ ├──────────────────────────────────────────────┤ │
│ │                      ...                     │ │
│ ├──────────────────────────────────────────────┤ │
│ │                   Postings N                 │ │
│ ├──────────────────────────────────────────────┤ │
│ │               Label Index Table              │ │
│ ├──────────────────────────────────────────────┤ │
│ │                 Postings Table               │ │
│ ├──────────────────────────────────────────────┤ │
│ │                      TOC                     │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘

除了文件開頭的Magic Number以及版本信息,index文件可以分為7個部分,各部分的內容及作用如下:

TOC:雖然位於文件的末尾,但是TOC包含了整個index文件的全局信息,它存儲的內容是其余六部分的位置信息,即它們的起始位置在index文件中的偏移量。

Symbol Table:一個symbol既可以是一個label的key,也可以是它的value,事實上Symbol Table存儲的就是在[minTime, maxTime]范圍內的samples所屬的series的所有label的key和value集合,並且為每個symbol進行了編號。之所以要這樣做,是因為后面在存儲series以及Label Index等信息的時候,就不需要完整存儲所有的label了,只需將label的key和value用對應的字符串在Symbol Table中的編號表示即可,從而大大減小了index文件的體積。

Series:存儲的自然是series的相關信息,首先存儲series的各個label,正如上文所述,存儲的是對應key和value在Symbol Table中的編號。緊接着存儲series相關的chunks信息,包含每個chunk的時間窗口,以及該chunk在chunks子目錄下具體的位置信息。

Label Index:存儲了各個label的key和它所有可能的value的關聯關系。例如,對於一個有着四個不同的value的key,它在這部分存儲的條目如下所示:

┌────┬───┬───┬──────────────┬──────────────┬──────────────┬──────────────┬───────┐
│ 24 │ 1 │ 4 │ ref(value_0) | ref(value_1) | ref(value_2) | ref(value_3) | CRC32 |
└────┴───┴───┴──────────────┴──────────────┴──────────────┴──────────────┴───────┘
各段含義如下:
24 --> 存儲的內容包含24個字節
1 --> 本條目僅僅包含一個key
4 --> 與keys相關的有4個value
ref -> 各個value在Symbol Table中的編號

事實上這樣一個條目可以存儲多個key和它們的value的映射關系,但一般key的個數都為1。這部分的內容乍一看非常讓人疑惑,key的信息呢?為什么只記錄key的數目,而沒有保存具體的key,哪怕是它在Symbol Table中的編號?其實,我們應該將這部分的內容和Label Index Table中的內容聯合起來看。

Label Index Table:存儲了所有label的key,以及它們在Label Index中對應的位置信息。那么為什么要將這兩部分的內容分開存儲呢?Prometheus在讀取Block中的數據時會加載index文件,但是只會首先加載Label Index Table獲取所有label的key,只有在需要對key相關的value進行匹配時,才會加載Label Index相應的部分以及對應的Symbol。通過Label Index TableLabel Index的分離,使得我們能夠只對必要數據進行加載,從而加快了index文件的加載速度。

Postings: 這部分存儲的顯然是倒排索引的信息,每一個條目存儲的都是包含某個label pair的所有series的ID。但是與Label Index相似,條目中並沒有指定具體的key和value。

Postings Offset Table:這部分直接對每個label的key和value以及相關索引在Postings中的位置進行存儲。同樣,它會首先被加載到內存中,如果需要知道包含某個label的所有series,再通過相關索引的偏移位置從Postings中依次獲取。

可以看到,index文件結構頗為復雜,但其實設計得相當巧妙,環環相扣。不僅高效地對索引信息進行了存儲,而且也最大限度保證了對它進行加載的速度。

總結

Prometheus精巧的設計使它能夠對海量時序數據進行高效地讀寫。但是,通篇分析下來,Prometheus並沒有所謂"黑科技"式的創新,有的不過是“逢山開路,遇水搭橋”式的優化。而這本身也是Prometheus的設計哲學,"Do one thing and do it well"。事實上,Prometheus默認僅支持15天的本地存儲,超過這個期限的Block都會被清除。當然,社區對此已經提供了解決方案,ThanosCortex都基於Prometheus進行了擴展,提供了持久化存儲,高可用等特性,從而能夠真正做到"Prometheus As A Service"。

參考文獻


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM