Prometheus 存儲引擎分析

Prometheus 作爲雲原生時代的時序數據庫, 是當下最流行的監控平臺之一,儘管其整體架構一直沒怎麼變,但其底層的存儲引擎卻演進了幾個版本,感興趣的讀者可參考 Prometheus 存儲層的演進 (https://tech.ipalfish.com/blog/2020/03/31/the-evolution-of-prometheus-storage-layer/)。本文主要介紹 Prometheus V2(即現在使用的)版本的存儲格式細節,以及查詢是如何定位到符合條件的數據,旨在通過本文的分析,對 Prometheus 的存儲引擎有更深入瞭解。

說明:本文並不會涉及到查詢的解析與函數求值過程。代碼分析基於 v2.25.2 版本。

背景知識

時序特點

時序數據的特點可以用一話概括:垂直寫(最新數據),水平查。

對於雲原生場景來說,另一個特點是數據生命週期短,一次容器的擴縮容會導致時間線膨脹一倍。瞭解這兩個特點後,來看看 Prometheus 是如何存儲數據來迎合上述模式:

├── 01BKGV7JC0RY8A6MACW02A2PJD  // block 的 ULID
│   ├── chunks
│   │   └── 000001
│   ├── tombstones
│   ├── index
│   └── meta.json
├── chunks_head
│   └── 000001
└── wal
    ├── 000000002
    └── checkpoint.00000001
        └── 00000000

可以看到,數據目錄主要有以下幾部分:

通過上面的目錄結構,不難看出 Prometheus 的設計思路:

數據模式

Prometheus 支持的模式比較簡單,只支持單值模式,如下:

cpu_usage{core="1", ip="130.25.175.171"} 14.04 1618137750
metric     labels                        value timesample

倒排索引

索引是支持多維搜索的主要手段,時序中的索引結構和搜索引擎的類似,是個倒排索引,可參考下圖

在一次查詢中,會對涉及到的 label 分別求對應的 postings lists(即時間線集合),然後根據 filter 類型進行集合運算,最後根據運算結果得出的時間線,去查相應數據即可。

磁盤存儲格式

數據格式

┌──────────────────────────────┐
│  magic(0x0130BC91) <4 byte>  │
├──────────────────────────────┤
│    version(1) <1 byte>       │
├──────────────────────────────┤
│    padding(0) <3 byte>       │
├──────────────────────────────┤
│ ┌──────────────────────────┐ │
│ │         Chunk 1          │ │
│ ├──────────────────────────┤ │
│ │          ...             │ │
│ ├──────────────────────────┤ │
│ │         Chunk N          │ │
│ └──────────────────────────┘ │
└──────────────────────────────┘
# 單個 chunk 內的結構
┌─────────────────────┬───────────────────────┬───────────────────────┬───────────────────┬───────────────┬──────────────┬────────────────┐
| series ref <8 byte> | mint <8 byte, uint64> | maxt <8 byte, uint64> | encoding <1 byte> | len <uvarint> | data <bytes> │ CRC32 <4 byte> │
└─────────────────────┴───────────────────────┴───────────────────────┴───────────────────┴───────────────┴──────────────┴────────────────┘

chunk 爲數據在磁盤中的最小組織單元,需要明確以下兩點:

  1. 單個 chunk 的時間跨度默認是 2 小時,Prometheus 後臺會有合併操作,把時間相鄰的 block 合到一起

  2. series ref 爲時間線的唯一標示,由 8 個字節組成,前 4 個表示文件 id,後 4 個表示在文件內的 offset,需配合後文的索引結構來實現數據的定位

索引格式

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

在一個索引文件中,最主要的是以下幾部分(從下往上):

  1. TOC 存儲的是其他部分的 offset

  2. Postings Offset Table,用來存儲倒排索引,Key 爲 label name/value 序對,Value 爲 Postings 在文件中的 offset。

  3. Postings N,存儲的是具體的時間線序列

  4. Series,存儲的是當前時間線,對應的 chunk 文件信息

  5. Label Offset Table 與 Label Index 目前在查詢時沒有使用到,這裏不再講述

每個部分的具體編碼格式,可參考官方文檔 Index Disk Format,這裏重點講述一次查詢是如何找到符合條件的數據的:

使用方式

Prometheus 在啓動時,會去加載數據元信息到內存中。主要有下面兩部分:

下面代碼爲根據 label 查詢 postings 的邏輯,完整可見 index 的 Postings 方法:

e, ok := r.postings[name] // name 爲 label key
if !ok || len(values) == 0 { // values 爲當前需要查詢的 label values
    return EmptyPostings(), nil
}
res := make([]Postings, 0, len(values))
skip := 0
valueIndex := 0
for valueIndex < len(values) && values[valueIndex] < e[0].value {
    // Discard values before the start.
    valueIndex++
}
for valueIndex < len(values) {
    value := values[valueIndex]
    // 用二分查找,找到當前 value 在 postings 中的位置
    i := sort.Search(len(e), func(i int) bool { return e[i].value >= value })
    if i == len(e) {
        // We're past the end.
        break
    }
    if i > 0 && e[i].value != value {  // postings 中沒有該 value,需要用前面一個來在文件中搜索
        // Need to look from previous entry.
        i--
    }
    // Don't Crc32 the entire postings offset table, this is very slow
    // so hope any issues were caught at startup.
    d := encoding.NewDecbufAt(r.b, int(r.toc.PostingsTable), nil)
    d.Skip(e[i].off)
    // Iterate on the offset table.
    var postingsOff uint64 // The offset into the postings table.
    for d.Err() == nil {
        // ... skip 邏輯省略
        v := d.UvarintBytes()       // Label value.
        postingsOff = d.Uvarint64() // Offset.
        for string(v) >= value {
            if string(v) == value {
                // Read from the postings table.
                d2 := encoding.NewDecbufAt(r.b, int(postingsOff), castagnoliTable)
                _, p, err := r.dec.Postings(d2.Get())
                res = append(res, p)
            }
            valueIndex++
            if valueIndex == len(values) {
                break
            }
            value = values[valueIndex]
        }
        if i+1 == len(e) || value >= e[i+1].value || valueIndex == len(values) {
            // Need to go to a later postings offset entry, if there is one.
            break
        }
    }
}

內存結構

Block 在 Prometheus 實現中,主要分爲兩類:

type DB struct {
    blocks []*Block
    head *Head
    // ... 忽略其他字段
}
// Block 內的主要字段是 IndexReader,其內部主要是 postings,即倒排索引
// Map of LabelName to a list of some LabelValues's position in the offset table.
// The first and last values for each name are always present.
postings map[string][]postingOffset
type postingOffset struct {
    value string // label value
    off   int    // posting 在對於文件中的 offset
}

在上文磁盤結構中介紹過,postingOffset 不是全量加載,而是每隔 32 個。

type Head struct {
    postings *index.MemPostings // Postings lists for terms.
    // All series addressable by their ID or hash.
    series *stripeSeries
    // ... 忽略其他字段
}
type MemPostings struct {
    mtx     sync.RWMutex
    m       map[string]map[string][]uint64  // label key -> label value -> posting lists
    ordered bool
}
type stripeSeries struct {
    size                    int
    series                  []map[uint64]*memSeries
    hashes                  []seriesHashmap
    locks                   []stripeLock
    seriesLifecycleCallback SeriesLifecycleCallback
}
type memSeries struct {
    sync.RWMutex
    mmappedChunks []*mmappedChunk // 只讀
    headChunk     *memChunk // 讀寫
    ...... // 省略其他字段
    }
type mmappedChunk struct {
    // 數據文件在磁盤上的位置,即上文中的 series ref
    ref              uint64
    numSamples       uint16
    minTime, maxTime int64
}

使用方式

對於一個查詢,大概涉及的步驟:

  1. 根據 label 查出所涉及到的時間線,然後根據 filter 類型,進行集合運算,找出符合要求的時間線

  2. 根據時間線信息與時間範圍信息,去 block 內查詢符合條件的數據

在第一步主要在 PostingsForMatchers 函數中完成,主要有下面幾個優化點:

在第一步查出符合條件的 chunk 所在文件以及 offset 信息之後,第二步的取數據則相對簡單,直接使用 mmap 讀數據即可,這間接利用操作系統的 page cache 來做緩存,自身不需要再去實現 Buffer Pool 之類的數據結構。

總結

通過上文的分析,大體上把 Prometheus 的存儲結構以及查詢流程分析了一遍,還有些細節沒再展開去介紹,比如爲了節約內存使用,label 使用了字典壓縮,但這並不妨礙讀者理解其原理。

此外,Prometheus 默認 2 小時一個 Block 對大時間範圍查詢不友好,因此其後臺會對定期 chunk 文件進行 compaction,合併後的文件大小爲 min(31d, retention_time * 0.1) ,相關細節後面有機會再單獨介紹吧。

參考

作者:劉家財

來源:https://liujiacai.net/blog/2021/04/11/prometheus-storage-engine/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/LU609X_0St_icheFJSdRLQ