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

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

列で保存するということ — パート・列ファイル・圧縮

TT Labで続きを見る

一言でいうと

ClickHouseのMergeTreeは、行をソートキーの順に並べ、列ごとに別々に圧縮したファイルの集まり(パート)として保存します。そのためクエリのコストは、何行を選ぶかよりも、どの列に触れるかとソートキーでどれだけスキップできるかで決まります。

なぜ必要なのか

行指向DBに分析クエリを投げると、「売上の合計を1つ求めたいだけなのに、なぜテーブル全体を読むのか」という疑問にすぐ突き当たります。行指向の保存では1行のすべての列が隣り合っているため、amountの1列だけを足したくても、ディスクからは行全体を読み出します。取引処理ならそれで正解です。注文1件を丸ごと読み書きするからです。ところが分析はその逆で、数億行から2、3列だけを読みます。

列指向の保存は、この非対称を逆転させます。同じ列の値を集めておけば、クエリが使う列だけを読めばよく、似た値が隣り合うので圧縮もずっとよく効きます。その代わり、「1行だけを変更する」コストは高くなります。複数の列ファイルに触れる必要があるからです。ClickHouseの設計上の判断の大半、つまり一度書いたパートは書き換えない、削除と更新はあとのマージのときに処理する、といった方針は、このトレードオフから出てきます。

どう動くのか

INSERT 1回で、パートのディレクトリが1つできます。パートの中で行はソートキーの順に並び、列ごとに.binデータファイルとマークファイルが別々にあり、8192行ごとにグラニュールに分かれ、primary.idxにはグラニュールの先頭行のキー値だけが記録されます。バックグラウンドのマージが小さなパート2つを大きなパート1つにまとめ、古いパートは非アクティブになります

公式ドキュメント(Table parts)が示すINSERTの4つの段階が、そのまま保存構造です。

  1. 届いた行をテーブルのソートキー(ORDER BY)の順に並べ、スパースなプライマリインデックスを作る
  2. 並べた行を列に分ける
  3. 列ごとに圧縮する
  4. 圧縮した列ファイルとインデックスを、新しいパートのディレクトリ1つに書く

パートは一度書かれると変わりません(immutable)。INSERTが来るたびにパートが1つずつ増え、バックグラウンドのマージが小さなパートをまとめて大きなパートにします。まとめられた古いパートは非アクティブ(inactive)になり、やがて削除されます。パート名all_1_2_1は、パーティション(all)、含まれるブロック番号の最初と最後(1–2)、マージレベル(1)を表します。レベル0は、まだ一度もマージされていないパートです。

列ファイルの中はグラニュールに分かれています。デフォルトでは8192行が1つのグラニュールで、処理の最小単位です。プライマリインデックスは、すべての行ではなくグラニュールごとに先頭行のキー値を1つだけ記録します。だから「スパース(sparse)」なインデックスです。WHERE site = 'docs.example'が来ると、インデックスを調べて、その値があり得ないグラニュールをまるごとスキップします。ソートキーにない列で絞り込むと、スキップする根拠がないためすべて読みます。system.partsのmarksは、グラニュールごとに1つ付く位置の目印の数で、終端を示すマークが1つ加わります(200万行ならグラニュール245個に対してマークは246個)。

圧縮率は列によって大きく違います。同じ値が長く続く列(ソートキーの先頭の列や、種類が数個しかない列)は数百分の1に縮み、乱数に近い整数はほとんど縮みません。LowCardinality(String)は文字列を辞書の番号に置き換えて保存する型なので、サイト名が5つしかない列はほぼ無料同然になります。逆に、時刻のように増え続ける整数は、デフォルトのコーデック(LZ4)だけでは縮みません。これを縮めるコーデックは、あとのモジュールで扱います。

SELECT name, data_compressed_bytes, data_uncompressed_bytes
FROM system.columns WHERE database = 'col' AND table = 'events';   -- 열별 크기

SELECT sum(dur_ms) FROM col.events FORMAT JSON;   -- 맨 끝 statistics 에 rows_read · bytes_read

コストを測る指標は2つあります。rows_readはスキップできずに読んだ行数で、bytes_readはその行から触れた列の(圧縮を解いた)バイト数です。UInt32の列1つの合計を求めると1行あたり4バイトで、200万行ならちょうど8,000,000バイトです。同じ200万行でも、長い文字列の列の内容を読むと数倍になります。1つ注意点があります。length(url)は、文字列の内容ではなく長さだけを持つサブカラム(url.size)を読むように書き換えられるため、1行あたり8バイトしか読みません。何を読んだかは推測せず、数値で確認します。

すべてのクエリについて、この数値はsystem.query_logにも残ります。SETTINGS log_comment = '...'で目印を付けておくと、あとで自分のクエリを探しやすくなります。ログのテーブルは約1秒ごとにフラッシュされるので、今実行したクエリをすぐ見たいときは、先にSYSTEM FLUSH LOGSを実行します。

最後にパートの形式です。パートが小さいときはすべての列を1つのファイルに収めるCompact、大きいときは列ごとにファイルを分けるWideになります。境目はテーブル設定のmin_bytes_for_wide_part・min_rows_for_wide_partが決めます。小さなINSERTが頻繁に来たときに、ファイル数が爆発しないようにするための仕組みです。

現場での姿

分析ダッシュボードが遅いという報告を受けたら、まずSELECT *を探します。行指向DBではほとんど無料だった習慣が、列指向の保存ではすべての列ファイルを開く作業になります。画面に必要な列だけを選ぶように変えるだけで、読み取りバイト数が数分の1に減ることはよくあります。

2番目によくあるのは、ソートキーを「主キーだからid」で決めてしまったテーブルです。ほとんどのクエリがサイトと期間で絞り込むのにソートキーがidだと、インデックスはあっても一度もスキップできません。rows_readがいつも全行数と同じなら、ソートキーを疑います。

3番目は容量計画です。「元のログが1日100GBなので、ディスクは100GB×保存日数」と見積もると大きく外れます。列ごとに圧縮率が数倍から数百倍まで違うので、実際のデータ1日分を入れてsystem.columnsの圧縮後サイズを測り、そこから掛け算するのが正しい方法です。

次のラボですること

col.eventsテーブルをソートキー(site, ts)で作って200万行を入れ、パートを1つにマージして、名前・行数・マーク数をsystem.partsから読みます。列ごとの圧縮率から最もよく縮んだ列と最も縮まなかった列を見つけ、1列だけを読むクエリと長い文字列の列を読むクエリのbytes_readを比べます。ソートキーで絞り込むときとキー以外の列で絞り込むときのrows_readを測り、query_logから自分のクエリを探したあと、小さなテーブルをもう1つ作ってCompactパートを確認します。