從零寫一個時間序列數據庫

編者按:Prometheus 是 CNCF 旗下的開源監控告警解決方案,它已經成爲 Kubernetes 生態圈中的核心監控系統。本文做者 Fabian Reinartz 是 Prometheus 的核心開發者,這篇文章是其於 2017 年寫的一篇關於 Prometheus 中的時間序列數據庫的設計思考,雖然寫做時間有點久了,可是其中的考慮和思路很是值得參考。長文預警,請坐下來慢慢品味。node


我從事監控工做。特別是在 Prometheus 上,監控系統包含一個自定義的時間序列數據庫,而且集成在 Kubernetes 上。linux

在許多方面上 Kubernetes 展示出了 Prometheus 全部的設計用途。它使得持續部署continuous deployments彈性伸縮auto scaling和其餘高動態環境highly dynamic environments下的功能能夠輕易地訪問。查詢語句和操做模型以及其它概念決策使得 Prometheus 特別適合這種環境。可是,若是監控的工做負載動態程度顯著地增長,這就會給監控系統自己帶來新的壓力。考慮到這一點,咱們就能夠特別緻力於在高動態或瞬態服務transient services環境下提高它的表現,而不是回過頭來解決 Prometheus 已經解決的很好的問題。nginx

Prometheus 的存儲層在歷史以來都展示出卓越的性能,單一服務器就可以以每秒數百萬個時間序列的速度攝入多達一百萬個樣本,同時只佔用了不多的磁盤空間。儘管當前的存儲作的很好,但我依舊提出一個新設計的存儲子系統,它能夠修正現存解決方案的缺點,並具有處理更大規模數據的能力。git

備註:我沒有數據庫方面的背景。我說的東西多是錯的並讓你誤入歧途。你能夠在 Freenode 的 #prometheus 頻道上對我(fabxc)提出你的批評。github

問題,難題,問題域

首先,快速地概覽一下咱們要完成的東西和它的關鍵難題。咱們能夠先看一下 Prometheus 當前的作法 ,它爲何作的這麼好,以及咱們打算用新設計解決哪些問題。正則表達式

時間序列數據

咱們有一個收集一段時間數據的系統。算法

identifier -> (t0, v0), (t1, v1), (t2, v2), (t3, v3), ....
複製代碼

每一個數據點是一個時間戳和值的元組。在監控中,時間戳是一個整數,值能夠是任意數字。64 位浮點數對於計數器和測量值來講是一個好的表示方法,所以咱們將會使用它。一系列嚴格單調遞增的時間戳數據點是一個序列,它由標識符所引用。咱們的標識符是一個帶有標籤維度label dimensions字典的度量名稱。標籤維度劃分了單一指標的測量空間。每個指標名稱加上一個惟一標籤集就成了它本身的時間序列,它有一個與之關聯的數據流value stream數據庫

這是一個典型的序列標識符series identifier集,它是統計請求指標的一部分:json

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 經過按期地抓取一組時間序列的當前值來獲取數據點。咱們從中獲取到的實體稱爲目標。所以,寫入模式徹底地垂直且高度併發,由於來自每一個目標的樣本是獨立攝入的。

這裏提供一些測量的規模:單一 Prometheus 實例從數萬個目標中收集數據點,每一個數據點都暴露在數百到數千個不一樣的時間序列中。

在每秒採集數百萬數據點這種規模下,批量寫入是一個不能妥協的性能要求。在磁盤上分散地寫入單個數據點會至關地緩慢。所以,咱們想要按順序寫入更大的數據塊。

對於旋轉式磁盤,它的磁頭始終得在物理上向不一樣的扇區上移動,這是一個不足爲奇的事實。而雖然咱們都知道 SSD 具備快速隨機寫入的特色,但事實上它不能修改單個字節,只能寫入一頁或更多頁的 4KiB 數據量。這就意味着寫入 16 字節的樣本至關於寫入滿滿一個 4Kib 的頁。這一行爲就是所謂的寫入放大,這種特性會損耗你的 SSD。所以它不只影響速度,並且還絕不誇張地在幾天或幾個周內破壞掉你的硬件。

關於此問題更深層次的資料,「Coding for SSDs」系列博客是極好的資源。讓咱們想一想主要的用處:順序寫入和批量寫入分別對於旋轉式磁盤和 SSD 來講都是理想的寫入模式。大道至簡。

查詢模式比起寫入模式明顯更不一樣。咱們能夠查詢單一序列的一個數據點,也能夠對 10000 個序列查詢一個數據點,還能夠查詢一個序列幾個周的數據點,甚至是 10000 個序列幾個周的數據點。所以在咱們的二維平面上,查詢範圍不是徹底水平或垂直的,而是兩者造成矩形似的組合。

記錄規則能夠減輕已知查詢的問題,但對於點對點ad-hoc查詢來講並非一個通用的解決方法。

咱們知道咱們想要批量地寫入,但咱們獲得的僅僅是一系列垂直數據點的集合。當查詢一段時間窗口內的數據點時,咱們不只很難弄清楚在哪才能找到這些單獨的點,並且不得不從磁盤上大量隨機的地方讀取。也許一條查詢語句會有數百萬的樣本,即便在最快的 SSD 上也會很慢。讀入也會從磁盤上獲取更多的數據而不只僅是 16 字節的樣本。SSD 會加載一整頁,HDD 至少會讀取整個扇區。不論哪種,咱們都在浪費寶貴的讀取吞吐量。

所以在理想狀況下,同一序列的樣本將按順序存儲,這樣咱們就能經過儘量少的讀取來掃描它們。最重要的是,咱們僅須要知道序列的起始位置就能訪問全部的數據點。

顯然,將收集到的數據寫入磁盤的理想模式與可以顯著提升查詢效率的佈局之間存在着明顯的抵觸。這是咱們 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 存儲帶來麻煩,由於:

  • 實際上,咱們須要的文件比當前收集數據的時間序列數量要多得多。多出的部分在序列分流Series Churn上。有幾百萬個文件,早晚會使用光文件系統中的 inode。這種狀況咱們只能經過從新格式化來恢復磁盤,這種方式是最具備破壞性的。咱們一般不想爲了適應一個應用程序而格式化磁盤。
  • 即便是分塊寫入,每秒也會產生數千塊的數據塊而且準備持久化。這依然須要每秒數千次的磁盤寫入。儘管經過爲每一個序列打包好多個塊來緩解,但這反過來仍是增長了等待持久化數據的總內存佔用。
  • 要保持全部文件打開來進行讀寫是不可行的。特別是由於 99% 的數據在 24 小時以後再也不會被查詢到。若是查詢它,咱們就得打開數千個文件,找到並讀取相關的數據點到內存中,而後再關掉。這樣作就會引發很高的查詢延遲,數據塊緩存加重會致使新的問題,這一點在「資源消耗」一節另做講述。
  • 最終,舊的數據須要被刪除,而且數據須要從數百萬文件的頭部刪除。這就意味着刪除其實是寫密集型操做。此外,循環遍歷數百萬文件而且進行分析一般會致使這一過程花費數小時。當它完成時,可能又得從新來過。喔天,繼續刪除舊文件又會進一步致使 SSD 產生寫入放大。
  • 目前所積累的數據塊僅維持在內存中。若是應用崩潰,數據就會丟失。爲了不這種狀況,內存狀態會按期的保存在磁盤上,這比咱們能接受數據丟失窗口要長的多。恢復檢查點也會花費數分鐘,致使很長的重啓週期。

咱們可以從現有的設計中學到的關鍵部分是數據塊的概念,咱們固然但願保留這個概念。最新的數據塊會保持在內存中通常也是好的主意。畢竟,最新的數據會大量的查詢到。

一個時間序列對應一個文件,這個概念是咱們想要替換掉的。

序列分流

在 Prometheus 的上下文context中,咱們使用術語序列分流series churn來描述一個時間序列集合變得不活躍,即再也不接收數據點,取而代之的是出現一組新的活躍序列。

例如,由給定微服務實例產生的全部序列都有一個相應的「instance」標籤來標識其來源。若是咱們爲微服務執行了滾動更新rolling update,而且爲每一個實例替換一個新的版本,序列分流便會發生。在更加動態的環境中,這些事情基本上每小時都會發生。像 Kubernetes 這樣的集羣編排Cluster orchestration系統容許應用連續性的自動伸縮和頻繁的滾動更新,這樣也許會建立成千上萬個新的應用程序實例,而且伴隨着全新的時間序列集合,天天都是如此。

series
  ^
  |   . . . . . .
  |   . . . . . .
  |   . . . . . .
  |               . . . . . . .
  |               . . . . . . .
  |               . . . . . . .
  |                             . . . . . .
  |                             . . . . . .
  |                                         . . . . .
  |                                         . . . . .
  |                                         . . . . .
  v
    <-------------------- time --------------------->
複製代碼

因此即使整個基礎設施的規模基本保持不變,過一段時間後數據庫內的時間序列仍是會成線性增加。儘管 Prometheus 很願意採集 1000 萬個時間序列數據,但要想在 10 億個序列中找到數據,查詢效果仍是會受到嚴重的影響。

當前解決方案

當前 Prometheus 的 V2 存儲系統對全部當前保存的序列擁有基於 LevelDB 的索引。它容許查詢語句含有給定的標籤對label pair,可是缺少可伸縮的方法來從不一樣的標籤選集中組合查詢結果。

例如,從全部的序列中選擇標籤 __name__="requests_total" 很是高效,可是選擇  instance="A" AND __name__="requests_total" 就有了可伸縮性的問題。咱們稍後會從新考慮致使這一點的緣由和可以提高查找延遲的調整方法。

事實上正是這個問題才催生出了對更好的存儲系統的最初探索。Prometheus 須要爲查找億萬個時間序列改進索引方法。

資源消耗

當試圖擴展 Prometheus(或其餘任何事情,真的)時,資源消耗是永恆不變的話題之一。但真正困擾用戶的並非對資源的絕對渴求。事實上,因爲給定的需求,Prometheus 管理着使人難以置信的吞吐量。問題更在於面對變化時的相對未知性與不穩定性。經過其架構設計,V2 存儲系統緩慢地構建了樣本數據塊,這一點致使內存佔用隨時間遞增。當數據塊完成以後,它們能夠寫到磁盤上並從內存中清除。最終,Prometheus 的內存使用到達穩定狀態。直到監測環境發生了改變——每次咱們擴展應用或者進行滾動更新,序列分流都會增長內存、CPU、磁盤 I/O 的使用。

若是變動正在進行,那麼它最終仍是會到達一個穩定的狀態,但比起更加靜態的環境,它的資源消耗會顯著地提升。過渡時間一般爲數個小時,並且難以肯定最大資源使用量。

爲每一個時間序列保存一個文件這種方法也使得一個單個查詢就很容易崩潰 Prometheus 進程。當查詢的數據沒有緩存在內存中,查詢的序列文件就會被打開,而後將含有相關數據點的數據塊讀入內存。若是數據量超出內存可用量,Prometheus 就會因 OOM 被殺死而退出。

在查詢語句完成以後,加載的數據即可以被再次釋放掉,但一般會緩存更長的時間,以便更快地查詢相同的數據。後者看起來是件不錯的事情。

最後,咱們看看以前提到的 SSD 的寫入放大,以及 Prometheus 是如何經過批量寫入來解決這個問題的。儘管如此,在許多地方仍是存在由於批量過小以及數據未精確對齊頁邊界而致使的寫入放大。對於更大規模的 Prometheus 服務器,現實當中會發現縮減硬件壽命的問題。這一點對於高寫入吞吐量的數據庫應用來講仍然至關廣泛,但咱們應該放眼看看是否能夠解決它。

從新開始

到目前爲止咱們對於問題域、V2 存儲系統是如何解決它的,以及設計上存在的問題有了一個清晰的認識。咱們也看到了許多很棒的想法,這些或多或少均可以拿來直接使用。V2 存儲系統至關數量的問題均可以經過改進和部分的從新設計來解決,但爲了好玩(固然,在我仔細的驗證想法以後),我決定試着寫一個完整的時間序列數據庫——從頭開始,即向文件系統寫入字節。

性能與資源使用這種最關鍵的部分直接影響了存儲格式的選取。咱們須要爲數據找到正確的算法和磁盤佈局來實現一個高性能的存儲層。

這就是我解決問題的捷徑——跳過使人頭疼、失敗的想法,數不盡的草圖,淚水與絕望。

V3—宏觀設計

咱們存儲系統的宏觀佈局是什麼?簡而言之,是當咱們在數據文件夾裏運行 tree 命令時顯示的一切。看看它能給咱們帶來怎樣一副驚喜的畫面。

$ 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
複製代碼

在最頂層,咱們有一系列以 b- 爲前綴編號的block。每一個塊中顯然保存了索引文件和含有更多編號文件的 chunk 文件夾。chunks 目錄只包含不一樣序列數據點的原始塊raw chunks of data points。與 V2 存儲系統同樣,這使得經過時間窗口讀取序列數據很是高效而且容許咱們使用相同的有效壓縮算法。這一點被證明行之有效,咱們也打算沿用。顯然,這裏並不存在含有單個序列的文件,而是一堆保存着許多序列的數據塊。

index 文件的存在應該不足爲奇。讓咱們假設它擁有黑魔法,可讓咱們找到標籤、可能的值、整個時間序列和存放數據點的數據塊。

但爲何這裏有好幾個文件夾都是索引和塊文件的佈局?而且爲何存在最後一個包含 wal 文件夾?理解這兩個疑問便能解決九成的問題。

許多小型數據庫

咱們分割橫軸,即將時間域分割爲不重疊的塊。每一塊扮演着徹底獨立的數據庫,它包含該時間窗口全部的時間序列數據。所以,它擁有本身的索引和一系列塊文件。

t0            t1             t2             t3             now
 +-----------+  +-----------+  +-----------+  +-----------+
 |           |  |           |  |           |  |           |                 +------------+
 |           |  |           |  |           |  |  mutable  | <--- write ---- ┤ Prometheus |
 |           |  |           |  |           |  |           |                 +------------+
 +-----------+  +-----------+  +-----------+  +-----------+                        ^
       +--------------+-------+------+--------------+                              |
                              |                                                  query
                              |                                                    |
                            merge -------------------------------------------------+
複製代碼

每一塊的數據都是不可變的immutable。固然,當咱們採集新數據時,咱們必須能向最近的塊中添加新的序列和樣本。對於該數據塊,全部新的數據都將寫入內存中的數據庫中,它與咱們的持久化的數據塊同樣提供了查找屬性。內存中的數據結構能夠高效地更新。爲了防止數據丟失,全部傳入的數據一樣被寫入臨時的預寫日誌write ahead log中,這就是 wal 文件夾中的一些列文件,咱們能夠在從新啓動時經過它們從新填充內存數據庫。

全部這些文件都帶有序列化格式,有咱們所指望的全部東西:許多標誌、偏移量、變體和 CRC32 校驗和。紙上得來終覺淺,絕知此事要躬行。

這種佈局容許咱們擴展查詢範圍到全部相關的塊上。每一個塊上的部分結果最終合併成完整的結果。

這種橫向分割增長了一些很棒的功能:

  • 當查詢一個時間範圍,咱們能夠簡單地忽略全部範圍以外的數據塊。經過減小須要檢查的數據集,它能夠初步解決序列分流的問題。
  • 當完成一個塊,咱們能夠經過順序的寫入大文件從內存數據庫中保存數據。這樣能夠避免任何的寫入放大,而且 SSD 與 HDD 均適用。
  • 咱們延續了 V2 存儲系統的一個好的特性,最近使用而被屢次查詢的數據塊,老是保留在內存中。
  • 很好,咱們也再也不受限於 1KiB 的數據塊尺寸,以使數據在磁盤上更好地對齊。咱們能夠挑選對單個數據點和壓縮格式最合理的尺寸。
  • 刪除舊數據變得極爲簡單快捷。咱們僅僅只需刪除一個文件夾。記住,在舊的存儲系統中咱們不得不花數個小時分析並重寫數億個文件。

每一個塊還包含了 meta.json 文件。它簡單地保存了關於塊的存儲狀態和包含的數據,以便輕鬆瞭解存儲狀態及其包含的數據。

mmap

將數百萬個小文件合併爲少數幾個大文件使得咱們用很小的開銷就能保持全部的文件都打開。這就解除了對 mmap(2) 的使用的阻礙,這是一個容許咱們經過文件透明地回傳虛擬內存的系統調用。簡單起見,你能夠將其視爲交換空間swap space,只是咱們全部的數據已經保存在了磁盤上,而且當數據換出內存後再也不會發生寫入。

這意味着咱們能夠看成全部數據庫的內容都視爲在內存中卻不佔用任何物理內存。僅當咱們訪問數據庫文件某些字節範圍時,操做系統纔會從磁盤上惰性加載lazy load頁數據。這使得咱們將全部數據持久化相關的內存管理都交給了操做系統。一般,操做系統更有資格做出這樣的決定,由於它能夠全面瞭解整個機器和進程。查詢的數據能夠至關積極的緩存進內存,但內存壓力會使得頁被換出。若是機器擁有未使用的內存,Prometheus 目前將會高興地緩存整個數據庫,可是一旦其餘進程須要,它就會馬上返回那些內存。

所以,查詢再也不輕易地使咱們的進程 OOM,由於查詢的是更多的持久化的數據而不是裝入內存中的數據。內存緩存大小變得徹底自適應,而且僅當查詢真正須要時數據纔會被加載。

就我的理解,這就是當今大多數數據庫的工做方式,若是磁盤格式容許,這是一種理想的方式,——除非有人自信能在這個過程當中超越操做系統。咱們作了不多的工做但確實從外面得到了不少功能。

壓縮

存儲系統須要按期「切」出新塊並將以前完成的塊寫入到磁盤中。僅在塊成功的持久化以後,纔會被刪除以前用來恢復內存塊的日誌文件(wal)。

咱們但願將每一個塊的保存時間設置的相對短一些(一般配置爲 2 小時),以免內存中積累太多的數據。當查詢多個塊,咱們必須將它們的結果合併爲一個總體的結果。合併過程顯然會消耗資源,一個星期的查詢不該該由超過 80 個的部分結果所組成。

爲了實現二者,咱們引入壓縮compaction。壓縮描述了一個過程:取一個或更多個數據塊並將其寫入一個可能更大的塊中。它也能夠在此過程當中修改現有的數據。例如,清除已經刪除的數據,或重建樣本塊以提高查詢性能。

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)
 +--------------------------+  +--------------------------+  +-----------+
複製代碼

在這個例子中咱們有順序塊 [1,2,3,4]。塊 一、二、3 能夠壓縮在一塊兒,新的佈局將會是 [1,4]。或者,將它們成對壓縮爲 [1,3]。全部的時間序列數據仍然存在,但如今總體上保存在更少的塊中。這極大程度地縮減了查詢時間的消耗,由於須要合併的部分查詢結果變得更少了。

保留

咱們看到了刪除舊的數據在 V2 存儲系統中是一個緩慢的過程,而且消耗 CPU、內存和磁盤。如何才能在咱們基於塊的設計上清除舊的數據?至關簡單,只要刪除咱們配置的保留時間窗口裏沒有數據的塊文件夾便可。在下面的例子中,塊 1 能夠被安全地刪除,而塊 2 則必須一直保留,直到它落在保留窗口邊界以外。

|
 +------------+  +----+-----+  +-----------+  +-----------+  +-----------+
 | 1          |  | 2  |     |  | 3         |  | 4         |  | 5         |   . . .
 +------------+  +----+-----+  +-----------+  +-----------+  +-----------+
                      |
                      |
             retention boundary
複製代碼

隨着咱們不斷壓縮先前壓縮的塊,舊數據越大,塊可能變得越大。所以必須爲其設置一個上限,以防數據塊擴展到整個數據庫而損失咱們設計的最初優點。

方便的是,這一點也限制了部分存在於保留窗口內部分存在於保留窗口外的塊的磁盤消耗總量。例如上面例子中的塊 2。當設置了最大塊尺寸爲總保留窗口的 10% 後,咱們保留塊 2 的總開銷也有了 10% 的上限。

總結一下,保留與刪除從很是昂貴到了幾乎沒有成本。

若是你讀到這裏並有一些數據庫的背景知識,如今你也許會問:這些都是最新的技術嗎?——並非;並且可能還會作的更好。

在內存中批量處理數據,在預寫日誌中跟蹤,並按期寫入到磁盤的模式在如今至關廣泛。

咱們看到的好處不管在什麼領域的數據裏都是適用的。遵循這一方法最著名的開源案例是 LevelDB、Cassandra、InfluxDB 和 HBase。關鍵是避免重複發明劣質的輪子,採用通過驗證的方法,並正確地運用它們。

脫離場景添加你本身的黑魔法是一種不太可能的狀況。

索引

研究存儲改進的最初想法是解決序列分流的問題。基於塊的佈局減小了查詢所要考慮的序列總數。所以假設咱們索引查找的複雜度是 O(n^2),咱們就要設法減小 n 個至關數量的複雜度,以後就至關於改進 O(n^2) 複雜度。——恩,等等……糟糕。

快速回顧一下「算法 101」課上提醒咱們的,在理論上它並未帶來任何好處。若是以前就很糟糕,那麼如今也同樣。理論是如此的殘酷。

實際上,咱們大多數的查詢已經能夠至關快響應。可是,跨越整個時間範圍的查詢仍然很慢,儘管只須要找到少部分數據。追溯到全部這些工做以前,最初我用來解決這個問題的想法是:咱們須要一個更大容量的倒排索引

倒排索引基於數據項內容的子集提供了一種快速的查找方式。簡單地說,我能夠經過標籤 app="nginx" 查找全部的序列而無需遍歷每一個文件來看它是否包含該標籤。

爲此,每一個序列被賦上一個惟一的 ID ,經過該 ID 能夠恆定時間內檢索它(O(1))。在這個例子中 ID 就是咱們的正向索引。

示例:若是 ID 爲 十、2九、9 的序列包含標籤 app="nginx",那麼 「nginx」的倒排索引就是簡單的列表 [10, 29, 9],它就能用來快速地獲取全部包含標籤的序列。即便有 200 多億個數據序列也不會影響查找速度。

簡而言之,若是 n 是咱們序列總數,m 是給定查詢結果的大小,使用索引的查詢複雜度如今就是 O(m)。查詢語句依據它獲取數據的數量 m 而不是被搜索的數據體 n 進行縮放是一個很好的特性,由於 m 通常至關小。

爲了簡單起見,咱們假設能夠在恆定時間內查找到倒排索引對應的列表。

實際上,這幾乎就是 V2 存儲系統具備的倒排索引,也是提供在數百萬序列中查詢性能的最低需求。敏銳的人會注意到,在最壞狀況下,全部的序列都含有標籤,所以 m 又成了 O(n)。這一點在預料之中,也至關合理。若是你查詢全部的數據,它天然就會花費更多時間。一旦咱們牽扯上了更復雜的查詢語句就會有問題出現。

標籤組合

與數百萬個序列相關的標籤很常見。假設橫向擴展着數百個實例的「foo」微服務,而且每一個實例擁有數千個序列。每一個序列都會帶有標籤 app="foo"。固然,用戶一般不會查詢全部的序列而是會經過進一步的標籤來限制查詢。例如,我想知道服務實例接收到了多少請求,那麼查詢語句即是 __name__="requests_total" AND app="foo"

爲了找到知足兩個標籤選擇子的全部序列,咱們獲得每個標籤的倒排索引列表並取其交集。結果集一般會比任何一個輸入列表小一個數量級。由於每一個輸入列表最壞狀況下的大小爲 O(n),因此在嵌套地爲每一個列表進行暴力求解brute force solution下,運行時間爲 O(n^2)。相同的成本也適用於其餘的集合操做,例如取並集(app="foo" OR app="bar")。當在查詢語句上添加更多標籤選擇子,耗費就會指數增加到 O(n^3)O(n^4)O(n^5)……O(n^k)。經過改變執行順序,能夠使用不少技巧以優化運行效率。越複雜,越是須要關於數據特徵和標籤之間相關性的知識。這引入了大量的複雜度,可是並無減小算法的最壞運行時間。

這即是 V2 存儲系統使用的基本方法,幸運的是,看似微小的改動就能得到顯著的提高。若是咱們假設倒排索引中的 ID 都是排序好的會怎麼樣?

假設這個例子的列表用於咱們最初的查詢:

__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)

我在這裏所描述的是幾乎全部全文搜索引擎使用的標準搜索索引的簡化版本。每一個序列描述符都視做一個簡短的「文檔」,每一個標籤(名稱 + 固定值)做爲其中的「單詞」。咱們能夠忽略搜索引擎索引中一般遇到的不少附加數據,例如單詞位置和和頻率。

關於改進實際運行時間的方法彷佛存在無窮無盡的研究,它們一般都是對輸入數據作一些假設。不出意料的是,還有大量技術來壓縮倒排索引,其中各有利弊。由於咱們的「文檔」比較小,並且「單詞」在全部的序列裏大量重複,壓縮變得幾乎可有可無。例如,一個真實的數據集約有 440 萬個序列與大約 12 個標籤,每一個標籤擁有少於 5000 個單獨的標籤。對於最初的存儲版本,咱們堅持使用基本的方法而不壓縮,僅作微小的調整來跳過大範圍非交叉的 ID。

儘管維持排序好的 ID 聽起來很簡單,但實踐過程當中不是總能完成的。例如,V2 存儲系統爲新的序列賦上一個哈希值來看成 ID,咱們就不能輕易地排序倒排索引。

另外一個艱鉅的任務是當磁盤上的數據被更新或刪除掉後修改其索引。一般,最簡單的方法是從新計算並寫入,可是要保證數據庫在此期間可查詢且具備一致性。V3 存儲系統經過每塊上具備的獨立不可變索引來解決這一問題,該索引僅經過壓縮時的重寫來進行修改。只有可變塊上的索引須要被更新,它徹底保存在內存中。

基準測試

我從存儲的基準測試開始了初步的開發,它基於現實世界數據集中提取的大約 440 萬個序列描述符,並生成合成數據點以輸入到這些序列中。這個階段的開發僅僅測試了單獨的存儲系統,對於快速找到性能瓶頸和高併發負載場景下的觸發死鎖相當重要。

在完成概念性的開發實施以後,該基準測試可以在個人 Macbook Pro 上維持每秒 2000 萬的吞吐量 —— 而且這都是在打開着十幾個 Chrome 的頁面和 Slack 的時候。所以,儘管這聽起來都很棒,它這也代表推進這項測試沒有的進一步價值(或者是沒有在高隨機環境下運行)。畢竟,它是合成的數據,所以在除了良好的第一印象外沒有多大價值。比起最初的設計目標高出 20 倍,是時候將它部署到真正的 Prometheus 服務器上了,爲它添加更多現實環境中的開銷和場景。

咱們實際上沒有可重現的 Prometheus 基準測試配置,特別是沒有對於不一樣版本的 A/B 測試。亡羊補牢爲時不晚,不過如今就有一個了

咱們的工具可讓咱們聲明性地定義基準測試場景,而後部署到 AWS 的 Kubernetes 集羣上。儘管對於全面的基準測試來講不是最好環境,但它確定比 64 核 128GB 內存的專用裸機服務器bare metal servers更能反映出咱們的用戶羣體。

咱們部署了兩個 Prometheus 1.5.2 服務器(V2 存儲系統)和兩個來自 2.0 開發分支的 Prometheus (V3 存儲系統)。每一個 Prometheus 運行在配備 SSD 的專用服務器上。咱們將橫向擴展的應用部署在了工做節點上,而且讓其暴露典型的微服務度量。此外,Kubernetes 集羣自己和節點也被監控着。整套系統由另外一個 Meta-Prometheus 所監督,它監控每一個 Prometheus 的健康情況和性能。

爲了模擬序列分流,微服務按期的擴展和收縮來移除舊的 pod 並衍生新的 pod,生成新的序列。經過選擇「典型」的查詢來模擬查詢負載,對每一個 Prometheus 版本都執行一次。

整體上,伸縮與查詢的負載以及採樣頻率極大的超出了 Prometheus 的生產部署。例如,咱們每隔 15 分鐘換出 60% 的微服務實例去產生序列分流。在現代的基礎設施上,一天僅大約會發生 1-5 次。這就保證了咱們的 V3 設計足以處理將來幾年的工做負載。就結果而言,Prometheus 1.5.2 和 2.0 之間的性能差別在極端的環境下會變得更大。

總而言之,咱們每秒從 850 個目標裏收集大約 11 萬份樣本,每次暴露 50 萬個序列。

在此係統運行一段時間以後,咱們能夠看一下數字。咱們評估了兩個版本在 12 個小時以後到達穩定時的幾個指標。

請注意從 Prometheus 圖形界面的截圖中輕微截斷的 Y 軸

Heap usage GB

堆內存使用(GB)

內存資源的使用對用戶來講是最爲困擾的問題,由於它相對的不可預測且可能致使進程崩潰。

顯然,查詢的服務器正在消耗內存,這很大程度上歸咎於查詢引擎的開銷,這一點能夠看成之後優化的主題。總的來講,Prometheus 2.0 的內存消耗減小了 3-4 倍。大約 6 小時以後,在 Prometheus 1.5 上有一個明顯的峯值,與咱們設置的 6 小時的保留邊界相對應。由於刪除操做成本很是高,因此資源消耗急劇提高。這一點在下面幾張圖中均有體現。

CPU usage cores

CPU 使用(核心/秒)

相似的模式也體如今 CPU 使用上,可是查詢的服務器與非查詢的服務器之間的差別尤其明顯。每秒獲取大約 11 萬個數據須要 0.5 核心/秒的 CPU 資源,比起評估查詢所花費的 CPU 時間,咱們的新存儲系統 CPU 消耗可忽略不計。總的來講,新存儲須要的 CPU 資源減小了 3 到 10 倍。

Disk writes

磁盤寫入(MB/秒)

迄今爲止最引人注目和意想不到的改進表如今咱們的磁盤寫入利用率上。這就清楚的說明了爲何 Prometheus 1.5 很容易形成 SSD 損耗。咱們看到最初的上升發生在第一個塊被持久化到序列文件中的時期,而後一旦刪除操做引起了重寫就會帶來第二個上升。使人驚訝的是,查詢的服務器與非查詢的服務器顯示出了很是不一樣的利用率。

在另外一方面,Prometheus 2.0 每秒僅向其預寫日誌寫入大約一兆字節。當塊被壓縮到磁盤時,寫入按期地出現峯值。這在整體上節省了:驚人的 97-99%。

Disk usage

磁盤大小(GB)

與磁盤寫入密切相關的是總磁盤空間佔用量。因爲咱們對樣本(這是咱們的大部分數據)幾乎使用了相同的壓縮算法,所以磁盤佔用量應當相同。在更爲穩定的系統中,這樣作很大程度上是正確地,可是由於咱們須要處理高的序列分流,因此還要考慮每一個序列的開銷。

如咱們所見,Prometheus 1.5 在這兩個版本達到穩定狀態以前,使用的存儲空間因其保留操做而急速上升。Prometheus 2.0 彷佛在每一個序列上的開銷顯著下降。咱們能夠清楚的看到預寫日誌線性地充滿整個存儲空間,而後當壓縮完成後瞬間降低。事實上對於兩個 Prometheus 2.0 服務器,它們的曲線並非徹底匹配的,這一點須要進一步的調查。

前景大好。剩下最重要的部分是查詢延遲。新的索引應當優化了查找的複雜度。沒有實質上發生改變的是處理數據的過程,例如 rate() 函數或聚合。這些就是查詢引擎要作的東西了。

Query latency

第 99 個百分位查詢延遲(秒)

數據徹底符合預期。在 Prometheus 1.5 上,查詢延遲隨着存儲的序列而增長。只有在保留操做開始且舊的序列被刪除後纔會趨於穩定。做爲對比,Prometheus 2.0 從一開始就保持在合適的位置。

咱們須要花一些心思在數據是如何被採集上,對服務器發出的查詢請求經過對如下方面的估計來選擇:範圍查詢和即時查詢的組合,進行更輕或更重的計算,訪問更多或更少的文件。它並不須要表明真實世界裏查詢的分佈。也不能表明冷數據的查詢性能,咱們能夠假設全部的樣本數據都是保存在內存中的熱數據。

儘管如此,咱們能夠至關自信地說,總體查詢效果對序列分流變得很是有彈性,而且在高壓基準測試場景下提高了 4 倍的性能。在更爲靜態的環境下,咱們能夠假設查詢時間大多數花費在了查詢引擎上,改善程度明顯較低。

Ingestion rate

攝入的樣本/秒

最後,快速地看一下不一樣 Prometheus 服務器的攝入率。咱們能夠看到搭載 V3 存儲系統的兩個服務器具備相同的攝入速率。在幾個小時以後變得不穩定,這是由於不一樣的基準測試集羣節點因爲高負載變得無響應,與 Prometheus 實例無關。(兩個 2.0 的曲線徹底匹配這一事實但願足夠具備說服力)

儘管還有更多 CPU 和內存資源,兩個 Prometheus 1.5.2 服務器的攝入率大大下降。序列分流的高壓致使了沒法採集更多的數據。

那麼如今每秒能夠攝入的絕對最大absolute maximum樣本數是多少?

可是如今你能夠攝取的每秒絕對最大樣本數是多少?

我不知道 —— 雖然這是一個至關容易的優化指標,但除了穩固的基線性能以外,它並非特別有意義。

有不少因素都會影響 Prometheus 數據流量,並且沒有一個單獨的數字可以描述捕獲質量。最大攝入率在歷史上是一個致使基準出現誤差的度量,而且忽視了更多重要的層面,例如查詢性能和對序列分流的彈性。關於資源使用線性增加的大體猜測經過一些基本的測試被證明。很容易推斷出其中的緣由。

咱們的基準測試模擬了高動態環境下 Prometheus 的壓力,它比起真實世界中的更大。結果代表,雖然運行在沒有優化的雲服務器上,可是已經超出了預期的效果。最終,成功將取決於用戶反饋而不是基準數字。

注意:在撰寫本文的同時,Prometheus 1.6 正在開發當中,它容許更可靠地配置最大內存使用量,而且可能會顯著地減小總體的消耗,有利於稍微提升 CPU 使用率。我沒有重複對此進行測試,由於總體結果變化不大,尤爲是面對高序列分流的狀況。

總結

Prometheus 開始應對高基數序列與單獨樣本的吞吐量。這仍然是一項富有挑戰性的任務,可是新的存儲系統彷佛向咱們展現了將來的一些好東西。

第一個配備 V3 存儲系統的 alpha 版本 Prometheus 2.0 已經能夠用來測試了。在早期階段預計還會出現崩潰,死鎖和其餘 bug。

存儲系統的代碼能夠在這個單獨的項目中找到。Prometheus 對於尋找高效本地存儲時間序列數據庫的應用來講可能很是有用,這一點使人很是驚訝。

這裏須要感謝不少人做出的貢獻,如下排名不分前後:

Bjoern Rabenstein 和 Julius Volz 在 V2 存儲引擎上的打磨工做以及 V3 存儲系統的反饋,這爲新一代的設計奠基了基礎。

Wilhelm Bierbaum 對新設計不斷的建議與看法做出了很大的貢獻。Brian Brazil 不斷的反饋確保了咱們最終獲得的是語義上合理的方法。與 Peter Bourgon 深入的討論驗證了設計並造成了這篇文章。

別忘了咱們整個 CoreOS 團隊與公司對於這項工做的贊助與支持。感謝全部那些聽我一遍遍嘮叨 SSD、浮點數、序列化格式的同窗。


via: fabxc.org/blog/2017-0…

做者:Fabian Reinartz 譯者:LuuMing 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

相關文章
相關標籤/搜索