序言
ClickHouse 是一款常用於大數據分析的 DBMS,因為其壓縮存儲,高性能,豐富的函數等特性,近期有很多嘗試 ClickHouse 做日誌系統的案例。本文將分享如何用 ClickHouse 做出通用日誌系統。
日誌系統簡述
在聊為什麼 ClickHouse 適合做日誌系統之前,我們先談談日誌系統的特點。
- 大數據量。對開發者來説日誌最方便的觀測手段,而且很多情況下會直接打印 HTTP、RPC 的請求響應日誌,這基本上就是把網絡流量複製了一份。
- 非固定檢索模式。用户有可能使用日誌中的任意關鍵字任意字段來查詢。
- 成本要低。日誌系統不宜在 IT 成本中佔比過高。
- 即席查詢。日誌對時效性要求普遍較高。
- 數據量大,檢索模式不固定,既要快,還得便宜。所以日誌是一道難解的題,它的需求幾乎違反了計算機的基本原則,不過幸好它還留了一扇窗,就是對併發要求不高。大部分查詢是人為即興的,即使做一些定時查詢,所檢索的範圍也一定有限。
現有日誌方案
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 數量不受控制。
架構設計
解決了核心問題後,我們設計了一整套架構,使之能夠成為通用日誌系統。整體架構如下:
在系統中有如下角色:
-
日誌上報服務
- 從 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: xxxxuser_id: 12345key:*表示篩選存在該列的日誌
-
短語查詢
- 匹配一段完整文字,如
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 的索引足夠大,很多情況下過濾效果更好。
查詢結果 rawLog 與 unIndex.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 不能用 訂單ID、1234 來進行搜索,因為這裏的冒號是全角的。
但 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 中數據已經被壓縮成塊,所以跳數索引是針對整個塊的數據,在查詢時篩選出有可能在的塊,再進入到塊中遍歷查詢。如果搜索的關鍵詞普遍存在,使用索引反而會減速,如下圖所示:
字段類型問題
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 的分析能力,去挖掘發現有價值的模式。
快速分析
統計列的 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)
這樣一來實時查詢項目的所有列變得非常快,不用考慮在查詢服務中做緩存,同時這些列名也幫助用户查詢時自動補全:
但快速查詢最麻煩的是難以對資源進行控制,日誌數量較多時或查詢條件複雜時,快速分析很容易超時變成慢速分析。所以我們控制最多掃描 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;
高級直方圖
直方圖用來指示時間與數量的關係,在此之上我們又加了一個維度,列統計。
即直方圖是由日誌級別堆疊而成的,不同日誌級別定義了灰藍橙紅等不同顏色,不需要搜索也能讓用户一眼看到是不是出現了異常日誌:
同時它還可以和快速分析結合,讓直方圖可使用任意列進行統計:
這個功能曾成功幫助業務方定位 MQ 消費堆積的問題,當時發現在一些時間點,只有個別線程在進行消費,而在平時每個線程消費數量都很均勻。
殺手鐗 - 高級查詢
很多日誌都是沒有結構化的內容,如果能現場抽取這些內容並分析,則對挖掘日誌數據大有幫助。現在我們已經有了一套語法來檢索日誌,但這套語法無論如何也不適合分析。SQL 非常適合用來分析,大部分開發者對 SQL 也並不陌生,説來也巧,ClickHouse 本身就是 SQL 語法。
於是我們參考了阿里雲日誌服務,將語法通過管道符 | 一分為二,管道符前為日誌查詢語法,管道符後為 SQL 語法。管道符也可以有多個,前者是後者的子查詢。
為了方便使用,我們也對 SQL 進行了一定簡化,否則用户就要用 string.values[indexOf(string.keys, 'logger')] as logger 來獲取字段,未免囉嗦。而 ClickHouse 中有 Map 類型,可以稍稍簡化下用 string['logger'] as logger 。語法結構:
下面用個完整的例子看下,在服務日誌中看到一些警告日誌:
現在想統計有多少個不存在的工作節點,即「workerId=」後邊的部分,查詢語句如下:
工作節點不存在 | select sublen(message, 'workerId=', 10) as workerId, count() group by workerId
首先通過「工作節點不存在」篩選日誌,再通過字符串截取獲取具體的 ID,最後 group 再 count() ,執行結果如下:
最終執行到 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 字段
查詢語句如下:
參與的活動不存在 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
結果如下:
在結果中發現有重複的 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
查詢結果中可見已經實現去重,結果數量也少了很多:
再進一步,也可以通過 inviteCode 邀請碼在 Grafana 上創建面板,查看邀請碼使用趨勢,並創建告警
自定義函數
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 構建的通用日誌系統,有希望帶領日誌走向另一條道路,日誌本就不應該是搜索引擎,而應該是大數據。未來日誌的側重點,應該更多從查詢瀏覽,轉向分析挖掘。
我們也在探索日誌在定時分析,批分析上的能力,讓日誌能夠發揮出更大的價值。