TT Lab
はじめる
学ぶ 学習パス コース

ClickHouse — 列指向分析 DB を中身から

型とコーデック — 同じ値をより小さく格納する

TT Labで続きを見る

一言でいうと

列指向の保存でディスクサイズを決めるつまみは、3つあります。ソートキー、型、コーデックです。型が圧縮前のサイズを決め、コーデックはそのバイトを2段階(値を変換する前処理、そして汎用圧縮)で縮めます。どれが勝つかはデータが決めるので、推測せず、同じデータで測って選びます。

なぜ必要なのか

最初のモジュールで、列ごとに圧縮率が大きく違うことを見ました。サイト名が5つしかない列はほぼ無料同然で、増え続ける時刻の列は、デフォルトのコーデック(LZ4)ではほとんど縮みませんでした。このラボのデータで測ると、10秒間隔の時刻100万個(4,000,000バイト)がLZ4を通ったあとで4,017,298バイトになり、むしろ少し大きくなります。LZ4は「同じバイト列がまた出てくるか」を探すアルゴリズムですが、増え続ける整数は毎回違うバイトなので、見つかるものがありません。

ところがこの列には、はっきりした構造があります。隣り合う値の差が、いつも10なのです。この構造を、LZ4が見分けられる形(10, 10, 10 …)に変えてやりさえすればよいのです。公式ドキュメント(Compression in ClickHouse)が、圧縮に影響する3つの要素としてソートキー・データ型・コーデックを挙げ、「すべてスキーマで決まる」と述べている理由がこれです。スキーマを一度うまく選べば、毎日入ってくるデータのすべてがそれだけ小さくなります。

どう動くのか

上側は、ソートされた時刻の値が前処理コーデックのDeltaを通って隣との差(10, 10, 10)に変わり、汎用コーデックのZSTDが繰り返しを縮めて列ファイルに書かれる過程です。下側は、同じstatusの値を、Stringは行ごとに文字で、LowCardinalityは辞書と行ごとの番号で、Enum8は行ごとに1バイトの番号で持つ様子と、Nullableが値ファイルの隣にnullマップファイルをもう1つ置く様子です

コーデックはチェーンです。CODEC(Delta, ZSTD(1))は、左から順に適用されます。公式ドキュメント(CODEC)は、コーデックを2つに分けます。汎用コーデック(LZ4・LZ4HC・ZSTD)はバイトのレベルで圧縮し、専門コーデックはデータの性質を利用して値を変換します。

コーデック 役割 向いているデータ
Delta 隣の値との差に変換します 単調増加する整数・時刻
DoubleDelta 差の差を、圧縮されたビットにします 間隔が一定の時系列
T64 64個ずつまとめて、使われていない上位ビットを切り落とします 範囲が狭い整数
Gorilla 直前の浮動小数点数とのXORを取ります ゆっくり変わるゲージ

Delta・DoubleDeltaは、ドキュメントに「data preparation codec」と書かれています。単独で使うと、何も縮みません。サーバーもそれを知っています。Float64 CODEC(Delta)は「does not compress anything」エラーで拒否され、CODEC(ZSTD(1), Delta)のように順序を逆にすると「meaningless」エラーが出ます。汎用圧縮のあとに変換をかけても意味がないからです。

同じ時刻の列をこのラボのデータで測ると、次のとおりです(圧縮後、概数): LZ4 4.0MB・ZSTD 2.9MB・Delta+ZSTD 5KB・DoubleDelta+ZSTD 5KB・DoubleDelta単独128KB。前処理が構造を浮かび上がらせると、汎用コーデックが残りをほとんど消します。ドキュメントが勧める「ZSTDをデフォルトに、整数や日付の数列にはDelta」が、そのまま当てはまります。

ただし、専門コーデックがいつも勝つわけではありません。同じラボで、CPU使用率(Float64)の列にGorillaを付けたところ、ZSTDだけを使った列より大きくなりました(3.0MB → 4.6MB)。小数第2位で区切られた値は、2進の浮動小数点では多くのビットが変わるため、XORが小さくなりません。逆に、0–1999の範囲のレイテンシ(UInt32)は、T64が使われていない上位ビットを切り落とし、ZSTD単独より小さくなりました。コーデックの選択は規則ではなく、測定です。

型がコーデックより先です。値が4種類しかないstatusをStringにすると、圧縮前で10MB、圧縮後で約1.07MBです。LowCardinality(String)は、値を辞書に1回ずつ書き、行ごとには小さな番号だけを持つので、圧縮前から1MBに縮み、圧縮後は約0.30MBになります。Enum8も行ごとに1バイトなので似ていますが、値のリストがテーブル定義に埋め込まれているため、新しい値が来たらALTERが必要です。ドキュメント(LowCardinality)は、文字列にはEnumよりLowCardinalityを先に検討するよう述べ、異なる値が1万個未満のときに効率がよく、10万個を超えるとかえって悪くなることがあると述べています。

Nullableはタダではありません。Nullable(UInt16)は、値ファイルの隣に、行ごとに1バイトのnullマップをもう1つ置きます。そのため圧縮前のサイズは、1行あたり2バイトではなく3バイトです。WHERE err IS NULLはnullマップだけを読み(1行あたり1バイト)、sum(err)は2つのファイルを両方読みます。意味も違います。avg()はNULLを除いて平均を出しますが、0は含めます。NULLを0で埋めたUInt16の列は、大部分が0だったため、このサーバーがsparseシリアライズ(デフォルト値でない行だけを書く方式)で保存し、system.parts_columnsのsubstreamsにerr_zero.sparse.idxが見えました。

現場での姿

最もよくあるのは、「とりあえずString、とりあえずNullable」で移してきたテーブルです。元のDBのスキーマをそのまま移すと、すべての列がNullableになり、状態・国・料金プランのような列がStringになります。このラボで、そのようなテーブル(plain)と、型とコーデックを選んだテーブル(tuned)の圧縮後サイズは、約18.5MB対7.7MBでした。スキーマを整える作業はクエリを直す作業より先で、1回でテーブル全体に効果が出ます。

2番目は、本番で稼働中のテーブルのコーデック変更です。ALTER TABLE ... MODIFY COLUMN ts CODEC(...)は、メタデータだけを変えます。すでに書かれたパートは古いコーデックのままで、新しいパートと、マージで書き直されるパートから、新しいコーデックが適用されます。ラボでは、ALTERの直後にサイズは1バイトも変わらず、OPTIMIZE ... FINALでパートを書き直したあとにやっと縮みました。一方、型を変えるMODIFY COLUMN host LowCardinality(String)は、パートを書き直すミューテーションなので、大きなテーブルでは重くなります(ミューテーションは最後のモジュールで扱います)。

3番目は自動選択です。ドキュメントは、テーブル設定enable_adaptive_codec_selectionを有効にすると、デフォルトのコーデックの列について、ブロックごとに最も小さくなるコーデックをマージのときに選ぶと述べています。このPodの26.8サーバーでは、デフォルト値が0(無効)でした。有効でも無効でも、判断の根拠は同じです。system.columnsのdata_compressed_bytesです。

次のラボですること

デフォルトの型・デフォルトのコーデックのcodecs.plainに100万行を入れ、パートを1つに固定します。同じstatusをString・LowCardinality・Enum8の3つの列に、同じ時刻をLZ4・ZSTD・Delta・DoubleDeltaの4つの列に、カウンター・レイテンシ・CPUを「ZSTDだけ」と「専門コーデック+ZSTD」のペアに入れて圧縮後バイト数を比べ、専門コーデックがかえって損をした列を見つけます。Nullableのnullマップを読み取りバイト数で確認したあと、勝った選択を集めたテーブルの全体サイズを測り、最後に本番テーブルのコーデックをALTERで変えて、サイズがいつ変わるかを記録します。