ClickHouse 在實時場景的應用和優化

https://v.qq.com/x/page/q3141p7q5m7.html

視頻地址

早期實踐

外部事務

在介紹實時場景之前,我先簡單講一下早期的離線數據是如何支持的:

在第一場分享中,技術負責人陳星介紹了 ClickHouse 在字節跳動內部最早支持的兩個業務場景,用戶行爲分析平臺和敏捷 BI 平臺。這兩個平臺的數據主要由分析師或者數倉同學產出,以 T+1 的離線指標爲主。考慮到 ClickHouse 並不支持事務,爲了保障數據的一致性,我們在 ClickHouse 系統外實現了一套外部事務:

數倉同學一般會在 HDFS/Hive 準備好原始數據;數據就緒後,會執行一個基於 Spark 的 ETL 服務,將數據切成 N 份再存回 HDFS(必要的話也會做一些數據的預處理);再發起 INSERT Query 給 ClickHouse 集羣的每一個 shard,將對應的數據文件從 HDFS 中直接導入到 MergeTree 表中,需要注意的是,這裏沒有把數據寫入分佈式表(i.e. Distributed table)中;每個節點上的 MergeTree 表寫入成功之後,會由外部事務校驗整個 集羣的數據 是否寫入成功:如果部分節點導入失敗,外部的導入服務會將部分寫入的數據回滾並重新執行導入任務,直到數據完全導入成功,才允許上層的分析平臺查詢數據。也就是說,當 ClickHouse 中僅有不完整的數據時,外部的查詢服務 不會查詢當天的數據。

除了離線的場景,也有業務方希望執行 INSERT Query 將數據即時地導入 ClickHouse 中,從而能查詢到實時的數據。然而,我們曾經出現過由於業務同學高頻寫入數據,導致文件系統壓力過大最後無法正常查詢的線上問題。

這裏我解釋一下直接寫入數據的風險:

直接寫入的風險

用戶寫入 ClickHouse 一般有兩種選擇:分佈式表(i.e. Distributed),MergeTree 表:

寫入分佈式表:

數據寫入分佈式表時,它會將數據先放入本地磁盤的緩衝區,再異步分發給所有節點上的 MergeTree 表。如果數據在同步給 MergeTree 裏面之前這個節點宕機了,數據就可能會丟失;此時如果在失敗後再重試,數據就可能會寫重。因而,直接將數據寫入用分佈式表時,不太好保證數據準確性的和一致性。

當然這個分佈式表還有其他問題,一般來說一個 ClickHouse 集羣會配置多個 shard,每個 shard 都會建立 MergeTree 表和對應的分佈式表。如果直接把數據寫入分佈式表,數據就可能會分發給每個 shard。假設有 N 個節點,每個節點每秒收到一個 INSERT Query,分發 N 次之後,一共就是每秒生成 NxN 個 part 目錄。集羣 shard 數越多,分發產生的小文件也會越多,最後會導致你寫入到 MergeTree 的 Part 的數會特別多,最後會拖垮整個文件的系統。

寫入 MergeTree 表:

直接寫入 MergeTree 表可以解決數據分發的問題,但是依然抗不住高頻寫入,如果業務方寫入頻次控制不好,仍然有可能導致 ClickHouse 後臺合併的速度跟不上寫入的速度,最後會使得文件系統壓力過大。

所以一段時間內,我們禁止用戶用 INSERT Query 把數據直接寫入到 ClickHouse。

典型案例-推薦系統

業務需求

隨着 ClickHouse 支持的業務範圍擴大,我們也決定支持一些實時的業務,第一個典型案例是推薦系統的實時數據指標:在字節跳動內部 AB 實驗 應用非常廣泛,特別用來驗證推薦算法和功能優化的效果。

最初,公司內部專門的 AB 實驗平臺已經提供了 T+1 的離線實驗指標,而推薦系統的算法工程師們希望能更快地觀察算法模型、或者某個功能的上線效果,因此需要一份能夠實時反饋的數據作爲補充。他們大致有如下需求:

  1. 研發同學有 debug 的需求,他們不僅需要看聚合指標,某些時間還需要查詢明細數據。
  2. 推薦系統產生的數據,維度和指標多達幾百列,而且未來可能還會增加。
  3. 每一條數據都命中了若干個實驗,使用 Array 存儲,需要高效地按實驗 ID 過濾數據。
  4. 需要支持一些機器學習和統計相關的指標計算(比如 AUC)。

當時公司也有維護其他的分析型引擎,比如 Druid 和 ES。ES 不適合大批量數據的查詢,Druid 則不滿足明細數據查詢的需求。而 ClickHouse 則剛好適合這個場景。

  1. 對於明細數據這個需求:ClickHouse > Druid。
  2. 對於維度、指標多的問題,可能經常變動,我們可以用 Map 列的功能,很方便支持動態變更的維度和指標。
  3. 按實驗 ID 過濾的需求,則可以用 Bloom filter 索引。
  4. AUC 之前則已經實現過。

這些需求我們當時剛好都能滿足。

方案設計和比較

常規方案:

比較常規的思路,是用 Flink 消費 Kafka,然後通過 JDBC 寫入 ClickHouse。

優點: 各個組件職責劃分清楚、潛在擴展性強

缺點: 需要額外資源、寫入頻次不好控制、難以處理節點故障、維護成本較高

關鍵是後面兩點:由於缺少事務的支持,實時導入數據時難以處理節點故障;ClickHouse 組技術棧以 C++爲主,維護 Flink 潛在的成本比較高。

Kafka Engine 方案:

第二個方案,則是使用 ClickHouse 內置的 Kafka Engine。我們可以在 ClickHouse 服務內部建一張引擎類型爲 Kafka 的表,該表會內置一個消費線程,它會直接請求 Kafka 服務,直接將 Kafka partition 的數據拉過來,然後解析並完成數據構建。對於一個 ClickHouse 集羣而言,可以在每個節點上都建一張 Kafka 表,在每個節點內部啓動一個消費者,這些消費者會分配到若干個 Kafka Partition,然後將數據直接消費到對應。

這樣的架構相對於使用了 Flink 的方案來說更簡單一些,由於少了一次數據傳輸,整體而言開銷會相對小一些,對我們來說也算是補齊了 ClickHouse 的一部分功能(比如 Druid 也支持直接消費 Kafka topic)缺點就是未來可擴展性會更差一些,也略微增加了引擎維護負擔。

Kafka engine 原理

這裏簡單介紹一下如何使用 kafka 引擎,爲了能讓 ClickHouse 消費 Kafka 數據,我們需要三張表:首先需要一張存數據的表也就是 MergeTree;然後需要一張 Kafka 表,它負責描述 Topic、消費數據和解析數據;最後需要一個物化視圖去把兩張表關聯起來,它也描述了數據的流向,某些時候我們可以裏面內置一個 SELECT 語句去完成一些 ETL 的工作。只有當三張表湊齊的時候我們纔會真正啓動一個消費任務。

這是一個簡單的例子:最後呈現的效果,就是通過表和 SQL 的形式,描述了一個 kafka -> ClickHouse 的任務。

最終效果

由於外部寫入並不可控、技術棧上的原因,我們最終採用了 Kafka Engine 的方案,也就是 ClickHouse 內置消費者去消費 Kafka。整體的架構如圖:

  1. 數據由推薦系統直接產生,寫入 Kafka。這裏推薦系統做了相應配合,修改 Kafka Topic 的消息格式適配 ClickHouse 表的 schema。
  2. 敏捷 BI 平臺也適配了一下實時的場景,可以支持交互式的查詢分析。
  3. 如果實時數據有問題,也可以從 Hive 把數據導入至 ClickHouse 中,不過這種情況不多。除此之外,業務方還會將 1%抽樣的離線數據導入過來做一些簡單驗證,1%抽樣的數據一般會保存更久的時間。

我們在支持推薦系統的實時數據時遇到過不少問題,其中最大的問題隨着推薦系統產生的數據量越來越大,單個節點的消費能力也要求越來越大:

改進一:異步構建索引

第一做的改進是將輔助索引的構建異步化了:在社區實現中,構建一個 Part 分爲三步:(1)解析輸入數據生成內存中數據結構的 Block;(2)然後切分 Block,並按照表的 schema 構建 columns 數據文件;(3) 最後掃描根據 skip index schema 去構建 skip index 文件。三個步驟完成之後纔會算 Part 文件構建完畢。

目前字節內部的 ClickHouse 並沒有使用社區版本的 skip index,不過也有類似的輔助索引(e.g. Bloom Filter Index, Bitmap Index)。構建 part 的前兩步和社區一致,我們構建完 columns 數據之後用戶即可正常查詢,不過此時的 part 不能啓用索引。此時,再將剛構建好數據的 part 放入到一個異步索引構建隊列中,由後臺線程構建索引文件。這個改進雖然整體的性能開銷沒有變化,但是由於隱藏了索引構建的時間開銷,整體的寫入吞吐量大概能提升 20%

改進二:支持多線程消費

第二個改進是在 Kafka 表內部支持了多線程的消費:

目前實現的 Kafka 表,內部默認只會有一個消費者,這樣會比較浪費資源並且性能達不到性能要求。一開始我們可以通過增大 消費者 的個數來增大消費能力,但社區的實現一開始是由一個線程去管理多個的消費者,多個的消費者各自解析輸入數據並生成的 Input Stream 之後,會由一個 Union Stream 將多個 Input Stream 組合起來。這裏的 Union Stream 會有潛在的性能瓶頸,多個消費者消費到的數據最後僅能由一個輸出線程完成數據構建,所以這裏沒能完全利用上多線程和磁盤的潛力。

一開始的解決方法,是建了多張 Kafka Table 和 Materialized View 寫入同一張表,這樣就有點近似於多個 INSERT Query 寫入了同一個 MergeTree 表。當然這樣運維起來會比較麻煩,最後我們決定通過改造 Kafka Engine 在其內部支持多個消費線程,簡單來說就是每一個線程它持有一個消費者,然後每一個消費者負責各自的數據解析、數據寫入,這樣的話就相當於一張表內部同時執行多個的 INSERT Query,最後的性能也接近於線性的提升。

改進三:增強容錯處理

對於一個配置了主備節點的集羣,我們一般來說只會寫入一個主備其中一個節點。

爲什麼呢?因爲一旦節點故障,會帶來一系列不好處理的問題。(1)首先當出現故障節點的時候,一般會替換一個新的節點上來,新替換的節點爲了恢復數據,同步會佔用非常大的網絡和 磁盤 IO ,這種情況,如果原來主備有兩個消費者就剩一個,此時消費性能會下降很大(超過一倍),這對於我們來說是不太能接受的。(2)早先 ClickHouse Kafka engine 對 Kafka partition 的動態分配支持不算好,很有可能觸發重複消費,同時也無法支持數據分片。因此我們默認使用靜態分配,而靜態分配不太方便主備節點同時消費。(3)最重要的一點,ClickHouse 通過分佈式表查詢 ReplicatedMergeTree 時,會基於 log delay 來計算 Query 到底要路由到哪個節點。一旦在主備同時攝入數據的情況下替換了某個節點,往往會導致查詢結果不準。

這裏簡單解釋一下查詢不準的場景。一開始我們有兩副本,Replica #1 某時刻出現故障,於是替換了一個新的節點上來,新節點會開始同步數據,白框部分是已經同步過的,虛線黃框是正在恢復的數據,新寫入的白色框部分就是新寫入的數據。如果此時兩個機器的數據同步壓力比較大或查詢壓力比較大,就會出現 Replica #1 新寫入的數據沒有及時同步到 Replica #2 ,也就是這個綠框部分,大量歷史數據也沒有及時同步到對應的黃框部分,這個情況下兩個副本都是缺少數據的。因此無論是查 Replica #1 還是 Replica #2 得到的數據都是不準的。

對於替換節點導致查詢不准問題,我們先嚐試解決只有一個節點消費的問題。爲了避免兩個節點消費這個數據,改進版的 Kafka engine 參考了 ReplicatedMergeTree 基於 ZooKeeper 的選主邏輯。對於每一對副本的一對消費者,(如上圖 A1 A2),它們會嘗試在 ZooKeeper 上完成選主邏輯,只有選舉稱爲主節點的消費者才能消費,另一個節點則會處於一個待機狀態。一旦 Replica #1 宕機,(如上圖 B1 B2 ),B1 已經宕機連不上 ZooKeeper 了,那 B2 會執行選主邏輯拿到 Leader 的角色,從而接替 B1 去消費數據。

當有了前面的單節點消費機制,就可以解決查詢的問題了。假設 Replica #1 是一個剛換上來的節點,它需要同步黃框部分的數據,這時候消費者會與 ReplicatedMergeTree 做一個聯動,它會檢測其對應的 ReplicatedMergeTree 表數據是否完整,如果數據不完整則代表不能正常服務,此時消費者會主動出讓 Leader,讓副本節點上的消費者也就是 Replica #2 上的 C2 去消費數據。

也就是說,我們新寫入的數據並不會寫入到缺少數據的節點,對於查詢而言,由於查詢路由機制的原因也不會把 Query 路由到缺少數據的節點上,所以一直能查詢到最新的數據。這個機制設計其實和分佈式表的查詢寫入是類似的,但由於分佈表性能和穩定原因不好在線上使用,所以我們用這個方式解決了數據完整性的問題。

小結一下上面說的主備只有一個節點消費的問題

配置兩副本情況下的 Kafka engine,主備僅有一個節點消費,另一個節點待機。

  1. 如果有故障節點,則自動切換到正常節點消費;
  2. 如果有新替換的節點無法正常服務,也切換到另一個節點;
  3. 如果不同機房,則由離 Kafka 更近的節點消費,減少帶寬消耗;
  4. 否則,由類似 ReplicatedMergeTree 的 ZooKeeper Leader 決定。

典型案例-廣告投放實時數據

業務背景

第二個典型案例是關於廣告的投放數據,一般是運營同學需要查看廣告投放的實時效果。由於業務的特點,當天產生的數據往往會涉及到多天的數據。這套系統原來基於 Druid + Superset 實現的,Druid 在這個場景會有一些難點:

難點一:產生的實時數據由於涉及到較多的時間分區,對於 Druid 來說可能會產生很多 segment,如果寫入今天之前的數據它需要執行一些 MR 的任務去把數據合併在一起,然後才能查歷史的數據,這個情況下可能會導致今天之前的數據查詢並不及時。

難點二:業務數據的維度也非常多,這種場景下使用 Druid 預聚合的效率並不高。

對比 Druid 和 ClickHouse 的特點和性能後,我們決定將該系統遷移到 ClickHouse + 自研敏捷 BI。最後由於維度比較多,並沒有採用預聚合的方式,而是直接消費明細數據。

因爲業務產生的數據由 (1) 大量的當天數據和 (2) 少量的歷史數據 組成。歷史數據一般涉及在 3 個月內,3 個月外的可以過濾掉,但是即便是 3 個月內的數據,在按天分區的情況下,也會因爲單批次生成的 parts 太多導致寫入性能有一定下降。所以我們一開始是把消費的 block_size 調的非常大,當然這樣也有缺點,雖然整個數據吞吐量會變大,但是由於數據落盤之前是沒法查到數據的,會導致整體延時更大。

改進一:Buffer Engine 增強

單次寫入生成過多 parts 的問題其實也有方案解決。社區提供了 Buffer Engine,可以在內存中緩存新寫入的數據,從而緩解 parts 高頻生成的問題。不過社區文檔也介紹了,Buffer Engine 的缺點是不太能配合 ReplicatedMergeTree 一起 工作。如果數據寫入到了一對副本(如上圖),那麼 Buffer #1 和 Buffer #2 緩存的數據其實是不一樣的,兩個 Buffer 僅緩存了各自節點上新寫入的數據。對於某個查詢而言,如果查詢路由到 Replica #1,那查詢到的數據是 MergeTree 部分的數據加上 Buffer #1,這部分的數據其實是和 Replica #2 的 MergeTree 加上 Buffer2 的數據並不等價,即便 MergeTree 的數據是相同的。

針對社區版 Buffer Table 存在的問題,我們也做了相應改進。

(1) 我們選擇將 Kafka/Buffer/MergeTree 三張表結合起來,提供的接口更加易用;

(2) 把 Buffer 內置到 Kafka engine 內部, 作爲 Kafka engine 的選項可以開啓/關閉;

(3) 最重要的是支持了 ReplicatedMergeTree 情況下的查詢;

(4) Buffer table 內部類似 pipeline 模式處理多個 Block。

這裏解釋一下我們如何解決查詢一致性的問題。前面提到,目前一對副本僅有一個節點在消費,所以一對副本的兩個 Buffer 表,只有一個節點有數據。比如 Consumer #1 在消費時,Buffer #1 就是有緩存數據,而 Buffer #2 則是空的。

對於任何發送到 Replica #1 的查詢,數據肯定是完整的;而對於發送到 Replica #2 的查詢則會額外構建一個特殊的查詢邏輯,從另一個副本的 Buffer #1 讀取數據。這樣發送到 Replica #2 的查詢,獲取到數據就是綠框部分也就是 Replica #2 的 MergeTree 再加上 Replica #1 的 Buffer,它的執行效果是等價於發送到 Replica #1 的查詢。

改進二:消費穩定性增強

由於業務數據的分區比較分散,某個批次的寫入往往生成多個 parts。以上圖爲例,如果某個批次消費到 6 條數據,假設可以分爲 3 個 part(比如涉及到昨天、今天、大前天三天數據),第一條和第四條寫入到第一個 part,第二第五條數據寫入到第二個 part,這時候服務宕機了,沒有及時寫入第三第六條數據。

由於 ClickHouse 沒有事務的支持,所以重啓服務後再消費時,要麼會丟失數據 {3, 6},要麼會重複消費 {1, 4, 2, 5}。對於這個問題我們參考了 Druid 的 KIS 方案自己管理 Kafka Offset, 實現單批次消費/寫入的原子語義:實現上選擇將 Offset 和 Parts 數據綁定在一起,增強了消費的穩定性。

每次消費時,會默認創建一個事務,由事務負責把 Part 數據和 Offset 一同寫入磁盤中:如果消費的途中寫入 part #1 part #2 失敗了,事務回滾的時候會把 Offset 和 part #1 part #2 一併回滾,然後從 Part #1 的位置重新消費並重試提交 offset 1-3。

實踐&經驗

接入平臺

很早的時候爲了推廣 ClickHouse 消費 Kafka 的方案,我們做了一個接入平臺,這個平臺可以創建、審覈、管理 Kafka -> ClickHouse 的消費任務。前面介紹到了創建一個消費任務需要三張表,直接建表的學習成本比較高,所以這個平臺提供這樣一個簡單易用的頁面來完成接入任務。

這裏解釋一下框起來的兩個部分:

首先是查詢粒度,如果大家有聽第一場分享就大概知道,我們目前對物化視圖做了一些改進,假如查詢粒度選了 5 分鐘或 10 分鐘,那消費數據時數據會像 Druid 一樣提前對數據預聚合。而查詢的時候也會做一些查詢改寫,用來達到類似 Druid 的效果,目的是爲了覆蓋公司內部一些用 Druid 的場景。

爲簡化用戶的使用成本,用戶也不用挨個填寫 Table 的 Schema,而是從 Kafka 的數據裏直接推斷出 schema。

推斷的 schema 是這個樣子,用戶可以填寫一些簡單的表達式來做一些聚合 & 轉換的工作。

我們也在其他的平臺 集成了 Kafka -> ClickHouse 的功能:比如說敏捷 BI 平臺,它可以直接支持將 Kafka Topic 作爲數據集。

診斷能力增強

前面提到,Kafka engine 方案有一個弊端是增加了引擎端的運維負擔。在推廣過程中,運維壓力也越來越重,我們開發了相應的工具來輔助診斷和運維。我們參考社區 system 表實現了用來輔助診斷 Kafka 消費的表,比如 system.kafka_log 和 system.kafka_tables。

system.kafka_log 表:每當有消費的事件發生的時,會寫入一條診斷日誌,比如從 Kafka partition 拉取數據對應了 POLL 事件;而寫入數據則會對應 WRITE 時間;一旦有異常產生,也會產生專門記錄異常的 EXCEPTION 事件。按時間和各類事件聚合之後,可以統計一段時間內的消費量和寫入量,也可以通過異常信息去定位和診斷線上問題。

<一個使用案例>

<前面 Query 的查詢結果>

如上圖,我們可以通過 exception 得知業務方的數據和表的 schema 匹配不上,因而觸發瞭解析異常。同時,也可以到錯誤消息的 topic、partition、offset。

當然業務方一般不會直接用到這個功能,而是通過運維平臺查詢消費是否有異常。

這個是 Kafka Tables,這裏提供了關於 Kafka engine 的基本信息。最常用的是 assigned partition,可以快速定位某個具體節點消費了哪些 Kafka partition。

SQL 增強

我們也擴展了一些常用的 query,比如 SYSTEM START/STOP 和 RESTART,可以比較快速地關閉、重啓消費;比如 ALTER Query 的擴展支持,在不用重建表的情況下變更表的 schema。

未來展望和計劃

小結

簡單來說在字節內部的應用場景主要分爲四類:AB 實驗、業務實時數據、服務的後臺日誌數據、機器的監控數據。Kafka Engine 的改進主要以穩定性改進爲主,同時也做了部分性能上的改進。爲了方便業務接入,我們也提供了配套的平臺和接口,除了自己運維的平臺,也和字節內部其他服務做了集成。運維層面則增加了 system table 和 system query 等一系列工具來輔助診斷,簡化操作。

目前字節內部仍然沒有 MySQL 同步到 ClickHouse 的場景。之前雖然有開發一個簡單的方案,但是因爲寫入和消費不夠穩定,並沒有在線上使用。因爲 MySQL 同步 ClickHouse 時一旦數據出錯,那麼 ClickHouse 很難再和 MySQL 保持一致了,需要一些額外手段去修復。我們目前有重新設計一套更完整的方案,未來能夠支持 TP 數據庫的同步,以及支持直接 UPDATE/DELETE Query 直接更新 ClickHouse 的數據。

未來展望

未來我們計劃持續投入人力來完善 ClickHouse 寫入的功能。

(1)第一步是實現在 ClickHouse 上分佈式事務,以此解決 Kafka engine 消費以及 INSERT Query 不穩定的問題。

(2)之後會嘗試實現讀寫分離,將消費數據的節點與查詢節點隔離;再基於讀寫分離做消費節點的動態伸縮。

(3)分開的消費/寫入節點做爲專門的寫入層,後續會引入 WAL 和 Buffer 解決高頻寫入的問題。如果有必要的話,也會在寫入層實現類似分佈式表分發數據的功能。

一旦上面的功能實現成熟,會考慮重新開放業務直接使用 INSERT Query 寫入數據。

本文轉載自公衆號字節跳動技術團隊(ID:toutiaotechblog)。

原文鏈接

ClickHouse 在實時場景的應用和優化

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