滴滴基於 Clickhouse 構建新一代日誌存儲系統

ClickHouse 是 2016 年開源的用於實時數據分析的一款高性能列式分佈式數據庫,支持向量化計算引擎、多核並行計算、高壓縮比等功能,在分析型數據庫中單表查詢速度是最快的。2020 年開始在滴滴內部大規模地推廣和應用,服務網約車和日誌檢索等核心平臺和業務。本文主要介紹滴滴日誌檢索場景從 ES 遷移到 CK 的技術探索。

背景

======

此前,滴滴日誌主要存儲於 ES 中。然而,ES 的分詞、倒排和正排等功能導致其寫入吞吐量存在明顯瓶頸。此外,ES 需要存儲原始文本、倒排索引和正排索引,這增加了存儲成本,並對內存有較高要求。隨着滴滴數據量的不斷增長,ES 的性能已無法滿足當前需求。

在追求降低成本和提高效率的背景下,我們開始尋求新的存儲解決方案。經過研究,我們決定採用 CK 作爲滴滴內部日誌的存儲支持。據瞭解,京東、攜程、B 站等多家公司在業界的實踐中也在嘗試用 CK 構建日誌存儲系統。

挑戰

======

面臨的挑戰主要來自下面三個方面:

  1. 數據量大:每天會產生 PB 級別的日誌數據,存儲系統需要穩定地支撐 PB 級數據的實時寫入和存儲。

  2. 查詢場景多:在一個時間段內的等值查詢、模糊查詢及排序場景等,查詢需要掃描的數據量較大且查詢都需要在秒級返回。

  3. QPS 高:在 PB 級的數據量下,對 Trace 查詢同時要滿足高 QPS 的要求。

爲什麼選 Clickhouse

===================

架構升級

========

舊的存儲架構下需要將日誌數據雙寫到 ES 和 HDFS 兩個存儲上,由 ES 提供實時的查詢,Spark 來分析 HDFS 上的數據。這種設計要求用戶維護兩條獨立的寫入鏈路,導致資源消耗翻倍,且操作複雜性增加。

在新升級的存儲架構中,CK 取代了 ES 的角色,分別設有 Log 集羣和 Trace 集羣。Log 集羣專門存儲明細日誌數據,而 Trace 集羣則專注於存儲 trace 數據。這兩個集羣在物理上相互隔離,有效避免了 log 的高消耗查詢(如 like 查詢)對 trace 的高 QPS 查詢產生干擾。此外,獨立的 Trace 集羣有助於防止 trace 數據過度分散。

日誌數據通過 Flink 直接寫入 Log 集羣,並通過 Trace 物化視圖從 log 中提取 trace 數據,然後利用分佈式表的異步寫入功能同步至 Trace 集羣。這一過程不僅實現了 log 與 trace 數據的分離,還允許 Log 集羣的後臺線程定期將冷數據同步到 HDFS 中。

新架構僅涉及單一寫入鏈路,所有關於 log 數據冷存儲至 HDFS 以及 log 與 trace 分離的處理均由 CK 完成,從而爲用戶屏蔽了底層細節,簡化了操作流程。

考慮到成本和日誌數據特點,Log 集羣和 Trace 集羣均採用單副本部署模式。其中,最大的 Log 集羣有 300 多個節點,Trace 集羣有 40 多個節點。

存儲設計

存儲設計是提升性能最關鍵的部分,只有經過優化的存儲設計才能充分發揮 CK 強大的檢索性能。借鑑時序數據庫的理念,我們將 logTime 調整爲以小時爲單位進行取整,並在存儲過程中按照小時順序排列數據。這樣,在進行其他排序鍵查詢時,可以快速定位到所需的數據塊。例如,查詢一個小時內數據時,最多隻需讀取兩個索引塊,這對於處理海量日誌檢索至關重要。

以下是我們根據日誌查詢特性和 CK 執行邏輯制定的存儲設計方案,包括 Log 表、Trace 表和 Trace 索引表:

Log 表


Log 表旨在爲明細日誌提供存儲和查詢服務,它位於 Log 集羣中,並由 Flink 直接從 Pulsar 消費數據後寫入。每個日誌服務都對應一張 Log 表,因此整個 Log 集羣可能包含數千張 Log 表。其中,最大的表每天可能會生成 PB 級別的數據。鑑於 Log 集羣面臨表數量衆多、單表數據量大以及需要進行冷熱數據分離等挑戰,以下是針對 Log 表的設計思路:

CREATE TABLE ck_bamai_stream.cn_bmauto_local
(
    `logTime` Int64 DEFAULT 0, -- log打印的時間
    `logTimeHour` DateTime MATERIALIZED toStartOfHour(toDateTime(logTime / 1000)), -- 將logTime向小時取整
    `odinLeaf` String DEFAULT '',
    `uri` LowCardinality(String) DEFAULT '',
    `traceid` String DEFAULT '',
    `cspanid` String DEFAULT '',
    `dltag` String DEFAULT '',
    `spanid` String DEFAULT '',
    `message` String DEFAULT '',
    `otherColumn` Map<String,String>,
    `_sys_insert_time` DateTime MATERIALIZED now()
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(logTimeHour)
ORDER BY (logTimeHour,odinLeaf,uri,traceid)
TTL _sys_insert_time + toIntervalDay(7),_sys_insert_time + toIntervalDay(3) TO VOLUME 'hdfs'
SETTINGS index_granularity = 8192,min_bytes_for_wide_part=31457280

Trace 表


Trace 表是用來提供 trace 相關的查詢,這類查詢對 QPS 要求很高,創建在 Trace 集羣。數據來源於從 Log 表中提取的 trace 記錄。Trace 表只會有一張,所有的 Log 表都會將 trace 記錄提取到這張 Trace 表,實現的方式是 Log 表通過物化視圖觸發器跨集羣將數據寫到 Trace 表中。

Trace 表的難點在於查詢速度快且 QPS 高,以下是 Trace 表的設計思路:

CREATE TABLE ck_bamai_stream.trace_view
(
    `traceid` String,
    `spanid` String,
    `clientHost` String,
    `logTimeHour` DateTime,
    `cspanid` AggregateFunction(groupUniqArray, String),
    `appName` SimpleAggregateFunction(any, String),
    `logTimeMin` SimpleAggregateFunction(min, Int64),
    `logTimeMax` SimpleAggregateFunction(max, Int64),
    `dltag` AggregateFunction(groupUniqArray, String),
    `uri` AggregateFunction(groupUniqArray, String),
    `errno` AggregateFunction(groupUniqArray, String),
    `odinLeaf` SimpleAggregateFunction(any, String),
    `extractLevel` SimpleAggregateFunction(any, String)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMMDD(logTimeHour)
ORDER BY (logTimeHour, traceid, spanid, clientHost)
TTL logTimeHour + toIntervalDay(7)
SETTINGS index_granularity = 1024

Trace 索引表


Trace 索引表的主要作用是加快 order_id、driver_id、driver_phone 等字段查詢 traceid 的速度。爲此,我們給需要加速的字段創建了一個聚合物化視圖,以提高查詢速度。數據則是通過爲 Log 表創建相應的物化視圖觸發器,將數據提取到 Trace 索引表中。

以下是建立 Trace 索引表的語句:

CREATE TABLE orderid_traceid_index_view
(
    `order_id` String,
    `traceid` String,
    `logTimeHour` DateTime
)
ENGINE = AggregatingMergeTree
PARTITION BY logTimeHour
ORDER BY (order_id, traceid)
TTL logTimeHour + toIntervalDay(7)
SETTINGS index_granularity = 1024

存儲設計的核心目標是提升查詢性能。接下來,我將介紹從 ES 遷移至 CK 過程中,在這一架構下所面臨的穩定性問題及其解決方法。

穩定性之路

支撐日誌場景對 CK 來說是非常大的挑戰,面臨龐大的寫入流量及超大集羣規模,經過一年的建設,我們能夠穩定的支撐重點節假日的流量高峯,下面的篇幅主要是介紹了在支撐日誌場景過程中,遇到的一些問題。

大集羣小表數據碎片化問題


在 Log 集羣中,90% 的 Log 表流量低於 10MB/s。若將所有表的數據都寫入數百個節點,會導致大量小表數據碎片化問題。這不僅影響查詢性能,還會對整個集羣性能產生負面影響,併爲冷數據存儲到 HDFS 帶來大量小文件問題。

爲解決大規模集羣帶來的問題,我們根據表的流量大小動態分配寫入節點。每個表分配的寫入節點數量介於 2 到集羣最大節點數之間,均勻分佈在整個集羣中。Flink 通過接口獲取表的寫入節點列表,並將數據寫入相應的 CK 節點,有效解決了大規模集羣數據分散的問題。

寫入限流及寫入性能提升


在滴滴日誌場景中,晚高峯和節假日的流量往往會大幅增加。爲避免高峯期流量過大導致集羣崩潰,我們在 Flink 上實現了寫入限流功能。該功能可動態調整每張表寫入集羣的流量大小。當流量超過集羣上限時,我們可以迅速降低非關鍵表的寫入流量,減輕集羣壓力,確保重保表的寫入和查詢不受影響。

同時爲了提升把脈的寫入性能,我們基於 CK 原生 TCP 協議開發了 Native-connector。相比於 HTTP 協議,Native-connector 的網絡開銷更小。此外,我們還自定義了數據類型的序列化機制,使其比之前的 Parquet 類型更高效。啓用 Native-connector 後,寫入延遲率從之前的 20% 降至 5%,整體寫入性能提升了 1.2 倍。

HDFS 冷熱分離的性能問題

用 HDFS 來存儲冷數據,在使用的過程中出現以下問題:

  1. 服務重啓變得特別慢且 Sys cpu 被打滿,原因是在服務重啓的過程中需要併發的加載 HDFS 上 Part 的元數據,而 libhdfs3 庫併發讀 HDFS 的性能非常差,每當讀到文件末尾都會拋出異常打印堆棧,產生了大量的系統調用。

  2. 當寫入歷史分區的數據時,數據不會落盤,而是直接往 HDFS 上寫,寫入性能很差,並且直接寫到 HDFS 的數據還需要拉回本地 merge,進一步降低了 merge 的性能。

  3. 本地的 Part 路徑和 HDFS 的路徑是通過 uuid 來映射的,所有表的數據都是存儲在 HDFS 的同一路徑下,導致達到了 HDFS 目錄文件數 100w 的限制。

  4. HDFS 上的 Part 文件路徑映射關係是存儲在本地的,如果出現節點故障,那麼文件路徑映射關係將會丟失,HDFS 上的數據丟失且無法被刪除。

爲此我們對 HDFS 冷熱分離功能進行了比較大的改造來解決上面的問題,解決 libhdfs3 庫併發讀 HDFS 的問題並在本地緩存 HDFS 的 Part 元數據文件,服務的啓動速度由原來的 1 小時到 1 分鐘。

同時禁止歷史數據直接寫 HDFS ,必須先寫本地,merge 之後再上傳到 HDFS ,最後對 HDFS 的存儲路徑進行改造。由原來數據只存儲在一個目錄下改爲按 cluster/shard/database/table/ 進行劃分,並按表級別備份本地的路徑映射關係到 HDFS。這樣一來,當節點故障時,可以通過該備份恢復 HDFS 的數據。

收益

======

在日誌場景中,我們已經成功完成了從 ES 到 CK 的遷移。目前,CK 的日誌集羣規模已超過 400 個物理節點,寫入峯值流量達到 40+GB/s,每日查詢量約爲 1500 萬次,支持的 QPS 峯值約爲 200。相較於 ES,CK 的機器成本降低了 30%。

查詢速度相比 ES 提高了約 4 倍。下圖展示了 bamailog 集羣和 bamaitrace 集羣的 P99 查詢耗時情況,基本都在 1 秒以內。

總結

======

將日誌從 ES 遷移至 CK 不僅可以顯著降低存儲成本,還能提供更快的查詢體驗。經過一年多的建設和優化,系統的穩定性和性能都有了顯著提升。然而,在處理模糊查詢時,集羣的資源消耗仍然較大。未來,我們將繼續探索二級索引、zstd 壓縮以及存算分離等技術手段,以進一步提升日誌檢索性能。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/7zUYmQ2jjPNTjTKqnPcRcg