引言
ClickHouse核心分析系列文章,本文将為大家深度解讀ClickHouse目前的MPP計算模型、使用者資源隔離、查詢限流機制,在此基礎上為大家介紹阿裡巴巴雲資料庫ClickHouse在八月份即将推出的自研彈性資源隊列功能。ClickHouse開源版本目前還沒有資源隊列相關的規劃,自研彈性資源隊列的初衷是更好地解決隔離和資源使用率的問題。下文将從ClickHouse的MPP計算模型、現有的資源隔離方案展開來看ClickHouse目前在資源隔離上的痛點,最後為大家介紹我們的自研彈性資源隊列功能。
MPP計算模型
在深入到資源隔離之前,這裡有必要簡單介紹一下ClickHouse社群純自研的MPP計算模型,因為ClickHouse的MPP計算模型和成熟的開源MPP計算引擎(例如:Presto、HAWQ、Impala)存在着較大的差異(que xian),這使得ClickHouse的資源隔離也有一些獨特的要求,同時希望這部分内容能指導使用者更好地對ClickHouse查詢進行調優。
ClickHouse的MPP計算模型最大的特點是:它壓根沒有分布式執行計劃,隻能通過遞歸子查詢和廣播表來解決多表關聯查詢,這給分布式多表關聯查詢帶來的問題是資料shuffle爆炸。另外ClickHouse的執行計劃生成過程中,僅有一些簡單的filter push down,column prune規則,完全沒有join reorder能力。對使用者來說就是"所寫即所得"的模式,要求人人都是DBA,下面将結合簡單的查詢例子來介紹一下ClickHouse計算模型最大的幾個原則。
遞歸子查詢
在閱讀源碼的過程中,我可以感受到ClickHouse前期是一個完全受母公司Yandex搜尋分析業務驅動成長起來的資料庫。而搜尋業務場景下的Metric分析(uv / pv ...),對分布式多表關聯分析的并沒有很高的需求,絕大部分業務場景都可以通過簡單的資料分表分析然後聚合結果(資料模組化比較簡單),是以從一開始ClickHouse就注定不擅長處理複雜的分布式多表關聯查詢,ClickHouse的核心隻是把單機(單表)分析做到了性能極緻。但是任何一個業務場景下都不能完全避免分布式關聯分析的需求,ClickHouse采用了一套簡單的Rule來處理多表關聯分析的查詢。
對ClickHouse有所了解的同學應該知道ClickHouse采用的是簡單的節點對等架構,同時不提供任何分布式的語義保證,ClickHouse的節點中存在着兩種類型的表:本地表(真實存放資料的表引擎),分布式表(代理了多個節點上的本地表,相當于"分庫分表"的Proxy)。當ClickHouse的節點收到兩表的Join關聯分析時,問題比較收斂,無非是以下幾種情況:本地表 Join 分布式表 、本地表 Join 本地表、 分布式表 Join 分布式表、分布式表 Join 本地表,這四種情況會如何執行這裡先放一下,等下一小節再介紹。
接下來問題複雜化,如何解決多個Join的關聯查詢?ClickHouse采用遞歸子查詢來解決這個問題,如下面的簡單例子所示ClickHouse會自動把多個Join的關聯查詢改寫成子查詢進行嵌套, 規則非常簡單:1)Join的左右表必須是本地表、分布式表或者子查詢;2)傾向把Join的左側變成子查詢;3)從最後一個Join開始遞歸改寫成子查詢;4)對Join order不做任何改動;5)可以自動根據where條件改寫Cross Join到Inner Join。下面是兩個具體的例子幫助大家了解:
例一
select * from local_tabA
join (select * from dist_tabB join local_tabC on dist_tabB.key2 = local_tabC.key2) as sub_Q1
on local_tabA.key1 = sub_Q1.key1
join dist_tabD on local_tabA.key1 = dist_tabD.key1;
=============>
select * from
(select * from local_tabA join
(select * from dist_tabB join local_tabC on dist_tabB.key2 = local_tabC.key2) as sub_Q1
on local_tabA.key1 = sub_Q1.key1) as sub_Q2
join dist_tabD on sub_Q2.key1 = dist_tabD.key1;
例二:
select * from local_tabA
join (select * from dist_tabB join local_tabC on dist_tabB.key2 = local_tabC.key2) as sub_Q1
on local_tabA.key1 = sub_Q1.key1
join dist_tabD on local_tabA.key1 = dist_tabD.key1;
=============>
select * from
(select * from local_tabA join
(select * from dist_tabB join local_tabC on dist_tabB.key2 = local_tabC.key2) as sub_Q1
on local_tabA.key1 = sub_Q1.key1) as sub_Q2
join dist_tabD on sub_Q2.key1 = dist_tabD.key1;
Join關聯中的子查詢在計算引擎裡就相關于是一個本地的"臨時表",隻不過這個臨時表的Input Stream對接的是一個子查詢的Output Stream。是以在處理多個Join的關聯查詢時,ClickHouse會把查詢拆成遞歸的子查詢,每一次遞歸隻處理一個Join關聯,單個Join關聯中,左右表輸入有可能是本地表、分布式表、子查詢,這樣問題就簡化了。
這種簡單的遞歸子查詢解決方案純在最緻命的缺陷是:
(1)系統沒有自動優化能力,Join reorder是優化器的重要課題,但是ClickHouse完全不提供這個能力,對核心不夠了解的使用者基本無法寫出性能最佳的關聯查詢,但是對經驗老道的工程師來說這是另一種體驗:可以完全掌控SQL的執行計劃。
(2)無法完全發揮分布式計算的能力,ClickHouse在兩表的Join關聯中能否利用分布式算力進行join計算取決于左表是否是分布式表,隻有當左表是分布式表時才有可能利用上Cluster的計算能力,也就是左表是本地表或者子查詢時Join計算過程隻在一個節點進行。
(3)多個大表的Join關聯容易引起節點的OOM,ClickHouse中的Hash Join算子目前不支援spill(落盤),遞歸子查詢需要節點在記憶體中同時維護多個完整的Hash Table來完成最後的Join關聯。
兩表Join規則
上一節介紹了ClickHouse如何利用遞歸子查詢來解決多個Join的關聯分析,最終系統隻會focus在單個Join的關聯分析上。除了正常的Join方式修飾詞以外,ClickHouse還引入了另外一個Join流程修飾詞"Global",它會影響整個Join的執行計劃。節點真正采用Global Join進行關聯的前提條件是左表必須是分布式表,Global Join會建構一個記憶體臨時表來儲存Join右測的資料,然後把左表的Join計算任務分發給所有代理的存儲節點,收到Join計算任務的存儲節點會跨節點拷貝記憶體臨時表的資料,用以建構Hash Table。
下面依次介紹所有可能出現的單個Join關聯分析場景:
(1)(本地表/子查詢)Join(本地表/子查詢):正常本地Join,Global Join不生效
(2)(本地表/子查詢)Join(分布式表):分布式表資料全部讀到目前節點進行Hash Table建構,Global Join不生效
(3)(分布式表)Join(本地表/子查詢):Join計算任務分發到分布式表的所有存儲節點上,存儲節點上收到的Join右表取決于是否采用Global Join政策,如果不是Global Join則把右測的(本地表名/子查詢)直接轉給所有存儲節點。如果是Global Join則目前節點會建構Join右測資料的記憶體表,收到Join計算任務的節點會來拉取這個記憶體表資料。
(4)(分布式表)Join(分布式表):Join計算任務分發到分布式表的所有存儲節點上,存儲節點上收到的Join右表取決于是否采用Global Join政策,如果不是Global Join則把右測的分布式表名直接轉給所有存儲節點。如果是Global Join則目前節點會把右測分布式表的資料全部收集起來建構記憶體表,收到Join計算任務的節點會來拉取這個記憶體表資料。
從上面可以看出隻有分布式表的Join關聯是可以進行分布式計算的,Global Join可以提前計算Join右測的結果資料建構記憶體表,當Join右測是帶過濾條件的分布式表或者子查詢時,降低了Join右測資料重複計算的次數,還有一種場景是Join右表隻在目前節點存在則此時必須使用Global Join把它替換成記憶體臨時表,因為直接把右表名轉給其他節點一定會報錯。
ClickHouse中還有一個開關和Join關聯分析的行為有關:distributed_product_mode,它隻是一個簡單的查詢改寫Rule用來改寫兩個分布式表的Join行為。當set distributed_product_mode = 'LOCAL'時,它會把右表改寫成代理的存儲表名,這要求左右表的資料分區對齊,否則Join結果就出錯了,當set distributed_product_mode = 'GLOBAL'時,它會把自動改寫Join到Global Join。但是這個改寫Rule隻針對左右表都是分布式表的case,複雜的多表關聯分析場景下對SQL的優化作用比較小,還是不要去依賴這個自動改寫的能力。
ClickHouse的分布式Join關聯分析中還有另外一個特點是它并不會對左表的資料進行re-sharding,每一個收到Join任務的節點都會要全量的右表資料來建構Hash Table。在一些場景下,如果使用者确定Join左右表的資料是都是按照某個Join key分區的,則可以使用(分布式表)Join(本地表)的方式來緩解一下這個問題。但是ClickHouse的分布式表Sharding設計并不保證Cluster在調整節點後資料能完全分區對齊,這是使用者需要注意的。
小結
總結一下上面兩節的分析,ClickHouse目前的MPP計算模型并不擅長做多表關聯分析,主要存在的問題:1)節點間資料shuffle膨脹,Join關聯時沒有資料re-sharding能力,每個計算節點都需要shuffle全量右表資料;2)Join記憶體膨脹,原因同上;3)非Global Join下可能引起計算風暴,計算節點重複執行子查詢;4)沒有Join reorder優化。其中的1和3還會随着節點數量增長變得更加明顯。在多表關聯分析的場景下,使用者應該盡可能為小表建構Dictionary,并使用dictGet内置函數來代替Join,針對無法避免的多表關聯分析應該直接寫成嵌套子查詢的方式,并根據真實的查詢執行情況嘗試調整Join order尋找最優的執行計劃。目前ClickHouse的MPP計算模型下,仍然存在不少查詢優化的小"bug"可能導緻性能不如預期,例如列裁剪沒有下推,過濾條件沒有下推,partial agg沒有下推等等,不過這些小問題都是可以修複。
資源隔離現狀
目前的ClickHouse開源版本在系統的資源管理方面已經做了很多的feature,我把它們總結為三個方面:全鍊路(線程-》查詢-》使用者)的資源使用追蹤、查詢&使用者級别資源隔離、資源使用限流。對于ClickHouse的資深DBA來說,這些資源追蹤、隔離、限流功能已經可以解決非常多的問題。接下來我将展開介紹一下ClickHouse在這三個方面的功能設計實作。
trace & profile
ClickHouse的資源使用都是從查詢thread級别就開始進行追蹤,主要的相關代碼在 ThreadStatus 類中。每個查詢線程都會有一個thread local的ThreadStatus對象,ThreadStatus對象中包含了對記憶體使用追蹤的 MemoryTracker、profile cpu time的埋點對象 ProfileEvents、以及監控thread 熱點線程棧的 QueryProfiler。
1.MemoryTracker
ClickHouse中有很多不同level的MemoryTracker,包括線程級别、查詢級别、使用者級别、server級别,這些MemoryTracker會通過parent指針組織成一個樹形結構,把記憶體申請釋放資訊層層回報上去。
MemoryTrack中還有額外的峰值資訊(peak)統計,記憶體上限檢查,一旦某個查詢線程的申請記憶體請求在上層(查詢級别、使用者級别、server級别)MemoryTracker遇到超過限制錯誤,查詢線程就會抛出OOM異常導緻查詢退出。同時查詢線程的MemoryTracker每申請一定量的記憶體都會統計出目前的工作棧,非常友善排查記憶體OOM的原因。
ClickHouse的MPP計算引擎中每個查詢的主線程都會有一個ThreadGroup對象,每個MPP引擎worker線程在啟動時必須要attach到ThreadGroup上,線上程退出時detach,這保證了整個資源追蹤鍊路的完整傳遞。最後一個問題是如何把CurrentThread::MemoryTracker hook到系統的記憶體申請釋放上去?ClickHouse首先是重載了c++的new_delete operator,其次針對需要使用malloc的一些場景封裝了特殊的Allocator同步記憶體申請釋放。為了解決記憶體追蹤的性能問題,每個線程的記憶體申請釋放會在thread local變量上進行積攢,最後以大塊記憶體的形式同步給MemoryTracker。
class MemoryTracker
{
std::atomic<Int64> amount {0};
std::atomic<Int64> peak {0};
std::atomic<Int64> hard_limit {0};
std::atomic<Int64> profiler_limit {0};
Int64 profiler_step = 0;
/// Singly-linked list. All information will be passed to subsequent memory trackers also (it allows to implement trackers hierarchy).
/// In terms of tree nodes it is the list of parents. Lifetime of these trackers should "include" lifetime of current tracker.
std::atomic<MemoryTracker *> parent {};
/// You could specify custom metric to track memory usage.
CurrentMetrics::Metric metric = CurrentMetrics::end();
...
}
2.ProfileEvents:
ProfileEvents顧名思義,是監控系統的profile資訊,覆寫的資訊非常廣,所有資訊都是通過代碼埋點進行收集統計。它的追蹤鍊路和MemoryTracker一樣,也是通過樹狀結構組織層層追蹤。其中和cpu time相關的核心名額包括以下:
///Total (wall clock) time spent in processing thread.
RealTimeMicroseconds;
///Total time spent in processing thread executing CPU instructions in user space.
///This include time CPU pipeline was stalled due to cache misses, branch mispredictions, hyper-threading, etc.
UserTimeMicroseconds;
///Total time spent in processing thread executing CPU instructions in OS kernel space.
///This include time CPU pipeline was stalled due to cache misses, branch mispredictions, hyper-threading, etc.
SystemTimeMicroseconds;
SoftPageFaults;
HardPageFaults;
///Total time a thread spent waiting for a result of IO operation, from the OS point of view.
///This is real IO that doesn't include page cache.
OSIOWaitMicroseconds;
///Total time a thread was ready for execution but waiting to be scheduled by OS, from the OS point of view.
OSCPUWaitMicroseconds;
///CPU time spent seen by OS. Does not include involuntary waits due to virtualization.
OSCPUVirtualTimeMicroseconds;
///Number of bytes read from disks or block devices.
///Doesn't include bytes read from page cache. May include excessive data due to block size, readahead, etc.
OSReadBytes;
///Number of bytes written to disks or block devices.
///Doesn't include bytes that are in page cache dirty pages. May not include data that was written by OS asynchronously
OSWriteBytes;
///Number of bytes read from filesystem, including page cache
OSReadChars;
///Number of bytes written to filesystem, including page cache
OSWriteChars;
以上這些資訊都是從linux系統中直接采集,參考 sys/resource.h 和 linux/taskstats.h。采集沒有固定的頻率,系統在查詢計算的過程中每處理完一個Block的資料就會依據距離上次采集的時間間隔決定是否采集最新資料。
3.QueryProfiler:
QueryProfiler的核心功能是抓取查詢線程的熱點棧,ClickHouse通過對線程設定timer_create和自定義的signal_handler讓worker線程定時收到SIGUSR信号量記錄自己目前所處的棧,這種方法是可以抓到所有被lock block或者sleep的線程棧的。
除了以上三種線程級别的trace&profile機制,ClickHouse還有一套server級别的Metrics統計,也是通過代碼埋點記錄系統中所有Metrics的瞬時值。ClickHouse底層的這套trace&profile手段保障了使用者可以很友善地從系統硬體層面去定位查詢的性能瓶頸點或者OOM原因,所有的metrics, trace, profile資訊都有對象的system_log系統表可以追溯曆史。
資源隔離
資源隔離需要關注的點包括記憶體、CPU、IO,目前ClickHouse在這三個方面都做了不同程度功能:
1.記憶體隔離
目前使用者可以通過max_memory_usage(查詢記憶體限制),max_memory_usage_for_user(使用者的記憶體限制),max_memory_usage_for_all_queries(server的記憶體限制),max_concurrent_queries_for_user(使用者并發限制),max_concurrent_queries(server并發限制)這一套參數去規劃系統的記憶體資源使用做到使用者級别的隔離。但是當使用者進行多表關聯分析時,系統派發的子查詢會突破使用者的資源規劃,所有的子查詢都屬于
default
使用者,可能引起使用者查詢的記憶體超用。
2.CPU隔離
ClickHouse提供了Query級别的CPU優先級設定,當然也可以為不同使用者的查詢設定不同的優先級,有以下兩種優先級參數:
///Priority of the query.
///1 - higher value - lower priority; 0 - do not use priorities.
///Allows to freeze query execution if at least one query of higher priority is executed.
priority;
///If non zero - set corresponding 'nice' value for query processing threads.
///Can be used to adjust query priority for OS scheduler.
os_thread_priority;
3.IO隔離
ClickHouse目前在IO上沒有做任何隔離限制,但是針對異步merge和查詢都做了各自的IO限制,盡量避免IO打滿。随着異步merge task數量增多,系統會開始限制後續單個merge task涉及到的Data Parts的disk size。在查詢并行讀取MergeTree data的時候,系統也會統計每個線程目前的IO吞吐,如果吞吐不達标則會反壓讀取線程,降低讀取線程數緩解系統的IO壓力,以上這些限制措施都是從局部來緩解問題的一個手段。
Quota限流
除了靜态的資源隔離限制,ClickHouse内部還有一套時序資源使用限流機制--Quota。使用者可以根據查詢的使用者或者Client IP對查詢進行分組限流。限流和資源隔離不同,它是限制查詢執行的"速率",目前主要包括以下幾種"速率":
QUERIES; /// Number of queries.
ERRORS; /// Number of queries with exceptions.
RESULT_ROWS; /// Number of rows returned as result.
RESULT_BYTES; /// Number of bytes returned as result.
READ_ROWS; /// Number of rows read from tables.
READ_BYTES; /// Number of bytes read from tables.
EXECUTION_TIME; /// Total amount of query execution time in nanoseconds.
使用者可以自定義規劃自己的限流政策,防止系統的負載(IO、網絡、CPU)被打爆,Quota限流可以認為是系統自我保護的手段。系統會根據查詢的使用者名、IP位址或者Quota Key Hint來為查詢綁定對應的限流政策。計算引擎在算子之間傳遞Block時會檢查目前Quota組内的流速是否過載,進而通過sleep查詢線程來降低系統負載。
總結一下ClickHouse在資源隔離/trace層面的優缺點:ClickHouse為使用者提供了非常多的工具元件,但是欠缺整體性的解決方案。以trace & profile為例,ClickHouse在自身系統裡內建了非常完善的trace / profile / metrics日志和瞬時狀态系統表,在排查性能問題的過程中它的鍊路是完備的。但問題是這個鍊路太複雜了,對一般使用者來說排查非常困難,尤其是碰上遞歸子查詢的多表關聯分析時,需要從使用者查詢到一層子查詢到二層子查詢步步深入分析。目前的資源隔離方案呈現給使用者的更加是一堆配置,根本不是一個完整的功能。Quota限流雖然是一個完整的功能,但是卻不容易使用,因為使用者不知道如何量化合理的"速率"。
彈性資源隊列
第一章為大家介紹了ClickHouse的MPP計算模型,核心想闡述的點是ClickHouse這種簡單的遞歸子查詢計算模型在資源利用上是非常粗暴的,如果沒有很好的資源隔離和系統過載保護,節點很容易就會因為bad sql變得不穩定。第二章介紹ClickHouse目前的資源使用trace profile功能、資源隔離功能、Quota過載保護。但是ClickHouse目前在這三個方面做得都不夠完美,還需要深度打磨來提升系統的穩定性和資源使用率。我認為主要從三個方面進行加強:性能診斷鍊路自動化使使用者可以一鍵診斷,資源隊列功能加強,Quota(負載限流)做成自動化并拉通來看查詢、寫入、異步merge任務對系統的負載,防止過載。
阿裡雲資料庫ClickHouse在ClickHouse開源版本上即将推出使用者自定義的彈性資源隊列功能,資源隊列DDL定義如下:
CREATE RESOURCE QUEUE [IF NOT EXISTS | OR REPLACE] test_queue [ON CLUSTER cluster]
memory=10240000000, ///資源隊列的總記憶體限制
concurrency=8, ///資源隊列的查詢并發控制
isolate=0, ///資源隊列的記憶體搶占隔離級别
priority=high ///資源隊列的cpu優先級和記憶體搶占優先級
TO {role [,...] | ALL | ALL EXCEPT role [,...]};
我認為資源隊列的核心問題是要在保障使用者查詢穩定性的基礎上最大化系統的資源使用率和查詢吞吐。傳統的MPP資料庫類似GreenPlum的資源隊列設計思想是隊列之間的記憶體資源完全隔離,通過優化器去評估每一個查詢的複雜度加上隊列的默人并發度來決定查詢在隊列中可占用的記憶體大小,在查詢真實開始執行之前已經限定了它可使用的記憶體,加上GreenPlum強大的計算引擎所有算子都可以落盤,使得資源隊列可以保障系統内的查詢穩定運作,但是吞吐并不一定是最大化的。因為GreenPlum資源隊列之間的記憶體不是彈性的,有隊列空閑下來它的記憶體資源也不能給其他隊列使用。抛開資源隊列間的彈性問題,其要想做到單個資源隊列内的查詢穩定高效運作離不開Greenplum的兩個核心能力:CBO優化器智能評估出查詢需要占用的記憶體,全算子可落盤的計算引擎。
ClickHouse目前的現狀是:1)沒有優化器幫助評估查詢的複雜度,2)整個計算引擎的落盤能力比較弱,在限定記憶體的情況下無法保障query順利執行。是以我們結合ClickHouse計算引擎的特色,設計了一套彈性資源隊列模型,其中核心的彈性記憶體搶占原則包括以下幾個:
- 對資源隊列内的查詢不設記憶體限制
- 隊列中的查詢在申請記憶體時如果遇到記憶體不足,則嘗試從優先級更低的隊列中搶占式申請記憶體
- 在2)中記憶體搶占過程中,如果搶占申請失敗,則檢查自己所屬的資源隊列是否被其他查詢搶占記憶體,嘗試kill搶占記憶體最多的查詢回收記憶體資源
- 如果在3)中嘗試回收被搶占記憶體資源失敗,則目前查詢報OOM Exception
- 每個資源隊列預留一定比例的記憶體不可搶占,當資源隊列中的查詢負載到達一定水位時,記憶體就變成完全不可被搶占。同時使用者在定義資源隊列時,isolate=0的隊列是允許被搶占的,isolate=1的隊列不允許被搶占,isolate=2的隊列不允許被搶占也不允許搶占其他隊列
- 當資源隊列中有查詢OOM失敗,或者因為搶占記憶體被kill,則把目前資源隊列的并發數臨時下調,等系統恢複後再逐漸上調。
ClickHouse彈性資源隊列的設計原則就是允許記憶體資源搶占來達到資源使用率的最大化,同時動态調整資源隊列的并發限制來防止bad query出現時導緻使用者的查詢大面積失敗。由于計算引擎的限制限制,目前無法保障查詢完全沒有OOM,但是使用者端可以通過錯誤資訊來判斷查詢是否屬于bad sql,同時對誤殺的查詢進行retry。
總結
ClickHouse核心分析系列文章:
MergeTree的存儲結構和查詢加速 MergeTree的Merge和Mutation機制 ZooKeeper在分布式叢集中的作用以及ReplicatedMergeTree表引擎的實作希望通過核心分析系列文章,讓大家更好地了解這款世界領先的列式存儲分析型資料庫。