Apache Druid 在 Shopee 的工程實踐

Shopee技術團隊發表於2022-02-07

本文作者

Yuanli,來自 Shopee Data Infra OLAP 團隊。

摘要

Apache Druid 是一款高效能的開源時序資料庫,它適用於互動式體驗的低延時查詢分析場景。本文將主要分享 Apache Druid 在支撐 Shopee 相關核心業務 OLAP 實時分析方面的工程實踐。

隨著 Shopee 業務不斷髮展,越來越多的相關核心業務愈加依賴基於 Druid 叢集的 OLAP 實時分析服務,越來越嚴苛的應用場景使得我們開始遇到開源專案 Apache Druid 的各種效能瓶頸。我們通過分析研讀核心原始碼,對出現效能瓶頸的後設資料管理模組和快取模組做了相關效能優化。

同時,為了滿足公司內部核心業務的定製化需求,我們開發了一些新特性,包括整型精確去重運算元和靈活的滑動視窗函式。

1. Druid 叢集在 Shopee 的應用

當前叢集部署方案是維護一個超大叢集,基於物理機器部署,叢集規模達 100+ 節點。Druid 叢集作為相關核心業務資料專案的下游,可以通過批任務和流任務寫入資料,然後相關業務方可以進行 OLAP 實時查詢分析。

2. 技術優化方案分享

2.1 Coordinator 負載平衡演算法效率優化

2.1.1 問題背景

我們通過實時任務監控報警發現,很多實時任務因為最後一步 segment 釋出交出(Coordinate Handoff)等待超時失敗,隨後陸續有使用者跟我們反映,他們的實時資料查詢出現了抖動。

通過調查發現,隨著更多業務開始接入 Druid 叢集,接入的 dataSource 越來越多,加上歷史資料的累積,整體叢集的 segment 數量越來越大。這使得 Coordinator 後設資料管理服務的壓力加大,逐漸出現效能瓶頸,影響整體服務的穩定性。

2.1.2 問題分析

Coordinator 一系列序列子任務分析

首先我們要分析這些序列是否可以並行,但分析發現,這些子任務存在邏輯上的前後依賴關係,因此需要序列執行。通過 Coordinator 的日誌資訊,我們發現其中一個負責平衡 segment 在歷史節點載入的子任務執行超級慢,耗時超過 10 分鐘。正是這個子任務拖慢了整個序列任務的總耗時,使得另一個負責安排 segment 載入的子任務執行間隔太長,導致前面提到的實時任務因為釋出階段超時而失敗。

通過使用 JProfiler 工具分析,我們發現負載平衡演算法中使用的蓄水池取樣演算法的實現存在效能問題。分析原始碼發現,當前的蓄水池取樣演算法每次呼叫只能從總量 500 萬 segment 中取樣一個元素,而每個週期需要平衡 2000 個 segment。也就是說,需要遍歷 500 萬的列表 2000 次,這顯然是不合理的。

2.1.3 優化方案

實現批量取樣的蓄水池演算法,只需要遍歷一次 500 萬的 segment 後設資料列表,就能完成 2000 個元素的取樣。優化之後,這個負責 segment 負載平衡的子任務的執行耗時只需要 300 毫秒。Coordinator 序列子任務的總耗時顯著減少。

Benchmark 結果

Benchmark 結果對比發現,批量取樣的蓄水池演算法效能顯著優於其他選項。

社群合作

我們已經把這個優化貢獻給 Apache Druid 社群,詳見 PR

2.2 增量後設資料管理優化

2.2.1 問題背景

當前 Coordinator 進行後設資料管理的時候,有一個定時任務執行緒預設每隔 2 分鐘從後設資料 MySQL DB 中全量拉取 segment 記錄,並在 Coordinator 程式的記憶體中更新一個 segment 集合的快照。當叢集中 segment 後設資料量非常大時,每次全量拉取的 SQL 執行變得很慢,並且反序列化大量的後設資料記錄也需要很大的資源開銷。Coordinator 中一系列 segment 管理的子任務都依賴於 segment 集合的快照更新,所以全量拉取 SQL 的執行太慢會直接影響到整體叢集資料(segment)可見性的及時性。

2.2.2 問題分析

我們首先從後設資料增刪改的角度,分 3 種不同的場景分析 segment 後設資料的變化情況。

後設資料增加

dataSource 的資料寫入會生成新的 segment 後設資料,而資料寫入方式主要分為批任務和 Kafka 實時任務。Coordinator 的 segment 管理子任務及時感知並管理這些新增加的 segment 後設資料,對於 Druid 叢集寫入資料的可見性非常關鍵。通過 Druid 內部自帶 metric 指標,分析發現 segment 單位時間內的增量遠遠小於總量 500w 的記錄數。

後設資料刪除

Druid 可以通過提交 kill 型別的任務來清理 dataSource 在指定時間區間內的 segment。kill 任務會首先清理後設資料 DB 中的 segment 記錄,然後刪除 HDFS 中的 segment 檔案。而已經 download 到歷史節點本地的 segment,則由 Coordinator 的 segment 管理子任務負責通知清理。

後設資料更改

Coordinator 的 segment 管理子任務中有一個子任務會根據 segment 的版本號,標記清除版本號比較舊的 segment。這個過程會更改相關後設資料記錄中代表 segment 是否有效的標誌位,而已經 download 到歷史節點本地的舊版本 segment,也是由 Coordinator 的 segment 管理子任務負責通知清理。

2.2.3 優化方案

通過對 segment 後設資料增刪改 3 種情況的分析,我們發現,對新增加的後設資料進行及時感知和管理非常重要,它會直接影響新寫入資料的及時可見性。而後設資料的刪除和更改主要影響資料清理,這塊的及時性要求相對低一些。

綜上分析,我們的優化思路是:實現一種增量的後設資料管理方式,只從後設資料 DB 中拉取最近一段時間新增加的 segment 後設資料,並與當前的後設資料快照合併得到新的後設資料快照,進行後設資料管理。同時,為了保證資料的最終一致性,完成優先順序相對低一些的資料清理,每隔較長一段時間會進行一次全量拉取後設資料。

原來全量拉取的 SQL 語句:

SELECT payload FROM druid_segments WHERE used=true;

增量拉取的 SQL 語句:

-- 為了保證SQL執行效率,提前在後設資料DB中為新加的過濾條件建立索引
SELECT payload FROM druid_segments WHERE used=true and created_date > :created_date;

增量功能屬性配置

# 增量拉取最近5分鐘新加的後設資料
druid.manager.segments.pollLatestPeriod=PT5M
# 每隔15分鐘全量拉取後設資料
druid.manager.segments.fullyPollDuration=PT15M

上線表現

通過監控系統指標發現,啟用增量管理功能之後,拉取後設資料和反序列化耗時顯著降低。同時也降低了後設資料 DB 的壓力,使用者反應的寫入資料可讀性慢的問題也得到了解決。

2.3 Broker 結果快取優化

2.3.1 問題背景

在查詢效能調優過程中,我們發現,很多查詢應用場景不能很好地利用 Druid 提供的快取功能。當前 Druid 裡面存在兩種快取方式,分別是結果快取和 segment 級別的中間結果快取。第一種結果快取只能應用於 Broker 程式,而 segment 級別的中間結果快取可以應用於 Broker 和其他資料節點。但是當前這兩種快取功能都存在明顯的侷限性,如下方表格所示。

快取方案/使用場景/是否可用場景一:使用 group by v2 引擎場景二:僅掃描歷史 segment場景三:同時掃描歷史 segment 和實時 segment場景四:高效快取大量 segment 的結果
segment 級別快取
結果快取

2.3.2 問題分析

使用 group by v2 引擎的情況下快取不可用

group by v2 引擎在過去很長時間的很多穩定版本中,都是 groupBy 型別查詢的預設引擎,在可預見的未來很長一段時間也一樣。而且 groupBy 型別的查詢又是最常見的查詢型別之一,另外兩種型別是 topN 和 timeseries。group by v2 引擎不支援快取的問題直到 0.22.0 版本依然存在,見快取不支援場景

通過跟蹤社群的變更記錄,我們發現 group by v2 引擎不支援快取的原因是,segment 級別的中間結果沒有排序可能會導致查詢合併結果不正確,具體細節見社群的這個 issue

下面簡單總結一下,為什麼 Druid 社群選擇通過禁用功能來修復這個 Bug:

  • 如果排序 segment 級別的中間結果,然後再把排序結果快取起來的話,當 segment 數量很多的時候,會增加歷史節點的負載;
  • 如果不排序 segment 級別的中間結果直接快取,那麼 Broker 需要對每個 segment 的中間結果進行重新排序,會增加 Broker 的負擔;
  • 如果直接禁用這個功能的話,那麼不僅歷史節點不會受到任何影響,而且 Broker 合併結果不對的 bug 也解決了。 :)

社群修復方案同時還誤傷了結果快取的功能,使得修復之後的版本使用 group by v2 引擎時,Broker 上面的結果快取也不可用了,見快取不支援場景

結果快取的侷限性

結果快取要求查詢每次掃描的 segment 集合一致,並且所有 segment 都是歷史 segment。也就是說,只要查詢條件需要查詢最新的實時資料,那麼結果快取就不可用。

對於 Druid 這種實時查詢分析應用場景見長的服務來說,結果快取的這個侷限顯得尤為突出。很多業務場景的查詢皮膚都是查詢最近一天/一週/一月的時序聚合結果,包括最新實時資料,但是這些查詢都不支援結果快取。

segment 級別中間結果快取的侷限性

segment 級別中間結果快取的功能可以同時在 Broker 和其他資料節點上面啟用,主要適用於歷史節點。

Broker 上啟用 segment 級別中間結果快取,當掃描 segment 數量很大的情況下,存在如下侷限性:

  • 提取快取結果的反序列化過程會給 Broker 增加額外開銷;
  • 增加 Broker 節點合併中間結果的開銷,沒法利用歷史節點來合併部分中間結果。

在歷史節點上啟用 segment 級別中間結果快取,其工作流程圖如下:

在實際應用場景中,我們發現,當 segment 的中間快取結果很大的時候,序列化和反序列化快取結果的開銷也不可忽視。

2.3.3 優化方案

通過上述分析,我們發現當前兩種快取功能都存在明顯的侷限性。為了更好地提高快取效率,我們在 Broker 上面設計並實現了一種新的快取功能,該功能會快取歷史 segment 的中間合併結果,能很好地彌補當前兩種快取的不足。

新快取屬性配置

druid.broker.cache.useSegmentMergedResultCache=true
druid.broker.cache.populateSegmentMergedResultCache=true

適用場景對比

快取方案/使用場景/是否可用場景一:使用 group by v2 引擎場景二:僅掃描歷史 segment場景三:同時掃描歷史 segment 和實時 segment場景四:高效快取大量 segment 的結果
segment 級別快取
結果快取
segment 合併中間結果快取

工作原理

Benchmark 結果

通過 benchmark 結果可以發現,segment 合併中間結果快取功能不僅初次查詢不存在明顯額外開銷,而且快取效率明顯優於其他快取選項。

上線表現

啟用新的快取功能後,叢集總體查詢延遲降低約 50%。

社群合作

我們準備把這個新的快取功能貢獻給社群,當前該 PR 還在等待更多的社群反饋。

3. 定製化需求開發

3.1 基於點陣圖的精確去重運算元

3.1.1 問題背景

不少關鍵的業務需要統計精確的訂單量和 UV,而 Druid 自帶幾種去重運算元都是基於近似演算法實現,在實際應用中存在誤差。因此,相關業務都希望我們能提供一種精確的去重實現。

3.1.2 需求分析

去重欄位型別分析

通過分析收集到的需求,發現急切需求中的訂單 ID 和使用者 ID 都是整型或者長整型,這就使得我們可以考慮省掉字典編碼的過程。

3.1.3 實現方案

由於 Druid 社群缺少這塊的實現,於是我們選用常用的 Roaring Bitmap 來定製新的運算元(Aggregator)。針對整形和長整型分別開發相應的運算元,都支援序列化和反序列化用於 rollup 匯入模型。於是我們很快釋出了這個功能的第一個穩定版,它能很好地解決資料量比較小的需求。

運算元 API

// native JSON API
{
    "type": "Bitmap32ExactCountBuild or Bitmap32ExactCountMerge",
    "name": "exactCountMetric",
    "fieldName": "userId"
}
-- SQL support
SELECT "dim", Bitmap32_EXACT_COUNT("exactCountMetric") FROM "ds_name" WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY GROUP BY key

侷限性分析和優化方向

當前的簡單實現方案,面對資料量很大的需求,它的效能瓶頸也暴露出來了。

中間結果集太大導致的效能瓶頸

新的運算元記憶體空間佔用過大,快取寫入和提取都存在明顯的開銷,並且這類運算元主要用於 group by 查詢,所以當前現有的快取都不能發揮應有的作用。這也進一步驅動我們設計開發了一個新的快取選項,segment 合併中間結果,詳見前文所述。

通過有效快取 segment 合併的中間結果,大大降低了 segment 級別中間結果太大帶來的序列化和反序列化開銷。另外,未來也會考慮通過重新編碼的方式,降低資料分佈的離散程度,提高 bitmap 對整型序列的壓縮率。

記憶體估算困難的問題

由於 Druid 查詢引擎主要通過堆外記憶體 buffer 處理中間結算結果來減少 GC 影響,這就要求運算元內部資料結構支援比較準確的記憶體估算。但是這類基於 Roaring bitmap 的運算元不僅難以估算記憶體,而且在運算過程中只能在堆記憶體中構造物件例項。這使得這類運算元在查詢中記憶體開銷不可控,極端查詢情況下甚至可能出現 OOM 的情況。

針對這類問題,短期內我們主要通過結合上游資料處理來緩解,比如重新編碼,合理分割槽分片等等。

3.2 靈活的滑動視窗函式

3.2.1 問題背景

Druid 核心查詢引擎僅支援固定視窗大小的聚合函式,缺少對靈活滑動視窗函式的支援。一些關鍵業務方希望每日統計近 7 天的 UV,這就要求 Druid 支援滑動視窗聚合函式。

3.2.2 需求分析

社群 Moving Average Query 擴充套件的侷限性

通過調查,我們發現社群已有的擴充套件外掛 Moving Average Query 支援一些基本型別的滑動視窗計算,但是缺少對其他複雜型別(物件型別)的 Druid 原生運算元的支援,比如廣泛應用的 HLL 型別近似運算元等。同時,這個擴充套件也缺少對 SQL 的支援適配。

3.2.3 實現方案

通過研讀原始碼,我們發現這個擴充套件還可以更加通用和簡潔。我們增加了一個 default 型別的運算元實現,它能根據基礎欄位的型別,實現對基礎欄位的滑動視窗聚合。也就是說,通過這一個 default 型別的運算元就可以讓所有 Druid 原生運算元(Aggregator)支援滑動視窗聚合。

同時,我們為這個通用的運算元適配了 SQL 函式支援。

運算元 API

// native JSON API
{
    "aggregations": [
        {
            "type": "hyperUnique",
            "name": "deltaDayUniqueUsers",
            "fieldName": "uniq_user"
        }
    ],
    "averagers": [
        {
            "name": "trailing7DayUniqueUsers",
            "fieldName": "deltaDayUniqueUsers",
            "type": "default",
            "buckets": 7
        }
    ]
}
-- SQL support
select TIME_FLOOR(__time, 'PT1H'), dim, MA_TRAILING_AGGREGATE_DEFAULT(DS_HLL(user), 7) from ds_name where __time >= '2021-06-27T00:00:00.000Z' and __time < '2021-06-28T00:00:00.000Z' GROUP BY 1, 2

社群合作

我們準備把這個新功能貢獻給社群,當前該 PR 還在等待更多的社群反饋。

4. 未來架構演進

為了更好地從架構層面解決穩定性問題,實現降本增效,我們開始探索和落地 Druid 的雲原生部署方案。後續我們還會分享關於這一塊的實踐經驗,敬請期待!

? 參考連結

相關文章