ClickHouse主鍵索引最佳實踐

在本文中,我們將深入研究ClickHouse索引。我們將對此進行詳細說明和討論:

  • ClickHouse的索引與傳統的關係數據庫有何不同
  • ClickHouse是怎樣構建和使用主鍵稀疏索引的
  • ClickHouse索引的最佳實踐

這篇文章主要關注稀疏索引,clickhouse主鍵使用的就是稀疏索引。

數據集

在本文中,我們將使用一個匿名的web流量數據集。

  • 我們將使用樣本數據集中的887萬行(事件)的子集。
  • 未壓縮的數據大小爲887萬個事件和大約700mb。當存儲在ClickHouse時,壓縮爲200mb。
  • 在我們的子集中,每行包含三列,表示在特定時間(EventTime列)單擊URL (URL列)的互聯網用戶(UserID列)。

通過這三個列,我們已經可以制定一些典型的web分析查詢,如:

  • 某個用戶點擊次數最多的前10個url是什麼?
  • 點擊某個URL次數最多的前10名用戶是誰?
  • 用戶點擊特定URL的最頻繁時間(比如一週中的幾天)是什麼?

測試環境

本文檔中給出的所有運行時數據都是在帶有Apple M1 Pro芯片和16GB RAM的MacBook Pro上本地運行ClickHouse 22.2.1。

全表掃描

爲了瞭解在沒有主鍵的情況下如何對數據集執行查詢,我們通過執行以下SQL DDL語句(使用MergeTree表引擎)創建了一個表:

CREATE TABLE hits_NoPrimaryKey
(
    `UserID` UInt32,
    `URL` String,
    `EventTime` DateTime
)
ENGINE = MergeTree
PRIMARY KEY tuple();

接下來,使用以下插入SQL將命中數據集的一個子集插入到表中。這個SQL使用URL表函數類型推斷從clickhouse.com加載一個數據集的一部分數據:

INSERT INTO hits_NoPrimaryKey SELECT
   intHash32(c11::UInt64) AS UserID,
   c15 AS URL,
   c5 AS EventTime
FROM url('https://datasets.clickhouse.com/hits/tsv/hits_v1.tsv.xz')
WHERE URL != '';

結果:

Ok.

0 rows in set. Elapsed: 145.993 sec. Processed 8.87 million rows, 18.40 GB (60.78 thousand rows/s., 126.06 MB/s.)

ClickHouse客戶端輸出了執行結果,插入了887萬行數據。

最後,爲了簡化本文後面的討論,並使圖表和結果可重現,我們使用FINAL關鍵字optimize該表:

OPTIMIZE TABLE hits_NoPrimaryKey FINAL;

NOTE

一般來說,不需要也不建議在加載數據後立即執行optimize。對於這個示例,爲什麼需要這樣做是很明顯的。

現在我們執行第一個web分析查詢。以下是用戶id爲749927693的互聯網用戶點擊次數最多的前10個url:

SELECT URL, count(URL) as Count
FROM hits_NoPrimaryKey
WHERE UserID = 749927693
GROUP BY URL
ORDER BY Count DESC
LIMIT 10;

結果:

┌─URL────────────────────────────┬─Count─┐
│ http://auto.ru/chatay-barana.. │   170 │
│ http://auto.ru/chatay-id=371...│    52 │
│ http://public_search           │    45 │
│ http://kovrik-medvedevushku-...│    36 │
│ http://forumal                 │    33 │
│ http://korablitz.ru/L_1OFFER...│    14 │
│ http://auto.ru/chatay-id=371...│    14 │
│ http://auto.ru/chatay-john-D...│    13 │
│ http://auto.ru/chatay-john-D...│    10 │
│ http://wot/html?page/23600_m...│     9 │
└────────────────────────────────┴───────┘

10 rows in set. Elapsed: 0.022 sec.
Processed 8.87 million rows,
70.45 MB (398.53 million rows/s., 3.17 GB/s.)

ClickHouse客戶端輸出表明,ClickHouse執行了一個完整的表掃描!我們的表的887萬行中的每一行都被加載到ClickHouse中,這不是可擴展的。

爲了使這種(方式)更有效和更快,我們需要使用一個具有適當主鍵的表。這將允許ClickHouse自動(基於主鍵的列)創建一個稀疏的主索引,然後可以用於顯著加快我們示例查詢的執行。

包含主鍵的表

創建一個包含聯合主鍵UserID和URL列的表:

CREATE TABLE hits_UserID_URL
(
    `UserID` UInt32,
    `URL` String,
    `EventTime` DateTime
)
ENGINE = MergeTree
PRIMARY KEY (UserID, URL)
ORDER BY (UserID, URL, EventTime)
SETTINGS index_granularity = 8192, index_granularity_bytes = 0;

爲了簡化本文後面的討論,並使圖和結果可重現,使用DDL語句有如下說明:

  • 通過ORDER BY子句指定表的複合排序鍵

  • 通過設置配置控制主索引有多少索引項:

    • index_granularity: 顯式設置爲其默認值8192。這意味着對於每一組8192行,主索引將有一個索引條目,例如,如果表包含16384行,那麼索引將有兩個索引條目。

    • index_granularity_bytes
      設置爲0表示禁止

      自適應索引粒度

      。自適應索引粒度意味着ClickHouse自動爲一組n行創建一個索引條目

      • 如果n小於8192,但n行的合併行數據大小大於或等於10MB (index_granularity_bytes的默認值)或
      • n達到8192

上面DDL語句中的主鍵會基於兩個指定的鍵列創建主索引。

插入數據:

INSERT INTO hits_UserID_URL SELECT
   intHash32(c11::UInt64) AS UserID,
   c15 AS URL,
   c5 AS EventTime
FROM url('https://datasets.clickhouse.com/hits/tsv/hits_v1.tsv.xz')
WHERE URL != '';

結果:

0 rows in set. Elapsed: 149.432 sec. Processed 8.87 million rows, 18.40 GB (59.38 thousand rows/s., 123.16 MB/s.)

optimize表:

OPTIMIZE TABLE hits_UserID_URL FINAL;

我們可以使用下面的查詢來獲取關於表的元數據:

SELECT
    part_type,
    path,
    formatReadableQuantity(rows) AS rows,
    formatReadableSize(data_uncompressed_bytes) AS data_uncompressed_bytes,
    formatReadableSize(data_compressed_bytes) AS data_compressed_bytes,
    formatReadableSize(primary_key_bytes_in_memory) AS primary_key_bytes_in_memory,
    marks,
    formatReadableSize(bytes_on_disk) AS bytes_on_disk
FROM system.parts
WHERE (table = 'hits_UserID_URL') AND (active = 1)
FORMAT Vertical;

結果:

part_type:                   Wide
path:                        ./store/d9f/d9f36a1a-d2e6-46d4-8fb5-ffe9ad0d5aed/all_1_9_2/
rows:                        8.87 million
data_uncompressed_bytes:     733.28 MiB
data_compressed_bytes:       206.94 MiB
primary_key_bytes_in_memory: 96.93 KiB
marks:                       1083
bytes_on_disk:               207.07 MiB


1 rows in set. Elapsed: 0.003 sec.

客戶端輸出表明:

  • 表數據以wide format存儲在一個特定目錄,每個列有一個數據文件和mark文件。
  • 表有887萬行數據。
  • 未壓縮的數據有733.28 MB。
  • 壓縮之後的數據有206.94 MB。
  • 有1083個主鍵索引條目,大小是96.93 KB。
  • 在磁盤上,表的數據、標記文件和主索引文件總共佔用207.07 MB。

針對海量數據規模的索引設計

在傳統的關係數據庫管理系統中,每個錶行包含一個主索引。對於我們的數據集,這將導致主索引——通常是一個B(+)-Tree的數據結構——包含887萬個條目。

這樣的索引允許快速定位特定的行,從而提高查找點查和更新的效率。在B(+)-Tree數據結構中搜索一個條目的平均時間複雜度爲O(log2n)。對於一個有887萬行的表,這意味着需要23步來定位任何索引條目。

這種能力是有代價的:額外的磁盤和內存開銷,以及向表中添加新行和向索引中添加條目時更高的插入成本(有時還需要重新平衡B-Tree)。

考慮到與B-Tee索引相關的挑戰,ClickHouse中的表引擎使用了一種不同的方法。ClickHouseMergeTree Engine引擎系列被設計和優化用來處理大量數據。

這些表被設計爲每秒接收數百萬行插入,並存儲非常大(100 pb)的數據量。

數據被一批一批的快速寫入表中,並在後臺應用合併規則。

在ClickHouse中,每個數據部分(data part)都有自己的主索引。當他們被合併時,合併部分的主索引也被合併。

在大規模中情況下,磁盤和內存的效率是非常重要的。因此,不是爲每一行創建索引,而是爲一組數據行(稱爲顆粒(granule))構建一個索引條目。

之所以可以使用這種稀疏索引,是因爲ClickHouse會按照主鍵列的順序將一組行存儲在磁盤上。

與直接定位單個行(如基於B-Tree的索引)不同,稀疏主索引允許它快速(通過對索引項進行二分查找)識別可能匹配查詢的行組。

然後潛在的匹配行組(顆粒)以並行的方式被加載到ClickHouse引擎中,以便找到匹配的行。

這種索引設計允許主索引很小(它可以而且必須完全適合主內存),同時仍然顯著加快查詢執行時間:特別是對於數據分析用例中常見的範圍查詢。

下面詳細說明了ClickHouse是如何構建和使用其稀疏主索引的。在本文後面,我們將討論如何選擇、移除和排序用於構建索引的表列(主鍵列)的一些最佳實踐。

數據按照主鍵排序存儲在磁盤上

上面創建的表有:

NOTE

  • 如果我們只指定了排序鍵,那麼主鍵將隱式定義爲排序鍵。
  • 爲了提高內存效率,我們顯式地指定了一個主鍵,只包含查詢過濾的列。基於主鍵的主索引被完全加載到主內存中。
  • 爲了上下文的一致性和最大的壓縮比例,我們單獨定義了排序鍵,排序鍵包含當前表所有的列(和壓縮算法有關,一般排序之後又更好的壓縮率)。
  • 如果同時指定了主鍵和排序鍵,則主鍵必須是排序鍵的前綴。

插入的行按照主鍵列(以及排序鍵的附加EventTime列)的字典序(從小到大)存儲在磁盤上。

NOTE

ClickHouse允許插入具有相同主鍵列的多行數據。在這種情況下(參見下圖中的第1行和第2行),最終的順序是由指定的排序鍵決定的,這裏是EventTime列的值。

如下圖所示:ClickHouse是列存數據庫

  • 在磁盤上,每個表都有一個數據文件(*.bin),該列的所有值都以壓縮格式存儲,並且
  • 在這個例子中,這887萬行按主鍵列(以及附加的排序鍵列)的字典升序存儲在磁盤上
    • UserID第一位,
    • 然後是URL,
    • 最後是EventTime:

imgUserID.bin,URL.bin,和EventTime.bin是UserID,URL,和EventTime列的數據文件。

NOTE

  • 因爲主鍵定義了磁盤上行的字典順序,所以一個表只能有一個主鍵。
  • 我們從0開始對行進行編號,以便與ClickHouse內部行編號方案對齊,該方案也用於記錄消息。

數據被組織成顆粒以進行並行數據處理

出於數據處理的目的,表的列值在邏輯上被劃分爲多個顆粒。顆粒是流進ClickHouse進行數據處理的最小的不可分割數據集。這意味着,ClickHouse不是讀取單獨的行,而是始終讀取(以流方式並並行地)整個行組(顆粒)。

NOTE

列值並不物理地存儲在顆粒中,顆粒只是用於查詢處理的列值的邏輯組織方式。

下圖顯示瞭如何將表中的887萬行(列值)組織成1083個顆粒,這是表的DDL語句包含設置index_granularity(設置爲默認值8192)的結果。

img

第一個(根據磁盤上的物理順序)8192行(它們的列值)在邏輯上屬於顆粒0,然後下一個8192行(它們的列值)屬於顆粒1,以此類推。

NOTE

  • 最後一個顆粒(1082顆粒)是少於8192行的。

  • 我們在本指南開頭的“DDL 語句詳細信息”中提到,我們禁用了自適應索引粒度(爲了簡化本指南中的討論,並使圖表和結果可重現)。

    因此,示例表中所有顆粒(除了最後一個)都具有相同大小。

  • 對於具有自適應索引粒度的表(默認情況下索引粒度是自適應的),某些粒度的大小可以小於 8192 行,具體取決於行數據大小。

  • 我們將主鍵列(UserID, URL)中的一些列值標記爲橙色。

    這些橙色標記的列值是每個顆粒中每個主鍵列的最小值。這裏的例外是最後一個顆粒(上圖中的顆粒1082),最後一個顆粒我們標記的是最大的值。

    正如我們將在下面看到的,這些橙色標記的列值將是表主索引中的條目。

  • 我們從0開始對行進行編號,以便與ClickHouse內部行編號方案對齊,該方案也用於記錄消息。

每個顆粒對應主索引的一個條目

主索引是基於上圖中顯示的顆粒創建的。這個索引是一個未壓縮的扁平數組文件(primary.idx),包含從0開始的所謂的數字索引標記。

下面的圖顯示了索引存儲了每個顆粒的最小主鍵列值(在上面的圖中用橙色標記的值)。 例如:

  • 第一個索引條目(下圖中的“mark 0”)存儲上圖中顆粒0的主鍵列的最小值,
  • 第二個索引條目(下圖中的“mark 1”)存儲上圖中顆粒1的主鍵列的最小值,以此類推。

img

在我們的表中,索引總共有1083個條目,887萬行數據和1083個顆粒:

img

NOTE

  • 最後一個索引條目(上圖中的“mark 1082”)存儲了上圖中顆粒1082的主鍵列的最大值。
  • 索引條目(索引標記)不是基於表中的特定行,而是基於顆粒。例如,對於上圖中的索引條目‘mark 0’,在我們的表中沒有UserID爲240.923且URL爲“goal://metry=10000467796a411…”的行,相反,對於該表,有一個顆粒0,在該顆粒中,最小UserID值是240.923,最小URL值是“goal://metry=10000467796a411…”,這兩個值來自不同的行。
  • 主索引文件完全加載到主內存中。如果文件大於可用的空閒內存空間,則ClickHouse將發生錯誤。

主鍵條目稱爲索引標記,因爲每個索引條目都標誌着特定數據範圍的開始。對於示例表:

  • UserID index marks:
    主索引中存儲的UserID值按升序排序。
    上圖中的‘mark 1’指示顆粒1中所有錶行的UserID值,以及隨後所有顆粒中的UserID值,都保證大於或等於4.073.710。

    正如我們稍後將看到的, 當查詢對主鍵的第一列進行過濾時,此全局有序使ClickHouse能夠對第一個鍵列的索引標記使用二分查找算法。

  • URL index marks:
    主鍵列UserID和URL有相同的基數,這意味着第一列之後的所有主鍵列的索引標記通常只表示每個顆粒的數據範圍。
    例如,‘mark 0’中的URL列所有的值都大於等於goal://metry=10000467796a411..., 然後顆粒1中的URL並不是如此,這是因爲‘mark 1‘與‘mark 0‘具有不同的UserID列值。

    稍後我們將更詳細地討論這對查詢執行性能的影響。

主索引被用來選擇顆粒

現在,我們可以在主索引的支持下執行查詢。

下面計算UserID 749927693點擊次數最多的10個url。

SELECT URL, count(URL) AS Count
FROM hits_UserID_URL
WHERE UserID = 749927693
GROUP BY URL
ORDER BY Count DESC
LIMIT 10;

結果:

┌─URL────────────────────────────┬─Count─┐
│ http://auto.ru/chatay-barana.. │   170 │
│ http://auto.ru/chatay-id=371...│    52 │
│ http://public_search           │    45 │
│ http://kovrik-medvedevushku-...│    36 │
│ http://forumal                 │    33 │
│ http://korablitz.ru/L_1OFFER...│    14 │
│ http://auto.ru/chatay-id=371...│    14 │
│ http://auto.ru/chatay-john-D...│    13 │
│ http://auto.ru/chatay-john-D...│    10 │
│ http://wot/html?page/23600_m...│     9 │
└────────────────────────────────┴───────┘

10 rows in set. Elapsed: 0.005 sec.
Processed 8.19 thousand rows,
740.18 KB (1.53 million rows/s., 138.59 MB/s.)

ClickHouse客戶端的輸出顯示,沒有進行全表掃描,只有8.19千行流到ClickHouse。

如果trace logging打開了,那ClickHouse服務端日誌會顯示ClickHouse正在對1083個UserID索引標記執行二分查找以便識別可能包含UserID列值爲749927693的行的顆粒。這需要19個步驟,平均時間複雜度爲O(log2 n):

...Executor): Key condition: (column 0 in [749927693, 749927693])
...Executor): Running binary search on index range for part all_1_9_2 (1083 marks)
...Executor): Found (LEFT) boundary mark: 176
...Executor): Found (RIGHT) boundary mark: 177
...Executor): Found continuous range in 19 steps
...Executor): Selected 1/1 parts by partition key, 1 parts by primary key,
              1/1083 marks by primary key, 1 marks to read from 1 ranges
...Reading ...approx. 8192 rows starting from 1441792

我們可以在上面的跟蹤日誌中看到,1083個現有標記中有一個滿足查詢。

Mark 176 was identified (the 'found left boundary mark' is inclusive, the 'found right boundary mark' is exclusive), and therefore all 8192 rows from granule 176 (which starts at row 1.441.792 - we will see that later on in this article) are then streamed into ClickHouse in order to find the actual rows with a UserID column value of 749927693.

我們也可以通過使用EXPLAIN來重現這個結果:

EXPLAIN indexes = 1
SELECT URL, count(URL) AS Count
FROM hits_UserID_URL
WHERE UserID = 749927693
GROUP BY URL
ORDER BY Count DESC
LIMIT 10;

結果如下:

┌─explain───────────────────────────────────────────────────────────────────────────────┐
│ Expression (Projection)                                                               │
│   Limit (preliminary LIMIT (without OFFSET))                                          │
│     Sorting (Sorting for ORDER BY)                                                    │
│       Expression (Before ORDER BY)                                                    │
│         Aggregating                                                                   │
│           Expression (Before GROUP BY)                                                │
│             Filter (WHERE)                                                            │
│               SettingQuotaAndLimits (Set limits and quota after reading from storage) │
│                 ReadFromMergeTree                                                     │
│                 Indexes:                                                              │
│                   PrimaryKey                                                          │
│                     Keys:                                                             │
│                       UserID                                                          │
│                     Condition: (UserID in [749927693, 749927693])                     │
│                     Parts: 1/1                                                        │
│                     Granules: 1/1083                                                  │
└───────────────────────────────────────────────────────────────────────────────────────┘

16 rows in set. Elapsed: 0.003 sec.

客戶端輸出顯示,在1083個顆粒中選擇了一個可能包含UserID列值爲749927693的行。

CONCLUSION

當查詢對聯合主鍵的一部分並且是第一個主鍵進行過濾時,ClickHouse將主鍵索引標記運行二分查找算法。

正如上面所討論的,ClickHouse使用它的稀疏主索引來快速(通過二分查找算法)選擇可能包含匹配查詢的行的顆粒。

這是ClickHouse查詢執行的第一階段(顆粒選擇)

第二階段(數據讀取中), ClickHouse定位所選的顆粒,以便將它們的所有行流到ClickHouse引擎中,以便找到實際匹配查詢的行。

我們將在下一節更詳細地討論第二階段。

標記文件用來定位顆粒

下圖描述了上表主索引文件的一部分。

img

如上所述,通過對索引的1083個UserID標記進行二分搜索,確定了第176個標記。因此,它對應的顆粒176可能包含UserID列值爲749.927.693的行。

顆粒選擇的具體過程
上圖顯示,標記176是第一個UserID值小於749.927.693的索引條目,並且下一個標記(標記177)的顆粒177的最小UserID值大於該值的索引條目。因此,只有標記176對應的顆粒176可能包含UserID列值爲749.927.693的行。

爲了確認(或排除)顆粒176中的某些行包含UserID列值爲749.927.693,需要將屬於此顆粒的所有8192行讀取到ClickHouse。

爲了讀取這部分數據,ClickHouse需要知道顆粒176的物理地址。

在ClickHouse中,我們表的所有顆粒的物理位置都存儲在標記文件中。與數據文件類似,每個表的列有一個標記文件。

下圖顯示了三個標記文件UserID.mrk、URL.mrk、EventTime.mrk,爲表的UserID、URL和EventTime列存儲顆粒的物理位置。

img

我們已經討論了主索引是一個扁平的未壓縮數組文件(primary.idx),其中包含從0開始編號的索引標記。

類似地,標記文件也是一個扁平的未壓縮數組文件(*.mrk),其中包含從0開始編號的標記。

一旦ClickHouse確定並選擇了可能包含查詢所需的匹配行的顆粒的索引標記,就可以在標記文件數組中查找,以獲得顆粒的物理位置。

每個特定列的標記文件條目以偏移量的形式存儲兩個位置:

  • 第一個偏移量(上圖中的'block_offset')是在包含所選顆粒的壓縮版本的壓縮列數據文件中定位塊。這個壓縮塊可能包含幾個壓縮的顆粒。所定位的壓縮文件塊在讀取時被解壓到內存中。
  • 標記文件的第二個偏移量(上圖中的“granule_offset”)提供了顆粒在解壓數據塊中的位置。

定位到的顆粒中的所有8192行數據都會被ClickHouse加載然後進一步處理。

爲什麼需要MARK文件

爲什麼主索引不直接包含與索引標記相對應的顆粒的物理位置?

因爲ClickHouse設計的場景就是超大規模數據,非常高效地使用磁盤和內存非常重要。

主索引文件需要放入內存中。

對於我們的示例查詢,ClickHouse使用了主索引,並選擇了可能包含與查詢匹配的行的單個顆粒。只有對於這一個顆粒,ClickHouse才需定位物理位置,以便將相應的行組讀取以進一步的處理。

而且,只有UserID和URL列需要這個偏移量信息。

對於查詢中不使用的列,例如EventTime,不需要偏移量信息。

對於我們的示例查詢,Clickhouse只需要UserID數據文件(UserID.bin)中176顆粒的兩個物理位置偏移,以及URL數據文件(URL.data)中176顆粒的兩個物理位置偏移。

由mark文件提供的間接方法避免了直接在主索引中存儲所有三個列的所有1083個顆粒的物理位置的條目:因此避免了在主內存中有不必要的(可能未使用的)數據。

下面的圖表和文本說明了我們的查詢示例,ClickHouse如何在UserID.bin數據文件中定位176顆粒。

img

我們在本文前面討論過,ClickHouse選擇了主索引標記176,因此176顆粒可能包含查詢所需的匹配行。

ClickHouse現在使用從索引中選擇的標記號(176)在UserID.mark中進行位置數組查找,以獲得兩個偏移量,用於定位顆粒176。

如圖所示,第一個偏移量是定位UserID.bin數據文件中的壓縮文件塊,該數據文件包含顆粒176的壓縮數據。

一旦所定位的文件塊被解壓縮到主內存中,就可以使用標記文件的第二個偏移量在未壓縮的數據中定位顆粒176。

ClickHouse需要從UserID.bin數據文件和URL.bin數據文件中定位(讀取)顆粒176,以便執行我們的示例查詢(UserID爲749.927.693的互聯網用戶點擊次數最多的10個url)。

上圖顯示了ClickHouse如何定位UserID.bin數據文件的顆粒。

同時,ClickHouse對URL.bin數據文件的顆粒176執行相同的操作。這兩個不同的顆粒被對齊並加載到ClickHouse引擎以進行進一步的處理,即聚合並計算UserID爲749.927.693的所有行的每組URL值,最後以計數降序輸出10個最大的URL組。

查詢使用第二位主鍵的性能問題

當查詢對複合鍵的一部分並且是第一個主鍵列進行過濾時,ClickHouse將對主鍵列的索引標記運行二分查找。

但是,當查詢對聯合主鍵的一部分但不是第一個鍵列進行過濾時,會發生什麼情況?

NOTE

我們討論了這樣一種場景:查詢不是顯式地對第一個主鍵列進行過濾,而是對第一個主鍵列之後的任何鍵列進行過濾。

當查詢同時對第一個主鍵列和第一個主鍵列之後的任何鍵列進行過濾時,ClickHouse將對第一個主鍵列的索引標記運行二分查找。

我們使用一個查詢來計算最點擊"http://public_search"的最多的前10名用戶:

SELECT UserID, count(UserID) AS Count
FROM hits_UserID_URL
WHERE URL = 'http://public_search'
GROUP BY UserID
ORDER BY Count DESC
LIMIT 10;

結果是:

┌─────UserID─┬─Count─┐
│ 2459550954 │  3741 │
│ 1084649151 │  2484 │
│  723361875 │   729 │
│ 3087145896 │   695 │
│ 2754931092 │   672 │
│ 1509037307 │   582 │
│ 3085460200 │   573 │
│ 2454360090 │   556 │
│ 3884990840 │   539 │
│  765730816 │   536 │
└────────────┴───────┘

10 rows in set. Elapsed: 0.086 sec.
Processed 8.81 million rows,
799.69 MB (102.11 million rows/s., 9.27 GB/s.)

客戶端輸出表明,儘管URL列是聯合主鍵的一部分,ClickHouse幾乎執行了一一次全表掃描!ClickHouse從表的887萬行中讀取881萬行。

如果啓用了trace日誌,那麼ClickHouse服務日誌文件顯示,ClickHouse在1083個URL索引標記上使用了通用的排除搜索,以便識別那些可能包含URL列值爲"http://public_search"的行。

...Executor): Key condition: (column 1 in ['http://public_search',
                                           'http://public_search'])
...Executor): Used generic exclusion search over index for part all_1_9_2
              with 1537 steps
...Executor): Selected 1/1 parts by partition key, 1 parts by primary key,
              1076/1083 marks by primary key, 1076 marks to read from 5 ranges
...Executor): Reading approx. 8814592 rows with 10 streams

我們可以在上面的跟蹤日誌示例中看到,1083個顆粒中有1076個(通過標記)被選中,因爲可能包含具有匹配URL值的行。

這將導致881萬行被讀取到ClickHouse引擎中(通過使用10個流並行地讀取),以便識別實際包含URL值"http://public_search"的行。

然而,稍後僅僅39個顆粒包含匹配的行。

雖然基於聯合主鍵(UserID, URL)的主索引對於加快過濾具有特定UserID值的行的查詢非常有用,但對於過濾具有特定URL值的行的查詢,索引並沒有提供顯著的幫助。

原因是URL列不是第一個主鍵列,因此ClickHouse是使用一個通用的排除搜索算法(而不是二分查找)查找URL列的索引標誌,和UserID主鍵列不同,它的算法的有效性依賴於URL列的基數。

爲了說明,我們給出通用的排除搜索算法的工作原理:

通用排除搜索算法

下面將演示當通過第一個列之後的任何列選擇顆粒時,當前一個鍵列具有或高或低的基數時,ClickHouse通用排除搜索算法 是如何工作的。

作爲這兩種情況的例子,我們將假設:

  • 搜索URL值爲"W3"的行。
  • 點擊表抽象簡化爲只有簡單值的UserID和UserID。
  • 相同聯合主鍵(UserID、URL)。這意味着行首先按UserID值排序,具有相同UserID值的行然後再按URL排序。
  • 顆粒大小爲2,即每個顆粒包含兩行。

在下面的圖表中,我們用橙色標註了每個顆粒的最小鍵列值。

前綴主鍵低基數

假設UserID具有較低的基數。在這種情況下,相同的UserID值很可能分佈在多個錶行和顆粒上,從而分佈在索引標記上。對於具有相同UserID的索引標記,索引標記的URL值按升序排序(因爲錶行首先按UserID排序,然後按URL排序)。這使得有效的過濾如下所述:

在上圖中,我們的抽象樣本數據的顆粒選擇過程有三種不同的場景:

  1. 如果索引標記0的(最小)URL值小於W3,並且緊接索引標記的URL值也小於W3,則可以排除索引標記0,因爲標記0、標記1和標記2具有相同的UserID值。注意,這個排除前提條件確保顆粒0和下一個顆粒1完全由U1 UserID值組成,這樣ClickHouse就可以假設顆粒0中的最大URL值也小於W3並排除該顆粒。
  2. 如果索引標記1的URL值小於(或等於)W3,並且後續索引標記的URL值大於(或等於)W3,則選擇索引標記1,因爲這意味着粒度1可能包含URL爲W3的行)。
  3. 可以排除URL值大於W3的索引標記2和3,因爲主索引的索引標記存儲了每個顆粒的最小鍵列值,因此顆粒2和3不可能包含URL值W3。

前綴主鍵高基數

當UserID具有較高的基數時,相同的UserID值不太可能分佈在多個錶行和顆粒上。這意味着索引標記的URL值不是單調遞增的:

img

正如在上面的圖表中所看到的,所有URL值小於W3的標記都被選中,以便將其關聯的顆粒的行加載到ClickHouse引擎中。

這是因爲雖然圖中的所有索引標記都屬於上面描述的場景1,但它們不滿足前面提到的排除前提條件,即兩個直接隨後的索引標記都具有與當前標記相同的UserID值,因此不能被排除。

例如,考慮索引標記0,其URL值小於W3,並且其直接後續索引標記的URL值也小於W3。這不能排除,因爲兩個直接隨後的索引標記1和2與當前標記0沒有相同的UserID值。

請注意,隨後的兩個索引標記需要具有相同的UserID值。這確保了當前和下一個標記的顆粒完全由U1 UserID值組成。如果僅僅是下一個標記具有相同的UserID,那麼下一個標記的URL值可能來自具有不同UserID的錶行——當您查看上面的圖表時,確實是這樣的情況,即W2來自U2而不是U1的行。

這最終阻止了ClickHouse對顆粒0中的最大URL值進行假設。相反,它必須假設顆粒0可能包含URL值爲W3的行,並被迫選擇標記0。

同樣的情況也適用於標記1、2和3。

結論

當查詢對聯合主鍵的一部分列(但不是第一個鍵列)進行過濾時,ClickHouse使用的通用排除搜索算法(而不是二分查找)在前一個鍵列基數較低時最有效。

在我們的示例數據集中,兩個鍵列(UserID、URL)都具有類似的高基數,並且,如前所述,當URL列的前一個鍵列具有較高基數時,通用排除搜索算法不是很有效。

看下跳數索引

因爲UserID和URL具有較高的基數,根據URL過濾數據不是特別有效,對URL列創建二級跳數索引同樣也不會有太多改善。

例如,這兩個語句在我們的表的URL列上創建並填充一個minmax跳數索引。

ALTER TABLE hits_UserID_URL ADD INDEX url_skipping_index URL TYPE minmax GRANULARITY 4;
ALTER TABLE hits_UserID_URL MATERIALIZE INDEX url_skipping_index;

ClickHouse現在創建了一個額外的索引來存儲—每組4個連續的顆粒(注意上面ALTER TABLE語句中的GRANULARITY 4子句)—最小和最大的URL值:

img

第一個索引條目(上圖中的mark 0)存儲屬於表的前4個顆粒的行的最小和最大URL值。

第二個索引條目(mark 1)存儲屬於表中下一個4個顆粒的行的最小和最大URL值,依此類推。

(ClickHouse還爲跳數索引創建了一個特殊的標記文件,用於定位與索引標記相關聯的顆粒組。)

由於UserID和URL的基數相似,在執行對URL的查詢過濾時,這個二級跳數索引不能幫助排除選擇的顆粒。

正在尋找的特定URL值('http://public_search')很可能是索引爲每組顆粒存儲的最小值和最大值之間的值,導致ClickHouse被迫選擇這組顆粒(因爲它們可能包含匹配查詢的行)。

因此,如果我們想顯著提高過濾具有特定URL的行的示例查詢的速度,那麼我們需要使用針對該查詢優化的主索引。

此外,如果我們想保持過濾具有特定UserID的行的示例查詢的良好性能,那麼我們需要使用多個主索引。

下面是實現這一目標的方法。

使用多個主鍵索引進行調優

如果我們想顯著加快我們的兩個示例查詢——一個過濾具有特定UserID的行,一個過濾具有特定URL的行——那麼我們需要使用多個主索引,通過使用這三個方法中的一個:

  • 新建一個不同主鍵的新表。
  • 創建一個物化視圖。
  • 增加projection。

這三個方法都會有效地將示例數據複製到另一個表中,以便重新組織表的主索引和行排序順序。

然而,這三個選項的不同之處在於,附加表對於查詢和插入語句的路由對用戶的透明程度。

當創建有不同主鍵的第二個表時,查詢必須顯式地發送給最適合查詢的表版本,並且必須顯式地插入新數據到兩個表中,以保持表的同步:

img

在物化視圖中,額外的表被隱藏,數據自動在兩個表之間保持同步:

img

projection方式是最透明的選項,因爲除了自動保持隱藏的附加表與數據變化同步外,ClickHouse還會自動選擇最有效的表版本進行查詢:

img

下面我們使用真實的例子詳細討論下這三種方式。

通過輔助表使用聯合主鍵索引

我們創建一個新的附加表,其中我們在主鍵中切換鍵列的順序(與原始表相比):

CREATE TABLE hits_URL_UserID
(
    `UserID` UInt32,
    `URL` String,
    `EventTime` DateTime
)
ENGINE = MergeTree
PRIMARY KEY (URL, UserID)
ORDER BY (URL, UserID, EventTime)
SETTINGS index_granularity = 8192, index_granularity_bytes = 0;

寫入887萬行源表數據:

INSERT INTO hits_URL_UserID
SELECT * from hits_UserID_URL;

結果:

Ok.

0 rows in set. Elapsed: 2.898 sec. Processed 8.87 million rows, 838.84 MB (3.06 million rows/s., 289.46 MB/s.)

最後optimize下:

OPTIMIZE TABLE hits_URL_UserID FINAL;

因爲我們切換了主鍵中列的順序,插入的行現在以不同的字典順序存儲在磁盤上(與我們的原始表相比),因此該表的1083個顆粒也包含了與以前不同的值:

img

主鍵索引如下:

img

現在計算最頻繁點擊URL"http://public_search"的前10名用戶,這時候的查詢速度是明顯加快的:

SELECT UserID, count(UserID) AS Count
FROM hits_URL_UserID
WHERE URL = 'http://public_search'
GROUP BY UserID
ORDER BY Count DESC
LIMIT 10;

結果:

┌─────UserID─┬─Count─┐
│ 2459550954 │  3741 │
│ 1084649151 │  2484 │
│  723361875 │   729 │
│ 3087145896 │   695 │
│ 2754931092 │   672 │
│ 1509037307 │   582 │
│ 3085460200 │   573 │
│ 2454360090 │   556 │
│ 3884990840 │   539 │
│  765730816 │   536 │
└────────────┴───────┘

10 rows in set. Elapsed: 0.017 sec.
Processed 319.49 thousand rows,
11.38 MB (18.41 million rows/s., 655.75 MB/s.)

現在沒有全表掃描了,ClickHouse執行高效了很多。

對於原始表中的主索引(其中UserID是第一個鍵列,URL是第二個鍵列),ClickHouse在索引標記上使用了通用排除搜索來執行該查詢,但這不是很有效,因爲UserID和URL的基數同樣很高。

將URL作爲主索引的第一列,ClickHouse現在對索引標記運行二分搜索。ClickHouse服務器日誌文件中對應的跟蹤日誌:

...Executor): Key condition: (column 0 in ['http://public_search',
                                           'http://public_search'])
...Executor): Running binary search on index range for part all_1_9_2 (1083 marks)
...Executor): Found (LEFT) boundary mark: 644
...Executor): Found (RIGHT) boundary mark: 683
...Executor): Found continuous range in 19 steps
...Executor): Selected 1/1 parts by partition key, 1 parts by primary key,
              39/1083 marks by primary key, 39 marks to read from 1 ranges
...Executor): Reading approx. 319488 rows with 2 streams

ClickHouse只選擇了39個索引標記,而不是使用通用排除搜索時的1076個。

請注意,輔助表經過了優化,以加快對url的示例查詢過濾的執行。

像之前我們查詢過濾URL一樣,如果我們現在對輔助表查詢過濾UserID,性能同樣會比較差,因爲現在UserID是第二主索引鍵列,所以ClickHouse將使用通用排除搜索算法查找顆粒,這對於類似高基數的UserID和URL來說不是很有效。

點擊下面瞭解詳情:

SELECT URL, count(URL) AS Count
FROM hits_URL_UserID
WHERE UserID = 749927693
GROUP BY URL
ORDER BY Count DESC
LIMIT 10;

結果

┌─URL────────────────────────────┬─Count─┐
│ http://auto.ru/chatay-barana.. │   170 │
│ http://auto.ru/chatay-id=371...│    52 │
│ http://public_search           │    45 │
│ http://kovrik-medvedevushku-...│    36 │
│ http://forumal                 │    33 │
│ http://korablitz.ru/L_1OFFER...│    14 │
│ http://auto.ru/chatay-id=371...│    14 │
│ http://auto.ru/chatay-john-D...│    13 │
│ http://auto.ru/chatay-john-D...│    10 │
│ http://wot/html?page/23600_m...│     9 │
└────────────────────────────────┴───────┘

10 rows in set. Elapsed: 0.024 sec.
Processed 8.02 million rows,
73.04 MB (340.26 million rows/s., 3.10 GB/s.)

服務端日誌:
...Executor): Key condition: (column 1 in [749927693, 749927693])
...Executor): Used generic exclusion search over index for part all_1_9_2
              with 1453 steps
...Executor): Selected 1/1 parts by partition key, 1 parts by primary key,
              980/1083 marks by primary key, 980 marks to read from 23 ranges
...Executor): Reading approx. 8028160 rows with 10 streams

現在我們有了兩張表。優化了對UserID和URL的查詢過濾,分別:

img

通過物化視圖使用聯合主鍵

在原表上創建物化視圖:

CREATE MATERIALIZED VIEW mv_hits_URL_UserID
ENGINE = MergeTree()
PRIMARY KEY (URL, UserID)
ORDER BY (URL, UserID, EventTime)
POPULATE
AS SELECT * FROM hits_UserID_URL;

結果:

Ok.

0 rows in set. Elapsed: 2.935 sec. Processed 8.87 million rows, 838.84 MB (3.02 million rows/s., 285.84 MB/s.)

NOTE

  • 我們在視圖的主鍵中切換鍵列的順序(與原始表相比)
  • 物化視圖由一個隱藏表支持,該表的行順序和主索引基於給定的主鍵定義
  • 我們使用POPULATE關鍵字,以便用源表hits_UserID_URL中的所有887萬行立即導入新的物化視圖
  • 如果在源表hits_UserID_URL中插入了新行,那麼這些行也會自動插入到隱藏表中
  • 實際上,隱式創建的隱藏表的行順序和主索引與我們上面顯式創建的輔助表相同:

img

ClickHouse將隱藏表的列數據文件(.bin)、標記文件(.mrk2)和主索引(primary.idx)存儲在ClickHouse服務器的數據目錄的一個特殊文件夾中:

img

物化視圖背後的隱藏表(和它的主索引)現在可以用來顯著加快我們在URL列上查詢過濾的執行速度:

SELECT UserID, count(UserID) AS Count
FROM mv_hits_URL_UserID
WHERE URL = 'http://public_search'
GROUP BY UserID
ORDER BY Count DESC
LIMIT 10;

結果:

┌─────UserID─┬─Count─┐
│ 2459550954 │  3741 │
│ 1084649151 │  2484 │
│  723361875 │   729 │
│ 3087145896 │   695 │
│ 2754931092 │   672 │
│ 1509037307 │   582 │
│ 3085460200 │   573 │
│ 2454360090 │   556 │
│ 3884990840 │   539 │
│  765730816 │   536 │
└────────────┴───────┘

10 rows in set. Elapsed: 0.026 sec.
Processed 335.87 thousand rows,
13.54 MB (12.91 million rows/s., 520.38 MB/s.)

物化視圖背後隱藏表(及其主索引)實際上與我們顯式創建的輔助表是相同的,所以查詢的執行方式與顯式創建的表相同。

ClickHouse服務器日誌文件中相應的跟蹤日誌確認了ClickHouse正在對索引標記運行二分搜索:

...Executor): Key condition: (column 0 in ['http://public_search',
                                           'http://public_search'])
...Executor): Running binary search on index range ...
...
...Executor): Selected 4/4 parts by partition key, 4 parts by primary key,
              41/1083 marks by primary key, 41 marks to read from 4 ranges
...Executor): Reading approx. 335872 rows with 4 streams

通過projections使用聯合主鍵索引

Projections目前是一個實驗性的功能,因此我們需要告訴ClickHouse:

SET allow_experimental_projection_optimization = 1;

在原表上創建projection:

ALTER TABLE hits_UserID_URL
    ADD PROJECTION prj_url_userid
    (
        SELECT *
        ORDER BY (URL, UserID)
    );

物化projection:

ALTER TABLE hits_UserID_URL
    MATERIALIZE PROJECTION prj_url_userid;

NOTE

  • 該projection正在創建一個隱藏表,該表的行順序和主索引基於該projection的給定order BY子句
  • 我們使用MATERIALIZE關鍵字,以便立即用源表hits_UserID_URL的所有887萬行導入隱藏表
  • 如果在源表hits_UserID_URL中插入了新行,那麼這些行也會自動插入到隱藏表中
  • 查詢總是(從語法上)針對源表hits_UserID_URL,但是如果隱藏表的行順序和主索引允許更有效地執行查詢,那麼將使用該隱藏表
  • 實際上,隱式創建的隱藏表的行順序和主索引與我們顯式創建的輔助表相同:

img

ClickHouse將隱藏表的列數據文件(.bin)、標記文件(.mrk2)和主索引(primary.idx)存儲在一個特殊的文件夾中(在下面的截圖中用橙色標記),緊挨着源表的數據文件、標記文件和主索引文件:

img

由投影創建的隱藏表(以及它的主索引)現在可以(隱式地)用於顯著加快URL列上查詢過濾的執行。注意,查詢在語法上針對投影的源表。

SELECT UserID, count(UserID) AS Count
FROM hits_UserID_URL
WHERE URL = 'http://public_search'
GROUP BY UserID
ORDER BY Count DESC
LIMIT 10;

結果:

┌─────UserID─┬─Count─┐
│ 2459550954 │  3741 │
│ 1084649151 │  2484 │
│  723361875 │   729 │
│ 3087145896 │   695 │
│ 2754931092 │   672 │
│ 1509037307 │   582 │
│ 3085460200 │   573 │
│ 2454360090 │   556 │
│ 3884990840 │   539 │
│  765730816 │   536 │
└────────────┴───────┘

10 rows in set. Elapsed: 0.029 sec.
Processed 319.49 thousand rows, 1
1.38 MB (11.05 million rows/s., 393.58 MB/s.)

因爲由投影創建的隱藏表(及其主索引)實際上與我們顯式創建的輔助表相同,所以查詢的執行方式與顯式創建的表相同。

ClickHouse服務器日誌文件中跟蹤日誌確認了ClickHouse正在對索引標記運行二分搜索:

...Executor): Key condition: (column 0 in ['http://public_search',
                                           'http://public_search'])
...Executor): Running binary search on index range for part prj_url_userid (1083 marks)
...Executor): ...
...Executor): Choose complete Normal projection prj_url_userid
...Executor): projection required columns: URL, UserID
...Executor): Selected 1/1 parts by partition key, 1 parts by primary key,
              39/1083 marks by primary key, 39 marks to read from 1 ranges
...Executor): Reading approx. 319488 rows with 2 streams

移除無效的主鍵列

帶有聯合主鍵(UserID, URL)的表的主索引對於加快UserID的查詢過濾非常有用。但是,儘管URL列是聯合主鍵的一部分,但該索引在加速URL查詢過濾方面並沒有提供顯著的幫助。

反之亦然:具有複合主鍵(URL, UserID)的表的主索引加快了URL上的查詢過濾,但沒有爲UserID上的查詢過濾提供太多支持。

由於主鍵列UserID和URL的基數同樣很高,過濾第二個鍵列的查詢不會因爲第二個鍵列位於索引中而受益太多。

因此,從主索引中刪除第二個鍵列(從而減少索引的內存消耗)並使用多個主索引是有意義的。

但是,如果複合主鍵中的鍵列在基數上有很大的差異,那麼查詢按基數升序對主鍵列進行排序是有益的。

主鍵鍵列之間的基數差越大,主鍵鍵列的順序越重要。我們將在以後的文章中對此進行演示。請繼續關注。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章