聊聊ClickHouse中的低基數(shù)(LowCardinality)類型

2020年快要過去了,寫博客的習(xí)慣還是得撿起來。最近剛剛忙完搬家的事情,抽出一點(diǎn)時(shí)間簡單聊兩句。

為什么要有LowCardinality

在常見數(shù)據(jù)庫系統(tǒng)的類型體系中,字符串是最靈活、表意性最強(qiáng)的類型,但是存儲成本無疑也最高。ClickHouse提供了兩種簡單字符串的更優(yōu)的存儲方式,即:

  • 存儲固定長度(按字節(jié)數(shù)計(jì))字符串的FixedString類型,
  • 以及將字符串轉(zhuǎn)為定長整形枚舉值的Enum類型。

但是,我們平時(shí)見到的字符串絕大多數(shù)都是變長的,只有哈希值、IP等少數(shù)種類適合用FixedString存儲;并且數(shù)據(jù)的定義域可能會經(jīng)常變動(dòng),頻繁修改Enum字段來增加枚舉值也顯然不可行。因此,ClickHouse又提供了第三條路,即LowCardinality——“低基數(shù)”類型。顧名思義,它適合長度和定義域都可變,但總體基數(shù)不是特別大的列。

根據(jù)官方文檔,低基數(shù)是一種修飾類型,即用法為LowCardinality(type)。其中type表示的原始類型可以是String、FixedString、Date、DateTime,以及除了Decimal之外的所有數(shù)值類型。但是,LowCardinality的設(shè)計(jì)初衷就是為了優(yōu)化字符串存儲,修飾其他類型的效率未必會更高,所以下面只考慮LowCardinality(String)的情況。

做個(gè)小實(shí)驗(yàn)

來創(chuàng)建兩張MergeTree測試表,其中一個(gè)用普通String類型,另一個(gè)用低基數(shù)String類型。

CREATE TABLE test.user_event_common_str (
  user_id Int64,
  event_type String
) ENGINE = MergeTree()
ORDER BY user_id;

CREATE TABLE test.user_event_lowcard_str (
  user_id Int64,
  event_type LowCardinality(String)
) ENGINE = MergeTree()
ORDER BY user_id;

從我們的埋點(diǎn)日志表中取一些數(shù)據(jù)(總計(jì)約2.3億行)分別存入兩張表中。event_type字段表示埋點(diǎn)事件類型,目前約有100種,且會隨著應(yīng)用的迭代而增加。

做個(gè)簡單的聚合查詢:

:) SELECT event_type,count() AS cnt
FROM test.user_event_lowcard_str
GROUP BY event_type ORDER BY cnt DESC;
-- ...
105 rows in set. Elapsed: 0.050 sec. Processed 229.77 million rows, 240.39 MB (4.59 billion rows/s., 4.80 GB/s.)

:) SELECT event_type,count() AS cnt
FROM test.user_event_common_str
GROUP BY event_type ORDER BY cnt DESC;
-- ...
105 rows in set. Elapsed: 0.297 sec. Processed 229.77 million rows, 5.34 GB (774.40 million rows/s., 18.00 GB/s.)

可見在這個(gè)場景下,對低基數(shù)String進(jìn)行聚合,速度是對普通String進(jìn)行聚合的6倍,并且讀取的數(shù)據(jù)量只有原來的4.5%。從系統(tǒng)表中查詢存儲空間的占用,低基數(shù)String也明顯要更?。?/p>

:) SELECT table,column,
   sum(rows) AS rows,
   formatReadableSize(sum(column_data_compressed_bytes)) AS comp_bytes,
   formatReadableSize(sum(column_data_uncompressed_bytes)) AS uncomp_bytes
FROM system.parts_columns
WHERE table LIKE 'user_event_%_str' AND column = 'event_type'
GROUP BY table,column;

┌─table──────────────────┬─column─────┬──────rows─┬─comp_bytes─┬─uncomp_bytes─┐
│ user_event_lowcard_str │ event_type │ 229770105 │ 186.89 MiB │ 219.57 MiB   │
│ user_event_common_str  │ event_type │ 229770105 │ 599.33 MiB │ 3.26 GiB     │
└────────────────────────┴────────────┴───────────┴────────────┴──────────────┘

我們甚至可以用DDL語句將String類型的列直接修改為低基數(shù)String類型的列,速度也相當(dāng)快:

:) ALTER TABLE test.user_event_common_str 
MODIFY COLUMN event_type LowCardinality(String);

0 rows in set. Elapsed: 7.420 sec.

低基數(shù)的背后

LowCardinality的實(shí)現(xiàn)方法同樣簡單而高效,即字典壓縮編碼(dictionary encoding)加上倒排索引(reverse index),如下圖所示。事實(shí)上,LowCardinality(String)類型還有一個(gè)別名StringWithDictionary,更貼近其本質(zhì)。

一旦有了字典,很多對字符串進(jìn)行操作的函數(shù)就可以下推到字典上執(zhí)行(如下圖所示),效率很高。另外,同一個(gè)字典上的操作會被緩存(甚至包括GROUP BY子句產(chǎn)生的哈希值),不必每次都進(jìn)行計(jì)算。

最后,ClickHouse還提供了low_cardinality_max_dictionary_size參數(shù)來控制單個(gè)字典的大小閾值,默認(rèn)為8192。也就是說,如果LowCardinality(String)列的基數(shù)大于該閾值,就會被拆分成多個(gè)字典文件存儲。

那么,低基數(shù)String的基數(shù)控制在什么范圍內(nèi)的效率最高呢?關(guān)于這點(diǎn),官方文檔和Altinity的blog給出了完全不同的答案。前者認(rèn)為控制在萬級別以內(nèi)較好,而后者認(rèn)為10M(即約1000萬)以下都可以。筆者利用現(xiàn)有數(shù)據(jù)集進(jìn)行測試,String的基數(shù)是10萬級別,采用LowCardinality的聚合效率仍然是普通String的4倍左右,看官可酌情參考。

The End

臨近年關(guān),clickhouse-client退出的時(shí)候還會預(yù)祝新年快樂,有點(diǎn)意思。

:) EXIT;
Happy new year.

民那晚安晚安。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容