引言
阿里雲數據庫ClickHouse二級索引功能近日已正式發佈上線,主要彌補了ClickHouse在海量數據分析場景下,多維度點查能力不足的短板。在以往服務用戶的過程當中,做者發現絕大部分用戶對ClickHouse單表查詢性能優化問題感到無從下手,藉此機會,本文會先爲你們展開介紹ClickHouse在單表分析查詢性能優化上的幾個方法,基本涵蓋了OLAP領域存儲層掃描加速的全部經常使用手段。在解決過各類各樣業務場景下的性能優化問題後,做者發現目前ClickHouse在解決多維搜索問題上確實能力不足,一條點查經常浪費巨大的IO、CPU資源,因而云數據庫ClickHouse自研了二級索引功能來完全解決問題,本文會詳細介紹二級索引的DDL語法、幾個典型適用場景和特點功能。但願能夠經過本文讓你們對ClickHouse在OLAP場景下的能力有更深的理解,同時闡述清楚二級索引適用的搜索場景。mysql
存儲掃描性能優化
在介紹各種OLAP存儲掃描性能優化技術以前,做者先在這裏申明一個簡單的代價模型和一些OLAP的背景知識。本文使用最簡單的代價模型來計算OLAP存儲掃描階段的開銷:磁盤掃描讀取的數據量。在相似ClickHouse這樣純列式的存儲和計算引擎中,數據的壓縮、計算、流轉都是以列塊爲單位按列進行的。在ClickHouse中,只能對數據列以塊爲單位進行定位讀取,雖然用戶的查詢是按照uid查詢肯定的某一條記錄,可是從磁盤讀取的數據量會被放大成塊大小 * 列數。本文中不考慮數據緩存(BlockCache / PageCache)這些優化因素,由於Cache能夠達到的優化效果不是穩定的。sql
排序鍵優化-跳躍掃描
排序鍵是ClickHouse最主要依賴的存儲掃描加速技術,它的含義是讓存儲層每一個DataPart裏的數據按照排序鍵進行嚴格有序存儲。正是這種有序存儲的模式,構成了ClickHouse "跳躍"掃描的基礎和重複數據高壓縮比的能力(對於ClickHouse的MergeTree存儲結構不熟悉的同窗能夠參考往期文章《ClickHouse內核分析-MergeTree的存儲結構和查詢加速》)。數據庫
CREATE TABLE order_info ( `oid` UInt64, --訂單ID `buyer_nick` String, --買家ID `seller_nick` String, --店鋪ID `payment` Decimal64(4), --訂單金額 `order_status` UInt8, --訂單狀態 ... `gmt_order_create` DateTime, --下單時間 `gmt_order_pay` DateTime, --付款時間 `gmt_update_time` DateTime, --記錄變動時間 INDEX oid_idx (oid) TYPE minmax GRANULARITY 32 ) ENGINE = MergeTree() PARTITION BY toYYYYMMDD(gmt_order_create) --以天爲單位分區 ORDER BY (seller_nick, gmt_order_create, oid) --排序鍵 PRIMARY KEY (seller_nick, gmt_order_create) --主鍵 SETTINGS index_granularity = 8192;
以一個簡單的訂單業務場景爲例(表結構如上),order by定義了數據文件中的記錄會按照店鋪ID , 下單時間以及訂單號組合排序鍵進行絕對有序存儲,而primary key和index_granularity二者則定義了排序鍵上的索引結構長什麼樣子,ClickHouse爲每個有序的數據文件構造了一個"跳躍數組"做爲索引,這個"跳躍數組"中的記錄是從原數據中按必定間隔抽取出來獲得的(簡化理解就是每隔index_granularity抽取一行記錄),同時只保留primary key定義裏的seller_nick, gmt_order_create兩個前綴列。以下圖所示,有了這個全內存的"跳躍數組"做爲索引,優化器能夠快速排除掉和查詢無關的行號區間,大大減小磁盤掃描的數據量。至於爲什麼不把oid列放到primary key中,讀者能夠仔細思考一下緣由,和index_granularity設定值大小也有關。數組
做者碰到過不少用戶在把mysql的binlog數據遷移到ClickHouse上作分析時,照搬照抄mysql上的主鍵定義,致使ClickHouse的排序鍵索引基本沒有發揮出任何做用,查詢性能主要就是靠ClickHouse牛逼的數據並行掃描能力和高效的列式計算引擎在硬抗,這也從側面反應出ClickHouse在OLAP場景下的絕對性能優點,沒有任何索引依舊能夠很快。業務系統中的mysql主要側重單條記錄的事務更新,主鍵是能夠簡單明瞭定義成oid,可是在OLAP場景下查詢都須要作大數據量的掃描分析,ClickHouse須要用排序鍵索引來進行"跳躍"掃描,用戶建表時應儘可能把業務記錄生命週期中不變的字段都放入排序鍵(通常distinct count越小的列放在越前)。緩存
分區鍵優化-MinMax裁剪
繼續上一節中的業務場景,當業務須要查詢某一段時間內全部店鋪的所有訂單量時,primary key中定義的"跳躍"數組索引效用就不那麼明顯了,查詢以下:性能優化
select count(*) from order_info where gmt_order_create > '2020-0802 00:00:00' and gmt_order_create < '2020-0804 15:00:00'
ClickHouse中的primary key索引有一個致命問題是,當前綴列的離散度(distinct value count)很是大時,在後續列上的過濾條件起到的"跳躍"加速做用就很微弱了。這個其實很好理解,當"跳躍數組"中相鄰的兩個元組是('a', 1)和('a', 10086)時,咱們能夠推斷出第二列在對應的行號區間內值域是[1, 10086];若相鄰的元素是('a', 1)和('b', 10086),則第二列的值域範圍就變成(-inf, +inf)了,沒法依據第二列的過濾條件進行跳過。框架
這時候就須要用到partition by優化了,ClickHouse中不一樣分區的數據是在物理上隔離開的,同時在數據生命管理上也是獨立的。partition by就好像是多個DataPart文件集合(數據分區)之間的"有序狀態",而上一節中的order by則是單個DataPart文件內部記錄的"有序狀態"。每個DataPart文件只屬於一個數據分區,同時在內存裏保有partition by列的MinMax值記錄。上述查詢case中,優化器根據每一個DataPart的gmt_order_create最大值最小值能夠快速裁剪掉不相關的DataPart,這中裁剪方式對數據的篩選效率比排序鍵索引更高。異步
這裏教你們一個小技巧,若是業務方既要根據下單時間範圍聚合分析,又要根據付款時間範圍聚合分析,該如何設計分區鍵呢?像這類有業務相關性的兩個時間列,同時時間差距上又是有業務約束的狀況下,咱們能夠把partition by定義成:ide
(toYYYYMMDD(gmt_order_create), (gmt_order_pay - gmt_order_create)/3600/240)函數
這樣一來DataPart在gmt_order_create和gmt_order_pay兩列上就都有了MinMax裁剪索引。在設計數據分區時,你們須要注意兩點:
1)partition by會對DataPart起到物理隔離,因此數據分區過細的狀況下會致使底層的DataPart數量膨脹,必定程度影響那種大範圍的查詢性能,要控制partition by的粒度;
2)partition by和order by都是讓數據存放達到"有序狀態"的技術,定義的時候應當儘可能錯開使用不一樣的列來定義二者,order by的第一個列必定不要重複放到partition by裏,通常來講時間列更適合放在partition by上。
Skipping index優化-MetaScan
在ClickHouse開源版本里,用戶就能夠經過自定義index來加速分析查詢。可是實際使用中,絕大部分用戶都不理解這個文檔上寫的"skipping index"是個什麼原理?爲何建立index以後查詢一點都沒有變快或者反而變慢了???由於"skipping index"並非真正意義上的索引,正常的索引都是讓數據按照索引key進行彙集,或者把行號按照索引key彙集起來。而ClickHouse的"skipping index"並不會作任何彙集的事情,它只是加速篩選Block的一種手段。以 INDEX oid_idx (oid) TYPE minmax GRANULARITY 32 這個索引定義爲例,它會對oid列的每32個列存塊作一個minmax值統計,統計結果存放在單獨的索引文件裏。下面的查詢在沒有oid skipping index的狀況下,至少須要掃描5天的數據文件,才能找到對應的oid行。有了索引後,數據掃描的邏輯變成了先掃描oid索引文件,檢查oid的minmax區間是否覆蓋目標值,後續掃描主表的時候能夠跳過不相關的Block,這其實就是在OLAP裏經常使用的Block Meta Scan技術。
select * from order_info where gmt_order_create > '2020-0802 00:00:00' and gmt_order_create < '2020-0807 00:00:00' and oid = 726495;
當oid列存塊的minmax區間都很大時,這個skipping index就沒法起到加速做用,甚至會讓查詢更慢。實際在這個業務場景下oid的skipping idex是有做用的。上一節講過在同一個DataPart內數據主要是按照店鋪ID和下單時間進行排序,全部在同一個DataPart內的oid列存塊minmax區間基本都是重疊的。可是ClickHouse的MergeTree還有一個隱含的有序狀態:那就是同一個partition下的多個DataPart是處於按寫入時間排列的有序狀態,而業務系統裏的oid是一個自增序列,恰好寫入ClickHouse的數據oid基本也是按照時間遞增的趨勢,不一樣DataPart之間的oid列存塊minmax就基本是錯開的。
不難理解,skipping index對查詢的加速效果是一個常數級別的,索引掃描的時間是和數據量成正比的。除了minmax類型的skipping index,還有set類型索引,它適用的場景也是要求列值隨着寫入時間有明顯局部性。剩下的bloom_filter、ngrambf_v一、tokenbf_v1則是經過把完整的字符串列或者字符串列分詞後的token用bloom_filter生成高壓縮比的簽名來進行排除Block,在長字符串的場景下有必定加速空間。
Prewhere優化-兩階段掃描
前面三節講到的全部性能優化技術基本都是依賴數據的"有序性"來加速掃描知足條件的Block,這也意味着知足查詢條件的數據自己就是存在某種"局部性"的。正常的業務場景中只要知足條件的數據自己存在"局部性"就必定能經過上面的三種方法來加速查詢。在設計業務系統時,咱們甚至應該刻意去創造出更多的"局部性",例如本文例子中的oid若是是設計成"toYYYYMMDDhh(gmt_order_create)+店鋪ID+遞增序列",那就能夠在DataPart內部篩選上得到"局部性",查詢會更快,讀者們能夠深刻思考下這個問題。
在OLAP場景下最難搞定的問題就是知足查詢條件的數據沒有任何"局部性":查詢條件命中的數據可能性數很少,可是分佈很是散亂,每個列存塊中都有一兩條記錄知足條件。這種狀況下由於列式存儲的關係,只要塊中有一條記錄命中系統就須要讀取完整的塊,最後幾百上千倍的數據量須要從磁盤中讀取。固然這是個極端狀況,不少時候狀況是一部分列存塊中沒有知足條件的記錄,一部分列存塊中包含少許知足條件的記錄,總體呈現隨機無序。這種場景下固然能夠對查詢條件列加上相似lucene的倒排索引快速定位到命中記錄的行號集合,但索引是有額外存儲、構建成本的,更好的方法是採用兩階段掃描來加速。
如下面的查詢爲例,正常的執行邏輯中存儲層掃描會把5天內的所有列數據從磁盤讀取出來,而後計算引擎再按照order_status列過濾出知足條件的行。在兩階段掃描的框架下,prewhere表達式會下推到存儲掃描過程當中執行,優先掃描出order_status列存塊,檢查是否有記錄知足條件,再把知足條件行的其餘列讀取出來,當沒有任何記錄知足條件時,其餘列的塊數據就能夠跳過不讀了。
--常規 select * from order_info where where order_status = 2 --訂單取消 and gmt_order_create > '2020-0802 00:00:00' and gmt_order_create < '2020-0807 00:00:00'; --兩階段掃描 select * from order_info where prewhere order_status = 2 --訂單取消 where gmt_order_create > '2020-0802 00:00:00' and gmt_order_create < '2020-0807 00:00:00';
這種兩階段掃描的思想就是優先掃描篩選率高的列進行過濾,再按需掃描其餘列的塊。在OLAP幾百列的大寬表分析場景下,這種加速方式減小的IO效果是很是明顯的。可是瓶頸也是肯定的,至少須要把某個單列的數據所有掃描出來。目前你們在使用prewhere加速的時候最好是根據數據分佈狀況來挑選最有篩選率同時掃描數據量最少的過濾條件。當趕上那種每一個Block中都有一兩條記錄知足查詢條件的極端狀況時,嘗試使用ClickHouse的物化視圖來解決吧。物化視圖的做用不光是預聚合計算,也可讓數據換個排序鍵從新有序存儲一份。還有一種zorder的技術也能緩解一部分此類問題,有興趣的同窗能夠本身瞭解一下。
小結
前面四節分別介紹了ClickHouse中四種不一樣的查詢加速技術,當知足查詢條件的數據有明顯的"局部性"時,你們能夠經過前三種低成本的手段來加速查詢。最後介紹了針對數據分佈很是散亂的場景下,prewhere能夠緩解多列分析的IO壓力。實際上這四種優化手段都是能夠結合使用的,本文拆開闡述只是爲了方便你們理解它們的本質。延續上一節的問題,當數據分佈很是散亂,同時查詢命中的記錄又只有若干條的場景下,就算使用prewhere進行兩階段掃描,它的IO放大問題也依舊是很是明顯的。簡單的例子就是查詢某個特定買家id(buyer_nick)的購買記錄,買家id在數據表中的分佈是徹底散亂的,同時買家id列全表掃描的代價過大。因此阿里雲ClickHouse推出了二級索引功能,專門來解決這種少許結果的搜索問題。這裏如何定義結果的少呢?通常列存系統中一個列存塊包括接近10000行記錄,當知足搜索條件的記錄數比列存塊小一個數量級時(篩選率超過100000:1),二級索引才能發揮比較明顯的性能優點。
二級索引多維搜索
ClickHouse的二級索引在設計的時候對標的就是ElasticSearch的多維搜索能力,支持多索引列條件交併差檢索。同時對比ElasticSearch又有更貼近ClickHouse的易用性優化,整體特色歸納以下:
- 多列聯合索引 & 表達式索引
- 函數下推
- In Set Clause下推
- 多值索引 & 字典索引
- 高壓縮比 1:1 vs lucene 8.7
- 向量化構建 4X vs lucene 8.7
常規索引
二級索引在建立表時的定義語句示例以下:
CREATE TABLE index_test ( id UInt64, d DateTime, x UInt64, y UInt64, tag String, KEY tag_idx tag TYPE range, --單列索引 KEY d_idx toStartOfHour(d) TYPE range, --表達式索引 KEY combo_idx (toStartOfHour(d),x, y) TYPE range, --多列聯合索引 ) ENGINE = MergeTree() ORDER BY id;
其餘二級索引相關的修改DDL以下:
--刪除索引定義 Alter table index_test DROP KEY tag_idx; --增長索引定義 Alter table index_test ADD KEY tag_idx tag TYPE range; --清除數據分區下的索引文件 Alter table index_test CLEAR KEY tag_idx tag in partition partition_expr; --從新構建數據分區下的索引文件 Alter table index_test MATERIALIZE KEY tag_idx tag in partition partition_expr;
支持多列索引的目的是減小特定查詢pattern下的索引結果歸併,針對QPS要求特別高的查詢用戶能夠建立針對性的多列索引達到極致的檢索性能。而表達式索引主要是方便用戶進行自由的檢索粒度變換,考慮如下兩個典型場景:
1)索引中的時間列在搜索條件中,只會以小時粒度進行過濾,這種狀況下用戶能夠對toStartOfHour(time)表達式建立索引,能夠必定程度加速索引構建,同時對time列的時間過濾條件均可以自動轉換下推索引。
2)索引中的id列是由UUID構成,UUID幾乎是能夠保證永久distinct的字符串序列,直接對id構建索引會致使索引文件太大。這時用戶可使用前綴函數截取UUID來構建索引,如prefix8(id)是截取8個byte的前綴,對應的還有prefix4和prefix16,prefixUTF四、prefixUTF八、prefixUTF16則是用來截取UTF編碼的。
值得注意的是,用戶對錶達式構建索引後,原列上的查詢條件也能夠正常下推索引,不須要特地改寫查詢。一樣用戶對原列構建索引,過濾條件上對原列加了表達式的狀況下,優化器也均可以正常下推索引。
In Set Clause下推則是一個關聯搜索的典型場景,做者常常碰到此類場景:user的屬性是一張單獨的大寬表,user的行爲記錄又是另外一張單獨的表,對user的搜索須要先從user行爲記錄表中聚合過濾出知足條件的user id,再用user ids從屬性表中取出明細記錄。這種in subquery的場景下,ClickHouse也能夠自動下推索引進行加速。
多值索引
多值索引主要針對的是array類型列上has()/hasAll()/hasAny()條件的搜索加速,array列時標籤分析裏經常使用的數據類型,每條記錄會attach對個標籤,存放在一個array列裏。對這個標籤列的過濾以往只能經過暴力掃描過濾,ClickHouse二級索引專門擴展了多值索引類型解決此類問題,索引定義示例以下:
CREATE TABLE index_test ( id UInt64, tags Array(String), KEY tag_idx tag TYPE array --多值索引 ) ENGINE = MergeTree() ORDER BY id; --包含單個標籤 select * from index_test where has(tags, 'aaa'); --包含全部標籤 select * from index_test where hasAll(tags, ['aaa', 'bbb']); --包含任意標籤 select * from index_test where has(tags, ['aaa', 'ccc']);
字典索引
字典索引主要是針對那種使用兩個array類型列模擬Map的場景進行檢索加速,key和value是兩個單獨的array列,經過元素的position一一對應進行kv關聯。ClickHouse二級索引專門爲這種場景擴展了檢索函數和索引類型支持,索引定義示例以下:
CREATE TABLE index_test ( id UInt64, keys Array(String), vals Array(UInt32), KEY kv_idx (keys, vals) TYPE map --字典索引 ) ENGINE = MergeTree() ORDER BY id; --指定key的value等值條件 map['aaa'] = 32 select * from index_test where hasPairEQ(keys, vals, ('aaa', 32)); --指定key的value大於條件 map['aaa'] > 32 select * from index_test where hasPairGT(keys, vals, ('aaa', 32)); --指定key的value大於等於條件 map['aaa'] >= 32 select * from index_test where hasPairGTE(keys, vals, ('aaa', 32)); --指定key的value小於條件 map['aaa'] < 32 select * from index_test where hasPairLT(keys, vals, ('aaa', 32)); --指定key的value小於等於條件 map['aaa'] <= 32 select * from index_test where hasPairLTE(keys, vals, ('aaa', 32));
索引構建性能
做者對ClickHouse的二級索引構建性能和索引壓縮率作了全方位多場景下的測試,主要對比的是lucene 8.7的倒排索引和BKD索引。ElasticSearch底層的索引就是採用的lucene,這裏的性能數據讀者能夠做個參考,但並不表明ElasticSearch和ClickHouse二級索引功能端到端的性能水平。由於ClickHouse的二級索引是在DataPart merge的時候進行構建,瞭解ClickHouse MergeTree存儲引擎的同窗應該明白MergeTree存在寫放大的狀況(一條記錄merge屢次),同時merge又徹底是異步的行爲。
日誌trace_id場景
mock數據方法:
substringUTF8(cast (generateUUIDv4() as String), 1, 16)
數據量:1E (ClickHouse數據文件1.5G)
構建耗時:
ClickHouse 65.32s vs Lucene 487.255s
索引文件大小:
ClickHouse 1.4G vs Lucene 1.3G
字符串枚舉場景
mock數據方法:
cast((10000000 + rand(number) % 1000) as String)
數據量:1E (ClickHouse數據文件316M)
構建耗時:
ClickHouse 37.19s vs Lucene 46.279s
索引文件大小:
ClickHouse 160M vs Lucene 163M
數值散列場景
mock數據方法:
toFloat64(rand(100000000))
數據量:1E (ClickHouse數據文件564M)
構建耗時:
ClickHouse 32.20s vs Lucene BKD 86.456s
索引文件大小:
ClickHouse 801M vs Lucene BKD 755M
數值枚舉場景
mock數據方法:
rand(1000)
數據量:1E (ClickHouse數據文件275M)
構建耗時:
ClickHouse 12.81s vs Lucene BKD 78.0s
索引文件大小:
ClickHouse 160M vs Lucene BKD 184M
結語
二級索引功能的主要目的是爲了彌補ClickHouse在搜索場景下的不足,在分析場景下ClickHouse目前原有的技術已經比較豐富。但願經過本文你們對OLAP查詢優化有更深的理解,歡迎你們嘗試使用二級索引來解決多維搜索問題,積極反饋使用體驗問題。
本文爲阿里雲原創內容,未經容許不得轉載。