博客 / 詳情

返回

使用 ClickHouse 構建通用日誌系統

序言

ClickHouse 是一款常用於大數據分析的 DBMS,因為其壓縮存儲,高性能,豐富的函數等特性,近期有很多嘗試 ClickHouse 做日誌系統的案例。本文將分享如何用 ClickHouse 做出通用日誌系統。

日誌系統簡述

在聊為什麼 ClickHouse 適合做日誌系統之前,我們先談談日誌系統的特點。

  1. 大數據量。對開發者來説日誌最方便的觀測手段,而且很多情況下會直接打印 HTTP、RPC 的請求響應日誌,這基本上就是把網絡流量複製了一份。
  2. 非固定檢索模式。用户有可能使用日誌中的任意關鍵字任意字段來查詢。
  3. 成本要低。日誌系統不宜在 IT 成本中佔比過高。
  4. 即席查詢。日誌對時效性要求普遍較高。
  5. 數據量大,檢索模式不固定,既要快,還得便宜。所以日誌是一道難解的題,它的需求幾乎違反了計算機的基本原則,不過幸好它還留了一扇窗,就是對併發要求不高。大部分查詢是人為即興的,即使做一些定時查詢,所檢索的範圍也一定有限。

現有日誌方案

ElasticSearch

ES 一定是最深入人心的日誌系統了,它可以滿足大數據量、全文檢索的需求,但在成本與查詢效率上很難平衡。ES 對成本過於敏感,配置低了查詢速度會下降得非常厲害,保障查詢速度又會大幅提高成本。

Loki

Grafana 推出的日誌系統。理念上比較符合日誌系統的需求,但現在還只是個玩具而已。不適合大規模使用。

三方日誌服務

國內比較傑出的有阿里雲日誌服務,國外的 Humio、DataDog 等,都是拋棄了 ES 技術體系,從存儲上重做。國內還有觀測雲,只不過其存儲還是 ES,沒什麼技術突破。

值得一提的是阿里雲日誌服務,它對接了諸如 OpenTracing、OpenTelemetry 等標準,可以接入監控、鏈路數據。因為鏈路數據與日誌具有很高的相似性,完全可以用同一套技術棧搞定。

三方服務優點是日誌攝入方式、查詢性能、數據分析、監控告警、冷熱分離、數據備份等功能齊備,不需要用户自行開發維護。

缺點是貴,雖然都説比 ES 便宜,但那是在相同性能下,正常人不會堆這麼多機器追求高性能。最後是要把日誌數據交給別人,怎麼想都不太放心。

ClickHouse 適合做日誌嗎?

從第一性原則來分析,看看 ClickHouse 與日誌場景是否契合。

大數據量,ClickHouse 作為大數據產品顯然是符合的。

非固定模式檢索,其本身就是張表,如果只輸入關鍵字沒有列名的話,搜索所有列對 ClickHouse 來説顯然是效率低下的。但此問題也有解,後文會提到。

成本低,ClickHouse 的壓縮存儲可將磁盤需求減少一個數量級,並能提高檢索速度。與之相比,ES 還需要大量空間維護索引。

即席查詢,即席有兩個方面,一個是數據可見時間,ClickHouse 寫入的能力較 ES 更強,且寫入完成即可見,而ES 需要 refresh_interval 配置最少 30s 來保證寫入性能;另一方面是查詢速度,通常單台 ClickHouse 每秒鐘可掃描數百萬行數據。

ClickHouse 日誌方案對比

很多公司如京東、唯品會、攜程等等都在嘗試,也寫了很多文章,但是大部分都不是「通用日誌系統」,只是針對一種固定類型的日誌,如 APP 日誌,訪問日誌。所以這類方案不具備普適性,沒有效仿實施的必要,在我看來他們只是傳達了一個信息,就是 ClickHouse 可以做日誌,並且成本確實有降低。

只有 Uber 的 日誌方案真正值得參考,他們將原本基於 ELK 的日誌系統全面替換成了 ClickHouse,並承接了系統內的所有日誌。

我們的日誌方案也是從 Uber 出發,使用 ClickHouse 作為存儲,同時參考了三方日誌服務中的優秀功能,從前到後構建了一套通用日誌系統。ClickHouse 就像一塊璞玉,像 ELK 日誌系統中的 Lucene,雖然它底子不過,但還需要大量的工作。

先説成果

ClickHouse 日誌系統對接了 Java 服務端日誌、客户端日誌、Nginx 日誌等,與雲平台相比,日誌方面的總成本減少了 ~85% ,多存儲了 ~80% 的日誌量,平均查詢速度降低了 ~80% 。

平台僅用了三台服務器,存儲了幾百 TB 原始日誌,高峯期攝入 500MB/s 的原始日誌,每日查詢超過 200W 次。

成本只是次要,好用才是第一位的,如何才能做出讓開發讚不絕口,恨不得天天躺在日誌裏打滾的日誌系統。

設計

存儲設計

存儲是最核心的部分,存儲的設計也會限制最終可以實現哪些功能,在此借鑑了 Uber 的設計並進一步改進。建表語句如下:

create table if not exists log.unified_log
(
    -- 項目名稱
    `project`        LowCardinality(String),
    -- DoubleDelta 相比默認可以減少 80% 的空間並加速查詢
    `dt`             DateTime64(3) CODEC(DoubleDelta, LZ4),
    -- 日誌級別
    `level`          LowCardinality(String),
    -- 鍵值使用一對 Array,查詢效率相比 Map 會有很大提升
    `string.keys`    Array(String),
    `string.values`  Array(String),
    `number.keys`    Array(String),
    `number.values`  Array(Float64),
    `unIndex.keys`   Array(String),
    -- 非索引字段單獨保存,提高壓縮率
    `unIndex.values` Array(String) CODEC (ZSTD(15)),
    `rawLog`         String,

    -- 建立索引加速低命中率內容的查詢
    INDEX idx_string_values `string.values` TYPE tokenbf_v1(4096, 2, 0) GRANULARITY 2,
    INDEX idx_rawLog rawLog TYPE tokenbf_v1(30720, 2, 0) GRANULARITY 1,

    -- 使用 Projection 記錄 project 的數量,時間範圍,列名等信息
    PROJECTION p_projects_usually (SELECT project, count(), min(dt), max(dt), groupUniqArrayArray(string.keys), groupUniqArrayArray(number.keys), groupUniqArrayArray(unIndex.keys) GROUP BY project)
)
ENGINE = MergeTree
    PARTITION BY toYYYYMMDD(dt)
    ORDER BY (project, dt)
    TTL toDateTime(dt) + toIntervalDay(30);

表中的基本元素如下

  • project: 項目名稱
  • dt: 日誌的時間
  • level: 日誌級別
  • rawLog: JSON 格式,記錄日誌的正文,以及冗餘了 string.keys、string.values

一條日誌一定符合這些基本元素,即日誌的來源,時間,級別,正文。結構化字段可以為空,都輸出到正文。

表數據排序使用了 ORDER BY (project, dt),order by 是數據在物理上的存儲順序,將 project 放在前邊,可以避免不同 project 之間相互干擾。典型的反例是 ElasticSearch,通常在我們會將所有後端服務放在一個索引上,通過字段標識來區分。於是查詢服務日誌時,會受整體日誌量的影響,即使你的服務沒幾條日誌,查起來還是很慢。

也就是不公平,90% 的服務受到 10% 服務的影響,因為這 10% 消耗了最多的存儲資源,拖累了所有服務。如果將 project 放在前邊,數據量小的查詢快,數據量大的查詢慢,彼此不會相互影響。

但是 PARTITION BY toYYYYMMDD(dt) 中卻沒有 project,因為 project 的數量可能會非常大,會導致 partition 數量不受控制。

架構設計

解決了核心問題後,我們設計了一整套架構,使之能夠成為通用日誌系統。整體架構如下:

image.png
在系統中有如下角色:

  • 日誌上報服務

    • 從 Kafka 中獲取日誌,解析後投遞到 ClickHouse 中
    • 備份日誌到對象存儲
  • 日誌控制面

    • 負責與 Kubernetes 交互,初始化、部署、運維 ClickHouse 節點
    • 提供內部 API 給日誌系統內其他服務使用
    • 管理日誌數據生命週期
  • 日誌查詢服務

    • 將用户輸入的類 Lucene 語法,轉換成 SQL 到 ClickHouse 中查詢
    • 給前端提供服務
    • 提供 API 給公司內部服務
    • 監控告警功能
  • 日誌前端

ClickHouse 部署架構

ClickHouse 的集羣管理功能比較孱弱,很容易出現集羣狀態不統一,集羣命令卡住的情況,很多情況下不得不被迫重啓節點。結合之前的運維經驗以及參考 Uber 的做法,我們將 ClickHouse 分為讀取節點(ReadNode)與數據節點(DataNode):

  • ReadNode: 不存儲數據。存儲集羣信息,負責轉發所有查詢。目前 2C 8G 的單節點也沒有任何壓力。
  • DataNode: 存儲數據。不關心集羣信息,不連接 ZooKeeper,每個 DataNode 節點相互都是獨立的。線上每個節點規格為 32C 128G。

由於 ReadNode 不涉及具體查詢,只在集羣拓撲信息變更時重載配置文件或重啓。由於不存儲什麼數據,重啓速度也非常快。DataNode 則通常沒有理由重啓,可以保持非常穩定的狀態提供服務。

擴縮容問題

ReadNode 拉起節點即可提供服務,擴縮容不成問題,但很難遇到需要擴容的場景。

DataNode 擴縮容後有數據不均衡的問題。縮容比較好解決,在日誌控制面標記為待下線,停止日誌寫入,隨後通過在其他節點 insert into log.unified_log SELECT * FROM remote('ip', log.unified_log, 'user', 'password') where dt between '2022-01-01 00:00' and '2022-01-01 00:10' 以 10 分鐘為單位,將數據均勻搬運到剩餘的節點後,下線並釋放存儲即可。

擴容想要數據均衡則比較難,數據寫入新節點容易,在舊節點刪除掉難。由於 ClickHouse 的機制,刪除操作是非常昂貴的,尤其是刪除少量數據時。所以最好是提前擴容,或者是存算分離防止原節點存儲被打滿。

日誌攝入

日誌上報服務通過 Kafka 來獲取日誌,除了標準格式外,還可以配置不同的 Topic 有不同的解析規則。例如對接 Nginx 日誌時,通過 filebeat 監聽日誌文件併發送到 kafka,日誌上報服務進行格式解析後投遞到 ClickHouse。

日誌從發送到 Kakfa、讀取、寫入到 ClickHouse 全程都是壓縮的,僅在日誌上報服務中短暫解壓,且解壓後馬上寫入 Gzip Stream,內存中不保留日誌原文。

而選擇 Kafka 而不是直接提供接口,因為 Kafka 可以提供數據暫存,重放等。這些對數據的可靠性,系統靈活性有很大的幫助,之後在冷數據恢復的時候也會提到。

在 Java 服務上,我們提供了非常高效的 Log4j2 的 Kafka Appender,支持動態更換 kafka 地址,可以從 MDC 獲取用户自定義列,並提供工具類給用户。

查詢

查詢語法

在查詢上參考了 Lucene、各種雲廠商,得出在日誌查詢場景,類 Lucene 語法是最為簡潔易上手的。想象當你有一張千億條數據的表,且字段的數量不確定,使用 SQL 語法篩選數據無疑是非常困難的。而 Lucene 的語法天然支持高效的篩選、反篩選。
但原生 Lucene 語法又有一定的複雜性,簡化後的語法可支持如下功能:

  • 關鍵詞查詢

    • 使用任意日誌內容進行全文查詢,如 ERROR /api/user/list
  • 指定列查詢

    • trace_id: xxxx user_id: 12345
    • key:* 表示篩選存在該列的日誌
  • 短語查詢

    • 匹配一段完整文字,如 message: "userId not exists"
    • 查詢內容含有保留字的情況,如 message: "userId:123456"
  • 模糊查詢

    • *Exception*logger: org.apache.*
  • 多值查詢

    • user_id: 1,2,3 等價於 user_id: 1 OR user_id: 2 OR user_id: 3,在複雜查詢下很方便,如 level:warn AND (user_id: 1 OR user_id: 2 OR user_id: 3) 即可簡寫為 level:warn AND user_id:1,2,3
  • 數字查詢

    • 支持 > = < ,如 http.elapsed > 100
    • 一條日誌中的兩個列也可互相比較,如 http.elapsed > http.expect_elapsed
  • 連接符

    • AND、OR、NOT
    • 用小括號表示優先級,如 a AND (b OR c)

日誌查詢服務會將用户輸入的類 Lucene 語法轉換為實際的 SQL。

全文查詢

該功能可謂是 ElasticSearch 的殺手鐗,難以想象無法全文檢索的日誌系統會是什麼體驗,而很多公司就這麼做了,如果查詢必須指定字段,體驗上想來不會怎麼愉悦。

我們通過將結構化列冗餘到 rawLog 中實現了全文查詢,同時對 rawLog 配置了跳數索引 tokenbf_v1 解決大數據量必須遍歷的問題。一條 rawLog 的內容如下:

{
    "project": "xxx-server",
    "dt": 1658160000058,
    "level": "INFO",
    "string$keys": [
        "trace_ext.endpoint_name",
        "trace_id",
        "trace_type"
    ],
    "string$values": [
        "/api/getUserInfo",
        "b7f7ae4a-f9ed-403a-b06c-ed46b84ba2a6",
        "SpringMVC"
    ],
    "unIndex$keys": [
        "http.header"
    ],
    "message": "HTTP requestLog"
}

當用户查詢 b7f7ae4a-f9ed-403a-b06c-ed46b84ba2a6 時,則使用 multiSearchAny(rawLog, ['b7f7ae4a-f9ed-403a-b06c-ed46b84ba2a6']) 查詢 rawLog 字段;

當用户查詢 trace_id: 7f7ae4a-f9ed-403a-b06c-ed46b84ba2a6 時,則使用 has(string.values, '7f7ae4a-f9ed-403a-b06c-ed46b84ba2a6') AND string.values[indexOf(string.keys, 'trace_id')] = '7f7ae4a-f9ed-403a-b06c-ed46b84ba2a6' 到列中查詢。

在實際使用中,即使使用列查詢,也會使用篩選條件 multiSearchAny(rawLog, 'xxx') ,因為 rawLog 的索引足夠大,很多情況下過濾效果更好

查詢結果 rawLogunIndex.keys、unIndex.values 構成了一條完整的日誌。這樣 where 條件中使用列進行過濾,select 的列則基本收斂到 rawLog 上,可大大提高查詢性能。

跳數索引

雖然 ClickHouse 的性能比較強,如果只靠遍歷數據量太大依然比較吃力。

在實際使用中,使用鏈路ID、用户ID搜索的場景比較多,這類搜索的特點是時間範圍可能不確定,關鍵詞的區分度很高。如果能針對這部分查詢加速,就能很大程度上解決問題。

ClickHouse 提供了三種字符串可用的跳數索引,均為布隆過濾器,分別如下:

  • bloom_filter 不對字符串拆分,直接使用整個值。
  • ngrambf_v1 會將每 N 個字符進行拆分。如果 N 太小,會導致總結果集太小,沒有任何過濾效果。如果 N 太大,比如 10,則長度低於 10 的查詢不會用到索引,這個度非常難拿捏。而且按每 N 字符拆分開銷未免過大,當 N 為 10,字符串長度為 100 時,會拆出來 90 個字符串用於布隆過濾器索引。
  • tokenbf_v1 按非字母數字字符(non-alphanumeric)拆分。相當於按符號分詞,而通常日誌中會有大量符號。

只有 tokenbf_v1 是最適合的,但也因此帶來了一些限制,如中文不能分詞,只能整段當做關鍵詞或使用模糊搜索。或者遇到中文符號(全角符號)搜不出來,因為不屬於 non-alphanumeric 的範圍,所以類似 訂單ID:1234 不能用 訂單ID1234 來進行搜索,因為這裏的冒號是全角的。

但 tokenbf_v1 確實是現階段唯一可用的了,於是我們建了一個很大的跳數索引 INDEX idx_rawLog rawLog TYPE tokenbf_v1(30720, 2, 0) GRANULARITY 1,大約會多使用 4% 的存儲才能達到比較好的篩選效果。以下是使用索引前後的對比,用 trace_id 查詢 1 天的日誌:

-- 不使用索引,耗時 61s
16 rows in set. Elapsed: 61.128 sec. Processed 225.35 million rows, 751.11 GB (3.69 million rows/s., 12.29 GB/s.)

-- 使用索引,耗時不到 1s
16 rows in set. Elapsed: 0.917 sec. Processed 2.27 thousand rows, 7.00 MB (2.48 thousand rows/s., 7.63 MB/s.)

-- 使用 set send_logs_level='debug' 可以看到索引過濾掉了 99.99% 的塊
<Debug> log.unified_log ... (SelectExecutor): Index `idx_rawLog` has dropped 97484/97485 granules.

繼續增加時間跨度差距會更加明顯,不使用索引需要幾百秒才能查到,使用索引仍然在數秒內即可查到。

跳數索引的原理和稀疏索引類似,由於在 ClickHouse 中數據已經被壓縮成塊,所以跳數索引是針對整個塊的數據,在查詢時篩選出有可能在的塊,再進入到塊中遍歷查詢。如果搜索的關鍵詞普遍存在,使用索引反而會減速,如下圖所示:
image.png

字段類型問題

ElasticSearch 在使用時會遇到字段類型推斷問題,一個字段有可能第一次以 Long 形式出現,但後續多了小數點成了 Float,一旦字段類型不兼容,後續的數據在寫入時會被丟棄。於是我們大部分時候都被迫選擇預先創建固定類型的列,限制服務打印日誌時不能隨意自定義列。

在日誌系統中,我們首先創建了 number.keys, number.values 來保存數字列,並將這些字段在 string.keys, string.values 裏冗餘了一份,這樣在查詢的時候不用考慮列對應的類型,以及類型變化等複雜場景,只需要知道用户的搜索方式。

如查詢 responseTime > 1000 時,就到 number 列中查詢,如果查詢 responseTime: 1000,就到 string 列中查詢。

一切都為了給用户一種無需思考的查詢方式,不用考慮它是不是數字,當它看起來像數字時,就可以用數字的方式搜索。同時也不需要預先創建日誌庫,創建日誌列,創建解析模式等。當你開始打印,日誌就出現了。

非索引字段

我們也提供了 unIndex 字段,配合 SDK 的實現用户可以將部分日誌輸出到非索引字段。在 unIndex 中的內容會被更有效地壓縮,不佔用 rawLog 字段可大幅加速全文查詢,只在查詢結果中展示。

日誌分析

如果僅僅是瀏覽,人眼能看到的日誌只佔總量的極少部分。尤其在動輒上億的量級下,我們往往只關注異常日誌,偶爾查查某條鏈路日誌。這種情況下數據的檢索率,或許只有百萬分之一。

而業務上使用的數據庫,某張表只有幾千萬條數據,一天卻要查上億次的情況屢見不鮮。

大量日誌寫入後直到過期,也沒有被檢索過。通過分析日誌來提高檢索率,提高數據價值,很多時候不是不想,而是難度太高。比如有很多實踐是用 hdfs 存儲日誌,flink 做分析,技術棧和工程複雜不説,添加新的分析模式不靈活,甚至無法發現新的模式。

ClickHouse 最強大的地方,正是其強悍到令人髮指的分析功能。如果只是用來存放、檢索日誌,無疑大材小用。如果做到存儲分析一體,不僅架構上會簡化,分析能力也可以大大提高,做到讓死日誌活起來。

於是我們通過一系列功能,讓用户能夠有效利用 ClickHouse 的分析能力,去挖掘發現有價值的模式。

ClickHouse 最強大的地方,正是其強悍到令人髮指的分析功能。如果只是用來存放、檢索日誌,無疑大材小用。如果做到存儲分析一體,不僅架構上會簡化,分析能力也可以大大提高,做到讓死日誌活起來。

於是我們通過一系列功能,讓用户能夠有效利用 ClickHouse 的分析能力,去挖掘發現有價值的模式。

快速分析

image.png
統計列的 TopN、佔比、唯一數。

這個功能不算稀罕,在各種三方日誌服務中算是標配。不過這裏的快速分析列不用事先配置,一旦日誌中出現這個列,就馬上在快速分析中可用。

為了這個功能,在日誌表中創建了一個 Projection:

PROJECTION p_projects_usually (SELECT project, count(), min(dt), max(dt), groupUniqArrayArray(string.keys), groupUniqArrayArray(number.keys), groupUniqArrayArray(unIndex.keys) GROUP BY project)

這樣一來實時查詢項目的所有列變得非常快,不用考慮在查詢服務中做緩存,同時這些列名也幫助用户查詢時自動補全:
image.png
但快速查詢最麻煩的是難以對資源進行控制,日誌數量較多時或查詢條件複雜時,快速分析很容易超時變成慢速分析。所以我們控制最多掃描 1000w 行,並利用 over() 在單條 SQL 中同時查出聚合與明細結果:

select logger, 
count() as cnt,
sum(cnt) over() as sum,
uniq(logger) over() as uniq from 
(
      select string.values[indexOf(string.keys, 'logger')] as logger
        from unified_log where project= 'xx-api-server' and dt between '2022-08-01' and '2022-08-01' and rawLog like '%abc%'
      limit 1000000
)
group by logger order by cnt desc limit 100;

高級直方圖

image.png
直方圖用來指示時間與數量的關係,在此之上我們又加了一個維度,列統計。

即直方圖是由日誌級別堆疊而成的,不同日誌級別定義了灰藍橙紅等不同顏色,不需要搜索也能讓用户一眼看到是不是出現了異常日誌:

image.png

同時它還可以和快速分析結合,讓直方圖可使用任意列進行統計:

image.png
這個功能曾成功幫助業務方定位 MQ 消費堆積的問題,當時發現在一些時間點,只有個別線程在進行消費,而在平時每個線程消費數量都很均勻。

殺手鐗 - 高級查詢

很多日誌都是沒有結構化的內容,如果能現場抽取這些內容並分析,則對挖掘日誌數據大有幫助。現在我們已經有了一套語法來檢索日誌,但這套語法無論如何也不適合分析。SQL 非常適合用來分析,大部分開發者對 SQL 也並不陌生,説來也巧,ClickHouse 本身就是 SQL 語法。

於是我們參考了阿里雲日誌服務,將語法通過管道符 | 一分為二,管道符前為日誌查詢語法,管道符後為 SQL 語法。管道符也可以有多個,前者是後者的子查詢。

為了方便使用,我們也對 SQL 進行了一定簡化,否則用户就要用 string.values[indexOf(string.keys, 'logger')] as logger 來獲取字段,未免囉嗦。而 ClickHouse 中有 Map 類型,可以稍稍簡化下用 string['logger'] as logger 。語法結構:

image.png
下面用個完整的例子看下,在服務日誌中看到一些警告日誌:

image.png
現在想統計有多少個不存在的工作節點,即「workerId=」後邊的部分,查詢語句如下:

工作節點不存在 | select sublen(message, 'workerId=', 10) as workerId, count() group by workerId

首先通過「工作節點不存在」篩選日誌,再通過字符串截取獲取具體的 ID,最後 group 再 count() ,執行結果如下:

image.png

最終執行到 ClickHouse 的 SQL 則比較複雜,在該示例中是這樣的:

SELECT
    sublen(message, 'workerId=', 10) AS workerId,
    COUNT()
FROM
    (
        SELECT
            dt,
            level,
            CAST((string.keys, string.values), 'Map(String,String)') AS string,
            CAST(
                (number.keys, number.values),
                'Map(String,Float64)'
            ) AS number,
            CAST(
                (unIndex.keys, unIndex.values),
                'Map(String,String)'
            ) AS unIndex,
            JSONExtractString(rawLog, 'message') AS message
        FROM
            log.unified_log_common_all
        WHERE
            project = 'xxx'
            AND dt BETWEEN '2022-08-09 21:19:12.099' AND '2022-08-09 22:19:12.099'
            AND (multiSearchAny(rawLog, [ '工作節點不存在' ]))
    )
GROUP BY
    workerId
LIMIT
    500

用户寫的 SQL 當做父查詢,我們在子查詢中通過 CAST 方法將一對數組拼成了 Map 交給用户使用,這樣也可以有效控制查詢的範圍。

而下面這個示例,則通過高級查詢定位了受影響的用户。如下圖日誌,篩選條件為包含「活動不存在」,並導出 activityId、uid、inviteCode 字段
image.png
查詢語句如下:

參與的活動不存在 and BIZ_ERROR 
| select dt, JSONExtractString(message, 'requestPayload') AS requestPayload 
| select dt, JSONExtractString(requestPayload, 'eventParam', 'uid') AS uid,
JSONExtractString(requestPayload, 'eventParam', 'activityId') AS activityId,
JSONExtractString(requestPayload, 'eventParam', 'inviteCode') AS inviteCode 

結果如下:
image.png
在結果中發現有重複的 uid、activityId 等,因為該日誌是 HTTP 請求日誌,用户會反覆請求。所以還需要去重一下,在 ClickHouse 中有 limit by語法可以很方便地實現,現在高級查詢如下:

參與的活動不存在 and BIZ_ERROR 
| select dt, JSONExtractString(message, 'requestPayload') AS requestPayload 
| select dt, JSONExtractString(requestPayload, 'eventParam', 'uid') AS uid,
JSONExtractString(requestPayload, 'eventParam', 'activityId') AS activityId,
JSONExtractString(requestPayload, 'eventParam', 'inviteCode') AS inviteCode 
limit 1 by uid, activityId

查詢結果中可見已經實現去重,結果數量也少了很多:
image.png

再進一步,也可以通過 inviteCode 邀請碼在 Grafana 上創建面板,查看邀請碼使用趨勢,並創建告警
image.png

自定義函數

ClickHouse 支持 UDF(User Defined Functions),於是也自定義了一些函數,方便使用。

  • subend,截取兩個字符串之間的內容
  • sublen,截取字符串之後 N 位
  • ip_to_country、ip_to_province、ip_to_city、ip_to_provider, IP 轉城市、省份等
  • JSONS、JSONI、JSONF: JSONExtractString、JSONExtractInt、JSONExtractFloat的簡寫

日誌週期管理

日誌備份

我們探索了很多種日誌備份方式,最開始是在日誌上報服務中,讀 Kafka 時另寫一份到 S3 中,但是遇到了很多困難。如果按照 project 的維度拆分,那麼在 S3 上會產生非常多的文件。又嘗試用 S3 的分片上傳,但如果中間停機了,會丟失很大一部分分片數據,導致數據丟失嚴重;如果不按照 project 拆分,將所有服務的日誌都放在一起,那麼恢復日誌的時候會很麻煩,即使只需要恢復 1GB 的日誌,也要檢索 1TB 的文件。

而 ClickHouse 本身的文件備份行不行呢,比如用 clickhouse-copier、ttl 等。首先問題還是無法按 project 區分,其次是這些在系統工程中,難以脱離人工執行。而如果使用 ttl,數據有可能沒到 ttl 時間就因故丟失了。況且,我們還要求不同的 project 有不同的保存時間。

我們的最終方案是,通過 ClickHouse 的 S3 函數實現。ClickHouse 備份恢復語句如下:

-- 寫入到 S3 
INSERT INTO FUNCTION s3('%s','%s', '%s', 'JSONCompactEachRow', 'dt DateTime64(3), project String, level String, string$keys Array(String), string$values Array(String), number$keys Array(String), number$values Array(Float64), unIndex$keys Array(String), unIndex$values Array(String) ,rawLog String', 'gzip') 
SELECT dt, project, level, string.keys, string.values, number.keys, number.values, unIndex.keys, unIndex.values, rawLog FROM log.unified_log where project = '%s' and dt between '%s' and '%s' order by dt desc limit 0,%d

-- 從 S3 恢復
insert into log.unified_log (dt, project, level, string.keys, string.values, number.keys, number.values, unIndex.keys, unIndex.values, rawLog) 
select * from s3('%s','%s', '%s', 'JSONCompactEachRow', 'dt DateTime64(3), project String, level String, string$keys Array(String), string$values Array(String), number$keys Array(String), number$values Array(Float64), unIndex$keys Array(String), unIndex$values Array(String) ,rawLog String') 

在日誌上報服務中,每晚 1 點會跑定時任務,將前一天的日誌數據逐步備份到 S3 中。

至於當天的日誌,則有 Kafka 做備份,如果當天的日誌丟了,則重置 kafka 的消費點位,從 0 點開始重新消費。

日誌生命週期

在表的維度,有 ttl 設置。在日誌控制面中可針對每個 project 配置保留時間,通過定時任務,對超時的日誌執行 delete 操作: alter table unified_log delete where project = 'api-server' and dt < '2022-08-01'

由於 delete 操作負載較高,在配置生命週期時需要注意,最好對量級較大的服務獨立配置生命週期。因為 delete 本質是將 Part 中的數據讀出來重新寫入一遍,在過程中排除符合 where 條件的數據。所以選擇日誌量較大的服務,才能降低 delete 操作的開銷,不然沒有刪除的必要。

同時日誌控制面也會定時監控磁盤使用量,一旦超過 95% 則啓動強制措施,從最遠一天日誌開始執行 alter table unified_log drop partition xxx ,快速刪除數據釋放磁盤,避免磁盤徹底塞滿影響使用。

冷數據恢復

用户選擇好時間範圍,指定過濾詞後,執行數據恢復任務。

日誌控制面會掃描 S3 上該服務的備份文件,並解凍文件(通常會對備份日誌配置歸檔存儲),等待文件解凍後,到 ClickHouse 中執行恢復。 此時用户在頁面上可以看到日誌恢復進度,並可以直接瀏覽已經恢復的日誌了。

被恢復的日誌會寫入到一個新的虛擬集羣中,具體實現為在 DataNode 中創建新的表,如 unified_log_0801,在 ReadNode 中創建新的分佈式表,連接到新表中。查詢時通過該分佈式表查詢即可。

在冷數據使用完後,刪除之前創建出的表,避免長時間佔用磁盤空間。

ClickHouse 性能淺談

性能優化

ClickHouse 本身是一款非常高效且設計良好的軟件,所以對它的優化也相對比較簡單,縱向擴容服務器配置即可線性提高,而擴容最主要的地方就在 CPU 和存儲。

在執行查詢時觀察 CPU 是否始終很高,在 SQL 後添加參數 settings max_threads=n 看是否明顯影響查詢速度。如果加了線程明顯查詢速度提高,則説明繼續加 CPU 對提高性能是有效的。反之瓶頸則不在 CPU 。

存儲上最好選擇 SSD,儘量大的讀寫速度對查詢速度幫助是極大的。而隨機尋址速度好處有限,只要保證表設計合理,最終的 Part 文件數量不會太多,那麼大部分的讀取都是順序的。

檢查存儲的瓶頸方式則很多,比如在查詢時 Top 觀察 CPU 的 wa 是否過高;通過 ClickHouse 命令行的查詢速度結合列壓縮比例,推斷原始的讀取速度;

而需要注意的是,如果列創建了很大的跳數索引,則可能在查詢時會消耗一定量的時間。因為跳數索引是針對塊的,一個 part 中可能包含幾千幾萬個塊,就有幾千幾萬個布隆過濾器,匹配索引時需要循環挨個匹配。比如上文中跳數索引示例中,查詢 trace_id 花費了 0.917s,實際上從 trace log可以看到,在索引匹配階段花了 0.8s。

這個問題可能會在全文索引推出時得到緩解,因為布隆過濾器只能針對某幾個塊,布隆過濾器之間無法協作,數據的實際維度是 塊 → 過濾器。而全文索引(倒排索引)正好將這個關係倒過來,過濾器→塊,索引階段不用循環匹配,速度則會提高很多。不過最終還是看官方怎麼實現了,而且全文索引在數據寫入時的開銷也一定會比布隆過濾器高一些。

性能成本平衡

對我來説,日誌自然是要充分滿足即席查詢的,所以優先保證查詢速度,而不是成本和存儲時長。而這套日誌系統也可以根據不同的權衡,有不同的玩法。

性能優先型

在我們的實踐中,使用了雲平台的自帶 SSD 型機器,CPU 基本夠用,可以提供極高的讀寫性能,單盤可以達到 3GB/s。在使用時我們做了軟 raid,來降低 ClickHouse 配置的複雜度。

這種部署成本也能做到很低,相比使用服務商的雲盤,要低 70% 左右。

存儲分離型

存儲使用服務商提供的雲盤,優點是雲盤可以隨時擴容而且不丟數據。可以一定程度上單獨擴容存儲量和讀寫能力。

缺點是雲盤通常不便宜,低等級的雲盤提供的讀寫能力較差,而且讀寫會受限於服務器的網絡帶寬。高等級的雲盤需要配合高規格的服務器才能完全發揮。

完全 S3 型

ClickHouse 的存儲策略添加 S3 類型,並將表的 storage_policy 指定為S3。這樣利用 S3 極低的存儲價格,基本不用擔心存儲費用問題。還能利用 S3 的生命週期管理來管理日誌。

缺點是 S3 存儲目前還不健全,可能會踩坑。S3 的性能當然也不算好,還會受限於單個 Bucket 的吞吐上限。不過用來承載低負載的場景還是很有價值的。

結語

基於 ClickHouse 構建的通用日誌系統,有希望帶領日誌走向另一條道路,日誌本就不應該是搜索引擎,而應該是大數據。未來日誌的側重點,應該更多從查詢瀏覽,轉向分析挖掘。

我們也在探索日誌在定時分析,批分析上的能力,讓日誌能夠發揮出更大的價值。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.