從頭編寫一個時序數據庫


從頭編寫一個時序數據庫

本文介紹如何去設計一個時序數據庫,可以學習一下文章中提及的一些技術點。需要注意的是,本文編寫的時間為2017年4月,因此文中需要改善的也是老版本的Prometheus存儲存在的問題。

譯自:Writing a Time Series Database from Scratch

在很多方面,Kubernetes 已經成為了 Prometheus 的設計目標,它實現了持續部署、自動擴容以及其他方便訪問的高度動態環境特性。請求語言、操作模型和其他概念設計使得Prometheus非常適合這類環境。然而,當監控的負載變得更動態的同時,也給監控系統本身帶來了新的壓力,相比於質疑Prometheus已經解決的問題,我們更傾向於提升高度動態或臨時服務環境下的性能。

在過去,Prometheus的存儲層展現了卓越的性能,單個服務每秒能夠從百萬級的時間序列中提取多達100萬個樣本,同時僅占用極少的磁盤空間。當前的存儲運行非常好,在此我提出了一個新的存儲子系統方案,它可以糾正現有解決方案的缺點,並能夠處理更大規模的場景。

現有問題

下面快速看一下我們嘗試達成的目的以及面臨的主要問題。對於每個問題,首先看下現有Prometheus的處理方式,看看哪些地方做的好,以及在新的方案中應該着重解決哪些問題。

時序數據

系統會隨時間采集數據點:

identifier -> (t0, v0), (t1, v1), (t2, v2), (t3, v3), ....

每個數據都是時間戳和值的元組。為了監控,時間戳是一個整型,值為任意數值。其中64位的浮點數非常適合表示counter和gauge類型的值。一系列按照時間戳嚴格遞增的數據點組成一個序列,並通過一個標識符進行定位。我們的標識符為帶有標簽維度的指標名稱。標簽維護划分了單個指標的測量空間。每個指標名稱加上一個唯一的標簽集就組成了與該指標有關的時間序列(並攜帶與之相關的值)。

下面是一組典型的序列標識符,為用於計算請求總數的指標的一部分:

requests_total{path="/status", method="GET", instance=”10.0.0.1:80”}
requests_total{path="/status", method="POST", instance=”10.0.0.3:80”}
requests_total{path="/", method="GET", instance=”10.0.0.2:80”}

下面簡化一下這種表達式:在我們的場景中,指標名稱也只是另一個標簽維度,即__name__。在請求層面,它需要被特殊處理,但在存儲時,與其他標簽並沒有什么不同:

{__name__="requests_total", path="/status", method="GET", instance=”10.0.0.1:80”}
{__name__="requests_total", path="/status", method="POST", instance=”10.0.0.3:80”}
{__name__="requests_total", path="/", method="GET", instance=”10.0.0.2:80”}

當請求時序數據時,我們希望通過標簽來選擇序列。在最簡單的場景下,如{__name__="requests_total"}會選擇所有與requests_total指標有關的序列,在特定的時間窗口內從所有選擇的序列中檢索數據點。

在更復雜的請求中,我們希望選擇一次性選擇滿足多個標簽的序列,以及使用比等於更復雜的條件來選擇時序,如不等於(method!="GET")或正則表達匹配(method=~"PUT|POST")。

這在很大程度上定義了存儲的數據以及如何進行調用。

垂直和水平

在簡化試圖中,所有數據點都可以被布局在二維平面中。水平維度代表時間,序列標識符空間則遍布在垂直維度。

series
  ^   
  │   . . . . . . . . . . . . . . . . .   . . . . .   {__name__="request_total", method="GET"}
  │     . . . . . . . . . . . . . . . . . . . . . .   {__name__="request_total", method="POST"}
  │         . . . . . . .
  │       . . .     . . . . . . . . . . . . . . . .                  ... 
  │     . . . . . . . . . . . . . . . . .   . . . .   
  │     . . . . . . . . . .   . . . . . . . . . . .   {__name__="errors_total", method="POST"}
  │           . . .   . . . . . . . . .   . . . . .   {__name__="errors_total", method="GET"}
  │         . . . . . . . . .       . . . . .
  │       . . .     . . . . . . . . . . . . . . . .                  ... 
  │     . . . . . . . . . . . . . . . .   . . . . 
  v
    <-------------------- time --------------------->

Prometheus通過周期性地抓取一組時間序列的當前值來檢索數據點,數據來源稱為目標(target)。因此,寫模式是完全垂直且高度並行的(對每個目標的數據提取都是各自獨立的)。

這里提供一些測量規模:單個Prometheus示例可以從上千個目標中采集數據點,以此暴露成百上千個時間序列。

為了支持每秒采集百萬級別的數據點,批量寫入是一個不可忽視的性能需求。跨磁盤寫入單個數據點會非常慢,因此我們希望順序寫入更大塊的數據。

對於旋轉磁盤來說不足為奇,它的磁頭需要不停地在不同的section之間移動。而SSD支持快速隨機寫入,但無法修改單獨的字節,只能以4KiB或更大的頁為單位執行寫操作。這意味着,寫入16字節的樣本和寫入一個完整的4KiB的頁是相等的。這種行為屬於寫放大的一部分,它會導致SSD磨損--不僅會降低寫入速度,還有可能在一段時間之后損壞硬盤,更多信息可以參見“Coding for SSDs” series。做個總結:對於旋轉磁盤和SSD來說,順序和批量寫入都是理想的寫模式。

請求模式和寫模式有很大區別,我們可以查詢單個序列的單個數據點,也可以查詢10000個序列的單個數據點,或單個序列的一周的數據點,以及10000個序列的一周的數據點等等。因此,在外面的二維平面中,請求既不是完全垂直的也不是完全水平的,而是二者的矩形組合。

Recording rules 可以緩解已知查詢中的問題,但不能作為臨時查詢的通用解決方案。

我們期望批量執行寫入,但批量的內容只是多個序列的數據點的集合。在一個時間窗口內查詢一個序列的數據點時,不僅需要指出這些數據點的位置,還需要從磁盤的各個地方讀取數據。由於每次查詢涉及的樣本可能有百萬級別,因此即使在高速SSD上也很慢。相比於請求16字節的樣本,讀操作還會從磁盤上檢索更多的數據。SSD會加載一個完整的頁,而HDD則至少會讀取一整個section。不管哪種方式,都會浪費寶貴的讀吞吐量。

因此理想上,當順序存儲相同序列的樣本時,就可以通過盡可能少的讀操作對其進行掃描。在此之上,我們只需要了解采集數據點的起始位置即可。

很顯然,理想的寫入模式和能夠顯著提升查詢的布局之間關系密切。這也是我們的TSDB需要解決的最根本的問題。

當前方案

看一下Prometheus的當前存儲(稱之為"V2")是如何解決該問題的。我們為每個時間序列創建一個文件,順序存儲了該序列的所有樣本。由於每幾秒就對這些文件追加單個樣本的開銷比較大,我們在內存中使用1KiB大小的塊來分批處理一個序列的數據,並在填充完一個塊之后,將其追加到文件中。這種方法解決了大部分問題。現在寫入是批量的,且順序存儲了樣本,此外還支持高效壓縮格式(由於相同序列中給定樣本和前一個樣本的區別非常小)。Facebook與Gorilla TSDB有關的論文中描述了一種類似塊的解決方案,並介紹了一種壓縮格式,可以將 16 字節樣本減少到平均 1.37 字節。V2的存儲使用了多種壓縮格式,包括一個Gorilla的變種。

   ┌──────────┬─────────┬─────────┬─────────┬─────────┐           series A
   └──────────┴─────────┴─────────┴─────────┴─────────┘
          ┌──────────┬─────────┬─────────┬─────────┬─────────┐    series B
          └──────────┴─────────┴─────────┴─────────┴─────────┘ 
                              . . .
 ┌──────────┬─────────┬─────────┬─────────┬─────────┬─────────┐   series XYZ
 └──────────┴─────────┴─────────┴─────────┴─────────┴─────────┘ 
   chunk 1    chunk 2   chunk 3     ...

雖然基於塊的方式很好,但為每個序列分配一個獨立的文件也給V2存儲帶來了各種問題:

  • 實際中,需要的文件要比當前采集的時間序列多得多。當文件數達到百萬級別時,遲早會耗盡文件系統的inode。此時只能通過重新格式化磁盤進行恢復。我們通常希望避免專門為單個應用程序而格式化磁盤。
  • 即時使用了塊,每秒需要被持久化的塊數目也可能有上千個,每秒也需要上千個獨立的磁盤寫操作。雖然通過分批處理一個序列的塊的方式可以緩解這種情況,但同時也增加了等待持久化的塊所占用的內存。
  • 保持所有文件的讀寫是不大現實的。特別是99%的數據在24小時之后將永遠不會被用到。即使需要查詢,我們將不得不打開上千個文件,查找並將相應的數據點加載到內存中,然后再關閉掉這些文件。這樣會導致較高的查詢延遲,此外粗暴地對數據塊進行緩存也會導致問題(見"資源消耗"一節)。
  • 最后,在刪除老數據時需要從上百萬個文件的前端移除這些數據。這意味着刪除也是寫密集型操作。此外,周期性地對上百萬個文件進行遍歷和分析使得該操作有可能會持續好幾個小時。當處理結束之后,有可能又要重新開始。且刪除老的文件可能會進一步導致SSD的寫放大。
  • 當前積累的塊會被保存到內存中。如果應用奔潰,數據就會丟失。為了避免這種情況,需要定期將內存狀態保存(checkpoint)到磁盤,這個過程花費的時間窗口可能要大於數據丟失的時間窗口。執行恢復時也可能會花費好幾分鍾,導致較長的重啟周期。

現有設計的關鍵概念是塊,這也是我們會保留的內容。將最新的塊保存在內存中通常也是合理的,且最近的數據被查詢的概率也相對大。

下面我們將尋求一種方式來替代為每個時間序列保留一個文件的方案。

Series Churn

在Prometheus的上下文中,我們使用術語series churn來描述一組非激活的時間序列,即這些序列不再接收數據點,並使用了一組新的激活的序列。

例如,一個特定的微服務實例暴露的所有序列標識符都包含一個"instance"標簽。當我們執行滾動更新該微服務時,會替換成新版本的實例,此時就發生了series churn。在更動態的環境中,可能會每小時發生一次。集群編排系統,如kubernetes允許自動擴容和經常性地對應用進行滾動升級,此時會創建上千個新的應用,與此同時,每天會產生新的時間序列。

series
  ^
  │   . . . . . .
  │   . . . . . .
  │   . . . . . .
  │               . . . . . . .
  │               . . . . . . .
  │               . . . . . . .
  │                             . . . . . .
  │                             . . . . . .
  │                                         . . . . .
  │                                         . . . . .
  │                                         . . . . .
  v
    <-------------------- time --------------------->

因此即使基礎設施的規模大致不變,數據庫中的時間序列隨着時間也會線性增長。雖然一個Prometheus服務可以輕易地采集1000萬個時間序列的數據,但需要在十億級別的序列中查找數據時,也會嚴重影響到查詢性能。

當前的解決方案

當前Prometheus的V2存儲為當前存儲的所有序列分配了一個基於 LevelDB 的索引。它允許查詢帶有特定標簽對的序列,但缺少一種可擴展的方式來組合不同標簽的查詢結果。

例如,可以有效地查詢帶有 __name__="requests_total" 標簽的所有序列,但當選擇instance="A" AND __name__="requests_total"的所有序列時會遇到擴展性問題。后續我們會回顧造成該問題的原因,以及如何來改善查找延遲。

該問題實際上是最初尋找更好的存儲系統的原因。Prometheus需要一種改進的索引方式來查找數億個時間序列。

資源消耗

資源消耗是嘗試擴展Prometheus時需要考慮到的一貫主題,但實際上困擾用戶的並不完全是資源上的匱乏。實際上,Prometheus管理着一個相當大的吞吐量,在面臨變更時會存在不確定性和不穩定性。V2存儲會緩慢構建樣本數據塊,導致內存消耗也會隨着時間增長。當完成塊(填充滿)之后,它們會被寫入磁盤,並從內存中驅逐出去。最終,Prometheus的內存使用會達到一個穩定狀態。直到監控環境發生變化--series churn會增加內存、CPU和磁盤IO的使用。

如果正在進行變更,最終也會達到穩定狀態,但資源使用會遠遠高於一個更加靜態的環境。轉換周期可能會持續數小時,且無法確認使用的最大資源。

為每個時間序列保持一個文件的方式很容易會導致Prometheus進程的退出。當請求的數據不在內存中時,需要打開被請求的序列對應的文件,並將包含相關數據的塊讀取到內存中。如果數據的總量超過可用的內存,Prometheus會被OOM退出。

當查詢結束后,需要釋放加載的數據,但通常會緩存較長時間來滿足后續對該數據的查詢。

最后,看下SSD上下文中的寫放大,以及Prometheus是如何通過批量寫入來緩解該問題的。然而,當處理小批量的寫入以及當數據沒有對齊頁邊界時仍然會造成寫放大。對於大型Prometheus服務,可以觀察到對硬件壽命的影響。對於具有高寫入吞吐量的數據庫應用程序來說,這種情況下仍然能夠正常運作,但應該密切關注,看是否可以緩解這些問題。

從頭開始

至此,我們已經了解到現有的問題、V2存儲是如何解決的及其存在的問題。此外還看到了一些很不錯的觀點,我們期望或多或少地去無縫采納這些觀點。通過改善或重新設計部分內容可以解決掉V2存儲中的大部分問題。

選擇的存儲格式會直接影響到性能和資源的使用。我們需要找到合適的算法以及磁盤布局來實現一個高性能存儲層。

V3--宏觀設計

注意:由於此處使用了block,為避免與chunk混淆,后續將直接使用block。

宏觀布局如下:

$ tree ./data
./data
├── b-000001
│   ├── chunks
│   │   ├── 000001
│   │   ├── 000002
│   │   └── 000003
│   ├── index
│   └── meta.json
├── b-000004
│   ├── chunks
│   │   └── 000001
│   ├── index
│   └── meta.json
├── b-000005
│   ├── chunks
│   │   └── 000001
│   ├── index
│   └── meta.json
└── b-000006
    ├── meta.json
    └── wal
        ├── 000001
        ├── 000002
        └── 000003

從最上層看,布局包含一個連續的以數字命名的block,前綴為b-。每個block中都有一個包含索引的文件,以及一個"chunk"目錄,其中包含更多的以數字命名的文件。"chunk"目錄中包含了各種序列的原始數據塊。與V2一樣,這種布局可以很輕易地讀取一個時間窗口內的序列數據,並允許采用相同的(高效)壓縮算法。由於這種方式運行地很好,因此我們將保留這種方式。顯然,不會為每個序列保持一個文件,轉而使用好幾個文件來保存多個序列的數據。

"index"文件的存在應該不足為奇,我們假設它包含了很多黑魔法,允許我們查找標簽、可能的值、整個時間序列以及持有的數據點的塊。

但為什么使用多個包含索引和塊文件的目錄?為什么最后一個包含一個"wal"目錄?理解了這兩個問題,就解決了我們90%的難題。

多個小數據庫

我們將水平維度(即時間空間)分割成了不重疊的block,每個block作為一個完全獨立的包含該時間窗口內的所有時間序列的數據庫,這樣,每個塊都有其各自的索引和塊文件。

t0            t1             t2             t3             now
 ┌───────────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐
 │           │  │           │  │           │  │           │                 ┌────────────┐
 │           │  │           │  │           │  │  mutable  │ <─── write ──── ┤ Prometheus │
 │           │  │           │  │           │  │           │                 └────────────┘
 └───────────┘  └───────────┘  └───────────┘  └───────────┘                        ^
       └──────────────┴───────┬──────┴──────────────┘                              │
                              │                                                  query
                              │                                                    │
                            merge ─────────────────────────────────────────────────┘

每個block中的數據都是不可變的,當然,必須能夠在采集到新數據時,在最近的block中添加新的序列和樣本。對於這類block,需要將所有新數據寫入內存數據庫,並能夠提供與已經持久化的block相同的查找功能。可以有效地更新內存數據結構。為了防止數據丟失,所有進入的數據都會被寫入一個臨時的預寫式日志中,即"wal"目錄,通過wal可以在重啟時重新填充內存數據庫。

所有這些文件都有自己的序列化格式,以及人們期望的內容:大量標志、偏移量、變量和 CRC32 校驗和等。

這種布局允許我們將查詢散布到與查詢時間范圍有關的所有塊。來自每個塊的局部結果最終會合並成整體結果。

這種水平分割增加了幾大功能:

  • 當請求一個時間范圍時,可以很容易地忽略不在該范圍內的所有數據塊(block)。它通過減少檢查的數據集來解決series churn問題。
  • 當完成一個block后,可以通過順序寫入一些較大的文件來保存內存數據庫中的數據。以此避免寫放大,並能夠在SSD和HDD上很好地運作。
  • 我們保留了V2中最新塊的屬性,這部分數據有可能被經常訪問,將其保存在內存中。
  • 此外,不再限制1KiB的塊大小來將數據對齊到磁盤。我們可以為單個數據點選擇最有意義的塊大小,並選擇壓縮格式。
  • 刪除舊數據變得非常快速便捷。只需要刪除單獨的目錄即可。在老的存儲中,我們需要進行分析並重新寫入數億個文件,這個過程可能需要數小時。

每個block同時包含一個meta.json文件。包含用戶可讀的關於block的信息,可以方便了解存儲的狀態和包含的數據。

mmap

從百萬個小文件變為相對較大的文件可以在較小的開銷下打開所有的文件。通過mmap(2)系統調用,可以給文件內容創建一個透明的虛擬內存域。

這意味着我們認為數據庫中的所有內容都位於內存中,而無需占用任何物理RAM。只有在訪問數據庫文件的特定字節段時,操作系統才會被動地從磁盤加載頁。這種方式使得操作系統負責與我們的持久化數據有關的所有內存管理。由於操作系統可以看到整個機器和進程的完整視圖,因此通常可以由操作系統來執行內存管理。查詢的數據可能被緩存到內存中,在內存有壓力時可以通過驅逐頁來釋放內存,如果機器存在未使用的內存,則Prometheus可以緩存整個數據庫,並在其他應用需要時立即返回相關的數據。因此,相比適應RAM,請求更多的持久化數據更容易OOM我們的進程。這樣,內存緩存大小完全是自適應的,且只有在真正需要時才會加載數據。

在我看來,上述方式也是如今很多數據庫所采用的方式,也是在磁盤格式允許(除非有人有信心在進程層面能夠打敗OS)下的一種理想方式。從我們的角度看,只需很少的工作就能獲得很多功能。

壓縮

存儲需要周期性地"切出"一個新的block,然后寫入前一個block,這就是如何完成將block持久化到磁盤的。只有在block成功持久化之后,才能刪除預寫式日志文件(用於恢復內存block)。

我們需要將每個block的大小維持在一個合理的范圍(通常設置為2小時),以此避免在內存中積累過多的數據。當請求多個blocks時,需要將多個結果合並成一個完整的結果。這個合並過程顯然是有代價的,例如一個一周長度的查詢不應該合並 80 多個局部結果。

為了達成上述兩個目的,我們引入了壓縮。壓縮描述了將一個使用一個或多個block的數據寫入到一個可能更大的block的過程。壓縮還可以在處理過程中修改現有的數據,如丟掉已刪除的數據,或重新構建樣本塊(用於提升查詢性能)。

t0             t1            t2             t3             t4             now
 ┌────────────┐  ┌──────────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐
 │ 1          │  │ 2        │  │ 3         │  │ 4         │  │ 5 mutable │    before
 └────────────┘  └──────────┘  └───────────┘  └───────────┘  └───────────┘
 ┌─────────────────────────────────────────┐  ┌───────────┐  ┌───────────┐
 │ 1              compacted                │  │ 4         │  │ 5 mutable │    after (option A)
 └─────────────────────────────────────────┘  └───────────┘  └───────────┘
 ┌──────────────────────────┐  ┌──────────────────────────┐  ┌───────────┐
 │ 1       compacted        │  │ 3      compacted         │  │ 5 mutable │    after (option B)
 └──────────────────────────┘  └──────────────────────────┘  └───────────┘

本例中由四個聯系的blocks [1, 2, 3, 4]。block 1,2,3可以一起壓縮,新的布局為[1, 4],此外,還可以將它們壓縮為 [1, 3]。所有時間序列數據仍然存在,但整體的blocks數變少了。這種方式大大降低了查詢時的合並開銷,即減少了合並的局部查詢結果的數目。

保留(Retention)

在V2存儲中可以看到刪除舊數據是一個比較慢的過程,並對CPU、內存和磁盤造成一定的負擔。那么在基於block的設計中如何丟棄老的數據?非常簡單,如果一個block中的數據不在保留窗口內,只需要刪除該block的目錄即可。在下例中,可以安全刪除block 1,而block 2則不能刪除,需要等到它完全不在保留邊界內才能刪掉。

                      |
 ┌────────────┐  ┌────┼─────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐
 │ 1          │  │ 2  |     │  │ 3         │  │ 4         │  │ 5         │   . . .
 └────────────┘  └────┼─────┘  └───────────┘  └───────────┘  └───────────┘
                      |
                      |
             retention boundary

獲取的數據越舊,block有可能會變得越大(壓縮過程會不斷壓縮先前已壓縮的塊)。因此需要設置一個上限來防止block增長到整個數據庫,進而違背我們設計的初衷。

同時也限制了部分在、部分不在保留窗口的block造成的磁盤開銷,如上例中的block 2。當block的最大大小設置為總的保留窗口的10% 時,保留block 2造成的總的開銷也限制在10%以內。

索引

調查存儲改進的最初動機是改善series churn帶來的問題。基於block的布局減少了一個處理請求考慮到的總序列數。假設我們查找索引的復雜度為O(n2)*,我們將n降低到了一個合理的數目,但復雜度則增加到了*O(n2)...

實際中,大部分情況下都可以很快地響應請求。然而當請求跨整個范圍時會很慢,即使只需要查詢少量序列。我最初的想法(可以追溯到所有這些工作開始之前)中,有一個解決該問題的方案:一個更強大的倒排索引

倒排索引提供一種基於內容子集快速查找數據項的方法。簡單地說,我可以查找所有包含app=”nginx"標簽的序列,而無需遍歷每個序列並檢驗該序列是否包含這個標簽。

因此,為每個序列分配一個唯一的ID,通過該ID可以以常數時間(即O(1))檢索該序列。這種情況下,ID作為前向索引。

例如:如果序列ID為 10, 29和9,且包含標簽 app="nginx",則標簽nginx的倒排索引為列表[10,29,9],可以使用該列表遍歷所有包含該標簽的序列。這樣,即使在200億個序列中進行查找,也不會影響查找速度。

簡而言之,如果n是總的序列數,m是特定查詢的結果大小,則使用索引進行查詢的復雜度為O(m)。這樣查詢會隨檢索的數據量(m)而非查找的數據體(n)進行縮放,通常m特別小。

為了簡潔,我們假設可以以常數時間去檢索倒排索引列表。

實際上,這幾乎就是V2使用的倒排索引類型,也是在數百萬個序列中提供高性能查詢的最低要求。敏銳的觀察者可能會注意到,在最壞條件下,如果所有序列都包含一個標簽,則復雜度又變成了O(n)。這看起來正符合預期,如果需要請求所有數據,那花費的時間自然也會較長。一旦我們涉及更復雜的請求時,又會出現新的問題。

組合標簽

關聯上百萬個序列的標簽很常見,假設一個水平擴展的微服務"foo",它有上百個示例,每個示例又有上百個序列,且每個序列都有標簽app="foo"。當然,用戶不會查詢所有的序列,反正會進一步使用標簽來限制查詢的范圍,例如,我希望知道服務實例接收到多少個請求,查詢語句為:__name__="requests_total" AND app="foo"

為了找到所有滿足標簽的序列,我們會為每個標簽查找對應的倒排索引,然后進行相交。最終的結果集通常遠少於單個輸入列表。由於每個輸入列表的最差情況為O(n),因此在兩個列表上嵌套迭代的解決方案的時間復雜度為O(n^2)。其他操作也會是相同的情況,如交集(app="foo" OR app="bar")。當在查詢語句中添加新的標簽時,復雜度會指數上升到O(n^3), O(n^4), O(n^5), …O(n^k)。實際中,有很多技巧可以通過更改執行順序來最小化有效運行時間。越復雜,就越需要了解數據的狀況和標簽之間的關系。這樣就引入了很多復雜性,且不會降低算法的最壞運行時。

這是V2存儲的基本方式,幸運的是,只需要進行很小的修改就可以獲得顯著的提升。如果倒排索引是有序的會發生什么?

假設我們這是初始的查詢:

__name__="requests_total"   ->   [ 9999, 1000, 1001, 2000000, 2000001, 2000002, 2000003 ]
     app="foo"              ->   [ 1, 3, 10, 11, 12, 100, 311, 320, 1000, 1001, 10002 ]

             intersection   =>   [ 1000, 1001 ]

上例中交叉的數據很少,我們可以在每列的首部設置一個游標,通過推進具有最小數值的游標進行查找。當兩個數值相同時,將該數值添加到結果中,並同時推進倆個游標。總之,我們使用這種之字形的模式對兩個列表進行掃描,由於只會在任意列表中移動游標,因此總的開銷為O(2n) = O(n)

對兩個以上不同集合操作列表的過程也是類似。這樣k 個集合操作的數量僅僅是修改了乘數因子(O(k*n)),而非最差查找下的指數因子(O(n^k)),提升相當大。

這里我使用的是范圍搜索索引(通常用於全文搜索引擎)的一個簡化版。每個序列描述符都被認為是一個短"document",每個標簽(名稱+固定值)被認為是"document"內的一個"word"。通常在使用搜索引擎進行索引時,可以忽略很多額外的數據,如"word"的位置以及頻率數據。

關於改進實際運行時方法的研究似乎無窮無盡,需要經常對輸入數據做一些假設。對倒排索引進行壓縮的很多技術都有其優缺點。由於我們的"document"很小,且"word"在很多序列中高度重復,因此壓縮並不那么重要。例如,在實際中,在包含12個標簽的約4.4百萬個序列的數據集中,具有唯一標簽的序列不超過5000個(即大部分時重復的) 。 在我們的初始版本中沒有使用壓縮,僅使用一些簡單的技巧來跳過大范圍的不感興趣的ID。

雖然保持ID有序聽起來很簡單,但實際並沒有那么容易。例如,V2存儲使用哈希作為新序列的ID,此時就無法有效構建倒排索引。

另一項艱巨的任務是在數據刪除或更新時修改磁盤上的索引。通常,最簡單的辦法是重新計算並重寫索引,但同時需要保證數據庫時可查詢且一致的。V3存儲通過為每個block分配一個獨立的不可變(只能通過在壓縮時重寫進行修改)索引來實現刪除和更新。只有完全在內存中的可變block的索引才需要被更新。

性能測試

作者分別使用 Prometheus 1.5.2 servers (V2 storage) 和 Prometheus 2.0 servers (V3 storage) 進行了性能驗證。細節請參考原文。

總結

  • 使用批量寫入來降低對磁盤IO的壓力。批量寫入磁盤的數據塊大小為4K,防止因為在SSD上發生寫放大而導致降低效率並損壞磁盤。

  • 順序寫入數據,這樣在讀數據時就無需掃描磁盤的多個地方,增加讀吞吐量。

  • 以上是解決一般讀寫性能問題的常用方式,與時序數據庫有關的改進點如下:

    • 存儲時為每個時間序列分配一個ID,使用倒排索引來通過標簽檢索到時間序列對應的ID,以此來加快查找速度

      • 一個block的目錄結構大體如下,chunks中記錄了原始指標數據,index中記錄了該block的倒排索引以及相關偏移量,可以使用index文件快速查找需要的內容。

        data
        ├── 01EM6Q6A1YPX4G9TEB20J22B2R
        |   ├── chunks
        |   |   ├── 000001
        |   |   └── 000002
        |   ├── index
        |   ├── meta.json
        |   └── tombstones
        ├── chunks_head
        |   ├── 000001
        |   └── 000002
        └── wal
            ├── checkpoint.000003
            |   ├── 000000
            |   └── 000001
            ├── 000004
            └── 000005
        
    • 使用mmap將文件內容映射到內存,讓操作系統去做內存管理。由於OS有整個系統的內存視圖,在內存不足是可以執行如內存頁替換等動作來防止OOM。

    • 使用時間窗口來創建數據塊文件(block文件),而非為每個時間序列創建一個文件。

    • 內存中的block用於接收新的采集的數據,在block填充滿之后才會被寫入文件,為了防止內存數據丟失,需要用到wal,以便在系統重啟之后恢復數據。只有當內存中的block持久化完成之后才能刪除wal。

    • 為了降低查詢需要合並的局部結果的數目,采用了壓縮,可以將多個block壓縮到一個block中。但需要對block的大小做一個限制,防止因為壓縮而生成一個非常大的block,導致因為部分在、部分不在內存保留區間的block占用過多內存。

    • 為了降低組合多個標簽的查詢的復雜度,可以對標標簽的倒排索引ID做排序,這樣可以降低組合的復雜度

更多Prometheus TSDB有關的內容請參見Prometheus TSDB


免責聲明!

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



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