把ES換成ClickHouse,B站的日志系統(tǒng)像開掛了一樣……
一、背景介紹
日志作為線上定位問題排障的重要手段,在可觀測領(lǐng)域有著不可替代的作用。穩(wěn)定性、成本、易用性、可擴(kuò)展性都是日志系統(tǒng)需要追求的關(guān)鍵點(diǎn)。
B站基于Elastic Stack的日志系統(tǒng)(Billions) 從2017建設(shè)以來, 已經(jīng)服務(wù)了超過5年,目前規(guī)模超過500臺機(jī)器,每日寫入日志量超過700TB。
ELK體系是業(yè)界最常用的日志技術(shù)棧,在傳輸上以結(jié)合規(guī)范key的JSON作為傳輸格式,易于多種語言實(shí)現(xiàn)和解析,并支持動態(tài)結(jié)構(gòu)化字段。存儲上ElasticSearch支持全文檢索,能夠快速從雜亂的日志信息中搜尋到關(guān)鍵字。展示上Kibana具有美觀、易用等特性。
隨著業(yè)務(wù)系統(tǒng)的高速發(fā)展,日志系統(tǒng)的規(guī)模也隨之快速擴(kuò)展,我們遇到了一系列的問題,同時(shí)可觀測業(yè)界隨著OpenTelemetry規(guī)范的成熟,推動著我們重新考量,邁入下一代日志系統(tǒng)。
二、遇到的問題
1)首先必須要提的就是成本和穩(wěn)定性。日志作為一種應(yīng)用產(chǎn)生的實(shí)時(shí)數(shù)據(jù),隨著業(yè)務(wù)應(yīng)用規(guī)模發(fā)展而緊跟著擴(kuò)大。日志系統(tǒng)必須在具備高吞吐量的同時(shí),也要具備較高的實(shí)時(shí)性要求。Elasticsearch由于分詞等特性,在寫吞吐量上有著明顯的瓶頸,分詞耗CPU且難以解決熱點(diǎn)問題。如果資源冗余不足,就容易導(dǎo)致穩(wěn)定性下降,日志攝入發(fā)生延遲,日志的延遲會對排障產(chǎn)生極大負(fù)面影響。
2)同時(shí)由于壓縮率不高的原因,ES的存儲成本也較高,對內(nèi)存有著較高的要求。這些因素導(dǎo)致我們?nèi)罩颈仨氝M(jìn)行常態(tài)化的采樣和限流,對用戶使用上造成了困擾,限制了排障的場景。
3)內(nèi)存使用率的問題也迫使我們必須將warm階段的索引進(jìn)行Close,避免占用內(nèi)存。用戶如果需要查詢就必須操作進(jìn)行Open,犧牲了一定的易用性。
4)為了穩(wěn)定性和成本,動態(tài)Mapping也必須被關(guān)閉,有時(shí)用戶引導(dǎo)不到位,就會導(dǎo)致用戶發(fā)現(xiàn)自己搜索的日志遺留了Mapping配置而導(dǎo)致難以追溯查詢。
5)在運(yùn)維上,ES7之前缺少生命周期的能力,我們必須維護(hù)一整套生命周期相關(guān)組件,來對索引進(jìn)行預(yù)創(chuàng)建、關(guān)閉和刪除,不可避免的帶來高維護(hù)成本。
6)Kibana雖然好用,但也不是沒有缺點(diǎn)的,整體代碼復(fù)雜,二次開發(fā)困難。且每次升級ES必須升級到對應(yīng)的Kibana版本也增加了用戶遷移的成本。還有一點(diǎn)就是Kibana Query雖然語法較為簡單,但對于初次接觸的研發(fā)還是有一定學(xué)習(xí)成本的。
7)在采集和傳輸上,我們制定了一套內(nèi)部的日志格式規(guī)范,使用JSON作為傳輸格式,并提供了Java和Golang的SDK。這套傳輸格式本身在序列化/反序列化上性能一般,且私有協(xié)議難以避免兼容性和可維護(hù)性問。
三、新架構(gòu)體系
針對上述的一系列問題,我們設(shè)計(jì)了Bilibili日志服務(wù)2.0的體系,主要的進(jìn)化為使用ClickHouse作為存儲,實(shí)現(xiàn)了自研的日志可視化分析平臺,并使用OpenTelemetry作為統(tǒng)一日志上報(bào)協(xié)議。
如圖所示為日志實(shí)時(shí)上報(bào)和使用的全鏈路。日志從產(chǎn)生到消費(fèi)會經(jīng)過采集→攝入→存儲 →分析四個(gè)步驟,分別對應(yīng)我們在鏈路上的各個(gè)組件,先做個(gè)簡單的介紹:
- OTEL Logging SDK
完整實(shí)現(xiàn)OTEL Logging日志模型規(guī)范和協(xié)議的結(jié)構(gòu)化日志高性能SDK,提供了Golang和Java兩個(gè)主要語言實(shí)現(xiàn)。
- Log-Agent
日志采集器,以Agent部署方式部署在物理機(jī)上,通過Domain Socket接收OTEL協(xié)議日志,同時(shí)進(jìn)行低延遲文件日志采集,包括容器環(huán)境下的采集。支持多種Format和一定的加工能力,如解析和切分等。
- Log-Ingester
負(fù)責(zé)從日志kafka訂閱日志數(shù)據(jù), 然后將日志數(shù)據(jù)按時(shí)間維度和元數(shù)據(jù)維度(如AppID) 拆分,并進(jìn)行多隊(duì)列聚合, 分別攢批寫入ClickHouse中.
- ClickHouse
我們使用的日志存儲方案,在ClickHouse高壓縮率列式存儲的基礎(chǔ)上,配合隱式列實(shí)現(xiàn)了動態(tài)Schema以獲得更強(qiáng)大的查詢性能,在結(jié)構(gòu)化日志場景如猛虎添翼。
- Log-Query
日志查詢模塊,負(fù)責(zé)對日志查詢進(jìn)行路由、負(fù)載均衡、緩存和限流,以及提供查詢語法簡化。
- BLS-Discovery
新一代日志的可視化分析平臺,提供一站式的日志檢索、查詢和分析,追求日志場景的高易用性,讓每個(gè)研發(fā)0學(xué)習(xí)成本無障礙使用。
下邊我們將針對幾個(gè)重點(diǎn)進(jìn)行詳細(xì)設(shè)計(jì)闡述。
1、基于ClickHouse的日志存儲
新方案最核心的部分就是我們將日志的通用存儲換成了ClickHouse。
先說結(jié)果,我們在用戶只需要付出微小遷移成本的條件下(轉(zhuǎn)過來使用SQL語法進(jìn)行查詢),達(dá)到了10倍的寫入吞吐性能,并以原先日志系統(tǒng)1/3的成本,存儲了同等規(guī)模量的日志。在查詢性能上,結(jié)構(gòu)化字段的查詢性能提升2倍,99%的查詢能夠在3秒內(nèi)完成。
下圖為同一份日志在Elasticsearch、ClickHouse和ClickHouse(zstd)中的容量,最終對比ES達(dá)到了1:6。
ClickHouse方案里另一個(gè)最大的提升是寫入性能,ClickHouse的寫入性能達(dá)到了ES的10倍以上。
在通用結(jié)構(gòu)化日志場景,用戶往往是使用動態(tài)Schema的,所以我們引入了隱式列Map類型來存儲動態(tài)字段, 以同時(shí)獲得動態(tài)性和高查詢性能,稍后將會重點(diǎn)介紹隱式列的實(shí)現(xiàn)。
我們的表設(shè)計(jì)如下,我們針對每個(gè)日志組,都建立了一張復(fù)制表。表中的字段分為公共字段(即OTEL規(guī)范的Resource字段, 以及trace_id和span_id等),以及隱式列字段,string_map,number_map,bool_map分別對應(yīng)字符串字段,數(shù)字字段和布爾字段。我們對常用的日志值類型進(jìn)行分組,使用這三個(gè)字段能滿足大部分查詢和寫入的需求。
同時(shí),我們根據(jù)日志重要程度和用戶需求定義了不同時(shí)間范圍的TTL。根據(jù)統(tǒng)計(jì)大部分(90%)的日志查詢集中在4小時(shí)以內(nèi),我們?yōu)槿罩局贫巳齻€(gè)階段的日志生命周期,Hot,Warm和Cold.Hot階段,所有日志在高速存儲中,保證高寫入和檢索性能,通常我們的資源保證24小時(shí)日志數(shù)據(jù)會在此階段中;Warm階段,日志遷移到Sata盤中,同樣可以進(jìn)行檢索,通常需要更長時(shí)間;Cold階段,在達(dá)到了第二個(gè)TTL時(shí)間后,在ClickHouse中刪除騰出空間,如果有需求會在HDFS中有備份。
對于大部分的字段,我們都使用了ZSTD(1)的壓縮模式,經(jīng)過測試相比默認(rèn)的Lz4提升了50%的壓縮率,在寫入和查詢性能上的代價(jià)不超過5%,適合日志寫多讀少的場景。
2、查詢網(wǎng)關(guān)
查詢網(wǎng)關(guān)承擔(dān)著查詢?nèi)肟诘娜蝿?wù)。我們主要在這個(gè)組件上集成了查詢路由,查詢負(fù)載均衡,簡化查詢語法,緩存,限流等功能。
這里先介紹下簡化查詢語法的功能。作為日志對前端以及對外的接口,我們的目標(biāo)是對用戶屏蔽一些底層復(fù)雜實(shí)現(xiàn)。如ClickHouse的Local表/分布式表,隱式列和公共字段的查詢區(qū)別,以及對用戶查詢進(jìn)行限制(強(qiáng)制Limit,強(qiáng)制時(shí)間范圍)。
上圖可見我們允許用戶在編寫查詢SQL時(shí),不需要關(guān)心字段是否為隱式列,也不需要關(guān)心目標(biāo)表和集群,以最簡單的方式通過Restful API與日志查詢網(wǎng)關(guān)進(jìn)行交互獲取日志數(shù)據(jù)。這樣的實(shí)現(xiàn)還有個(gè)目的就是為以后可能再一次的存儲引擎迭代做鋪墊,將日志查詢與底層實(shí)現(xiàn)解耦。
此外,在查詢網(wǎng)關(guān)上還集成了Luence語法的解析器,將其自動轉(zhuǎn)化為SQL語法,為用戶的API遷移提供便利。
3、可視化分析平臺
在升級日志架構(gòu)的過程中,很多用戶反應(yīng)希望能夠延續(xù)之前的日志使用方式,盡量減少遷移學(xué)習(xí)的成本。我們考慮了讓Kibana兼容ClickHouse作為存儲引擎。然而由于我們同時(shí)維護(hù)中Kibana5和7兩個(gè)版本,且越高版本的Kibana功能豐富,與Elastic Stack綁定過深,我們決定還是自研日志可視化查詢分析平臺。
我們的目標(biāo)是:
- 提供接近Kibana使用習(xí)慣的界面,減少用戶的遷移成本;
- 作為排障的入口,和內(nèi)部其他組件打通,如監(jiān)控告警,分布式追蹤等。
緊記著上面兩個(gè)關(guān)鍵目標(biāo),我們開始了自研之路。
Kibana作為非常成熟的日志分析界面,具有非常多的細(xì)節(jié),都是在使用過程中沉積下來的功能。任何一個(gè)功能用戶都有不低的使用頻率。如圖所示我們能實(shí)現(xiàn)了查詢語句高亮和提示、字段分布分析、日志時(shí)間分布預(yù)覽、查詢高亮以及日志略縮展示等等。
用戶在查詢?nèi)罩緯r(shí), 使用標(biāo)準(zhǔn)SQL的Where condition部分, 如 log.level = 'ERROR' 進(jìn)行日志過濾, 并對用戶將隱式列和具體的表透明化(通過查詢網(wǎng)關(guān)的能力)。
我們還使用code-mirror2實(shí)現(xiàn)了查詢的自動補(bǔ)全和查詢提示。提示的內(nèi)容包括常用歷史查詢, 字段, 關(guān)鍵字和函數(shù), 進(jìn)一步降低用戶的使用門檻。
在此基礎(chǔ)之上,我們開發(fā)了能夠領(lǐng)先于Kibana的使用體驗(yàn)的殺手锏,我們自研的可視化分析平臺集成了快速分析能力。用戶可以直接輸入SQL聚合語句,即時(shí)對日志進(jìn)行聚合分析。
同時(shí)我們可以將查詢分析界面作為一個(gè)入口,打通相關(guān)信息和功能。如日志告警快速的快速配置、日志寫入量統(tǒng)計(jì)和優(yōu)化點(diǎn)、快速配置二級索引、快速跳轉(zhuǎn)分布式追蹤平臺等。
這些都是為了能讓原先使用Kibana的用戶能夠在無縫地切換到新平臺上來使用的基礎(chǔ)上,擁有更好的排障體驗(yàn)。
4、日志告警
除了在日志分析界面上人為進(jìn)行日志查詢排障外,日志監(jiān)控規(guī)則也是常用且好用的快速感知系統(tǒng)問題的手段。
在日志服務(wù)2.0的版本中,日志告警服務(wù)在兼容了日志ClickHouse作為數(shù)據(jù)源的基礎(chǔ)上,將計(jì)算模型進(jìn)行了統(tǒng)一化,剝離了原先Elasticsearch場景的特有語義,使得計(jì)算和觸發(fā)規(guī)則更靈活,配置更容易。
我們將一個(gè)日志告警規(guī)則定義為由以下幾個(gè)屬性組成:
- 名稱和數(shù)據(jù)源;
- 查詢時(shí)間范圍,如1分鐘;
- 計(jì)算間隔,如1分鐘;
- 計(jì)算函數(shù),如日志計(jì)數(shù),日志字段和,日志字段最大值最小值,日志字段去重計(jì)數(shù)等;
- 日志過濾規(guī)則,如region = 'sh' AND log.level = 'ERROR';
- 觸發(fā)條件,如 大于/小于,環(huán)比上升/下降,基于智能時(shí)序異常檢測等;
- 告警通道,如電話,短信,郵件等;
- 告警附帶信息,如固定文案,關(guān)鍵日志等;
- 通知對象,一般為可配置的Group(可以結(jié)合Oncall切換人員),或者可以是固定的人;
- 告警風(fēng)暴抑制相關(guān),如告警間隔時(shí)間,連續(xù)觸發(fā)規(guī)則等。
目前B站系統(tǒng)中目前已配置超過5000+的日志告警,我們提供了告警遷移工具,能夠自動通過原ES語法的規(guī)則,生成對應(yīng)的2.0版本過濾規(guī)則,來幫助用戶快速遷移。
5、OpenTelemetry Logging
原OpenTracing[1]和OpenCensus[2]項(xiàng)目已經(jīng)合并入OpenTelemetry項(xiàng)目,從趨勢和未來考慮,新項(xiàng)目不再推薦使用前述兩個(gè)項(xiàng)目,建議直接使用Opentelemtry通用api收集可觀測數(shù)據(jù)。
Opentelemetry[3]是一套工具集,專注于可觀測性領(lǐng)域的數(shù)據(jù)收集端,致力于可觀測性3大領(lǐng)域metrics、logs、traces的通用api規(guī)范以及支持編程語言的sdk實(shí)現(xiàn)。除了嵌入用戶代碼的sdk ,Opentelemetry也提供了可觀測性數(shù)據(jù)收集時(shí)的收集器[4]實(shí)現(xiàn),該收集器可作為sidecar,daemonset,proxy 3種形式部署,支持多種可觀測協(xié)議的收集和導(dǎo)出。
目前,OTEL Logging的協(xié)議已經(jīng)處于stable,主要定義了以下的標(biāo)準(zhǔn)日志模型。
我們完整的按照OTEL的標(biāo)準(zhǔn)實(shí)現(xiàn)了Golang和Java的SDK,并在采集器(Log-Agent)集成了OTEL兼容層。
6、如何解決日志搜索問題
得益于ClickHouse的高壓縮率和查詢性能,小日志量的應(yīng)用日志直接可以搜索即可。在大日志量場景,對于某種唯一id的搜索,使用tokenbf_v1建立二級索引,并引導(dǎo)用戶使用hasToken)或通過上文描述的~`操作符進(jìn)行搜索,跳過大部分的part,能獲得不亞于ES的查詢性能。對于某種日志模式的搜索,引導(dǎo)用戶盡可能使用logger_name,或者source(代碼行號)來進(jìn)行搜索 同時(shí)盡量減少需要搜索的日志范圍。在此基礎(chǔ)上,我們還必須推進(jìn)日志結(jié)構(gòu)化。
一開始的日志往往是無結(jié)構(gòu)化的,人可讀的。隨著微服務(wù)架構(gòu)發(fā)展,日志作為可觀測性的三大支柱,越來越需要關(guān)注日志的機(jī)器可讀性。統(tǒng)一的日志平臺也對日志的結(jié)構(gòu)化有要求,來進(jìn)行復(fù)雜聚合分析。
ClickHouse方案中,由于缺少倒排索引,對日志結(jié)構(gòu)化程度的要求會更高。在推進(jìn)業(yè)務(wù)遷于新方案時(shí),我們也需要同步進(jìn)行結(jié)構(gòu)化日志的推進(jìn)。
對于這樣一段常見日志:
log.Info("report id=32 created by user 4253)"
結(jié)構(gòu)化我們可以抽取report id和user id,作為日志屬性獨(dú)立輸出,并定義該段日志的類型,如:
log.Infov(log.KVString("log_type","report_created"),log.KVInt("report_id",32), log.KVInt("user_id",4253))
結(jié)構(gòu)化后對于整體存儲和查詢性能上都能獲得提升,用戶可以更方便的對user id或report id進(jìn)行搜索或分析。
四、Clickhouse功能增強(qiáng)與優(yōu)化
在日志場景中,我們選用Clichouse作為底層的查詢引擎,主要原因有兩個(gè):一個(gè)是Clichouse相較于ES具備更高的資源利用效率,在磁盤,內(nèi)存方面的消耗都更低;另外一個(gè)就是Clichouse支持的豐富的數(shù)據(jù)和索引類型能夠滿足我們針對特定pattern的查詢性能需求。
1、Clickhouse配置優(yōu)化
因?yàn)槿罩镜臄?shù)據(jù)量比較大,在實(shí)際的接入過程中我們也是遇到了一些問題。
1)Too many parts
這個(gè)問題在clickhouse寫入過程中還是比較常見的,根因是clickhouse merge的速度跟不上part的 生成速度。這個(gè)時(shí)候我們的優(yōu)化方向可以從兩個(gè)方面層面考慮,寫入側(cè)和clickhouse側(cè)。
在日志的寫入側(cè)我們降低寫入的頻次,通過攢batch這種方式來降低clickhouse part的生成速度。缺點(diǎn)是這種攢批會導(dǎo)致數(shù)據(jù)的時(shí)延會增高,對于實(shí)時(shí)查詢的體感較差。而且,如果寫入只由batch大小控制,那么在數(shù)據(jù)斷流的時(shí)候就會有一部分?jǐn)?shù)據(jù)無法寫入。因?yàn)槿罩緢鼍跋聦?shù)據(jù)查詢的時(shí)效性要求比較高,所以我們除了設(shè)置batch大小還會有一個(gè)超時(shí)時(shí)間的配置項(xiàng)。當(dāng)達(dá)到這個(gè)超時(shí)時(shí)間,即便batch大小沒有達(dá)到指定大小,也會執(zhí)行寫入操作。
在寫入側(cè)配置了滿足業(yè)務(wù)需求的參數(shù)配置之后,我們的part生成速度依然超過了merge的速度。所以我們在clickhouse這邊也進(jìn)行了merge相關(guān)的參數(shù)配置,主要目的是控制merge的消耗的資源,同時(shí)提升merge的速度。
Merge相關(guān)的參數(shù),我們主要修改了以下幾個(gè):
min_bytes_for_wide_part
這個(gè)參數(shù)是用來指定落盤的part格式。當(dāng)part的大小超過了配置就會生成wide part,否則為compact part。日志屬于寫多讀少的一個(gè)場景,通過增大這個(gè)參數(shù),我們可以讓頻繁生成的part在落盤時(shí)多為compact part。因?yàn)閏ompact part相較于wide part小文件的個(gè)數(shù)要更少一些,測試下來,我們發(fā)現(xiàn)相同的數(shù)據(jù)量,compact part的merge速度要優(yōu)于wide part。相關(guān)的還有另一個(gè)參數(shù)min_rows_for_wide_part,作用跟min_bytes_for_wide_part相似。
max_bytes_to_merge_at_min_space_in_pool
當(dāng)merge的線程資源比較緊張時(shí),我們可以通過調(diào)整這個(gè)參數(shù)來配置可merge part的最大大小。默認(rèn)大小是1M,我們將這個(gè)參數(shù)上調(diào)了。主要的原因是,我們在頻繁寫入的情況下,merge資源基本處于打滿的狀態(tài)。而寫入的part大小基本也都超過了1M,此時(shí)這些part就不被merge,進(jìn)而導(dǎo)致part數(shù)據(jù)不斷變多,最終拋出Too many parts的問題。
max_bytes_to_merge_at_max_space_in_pool
這個(gè)參數(shù)是用來指定merge線程資源充足時(shí)可merge的最大part大小。通過調(diào)整這個(gè)參數(shù),我們可以避免去merge一些較大的part。因?yàn)檫@些part的合并耗時(shí)可能是小時(shí)級別的,如果在這期間有較為頻繁的數(shù)據(jù)寫入,那就有可能會出現(xiàn)merge線程不夠而導(dǎo)致的too many parts問題。
background_pool_size
默認(rèn)線程池大小為16,我們調(diào)整為32,可以有更多的線程資源參與merge。之所以沒有繼續(xù)上調(diào)這個(gè)參數(shù),是因?yàn)檩^多的merge線程可能會導(dǎo)致系統(tǒng)的CPU和IO負(fù)載過高。
2)Zookeeper負(fù)載過高
Clickhouse的副本表需要頻繁的讀寫clickhouse,熟悉clickhouse的用戶都知道zookeeper集群是clickhouse集群的性能瓶頸之一。因?yàn)閦ookeeper的寫壓力是沒法通過增加節(jié)點(diǎn)數(shù)得到緩解,所以當(dāng)一個(gè)集群的寫請求過于頻繁時(shí),zookeeper就可能成為我們寫入失敗的主要原因了。我們的日志系統(tǒng)為了能夠在服務(wù)級別進(jìn)行優(yōu)化和數(shù)據(jù)TTL設(shè)置,就將每個(gè)接入的服務(wù)通過不同的表進(jìn)行隔離。所以接入的服務(wù)越多,對應(yīng)的表和分區(qū)就會越多。而分區(qū)和part過多,就會導(dǎo)致整個(gè)集群對zookeeper的讀寫請求頻次增加,進(jìn)而導(dǎo)致zookeeper集群負(fù)載過高。針對這個(gè)問題,社區(qū)已經(jīng)有相關(guān)的PR解決了,那就是auxiliary zookeeper。這個(gè)特性讓我們可以在一個(gè)clickhouse集群中配置多個(gè)zookeeper集群,配置好之后我們只需要在建分布式表時(shí)指定對應(yīng)的zookeeper集群即可。這樣,zookeeper寫請求過于頻繁的問題也能得到很好的解決了。
2、Clickhouse動態(tài)類型Map
Map類型作為數(shù)據(jù)庫中一個(gè)重要的數(shù)據(jù)類型,能夠很好地滿足用戶對于表動態(tài)schema的需求。對于后期可能會動態(tài)增減的字段,或者因?yàn)閿?shù)據(jù)屬性而不同的字段,我們可以將其抽象成一個(gè)或多個(gè)map存儲,使用不同的k-v來存儲這些動態(tài)字段。Clickhouse的在版本v21.1.2.15將Map類型作為一個(gè)實(shí)驗(yàn)特性增加到支持的數(shù)據(jù)類型之中。而Map類型在B站的日志系統(tǒng)中解決的就是日志系統(tǒng)中不同服務(wù)具有不同的特有字段問題。使用Map字段一方面可以統(tǒng)一不同服務(wù)的表結(jié)構(gòu),另一方面使用Map能夠更好地預(yù)設(shè)表的schema,避免了后期因業(yè)務(wù)新增導(dǎo)致表結(jié)構(gòu)頻繁變更的問題, 降低了整個(gè)日志鏈路的復(fù)雜程度。
但是隨著數(shù)據(jù)體量的增加和查詢時(shí)間跨度的延伸,針對clickhouse原生map類型的查詢和過濾效果越來越不如人意,雖然clickhouse目前支持的map類型在功能上能夠滿足我們的需求,但是性能上卻依然有提升的空間。
3、Clickhouse Map實(shí)現(xiàn)原理
首先我們可以看看原生Map的實(shí)現(xiàn)。原生的Map是通過Array(Tupple(key, value))這種嵌套的數(shù)據(jù)結(jié)構(gòu)來保存Map的數(shù)據(jù)。當(dāng)我們讀取某個(gè)指定key值的數(shù)據(jù)時(shí),Clickhouse需要將指定的對應(yīng)的ColumnKey和ColumnValue都讀取到內(nèi)存中進(jìn)行反序列化。
假設(shè)原始表的數(shù)據(jù)如下表所示:
當(dāng)我們需要查詢k1對應(yīng)的數(shù)據(jù)時(shí),select map[’k1’] from map_table,那么每一行除了key值為k1的數(shù)據(jù)對我們而言都是不需要的。但是,在Clickhouse反序列化時(shí),這些數(shù)據(jù)都會被讀取到內(nèi)存之中,產(chǎn)生了不必要的計(jì)算和I/O。而且因?yàn)镃lickhouse不支持Map類型的索引,這種讀放大造成的損耗在數(shù)據(jù)體量很大的情況下對查詢性能有很大的影響。
通過上面的例子,可以看出,原生Map對于我們而言主要有兩個(gè)瓶頸:
- Clickhouse不支持Map類型索引,沒法很好地下推過濾掉不包含指定key值的數(shù)據(jù);
- 同一個(gè)Map中不需要的kv數(shù)據(jù)也會在查詢時(shí)讀取到內(nèi)存中處理。
4、Map類型索引支持
針對上面的問題,我們的首次嘗試是對Clickhouse索引進(jìn)行加強(qiáng),使其可以支持Map類型。因?yàn)槲覀兊膱鼍爸衚ey值多為String類型,所以我們優(yōu)先改造了bloom filter相關(guān)的索引。在數(shù)據(jù)寫入的時(shí)候我們會把每行數(shù)據(jù)的map都單獨(dú)拎出來處理,獲取到每一個(gè)key值,對key值創(chuàng)建索引。查詢時(shí),我們會解析出需要查詢的key值,索引中不包含該值時(shí)則會跳過對應(yīng)的granule,以達(dá)到盡量少讀取不必要數(shù)據(jù)的目的。
通過上述方式,我們在執(zhí)行select map[’k1’] from map_table時(shí),可以將未命中索引的granule都過濾掉,這樣可以在一定程度上減少不必要的計(jì)算和I/O。
創(chuàng)建測試表:
CREATE TABLE bloom_filter_map
(
`id` UInt32,
`map` Map(String, String),
INDEX map_index map TYPE tokenbf_v1(128, 3, 0) GRANULARITY 1
)
ENGINE = MergeTree
ORDER BY id
SETTINGS index_granularity = 2
----插入數(shù)據(jù)
insert into bloom_filter_map values (1, {'k1':'v1', 'k2':'v2'});
insert into bloom_filter_map values (2,{'k1':'v1_2','k3':'v3'});
insert into bloom_filter_map values (3,{'k4':'v4','k5':'v5'});
----查詢數(shù)據(jù)
select map['key1'] from bloom_filter_map;
通過query_id可以查看到只查詢了兩行數(shù)據(jù),索引生效
通過上述的實(shí)現(xiàn)在索引粒度設(shè)置合適的情況下我們能夠有效地下推過濾掉部分非必要的數(shù)據(jù)。這部分的源碼我們已經(jīng)貢獻(xiàn)到社區(qū),相關(guān)PR[5]。
雖然支持了Map類型的索引能夠過濾部分?jǐn)?shù)據(jù),但是當(dāng)map的key分布地比較稀疏時(shí),例如上例中的k1,如果在每個(gè)granule中都出現(xiàn)的話我們還是會讀取很多無效的數(shù)據(jù),同時(shí),一個(gè)map中的我們不需要的kv我們還是沒法跳過。為了解決這個(gè)問題,我們實(shí)現(xiàn)了Clickhouse Map類型的隱式列。
5、Map隱式列簡介
隱式列就是在Map數(shù)據(jù)寫入的時(shí)候,我們會把map中的每個(gè)key單獨(dú)抽出來在底層作為一個(gè)Column存儲。通過這種對用戶透明的轉(zhuǎn)換,我們可以在查詢指定key時(shí)讀取對應(yīng)的column,通過這種方式來避免讀取需要的kv。
通過這種轉(zhuǎn)換,當(dāng)我們再執(zhí)行類似select map[’k1’] from map_table這種SQL時(shí),Clickhouse就只會去讀取column_k1這個(gè)Column的數(shù)據(jù),能夠極大地避免讀放大的情況。
考慮到已有的業(yè)務(wù),我們并沒有直接在原生的Map類型上進(jìn)行改造,而是支持了一種新的數(shù)據(jù)類型MapV2,底層則是實(shí)現(xiàn)了Map到隱式列的轉(zhuǎn)換。
6、Map隱式列實(shí)現(xiàn)
1)數(shù)據(jù)寫入
隱式類的寫入格式跟原生Map一致。寫入時(shí)主要是在Map的反序列化過程中加入了一個(gè)構(gòu)造隱式列的流程,主要的功能就是檢查map中的每一個(gè)key值。如果key值對應(yīng)的Column存在則將value寫入到存在的Column中。如果Column不存在,就會創(chuàng)建一個(gè)新的Column,然后將value插入。需要注意的就是,每個(gè)隱式的Column行數(shù)需要保持一致,如果無值則需要插入默認(rèn)值。
在數(shù)據(jù)寫入的過程中,每個(gè)part對應(yīng)的columns.txt文件,我們額外追加隱式列的信息。這樣在加載part信息的時(shí)候,clichouse就可以通過loadColumns方法把隱式列的信息加載到part的元信息之中了。
2)數(shù)據(jù)查詢
查詢流程我們沒有做過多的改造, 就是在Parser層就的把select map[’k1’] from map_table轉(zhuǎn)換成針對隱式列的查詢,接下把它當(dāng)做普通的Column查詢即可。
7、查詢測試
創(chuàng)建測試表:
---- 隱式列
CREATE TABLE lt.implicit_map_local
(
`id` UInt32,
`map` MapV2(String, String)
)
ENGINE = MergeTree
ORDER BY id
SETTINGS index_granularity = 8192;
---- 原始map類型
CREATE TABLE lt.normal_map_local
(
`id` UInt32,
`map` Map(String, String)
)
ENGINE = MergeTree
ORDER BY id
SETTINGS index_granularity = 8192;
測試數(shù)據(jù)通過jdbc寫入,自動生成10000000條數(shù)據(jù),數(shù)據(jù)完全相同。
測試簡單的指定key值查詢。
此次測試中Map類型隱式列對于指定key值的查詢場景有顯著的性能提升。
Map類型是我們目前解決動態(tài)schame最優(yōu)的選擇,原生Map類型的讀放大問題在通過我們的優(yōu)化之后能夠一定緩解。而隱式列的實(shí)現(xiàn)則讓我們可以像對待普通列一樣,極大地避免了讀取非必要的map數(shù)據(jù)。同時(shí),我們后面也加上了對隱式列的二級索引支持,進(jìn)一步增加了優(yōu)化讀取的手段。
當(dāng)然,隱式列也有一些不適用的場景。
- 不支持select整個(gè)map字段
因?yàn)镸ap被拆成了多個(gè)column存儲,所以無法支持select *或者select map這樣的查詢。當(dāng)然我們也進(jìn)行過嘗試。主要實(shí)現(xiàn)過兩個(gè)方案:其一是冗余存儲一份Map數(shù)據(jù),按照原來的格式查詢,當(dāng)需要select整個(gè)map字段時(shí)我們就按原來的流程讀取。這樣查詢性能跟原生的Map一樣;還有一個(gè)方案就是在查詢時(shí)讀取Map對應(yīng)的所有隱式列,然后實(shí)時(shí)生成Map返回,這種方案經(jīng)過測試之后發(fā)現(xiàn)性能太差,最終也被放棄。
- 不適用于map key值非常稀疏的場景
通過以上幾個(gè)部分的講解,我們知道clickhouse的隱式列實(shí)際上就是把map中的key拆出來作為單獨(dú)的columns存儲。在這個(gè)情況下,如果用戶的map字段中的key值基數(shù)過高就會導(dǎo)致底層存儲的列過多。針對這種情況,我們是增加了一個(gè)max_implicit_columns的MergeTree參數(shù),通過這個(gè)參數(shù)我們可以控制隱式列的個(gè)數(shù),當(dāng)超過閾值時(shí)會直接返回錯(cuò)誤信息給用戶。在我們的日志系統(tǒng)的使用實(shí)踐手冊中我們會引導(dǎo)用戶按照規(guī)范創(chuàng)建key值,避免key的基數(shù)過高的情況出現(xiàn)。如果有不按照約束寫入的用戶,max_implicit_columns這個(gè)參數(shù)也會對其進(jìn)行限制。
針對于第一個(gè)問題,我們也是實(shí)現(xiàn)了一套退而求其次的方案,那就是通過select每一個(gè)隱式列來實(shí)現(xiàn)。為此,我們會將每個(gè)part的隱式列都寫到系統(tǒng)表system.parts中。當(dāng)用戶不知道有哪些key值時(shí)則可以通過查詢系統(tǒng)表獲取。
日志場景下,在功能上隱式列能夠滿足其對于動態(tài)schema的需求。同時(shí),在查詢時(shí),用戶一般都會勾選特定的字段。而在這種指定字段的查詢上,隱式列的查詢性能夠做到與普通列保持一致。而且,因?yàn)殡[式列能夠像普通列一樣配置二級索引,對于一些對性能要求更加極致的場景,我們的優(yōu)化手段也要更加的靈活一些。
五、下一步的工作
1、日志模式提取
目前雖然我們通過日志最佳實(shí)踐和日志規(guī)范引導(dǎo)用戶盡可能繼續(xù)結(jié)構(gòu)化日志的輸出,但是仍然不可避免部分場景難以進(jìn)行結(jié)構(gòu)化,用戶會將大段文本輸入到日志中,在分析和查詢上都有一定受限。我們計(jì)劃實(shí)現(xiàn)日志模式并使用在查詢交互,日志壓縮, 后置結(jié)構(gòu)化,異常模式檢測這些場景上。
2、結(jié)合湖倉一體
在湖倉一體日益成熟的背景下,日志入湖會帶來以下收益:
部分日志有著三年以上的存儲時(shí)間要求,比如合規(guī)要求的審計(jì)日志,關(guān)鍵業(yè)務(wù)日志等,數(shù)據(jù)湖的低成本存儲特性是這個(gè)場景的不二之選。
現(xiàn)在日志除了用來進(jìn)行研發(fā)排障外,也有大量的業(yè)務(wù)價(jià)值蘊(yùn)含其中。日志入湖后可以結(jié)合湖上的生態(tài)體系,快速結(jié)合如機(jī)器學(xué)習(xí)、BI分析、報(bào)表等功能,將日志的價(jià)值發(fā)揮到最大。
此外,我們長遠(yuǎn)期探索減少日志上報(bào)的中間環(huán)節(jié),如從agent直接到ClickHouse,去掉中間的Kafka,以及更深度的結(jié)合ClickHouse和湖倉一體,打通ClickHouse和iceberg。
3、Clickhouse全文檢索
在全文檢索場景下,Clickhouse的性能表現(xiàn)依舊與ES有一定的差距,為了能夠覆蓋日差查詢的全場景,clickhouse在這方面的探索依舊任重道遠(yuǎn)。
在全文檢索索引的實(shí)踐上,我們也需要嘗試不同的數(shù)據(jù)結(jié)構(gòu),極力做到內(nèi)存資源和查詢性能的雙重保證。