Prometheus 時序數據庫 - 磁盤中的存儲結構

前言

之前的文章裏,筆者詳細描述了監控數據在 Prometheus 內存中的結構。而其在磁盤中的存儲結構,也是非常有意思的, 關於這部分內容,將在本篇文章進行闡述。

磁盤目錄結構

首先我們來看 Prometheus 運行後,所形成的文件目錄結構

在筆者自己的機器上的具體結構如下:

prometheus-data
    |-01EY0EH5JA3ABCB0PXHAPP999D (block)
    |-01EY0EH5JA3QCQB0PXHAPP999D (block)
        |-chunks
            |-000001
            |-000002
            .....
            |-000021
        |-index
        |-meta.json
        |-tombstones
    |-wal
    |-chunks_head

Block

一個 Block 就是一個獨立的小型數據庫,其保存了一段時間內所有查詢所用到的信息。包括標籤 / 索引 / 符號表數據等等。Block 的實質就是將一段時間裏的內存數據組織成文件形式保存下來。

最近的 Block 一般是存儲了 2 小時的數據,而較爲久遠的 Block 則會通過 compactor 進行合併,一個 Block 可能存儲了若干小時的信息。值得注意的是,合併操作只是減少了索引的大小 (尤其是符號表的合併),而本身數據(chunks) 的大小並沒有任何改變。

meta.json

我們可以通過檢查 meta.json 來得到當前 Block 的一些元信息。

{
    "ulid":"01EY0EH5JA3QCQB0PXHAPP999D"
    // maxTime-minTime = 7200s => 2 h
    "minTime": 1611664000000
    "maxTime": 1611671200000
    "stats": {
        "numSamples": 1505855631,
        "numSeries": 12063563,
        "numChunks": 12063563
    }
    "compaction":{
        "level" : 1
        "sources: [
            "01EY0EH5JA3QCQB0PXHAPP999D"
        ]
    }
    "version":1
}

其中的元信息非常清楚明瞭。這個 Block 記錄了 2 個小時的數據。

讓我們再找一個比較陳舊的 Block 看下它的 meta.json.

    "ulid":"01EXTEH5JA3QCQB0PXHAPP999D",
    // maxTime - maxTime =>162h
    "minTime":1610964800000,
    "maxTime":1611548000000
    ......
    "compaction":{
        "level": 5,
        "sources: [
            31個01EX......
        ]
    },
    "parents: [
        {    
            "ulid": 01EXTEH5JA3QCQB1PXHAPP999D
            ...
        }
        {    
            "ulid": 01EXTEH6JA3QCQB1PXHAPP999D
            ...
        }
                {    
            "ulid": 01EXTEH5JA31CQB1PXHAPP999D
            ...
        }
    ]

從中我們可以看到,該 Block 是由 31 個原始 Block 經歷 5 次壓縮而來。最後一次壓縮的三個 Block ulid 記錄在 parents 中。如下圖所示:

Chunks 結構

CUT 文件切分

所有的 Chunk 文件在磁盤上都不會大於 512M, 對應的源碼爲:

func (w *Writer) WriteChunks(chks ...Meta) error {
    ......
    for i, chk := range chks {
        cutNewBatch := (i != 0) && (batchSize+SegmentHeaderSize > w.segmentSize)
        ......
        if cutNewBatch {
            ......
        }
        ......
    }
}

當寫入磁盤單個文件超過 512M 的時候,就會自動切分一個新的文件。

一個 Chunks 文件包含了非常多的內存 Chunk 結構, 如下圖所示:

圖中也標出了,我們是怎麼尋找對應 Chunk 的。通過將文件名 (000001,前 32 位) 以及 (offset, 後 32 位) 編碼到一個 int 類型的 refId 中,使得我們可以輕鬆的通過這個 id 獲取到對應的 chunk 數據。

chunks 文件通過 mmap 去訪問

由於 chunks 文件大小基本固定 (最大 512M), 所以我們很容易的可以通過 mmap 去訪問對應的數據。直接將對應文件的讀操作交給操作系統,既省心又省力。對應代碼爲:

func NewDirReader(dir string, pool chunkenc.Pool) (*Reader, error) {
    ......
    for _, fn := range files {
        f, err := fileutil.OpenMmapFile(fn)
        ......
    }
    ......
    bs = append(bs, realByteSlice(f.Bytes()))
}
通過sgmBytes := s.bs[offset]就直接能獲取對應的數據

index 索引結構

前面介紹完 chunk 文件,我們就可以開始闡述最複雜的索引結構了。

尋址過程

索引就是爲了讓我們快速的找到想要的內容,爲了便於理解。筆者就通過一次數據的尋址來探究 Prometheus 的磁盤索引結構。考慮查詢一個

擁有系列三個標籤
({__name__:http_requests}{job:api-server}{instance:0})
且時間爲start/end的所有序列數據

我們先從選擇 Block 開始, 遍歷所有 Block 的 meta.json,找到具體的 Block

前文說了,通過 Labels 找數據是通過倒排索引。我們的倒排索引是保存在 index 文件裏面的。那麼怎麼在這個單一文件裏找到倒排索引的位置呢?這就引入了 TOC(Table Of Content)

TOC(Table Of Content)


由於 index 文件一旦形成之後就不再會改變,所以 Prometheus 也依舊使用 mmap 來進行操作。採用 mmap 讀取 TOC 非常容易:

func NewTOCFromByteSlice(bs ByteSlice) (*TOC, error) {
    ......
    // indexTOCLen = 6*8+4 = 52
    b := bs.Range(bs.Len()-indexTOCLen, bs.Len())
    ......
    return &TOC{
        Symbols:           d.Be64(),
        Series:            d.Be64(),
        LabelIndices:      d.Be64(),
        LabelIndicesTable: d.Be64(),
        Postings:          d.Be64(),
        PostingsTable:     d.Be64(),
    }, nil
}

Posting offset table 以及 Posting 倒排索引

首先我們訪問的是 Posting offset table。由於倒排索引按照不同的 LabelPair(key/value) 會有非常多的條目。所以 Posing offset table 就是決定到底訪問哪一條 Posting 索引。offset 就是指的這一 Posting 條目在文件中的偏移。

Series

我們通過三條 Postings 倒排索引索引取交集得出

{series1,Series2,Series3,Series4}{series1,Series2,Series3}{Series2,Series3}
=
{Series2,Series3}

也就是要讀取 Series2 和 Serie3 中的數據,而 Posting 中的 Ref(Series2) 和 Ref(Series3) 即爲這兩 Series 在 index 文件中的偏移。

Series 以 Delta 的形式記錄了 chunkId 以及該 chunk 包含的時間範圍。這樣就可以很容易過濾出我們需要的 chunk, 然後再按照 chunk 文件的訪問,即可找到最終的原始數據。

SymbolTable

值得注意的是,爲了儘量減少我們文件的大小,對於 Label 的 Name 和 Value 這些有限的數據,我們會按照字母序存在符號表中。由於是有序的,所以我們可以直接將符號表認爲是一個
[]string 切片。然後通過切片的下標去獲取對應的 sting。考慮如下符號表:

讀取 index 文件時候,會將 SymbolTable 全部加載到內存中,並組織成 symbols []string 這樣的切片形式,這樣一個 Series 中的所有標籤值即可通過切片下標訪問得到。

Label Index 以及 Label Table

事實上,前面的介紹已經將一個普通數據尋址的過程全部講完了。但是 index 文件中還包含 label 索引以及 label Table,這兩個是用來記錄一個 Label 下面所有可能的值而存在的。
這樣,在正則的時候就可以非常容易的找到我們需要哪些 LabelPair。詳情可以見前篇。

事實上,真正的 Label Index 比圖中要複雜一點。它設計成一條 LabelIndex 可以表示 (多個標籤組合) 的所有數據。不過在 Prometheus 代碼中只會採用存儲一個標籤對應所有值的形式。

完整的 index 文件結構

這裏直接給出完整的 index 文件結構,摘自 Prometheus 中 index.md 文檔。

┌────────────────────────────┬─────────────────────┐
│ 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                     │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘

tombstones

由於 Prometheus Block 的數據一般在寫完後就不會變動。如果要刪除部分數據,就只能記錄一下刪除數據的範圍,由下一次 compactor 組成新 block 的時候刪除。而記錄這些信息的文件即是 tomstones。

Prometheus 入門書籍推薦

總結

Prometheus 作爲時序數據庫,設計了各種文件結構來保存海量的監控數據,同時還兼顧了性能。只有徹底瞭解其存儲結構,才能更好的指導我們應用它!

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