實時OLAP(五)Apache Pinot實時自動調優

原文鏈接:https://engineering.linkedin.com/blog/2019/auto-tuning-pinot

Pinot 是可擴展分佈式列式 OLAP 數據存儲,由 LinkedIn 開發,爲面向站點的用例(如 LindedIn 的 Who viewed my profile、Talent insights 等等)提供實時分析。Pinot 使用 Apache Helix 管理集羣資源,並使用 Apache Zookeeper 存儲元數據。Piont 在 LinkedIn 得到了廣泛的採用:從內部控制面板到面向站點的應用程序。

Pinot 通過 Hadoop 支持批數據攝取(稱爲“離線”數據),以及通過流(如 Kafka)支持實時數據攝取。Pinot 使用 離線和實時數據,提供持續時間線上的分析,從最早可用的行(可以在離線數據中)開始一直到流中的最近使用過的行。

從實時流中攝取行對數據查詢服務提出了一系列獨特的挑戰。Pinot 一直在努力解決這些挑戰,並隨着時間的推移,做得越來越好。

Pinot 在名爲“段(segment)”的碎片中存儲數據。在執行查詢期間,Pinot 並行處理這些段,並跨段合併結果以構造對查詢的最終響應。離線數據作爲預構段(離線段)被推入 Pinot,並存儲於 Segment Store(請參看 架構圖)。這些段作爲 ImmutableSegment 對象(在這些段上不可進行行的添加、刪除、或修改)存儲。另一方面,實時數據的消費是在持續從底層流分區到被稱爲 MutableSegment 的段(或“消費”段)的基礎上進行的。這些段允許給它們添加行(但是,這些行仍然不可以被刪除或更新)。MutableSegment 以非壓縮(但仍爲列式)的形式把行存儲於易失性存儲器(在重啓時被丟棄)。

有時,MutableSegment 中的行被壓縮,作爲 ImmutableSegment,通過“提交”該段進入 Segment Store 而持久化。然後,Pinot 繼續使用來自流分區到新 MutableSegment 的下一組行。這裏的關鍵問題是:“Pinot 應該在什麼時間點(或,多久)決定提交消費段?”

過於頻繁地提交段會導致表中有很多小段。由於 Pinot 查詢是在段級別處理的,因此,有太多的段會增加處理查詢(產生的線程數、元數據處理等等)的開銷,從而導致更高的查詢延遲。

另一方面,不那麼頻繁地提交段會導致服務器耗盡內存,這是因爲新的行一直在被添加到 MutableSegment,從而擴展了這些段的內存佔用。此外,服務器可以在任何時候重啓(在 LinkedIn,我們每週推送新代碼),這引起 MutableSegment 丟棄所有行並再次重新從 MutableSegment 的首行開始消費。這對它本身來說不是問題(Pinot 可以以非常高的速率攝取後備數據),但是,底層流主題可能已經配置了保留,因此,MutableSegment 的首行已經被保留。在這種情況下,我們丟失了數據,這可不好!

事實證明,答案取決於幾個因素,如攝取速率和模式中的列數量等等,這些因素隨着不同的應用程序而不同。Pinot 提供了一些配置設置(如,在 MutableSegment 中行數最大值的設置)以解決問題,但是,如何根據每個應用程序來設置這些設置的正確值,在管理員看來仍然存疑。

考慮到 Pinot 在 LinkedIn 的採用率,爲每個應用程序用不同的設置(或組合)進行試驗不是可擴展的解決方案。在本博文中,我們將解釋我們如何實施實時消費的自動調整以完全消除試驗過程,並有助於管理員擴展到 Pinot 的採用率。

爲了更好地理解這個問題及其解決方案,更詳細地介紹一下 Pinot 實時架構是很有用的。

Pinot 實時攝取

Pinot 實時服務器爲每個流分區創建 PartitionComsumer 對象,這些流分區是 Pinot 實時服務器定向(通過 Helix)消費的。如果表配置爲有 q 個副本,並且有 p 個流分區,那麼,表中所有的服務器上會有 PartitionComsumber 對象的(p*q)個實例。如果有 S 個服務器爲該表服務,那麼每個服務器將有⌈(p*q)/S ⌉個 PartitionComsumer 實例。

下圖是 PartitionConsumer 對象如何跨 Pinot 實時服務器分佈的示意圖。

實時服務器從 p 分區的流消費

Helix 確保,在同一個實時服務器中永遠不會消費任何流分區的多個副本。(因此,我們必須設置 S >= q),否則,表的創建將不會成功。

Pinot 假設,底層流分區有消息,這些消息根據它們到達分區的時間進行了排序,並且,每個消息以特定的“偏移量”(本質上是指向該消息的指針)在該分區中被定位。流分區的每個消息被轉換成 MutableSegment 中的行。每個 MutableSegment 實例都有來自只有一個流分區的行。

MutbaleSegment(在 Zookeeper 中)的元數據在分區中有偏移量,應該從該分區開始消費該段。這個起始偏移量適用於 MutableSegment 的所有副本。Pinot 控制器在創建段的時候(即,或者是在第一次創建表的時候,或者是在提交該分區中前一個段的時候)於段元數據中設置起始偏移量。

該提交段的算法涉及幾個步驟,在此期間,繼續從 MutableSegment 提供查詢服務。在提交了段之後,MutbaleSegment 被原子性地與(等效)ImmutableSegment 進行交換。MutableSegment 實例佔用的內存在耗盡該實例上最後一個查詢後就被釋放。在整個過程中,該應用程序都不知道哪個段提交在進行。該提交段的算法如下所示:

  1. 暫停消費(直到第 5 步)。

  2. 執行 段完成協議 的步驟以確定哪個副本提交該段。

  3. 用 MutableSegment 中的行構建一個 ImmutbaleSegment。

  4. 提交該段給控制器(在這個步驟,控制器在分區中創建下一個段)

  5. 等待給下一個段的信號(來自 Helix)

  6. 當收到信號時,恢復消費,將行索引到新的 MutableSegment。

該算法如下圖所示。段完成的實際步驟還有很多,但是,我們在本文中略去了細節。

提供的問題

所提供的應用程序的特徵之間可以有很大的差異。以下是跨應用程序的部分變體列表:

  • 在內存中保持一行的開銷取決於數據模式(列越多,則所需的內存也越多)。

  • Pinot 使用字典編碼來優化內存消耗(行中的值作爲整數字典 ID 存儲,整數字典 ID 引用字典中的實際值)。因此,任何列的唯一值越多,所消耗的字典中的內存也越多。

  • 事件攝入主題的速率在不同的應用程序之間有很大的差異,甚至在同一個應用程序中,隨着時間的不同也有差異。比如,事件進入的速率可能在週一早上比在週五晚上要高得多。

  • 流分區的數量可以隨着不同的應用程序有所不同(請參考下圖,查看影響)。

  • 比起另一個具有較低查詢負載的應用程序,我們可以給一個具有更高查詢負載的應用程序提供不同數量的機器。

在 Pinot 的早期版本中,我們提供了兩種配置設置:

  • 在一個服務器中,跨所有 MutableSegment 可以保留的最大行數值(N)。

  • 可存在的 MutableSegment 的最大時間值(T)。在這個時間之後,無論當時段中有多少行,都提交該段。管理員可以根據底層流的保留情況來設置 T 的值。

如果一個服務器最終擁有 k ( = ⌈(p * q)/S ⌉) 個表分區,那麼 Pinot 控制器設置段的元數據最多消費 x (= N/k) 行。PartitionConsumer 旨在根據到達時間 T 或在消費 x 行到 MutableSegment 後,停止消費並啓動提交過程。然而,跨應用程序的變化要求針對不同的應用程序有不同的 N 值。

管理員在選擇 N 之前還要考慮另一件事情:每個服務器的駐留內存大小(用於 MutableSegments 和 ImmutableSegments):

  • 在創建 MutableSegment 時,(儘可能地)獲取用於 MutableSegment 的內存。基於該段閾值 x 的設置,取得相應的內存數量(因此,x 的值很高而沒有使用分配到的內存就是浪費)。

  • ImmutableSegment 駐留在虛擬內存中,直到實時表的保留時間結束,並在那個時間點卸載。x 的值更高就意味着更少的(更大的)ImmutableSegment 對象,及更大的 MutableSegment 對象。

服務器上的總駐留內存將取決於以下因素:

  1. 服務器託管的流分區數量(k)。

  2. 在保留期間所創建的 ImmutableSegment 的數量。

  3. ImmutableSegment 的大小。

  4. MutableSegment 的大小(取決於 x,以及上面概述的其他內容)。

k 的值取決於部署的服務器數量。管理員可以決定在給定的要求延遲下,部署儘可能多的服務器以支持查詢吞吐量。

正如我們可以看到的,變量的數量很快失控,我們似乎需要用一個變量來估計另一個,爲了達成可用的配置設置,在提供用例前,管理員必須運行基準測試:

  1. 建立一張包含若干服務器和 N 值的表。

  2. 從流分區中最早的偏移量開始消費,以便我們能夠讓 ImmutableSegment 到位(這是一個近似值,因爲對任何給定的流主題,攝取速率隨時間而變化,導致我們觸及時間限制而不是行限制)。

  3. 運行保留管理器以保留舊段。

  4. 如果有太多的分頁或者內存不足,那麼,更改服務器的數量或 N(取決於段的大小),並回到步驟 1。

  5. 運行一個查詢基準測試,以應用程序期望的速度觸發查詢。如果沒有達到期望的性能,那麼,增加主機的數量並回到步驟 1,根據需要重新調整 N 的值。

爲應用程序實現正確的配置設置需要一些日子(有時候是幾天),更不用說,當 Pinot 管理員有更多緊急事件要關注的時候需要花費的時間了。

自動調優

爲了幫助管理員提供用例,我們決定提供:

  • 已提交段的目標段大小設置。Pinot 將嘗試創建這麼大的 ImmutableSegment 對象。

  • 命令行工具,用來幫助管理員選擇目標段大小。

有了這兩樣工具, 管理員需要做的就是,用一個示例段(通過 ETL 之前在同一個主題上收集的數據生成)運行命令行工具。根據查詢處理所需服務器的數量,該工具輸出一些供選擇的選項。然後,管理人員可以選擇其一併提供該表,確信其按期望的合理性能工作。

命令行工具

給定示例段,該工具估計主機上的駐留內存及段大小的設置。該工具的工作原理是通過這些段大小估計駐留內存。

以下是 RealtimeProvisioningHelper 爲一張表提供的示例輸出:

Memory used per host

numHosts --> 8        |10       |12       |14       |
numHours
 4 --------> 31.94GB  |26.61GB  |21.29GB  |18.63GB  |
 6 --------> 31.81GB  |26.51GB  |21.2GB   |18.55GB  |
 8 --------> 31.68GB  |26.4GB   |21.12GB  |18.48GB  |
10 --------> 34.94GB  |29.12GB  |23.29GB  |20.38GB  |
12 --------> 31.42GB  |26.19GB  |20.95GB  |18.33GB  |

Optimal segment size

numHosts --> 8        |10       |12       |14       |
numHours
 4 --------> 144.68MB |144.68MB |144.68MB |144.68MB |
 6 --------> 217.02MB |217.02MB |217.02MB |217.02MB |
 8 --------> 289.36MB |289.36MB |289.36MB |289.36MB |
10 --------> 361.7MB  |361.7MB  |361.7MB  |361.7MB  |
12 --------> 434.04MB |434.04MB |434.04MB |434.04MB |

Consuming memory

numHosts --> 8        |10       |12       |14       |
numHours
 4 --------> 3.11GB   |2.59GB   |2.07GB   |1.82GB   |
 6 --------> 3.83GB   |3.19GB   |2.55GB   |2.24GB   |
 8 --------> 4.55GB   |3.79GB   |3.03GB   |2.66GB   |
10 --------> 5.27GB   |4.39GB   |3.51GB   |3.08GB   |
12 --------> 5.99GB   |4.99GB   |3.99GB   |3.5GB    |

該輸出顯示,針對不同數量的服務器和不同時間,MutableSegment 消費數據的情況:

  • 服務器中所用的總內存(用於 MutableSegment 和 ImmutableSegment)。

  • 優化段大小設置。

  • MutableSegment 將使用的內存量(內存消耗)。

這其中的每一個值都根據消費的小時數而不同,因此,針對命令行參數中提供的不同數值來顯示這些值。管理員指定他們正在考慮的主機的數量(在本例中,是 8、10、12 或 14 臺主機)、來自消費數據的示例段(或來自離線數據的示例段)以及表的配置(用於保留時間等)。該實用程序按上面的方式打印出矩陣。

根據這個輸出,管理人員可以選擇部署 8、10、12 或 14 臺主機,並根據每張表適當地選擇段的大小限制。在上面的例子中,如果管理員選擇使用 12 臺服務器(比方說,基於查詢吞吐的需求),那麼,10 個小時似乎是最優的內存使用時間。該最優的段的大小似乎是 360MB,配置將如下所示(出於簡潔的考慮,略去 StreamConfigs 的其他參數):

    streamConfigs {

        "realtime.segment.flush.threshold.size": "0",

        "realtime.segment.flush.desired.size": "360M",

        "realtime.segment.flush.threshold.time": "10h"

}

基於該工具的輸出,我們知道,在段的大小約爲 360MB 的時候,如果 PartitionConsumer 提交段,我們應該是在 MutableSegment 和 ImmutableSegment 之間最優地利用駐留內存。請注意,360MB 是 ImmutableSegment 的大小。正如前面的解釋,MutableSegment 在提交段的時候轉換爲 ImmutableSegment,因此,在構建 ImmutableSegment 之前,確定其大小是雞生蛋還是蛋生雞的問題。

回想一下,當我們觸及行限制(x)或時間限制(T)時,我們就停止消費。因此,如果我們能夠爲一個段設置行限制時採用這麼一種方法:我們可以預計產生的段大小接近目標段大小,那麼就好了。但是,我們怎樣估計產生所需段大小的行數呢?

估計所需段大小的行限制

爲了對一個 MutableSegment 提出行限制,我們決定利用這個事實:即控制器負責提交段和創建新段(這是它在一個步驟中所做的工作,如上圖所示)。

其思想是讓控制器來決定下一個段的 x 值,從而達到所期望的段大小。在段完成時,控制器根據當前段大小和在當前段內消費的行數來估計在下一個段中需要消費的行數。

ImmutableSegment 具有壓縮表示中的索引、字典等形式。因此,段大小可能不會隨行數線性地變化(如,無論段內有多少行,字典的大小是基於列的唯一值的數量和列的平均寬度來決定的)。還有,段大小可能有很大不同,這取決於單個段中的實際值。

因此,我們在估計下個段大小時,要考慮段大小的過去值。我們保持段大小與行數的比率,在每次段完成時提高這個比率而不是隨着時間保持段大小,以便我們爲下個段合理地估計行數。

用於設置行限制的算法

我們假設每個表的段大小與行數的之比都是常數(比如說,R)。由於即使創建只有一行的段也有固定開銷,因此,R 不是一個真正的常數,但是,它是比較好的近似。每次段完成時,我們計算 R 的值,並對學習得到的 R 值進行調整,使其更準確,如下所示:

Rn+1 = Rn * α + Rcurrent * (1 - α),    where 0 < α < 1

在這裏,Rcurrent 是當前段(即正在完成過程中的段)行數與段大小之比。我們選擇α是個比 0.5 高的值,以便我們給所得到的值比新值更高的權值。下一個段的行數閾值計算如下:

xn+1 = desiredSegmentSize / Rn+1

還有,即使我們可以爲段設置 x 爲某個 x1,PartitionConsumer 也有可能在 x2 行後就觸及時間限制 T,其中 x2 < x1。

在這種情況下,對於接下來的段,我們更希望設置行數限制爲 x2,因此,我們總是嘗試通過觸及行限制而不是時間限制來結束段(這又回到了不浪費內存分配的問題,如前所述)。

把這些因素都考慮在內,最終的算法如下所示:

Long optimalSegmentSize = getDesiredSegmentSizeFromTableConfig();
Double sizeToRowsRatio; // Value of 'R'
Double ALPHA = 0.1

/*
 * Returns the number of rows of a completing segment
 */
int computeRowThreshold(segmentSize, numRowsConsumed, stoppedDueToTimeLimit) {
  if (segmentSize == 0) { // first segment of table
    return 100_000; // First guess on number of rows

  currentRatio = segmentSize/numRowsConsumed;
  // Update the value of R
  if (sizeToRowsRatio != null) {
    sizeToRowsRatio = sizeToRowsRatio * (1 - ALPHA) + currentRatio * ALPHA;
  } else {
    sizeToRowsRatio = currentRatio;

  if (stoppedDueToTimeLimit) {
    // Increase the number of rows a little bit beyond, aim to hit the row threshold next time
    newNumRows = numRowsConsumed * 1.1;
  } else {
    if (segmentSize <= 0.5 * optimalSegmentSize) {
      // Need quicker ramp up
      newNumRows = numRowsConsumed * 1.5;
    } else if (segmentSize >= 2 * optimalSegmentSize) {
      // Need quicker ramp down
      newNumRows /= 2;
    } else { // Within range, apply formula
      newNumRows = optimalSegmentSize/sizeToRowsRatio; // Most of the time we will be in this case


  return newNumRows;
}

請注意,R 的值存於本地內存,而不是持久性存儲中。可能發生引導控制器(lead controller)需要重啓的情況(如,用於部署、故障等)。在這種情況下,另一個控制器接管領導權,並根據算法,從 R 的空值開始。但是,該算法從完成的段獲取 R 的第一個值,從而有效地把該值連同所有舊段的歷史傳遞給新的控制器。

最後,我們只在一個主題的一個分區上運行該算法。流的多個分區往往在相似的時候有相似的特徵。比如,如果在早上 8 點到 9 點之間出現了 100 篇新文章,那麼,在那個時間段,點擊這些文章的事件可能在點擊流的所有分區中遵循類似的分佈。因此,在任何分區的段完成的時候,改變 R 的值都不是好主意(適用表的所有分區),這是因爲,我們將會把 R 值偏向最近的段,而這不是我們希望的段。

在實踐中,我們看到一個主題的所有流分區或多或少導致相同的段大小,並且同時或多或少地完成。

結   果

呈現的算法實質上是基於當前完成段的一些歷史和特徵,計算下個段的行限制。下圖顯示了爲達到前 20 個段的目標段大小對段大小的調整。這些是針對一張表的流主題的單個分區的度量。平均事件攝取速率爲每秒 630 行,最大值在每秒 1000 行左右。

一列中唯一值(在一個段內)的數量、字典大小等等在不同段之間可以有明顯的不同,尤其是我們從週末過渡到平日,或從較長的假期過渡到工作日的時候。根據主題(在生產環境中,Pinot 給 50 多個主題提供服務)、重大的世界事件、出版物、新產品發佈等等,都可以顯著地改變數據的特徵,因而,只通過使用行數來預測段大小就變得很困難。因此,段的估計行數會導致更大(在上圖的情況下,段大小目標是 500MB)或更小的段大小。

然而,在最初的學習階段,通常會發生很誇張的變化。通常,首先提供表,隨着時間查詢會迅速飆升。

下圖顯示在 10 幾天內,段大小的變化情況,目標段大小是 500M。

查看該算法的代碼:

https://github.com/apache/incubator-pinot/blob/master/pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/realtime/segment/SegmentSizeBasedFlushThresholdUpdater.java

結   論

現在,我們基於 RealtimeProvisioningHelper 的輸出提供所有單個租戶實時表。這把我們評估容量的時間從以天計算減少到以分鐘計算,因爲管理員無需在提供集羣前嘗試不同的組合,並且管理員可以對此相當自信:一旦提供,則集羣將按指定的方式來承擔消費負載。

未來的工作

如前所述,在開始消耗內存時,我們嘗試爲 MutableSegment 獲取所需的最大內存。在進入行時,自動分配內存是一個選項,但是,這將導致兩個問題:

  1. 在處理查詢時,我們將需要讀鎖適當的數據結構,並且在我們擴展它們的時候,我們需要寫鎖該結構。在處理實時消費段上的查詢時,我們儘可能將鎖減到最少,並儘量避免鎖競爭,因此,添加更多的讀鎖對實現低延遲沒有什麼幫助。

  2. 爲了避免浪費內存,我們可能一小塊一小塊地分配內存,這進一步加劇了鎖競爭。

這塊領域需要更多的工作。隨着時間的推移,算法會穩定下來,但是,在學習階段,有時它會過度調整段大小。避免這這種情況的發生是件好事。比如,爲消費段用到的最大內存添加另一個配置很有用。如果我們達到了駐留內存的一定限制,那麼我們可以停止消費。通常,由於基數或列寬度(字典大小的變化)的波動會引起過度的調整。如果這些是暫時的,我們真的不希望把它們作爲未來段的經驗。在這些情況下,儘早停止消費是很有用的。

將來,我們要研究的另一個領域是多租戶系統,其中單個主機可以處理多張表的流分區。在這種情況下,單個工具將不足以設置段大小。考慮到主機內所有的 MutableSegment,無論它們屬於哪張表,我們都需要其他機制以不斷評估內存的使用情況。

致    謝

我們感謝 Pinot 團隊的所有成員,感謝他們爲了讓 Pinot 變得更好而付出的努力,他們是 Dino Occhialini、Jean-Francois Im、Jennifer Dai、 Jialiang Li、John Gutmann、Kishore Gopalakrishna、Mayank Shrivastava、 Neha Pawar, Seunghyun Lee、 Sunitha Beeram、 Walter Huf、 Xiaotian (Jackie) Jiang、和我們的工程經理 Shraddha Sahay  及 SRE 經理 Prasanna Ravi。我們還要感謝 Ravi Aringunram、Eric Baldeschwieler、Kapil Surlaker 和 Igor Perisic,謝謝他們的領導和不斷的支持。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章