Prometheus是時下最為流行的開源監控解決方案,我們可以很輕松地以Prometheus為核心快速構建一套包含監控指標的抓取,存儲,查詢以及告警的完整監控系統。單個的Prometheus實例就能實現每秒上百萬的采樣,同時支持對於采集數據的快速查詢,而對於Kubernetes這類抓取對象變更頻繁的環境,Prometheus也是最好的選擇。顯然,這些優秀特性的實現都離不開一個設計優良的時序數據庫的支撐。本文就將對Prometheus內置的時序數據庫tsdb的設計與實現進行剖析,從架構設計以及代碼層面理解它何以支持Prometheus強大的讀寫表現。
1. 時序數據概述
Prometheus讀寫的是時序數據,與一般的數據對象相比,時序數據有其特殊性,tsdb對此進行了大量針對性的設計與優化。因此理解時序數據是理解Prometheus存儲模型的第一步。通常,它由如下所示的標識和采樣數據兩部組成:
標識 -> {(t0, v0), (t1, v1), (t2, v2), (t3, v3)...}
- 標識用於區分各個不同的監控指標,在Prometheus中通常用指標名+一系列的label唯一地標識一個時間序列。如下為Prometheus抓取的一條時間序列,其中
http_request_total
為指標名,表示HTTP請求的總數,它有path
和method
兩個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"}
- 采樣數據則由諸多的采樣點(Prometheus中稱為sample)構成,t0, t1, t2...表示樣本采集的時間,v0, v1, v2...則表示指標在采集時刻的值。采樣時間一般是單調遞增的並且相鄰sample的時間間隔往往相同,Prometheus中默認為15s。而且一般相鄰sample的指標值v並不會相差太多。基於采樣數據的上述特性,對它進行高效地壓縮存儲是完全可能的。Prometheus對於采樣數據壓縮算法的實現,參考了Facebook的時序數據庫Gorilla中的做法,通過該算法,16字節的sample平均只需要1.37個字節的存儲空間。
2. 架構設計
監控數據是一種時效性非常強的數據類型,它被查詢的熱度會隨着時間的流逝而不斷降低,而且對於監控指標的訪問通常會指定一個時間段,例如,最近十五分鍾,最近一小時,最近一天等等。一般來說,最近一個小時采集到的數據被訪問地是最為頻繁的,過去一天的數據也經常會被訪問用來了解某個指標整體的波動情況,而一個月乃至一年之前的數據被訪問的意義就不是很大了。
基於監控數據的上述特性,tsdb的設計就非常容易理解了,其整體架構如下:
對於最新采集到的數據,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
主要由三部分組成:
lset
:用以識別這個series
的label集合ref
:每接收到一個新的時間序列(即它的label集合與已有的時間序列都不同)Prometheus就會用一個唯一的整數標識它,如果有ref
,我們就能輕易找到相應的series
memChunks
:每一個memChunk
是一個時間段內該時間序列所有sample
的集合。如果我們想要讀取[tx, ty](t1 < tx < t2, t2 < ty < t3 )時間范圍內該時間序列的數據,只需要對[t1, t3]范圍內的兩個memChunk
的sample
數據進行裁剪即可,從而提高了查詢的效率。每當采集到新的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請求的數目(請求的方法可以為GET
,POST
等等),此時提交給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
的內容。
那么series
和sample
在Segment
中是如何組織的呢?在將時序數據備份到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 }
每當需要寫入數據時,就要創建一個Appender
,Appender
是一個臨時結構,僅供一次批量操作使用。一個Appender
類似於其他數據庫中事務的概念,通過Add()
或者AddFast()
添加的時序數據會臨時在Appender
中進行緩存,只有在最后調用Commit()
之后,這批數據才正式提交給Prometheus,同時寫入WAL
。而如果最后調用的Rollback()
,則這批數據的samples
會被全部丟棄,但是通過Add()
方法新增的series
結構則依然會被保留。
series
和sample
在Appender
中是分開存儲的,它們在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 }
當調用Appender
的Commit()
方法提交這些時序數據時,series
和samples
這兩個切片會分別編碼,形成兩條Record
,如下所示:
|RecordType|RecordContent|
RecordType可以取“RecordSample”或者“RecordSeries”,表示這條Record的類型
RecordContent則根據RecordType可以series或者samples編碼后的內容
最后,series
和samples
以Record
的形式被批量寫入Segment
文件中,默認當Segment
超過128M時,會創建新的Segment
文件。若Prometheus因為各種原因崩潰了,WAL
里的各個Segment
以及checkpoint
里的內容就是在崩潰時刻Prometheus內存的映像。Prometheus在重啟時只要加載WAL
中的內容就能完全"恢復現場"。
Block
雖然將時序數據存儲在內存中能夠最大化讀寫效率,但是時序數據的寫入是穩定而持續的,隨着時間的流逝,數據量會線性增長,而且相對較老的數據被訪問的概率也將逐漸下降。因此,定期將內存中的數據持久化到磁盤是合理的。每一個Block存儲了對應時間窗口內的所有數據,包括所有的series
,samples
以及相關的索引結構。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的目錄名一致
minTime
和maxTime
:表示這個Block存儲的數據的時間窗口
stats
:表示這個Block包含的sample
, series
以及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 Table
和Label 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都會被清除。當然,社區對此已經提供了解決方案,Thanos和Cortex都基於Prometheus進行了擴展,提供了持久化存儲,高可用等特性,從而能夠真正做到"Prometheus As A Service"。