はじめに
- ClickHouse の索引が従来の関係データベース管理システムとどう異なるか
- ClickHouse がテーブルのスパースプライマリインデックスをどのように構築し、利用するか
- ClickHouse で索引を使う際のベストプラクティス
データセット
- サンプルデータセットから、887万行 (イベント) のサブセットを使用します。
- 非圧縮データサイズは 887万イベント、約 700 MB です。これを ClickHouse に保存すると、200 MB に圧縮されます。
- このサブセットでは、各行に 3 つのカラムがあり、あるインターネットユーザー (
UserIDカラム) が特定の時刻 (EventTimeカラム) に URL (URLカラム) をクリックしたことを示します。
- 「特定のユーザーが最も多くクリックした URL の上位 10 件は?」
- 「特定の URL を最も頻繁にクリックしたユーザーの上位 10 人は?」
- 「ユーザーが特定の URL をクリックする時刻として最も多いのはいつか (例: 曜日) ?」
テストマシン
フルテーブルスキャン
ClickHouse の索引設計
大規模データ向けの索引設計
B(+)-Tree データ構造でエントリを検索する平均時間計算量は O(log n) です。より正確には、b を B(+)-Tree の分岐係数、n を索引対象の行数とすると、log_b n = log_2 n / log_2 b です。b は通常、数百から数千の範囲にあるため、B(+)-Trees は非常に浅い構造で、レコードの特定に必要なディスク seek もごく少なくて済みます。887 万行で分岐係数が 1000 の場合、必要なディスク seek は平均 2.3 回です。ただし、この性能には代償があります。追加のディスクおよびメモリのオーバーヘッド、テーブルへの新規行追加や索引へのエントリ追加に伴う挿入コストの増加、さらに場合によっては B-Tree の再平衡化も必要になります。
B-Tree 索引に伴うこうした課題を踏まえ、ClickHouse のテーブルエンジンでは別のアプローチを採用しています。ClickHouse の MergeTree Engine Family は、膨大なデータ量を扱えるように設計・最適化されています。これらのテーブルは、毎秒数百万行の挿入を受け付け、非常に大規模な (数百 PB 規模の) データを保存できるよう設計されています。データは パーツごとに テーブルへすばやく書き込まれ、バックグラウンドでそれらのパーツをマージするルールが適用されます。ClickHouse では各パーツがそれぞれ独自のプライマリインデックスを持ちます。パーツがマージされると、マージ後のパーツのプライマリインデックスもマージされます。ClickHouse が対象とするこのような大規模環境では、ディスクとメモリの効率が極めて重要です。そのため、各行に索引を付けるのではなく、パーツのプライマリインデックスは行のグループ (「granule」と呼ばれます) ごとに 1 つの索引エントリ (「mark」と呼ばれます) を持ちます。この手法は スパースインデックス と呼ばれます。
このようなスパースインデックスが可能なのは、ClickHouse がパーツ内の行を主キーカラム順に並べてディスクに保存しているためです。スパースプライマリインデックスは、単一の行を直接特定するのではなく (B-Tree ベースの索引のように) 、索引エントリに対する二分探索によって、クエリに一致する可能性のある行グループをすばやく絞り込めます。こうして特定された一致候補の行グループ (granules) は、その後 ClickHouse engine に並列にストリーミングされ、一致する行が見つけられます。この索引設計により、プライマリインデックスを小さく保つことができ (完全に main memory に収まる必要があり、実際に収まります) 、それでもクエリ実行時間を大幅に短縮できます。特に、データ分析のユースケースで一般的な範囲クエリで効果を発揮します。
以下では、ClickHouse がどのようにスパースプライマリインデックスを構築し、利用しているかを詳しく説明します。記事の後半では、索引の構築に使うテーブルカラム (主キーカラム) の選び方、削除方法、並べ方について、いくつかのベストプラクティスを紹介します。
主キーを持つテーブル
DDLステートメントの詳細
DDLステートメントの詳細
このガイドの後半での説明をわかりやすくし、図や結果を再現できるようにするため、次のDDLステートメントでは以下を指定しています。
ORDER BY句で、テーブルの複合ソートキーを指定します。- 次の設定により、プライマリインデックスの索引エントリ数を明示的に制御します。
index_granularity: デフォルト値の 8192 に明示的に設定します。つまり、8192 行ごとの各グループに対して、プライマリインデックスには 1 つの索引エントリが作成されます。たとえば、テーブルに 16384 行が含まれている場合、索引エントリは 2 つになります。index_granularity_bytes: アダプティブインデックス粒度を無効にするため、0 に設定します。アダプティブインデックス粒度とは、次のいずれかの条件を満たす場合に、ClickHouse が n 行のグループに対して 1 つの索引エントリを自動的に作成する仕組みです。nが 8192 未満で、かつそのn行を合わせたデータサイズが 10 MB 以上である場合 (index_granularity_bytesのデフォルト値) 。n行を合わせたデータサイズが 10 MB 未満で、nが 8192 である場合。
compress_primary_key: プライマリインデックスの圧縮を無効にするため、0 に設定します。これにより、必要に応じて後でその内容を確認できます。
次に、データを挿入します。
次に、テーブルを最適化します。
次のクエリを使用して、テーブルのメタデータを取得できます。
- テーブルのデータは、ディスク上の特定のディレクトリに ワイド形式 で保存されます。つまり、そのディレクトリ内にはテーブルの各カラムごとに 1 つのデータファイル (および 1 つのマークファイル) が存在します。
- このテーブルには 887 万行があります。
- すべての行を合わせた非圧縮データサイズは 733.28 MB です。
- すべての行を合わせたディスク上の圧縮サイズは 206.94 MB です。
- このテーブルには 1083 個のエントリ (‘marks’ と呼ばれます) を持つプライマリインデックスがあり、そのサイズは 96.93 KB です。
- 合計すると、テーブルのデータファイル、マークファイル、プライマリインデックスファイルを合わせて、ディスク上で 207.07 MB を使用します。
データは主キーカラム順にディスク上へ保存されます
- ソートキーだけを指定した場合、主キーは暗黙的にソートキーと同一のものとして定義されます。
- メモリ効率を高めるため、クエリのフィルタ条件で使用するカラムだけを含む主キーを明示的に指定しています。主キーに基づくプライマリインデックスは、すべてメインメモリに読み込まれます。
- ガイド内の図との整合性を保ち、圧縮率を最大化するために、テーブルのすべてのカラムを含む別個のソートキーを定義しました (たとえばソートによって、類似したデータがカラム内で近くに配置されると、より高く圧縮できます) 。
- 両方を指定する場合、主キーはソートキーのプレフィックスである必要があります。
EventTime カラム) に従って、辞書式順序の昇順でディスク上に保存されます。
EventTime カラムの値によって決まります。- ディスク上の表現では、テーブルの各カラムごとに 1 つのデータファイル (*.bin) があり、そのカラムのすべての値が圧縮フォーマットで保存されます。
- 887 万行は、主キーカラム (および追加のソートキーカラム) に従って、辞書式順序の昇順でディスク上に保存されます。つまり、この場合は
- まず
UserID、 - 次に
URL、 - 最後に
EventTimeの順です。
- まず
UserID.bin、URL.bin、EventTime.bin は、UserID、URL、EventTime カラムの値が保存されているディスク上のデータファイルです。
- 主キーはディスク上の行の辞書式順序を定義するため、テーブルに設定できる主キーは 1 つだけです。
- ClickHouse の内部的な行番号付け方式 (ログメッセージでも使われます) に合わせるため、行番号は 0 から始めています。
データは並列処理のためにグラニュールに編成されます
index_granularity が含まれていることで、
このテーブルの 887 万行の (カラム値) が 1083 個のグラニュールにどのように編成されるかを示しています
(この設定にはデフォルト値の 8192 が設定されています) 。
最初の 8192 行 (そのカラム値) は、ディスク上の物理的な順序に基づいて論理的にグラニュール 0 に属し、次の 8192 行 (そのカラム値) はグラニュール 1 に属します。以降も同様です。
- 最後のグラニュール (グラニュール 1082) に「含まれる」行数は、8192 行未満です。
- このガイドの冒頭の「DDL Statement Details」で、アダプティブインデックス粒度 を無効にしたことに触れました (このガイドでの説明を簡潔にし、図と結果を再現可能にするためです) 。 そのため、サンプルテーブルのすべてのグラニュール (最後のものを除く) は同じサイズになっています。
- アダプティブインデックス粒度を持つテーブルでは、index granularity はデフォルトでアダプティブであるため、行データのサイズに応じて、一部のグラニュールは 8192 行未満になることがあります。
-
主キーのキーカラム (
UserID、URL) の一部のカラム値をオレンジ色で示しています。 これらのオレンジ色で示したカラム値は、各グラニュールの先頭行にある主キーのキーカラム値です。 後で説明するように、これらのオレンジ色で示したカラム値が、テーブルのプライマリインデックスのエントリになります。 - ClickHouse の内部的な番号付け方式 (ログメッセージでも使われています) に合わせるため、グラニュールの番号は 0 から付けています。
プライマリインデックスはグラニュールごとに1つのエントリを持ちます
primary.idx) で、0 から始まる、いわゆる数値のインデックスマークが格納されています。
下の図が示すように、この索引には各グラニュールの先頭行の主キーカラムの値 (上の図でオレンジ色で示した値) が格納されます。
言い換えると、プライマリインデックスには、テーブルの8192行ごとの主キーカラムの値が格納されます (主キーカラムで定義される物理的な行順序に基づきます) 。
例えば、
- 最初の索引エントリ (下の図の「mark 0」) には、上の図のグラニュール 0 の先頭行のキーカラムの値が格納されています。
- 2番目の索引エントリ (下の図の「mark 1」) には、上の図のグラニュール 1 の先頭行のキーカラムの値が格納されており、以降も同様です。
- アダプティブインデックス粒度 を使用するテーブルでは、最後のテーブル行の主キーカラムの値を記録する追加の「final」マークも 1 つプライマリインデックスに格納されます。ただし、このガイドでの説明を簡潔にし、図や結果を再現できるようにするため、ここではアダプティブインデックス粒度を無効にしているため、この例のテーブルの索引にはこの final マークは含まれていません。
- プライマリインデックスファイルは全体が主記憶に読み込まれます。ファイルサイズが利用可能な空きメモリ容量を超えると、ClickHouse はエラーを返します。
プライマリインデックスの内容を調べる
プライマリインデックスの内容を調べる
セルフマネージドのClickHouseクラスターでは、サンプルテーブルのプライマリインデックスの内容を調べるために、file table functionを使用できます。そのためには、まず実行中のクラスター内のノードのuser_files_pathにプライマリインデックスファイルをコピーする必要があります:
- ステップ 1: プライマリインデックスファイルを含むパーツのパスを取得する
- ステップ 2: user_files_path を取得する Linux でのデフォルトの user_files_pathは
- ステップ 3: プライマリインデックスファイルを user_files_path にコピーする
SELECT path FROM system.parts WHERE table = 'hits_UserID_URL' AND active = 1テストマシンでは、/Users/tomschreiber/Clickhouse/store/85f/85f4ee68-6e28-4f08-98b1-7d8affa1d88c/all_1_9_4が返されます。/var/lib/clickhouse/user_files/です。Linux では、変更されているかどうかを次のように確認できます: $ grep user_files_path /etc/clickhouse-server/config.xmlテストマシンでのパスは/Users/tomschreiber/Clickhouse/user_files/です。cp /Users/tomschreiber/Clickhouse/store/85f/85f4ee68-6e28-4f08-98b1-7d8affa1d88c/all_1_9_4/primary.idx /Users/tomschreiber/Clickhouse/user_files/primary-hits_UserID_URL.idxこれで、SQL 経由でプライマリインデックスの内容を調べられます:
- エントリ数を取得する
- 最初の 2 つのインデックスマークを取得する
- 最後のインデックスマークを取得する
SELECT count( )<br/>FROM file('primary-hits_UserID_URL.idx', 'RowBinary', 'UserID UInt32, URL String');
1083が返されます。SELECT UserID, URL<br/>FROM file('primary-hits_UserID_URL.idx', 'RowBinary', 'UserID UInt32, URL String')<br/>LIMIT 0, 2;結果は次のとおりです。240923, http://showtopics.html%3...<br/> 4073710, http://mk.ru&pos=3_0SELECT UserID, URL FROM file('primary-hits_UserID_URL.idx', 'RowBinary', 'UserID UInt32, URL String')<br/>LIMIT 1082, 1;
結果は次のとおりです。
4292714039 │ http://sosyal-mansetleri...これは、サンプルテーブルのプライマリインデックスの内容を示した図と完全に一致しています:
-
UserID インデックスマーク:
プライマリインデックスに格納されている
UserIDの値は昇順に並んでいます。
したがって、上の図の「mark 1」は、グラニュール 1 のすべてのテーブル行と、それ以降のすべての グラニュール に含まれるUserIDの値が、4.073.710 以上であることを示しています。
-
URL インデックスマーク:
主キーカラム
UserIDとURLのカーディナリティがかなり近いため、一般に、2 番目以降のすべてのキーカラムのインデックスマークが示せるデータ範囲は、少なくとも現在の グラニュール 内のすべてのテーブル行で直前のキーカラムの値が同じである場合に限られます。
たとえば、上の図では mark 0 と mark 1 の UserID の値が異なるため、ClickHouse は グラニュール 0 内のすべてのテーブル行の URL の値が'http://showtopics.html%3...'以上であるとは判断できません。ですが、上の図で mark 0 と mark 1 の UserID の値が同じであれば (つまり、グラニュール 0 内のすべてのテーブル行で UserID の値が変わらないことを意味します) 、ClickHouse は グラニュール 0 内のすべてのテーブル行の URL の値が'http://showtopics.html%3...'以上であると判断できます。 これがクエリ実行性能にどのような影響を与えるかについては、後ほどさらに詳しく説明します。
プライマリインデックスはグラニュールの選択に使われます
749927693 の UserID カラム値を持つ行を含む可能性があるグラニュールを特定するために、1083 個の UserID インデックスマーク に対して ClickHouse が二分探索を実行していたことが表示されます。これには、平均時間計算量 O(log2 n) で 19 ステップが必要です:
トレースログの詳細
トレースログの詳細
マーク176が特定され (‘found left boundary mark’ は境界を含み、‘found right boundary mark’ は境界を含みません) 、その結果、グラニュール176の8192行すべてが (これは行1.441.792から始まります。これについてはこのガイドの後半で説明します) 、UserIDカラムの値が 749927693 である実際の行を見つけるために ClickHouse に読み込まれます。
前述のとおり、ClickHouse は、クエリに一致する行を含む可能性があるグラニュールをすばやく (二分探索によって) 選択するために、スパースプライマリインデックスを使用します。 これは、ClickHouse のクエリ実行における 第 1 段階 (グラニュールの選択) です。 第 2 段階 (データの読み取り) では、ClickHouse は選択されたグラニュールの位置を特定し、クエリに実際に一致する行を見つけるために、それらに含まれるすべての行を ClickHouse エンジンにストリーミングします。 次のセクションでは、この第 2 段階についてさらに詳しく説明します。
グラニュール の特定には マークファイル が使われます
グラニュール の選択の詳細
グラニュール の選択の詳細
上の図が示すように、mark 176 は、対応する グラニュール 176 の最小 UserID 値が 749.927.693 より小さく、かつ次の mark (mark 177) に対応する グラニュール 177 の最小 UserID 値がこの値より大きい、最初の index entry です。したがって、UserID カラムの値が 749.927.693 の行を含む可能性があるのは、mark 176 に対応する グラニュール 176 だけです。
UserID、URL、EventTime カラムに対応する グラニュール の物理的位置を格納する 3 つの マークファイル、UserID.mrk、URL.mrk、EventTime.mrk を示しています。
ここまで説明してきたように、プライマリインデックスはフラットな非圧縮の配列ファイル (primary.idx) で、0 から始まる番号が付いた index mark を含んでいます。
同様に、マークファイル もフラットな非圧縮の配列ファイル (*.mrk) で、0 から始まる番号が付いた mark を含んでいます。
ClickHouse が、クエリに一致する行を含む可能性がある グラニュール の index mark を特定して選択すると、マークファイル に対して位置ベースの配列ルックアップを行うことで、その グラニュール の物理的位置を取得できます。
特定のカラムに対応する各 マークファイル エントリには、offset の形で 2 つの位置が格納されています。
- 1 つ目の offset (上図の ‘block_offset’) は、選択された グラニュール の圧縮版を含む、圧縮されたカラムデータファイル内の block の位置を示します。この圧縮 block には、複数の圧縮された グラニュール が含まれている可能性があります。特定された圧縮ファイル block は、読み取り時にメインメモリ上で展開されます。
- 2 つ目の offset (上図の ‘granule_offset’) は マークファイル から取得され、非圧縮の block data 内における グラニュール の位置を示します。
- ワイド形式 で、かつ アダプティブインデックス粒度 を使用しないテーブルでは、ClickHouse は上図のような
.mrkマークファイル を使用します。これには、各エントリに 8 バイト長のアドレスが 2 つ含まれています。これらのエントリは、いずれも同じサイズを持つ グラニュール の物理的位置を表します。
-
ワイド形式でアダプティブインデックス粒度を使用するテーブルでは、ClickHouse は
.mrk2マークファイル を使用します。これには.mrkマークファイル と同様のエントリが含まれますが、各エントリには追加の 3 つ目の値として、そのエントリに対応する グラニュール の行数が格納されます。 -
compact パーツ のテーブルでは、ClickHouse は
.mrk3マークファイル を使用します。
EventTime にはオフセット情報は必要ありません。このサンプルクエリで ClickHouse に必要なのは、UserID データファイル (UserID.bin) 内のグラニュール 176 に対応する 2 つの物理位置オフセットと、URL データファイル (URL.bin) 内のグラニュール 176 に対応する 2 つの物理位置オフセットだけです。マークファイルによる間接参照によって、3 つすべてのカラムについて、1083 個すべてのグラニュールの物理的位置エントリをプライマリインデックス内に直接格納せずに済みます。これにより、メインメモリに不要な (実際には使われないかもしれない) データを保持せずに済みます。複数のプライマリインデックスを使う
セカンダリキーカラムは効率的とは限らない
"http://public_search" という URL を最も頻繁にクリックしたユーザー上位 10 人を算出するクエリを使用します:
汎用排除検索アルゴリズム
- URL 値 = “W3” の行を検索するクエリ。
- UserID と URL の値を簡略化した、抽象化された hits テーブル。
- 索引には同じ複合主キー (UserID, URL) を使用すること。つまり、行はまず UserID の値で並び替えられ、同じ UserID 値を持つ行はその後 URL で並び替えられます。
- グラニュール のサイズは 2、つまり各 グラニュール には 2 行が含まれます。
- URL 値が W3 より小さく、かつ直後の 索引マーク の URL 値も W3 より小さい 索引マーク 0 は除外できます。これは mark 0 と 1 が同じ UserID 値を持つためです。この除外の前提条件により、グラニュール 0 はすべて U1 の UserID 値で構成されていることが保証されるため、ClickHouse は グラニュール 0 の最大 URL 値も W3 より小さいと判断して、この グラニュール を除外できます。
- URL 値が W3 以下で、かつ直後の 索引マーク の URL 値が W3 以上である 索引マーク 1 は選択されます。これは、グラニュール 1 に URL が W3 の行が含まれている可能性があることを意味するためです。
- URL 値が W3 より大きい 索引マーク 2 と 3 は除外できます。プライマリインデックスの 索引マーク には各 グラニュール の先頭行のキーカラム値が格納され、テーブル行はディスク上でキーカラム値に従ってソートされているため、グラニュール 2 と 3 に URL 値 W3 が含まれることはありません。
データスキッピングインデックスに関する注意
ALTER TABLE ステートメントの GRANULARITY 4 句に注目してください) 、URL の最小値と最大値を格納する追加の索引を作成しました。
最初の索引エントリ (上図の ‘mark 0’) には、テーブルの最初の 4 つのグラニュールに属する行について、URL の最小値と最大値が格納されています。
2 番目の索引エントリ (‘mark 1’) には、テーブルのその次の 4 つのグラニュールに属する行について、URL の最小値と最大値が格納されており、以降も同様です。
(ClickHouse は、索引マークに対応するグラニュールのグループを特定するために、データスキッピングインデックス用の特別なマークファイルも作成しました。)
UserID と URL はどちらもカーディナリティが高いため、このセカンダリデータスキッピングインデックスは、URL でフィルタするクエリの実行時に、選択対象からグラニュールを除外するのに役立ちません。
クエリが探している特定の URL 値 (すなわち ‘http://public_search') は、各グラニュールグループについて索引に格納された最小値と最大値の間に入る可能性が非常に高いため、ClickHouse はそのグラニュールグループを選択せざるを得ません (クエリに一致する行が含まれている可能性があるためです) 。
複数のプライマリインデックスが必要になる理由
追加のプライマリインデックスを作成するための選択肢
- 異なる主キーを持つ 2 つ目のテーブル を作成する。
- 既存のテーブル上に materialized view を作成する。
- 既存のテーブルに プロジェクション を追加する。
オプション 1: セカンダリテーブル
UserIDs で絞り込むサンプルクエリも、この新しい追加テーブルではあまり効率よく実行されません。これは、UserID がそのテーブルのプライマリインデックスにおける 2 番目のキーカラムになっており、その結果、ClickHouse がグラニュールの選択に汎用排除検索を使用するためです。これは、UserID と URL のように同程度に高いカーディナリティに対してはあまり効果的ではありません。
詳細は詳細ボックスを開いて確認してください。
`UserIDs` で絞り込むクエリは現在パフォーマンスが悪い
`UserIDs` で絞り込むクエリは現在パフォーマンスが悪い
UserIDs で絞り込むクエリの高速化用と、URL で絞り込むクエリの高速化用に、それぞれ最適化されています。
オプション 2: materialized view
- ビューの主キーでは、元のテーブル と比べてキーカラムの順序を入れ替えています
- materialized view は、指定した主キー定義に基づく行順序とプライマリインデックスを持つ暗黙的に作成されるテーブルを基盤としています
- この暗黙的に作成されるテーブルは
SHOW TABLESクエリに表示され、名前は.innerで始まります - また、materialized view のバックエンドテーブルを先に明示的に作成しておき、その後ビューから
TO [db].[table]句 でそのテーブルを指定することもできます POPULATEキーワードを使うことで、ソーステーブル hits_UserID_URL にある 887 万行すべてを、暗黙的に作成されるテーブルに即座に投入します- ソーステーブル hits_UserID_URL に新しい行が挿入されると、それらの行は暗黙的に作成されるテーブルにも自動的に挿入されます
- 実質的には、暗黙的に作成されるテーブルは、明示的に作成したセカンダリテーブル と同じ行順序およびプライマリインデックスを持ちます:
オプション 3: プロジェクション
- プロジェクションは、プロジェクションで指定された
ORDER BY句に基づく行順序とプライマリインデックスを持つ非表示テーブルを作成します - この非表示テーブルは、
SHOW TABLESクエリの一覧には表示されません MATERIALIZEキーワードを使うことで、ソーステーブル hits_UserID_URL の 887 万行すべてを直ちに非表示テーブルに格納します- ソーステーブル hits_UserID_URL に新しい行が挿入されると、その行は自動的に非表示テーブルにも挿入されます
- クエリは常に (構文上は) ソーステーブル hits_UserID_URL を対象としますが、非表示テーブルの行順序とプライマリインデックスを使うことでより効率的にクエリを実行できる場合は、代わりにその非表示テーブルが使用されます
ORDER BYがプロジェクションのORDER BYステートメントと一致していても、プロジェクションによってORDER BYを使用するクエリが効率化されるわけではない点に注意してください (https://github.com/ClickHouse/ClickHouse/issues/47333 を参照)- 実際には、暗黙的に作成される非表示テーブルは、明示的に作成した secondary table と同じ行順序およびプライマリインデックスを持ちます。
要約
キーカラムの順序を効率的に決める
- クエリで後続のキーカラムをフィルタリングする際の効率
- テーブルのデータファイルの圧縮率
UserID カラム) による URL (URL カラム) へのアクセスが、ボットトラフィックとしてマークされたかどうかを示します (IsRobot カラム) 。
ここでは、前述の 3 つのカラムすべてを含む複合プライマリキーを使用します。これは、次のような典型的な web analytics のクエリを高速化するために使用できます。
- 特定の URL へのトラフィックのうち、どの程度 (何パーセント) がボットによるものか
- 特定のユーザーがボットである (またはボットではない) と、どの程度確信できるか (そのユーザーからのトラフィックのうち、何パーセントがボットトラフィックだと見なされるか、またはそうでないと見なされるか)
clickhouse client で実行してください。
URL カラムと IsRobot カラムの差は顕著です。したがって、複合プライマリキーにおけるこれらのカラムの順序は、それらのカラムで絞り込むクエリの高速化にも、テーブルのカラムデータファイルで最適な圧縮率を実現するうえでも重要です。
これを示すため、ボットトラフィック分析データ用に 2 つのバージョンのテーブルを作成します。
- 複合プライマリキー
(URL, UserID, IsRobot)を持つテーブルhits_URL_UserID_IsRobot。ここでは、キーカラムをカーディナリティの高い順に並べます - 複合プライマリキー
(IsRobot, UserID, URL)を持つテーブルhits_IsRobot_UserID_URL。ここでは、キーカラムをカーディナリティの低い順に並べます
(URL, UserID, IsRobot) を持つテーブル hits_URL_UserID_IsRobot を作成します。
(IsRobot, UserID, URL) を持つテーブル hits_IsRobot_UserID_URL を作成します。
セカンダリキーカラムでの効率的なフィルタリング
(URL, UserID, IsRobot) をカーディナリティの高い順に並べたテーブルで、UserID カラムに対してフィルタリングするクエリです。
(IsRobot, UserID, URL) をカーディナリティの昇順で並べたテーブルに対する同じクエリです。
データファイルの最適な圧縮率
UserID カラムの圧縮率を比較します。
UserID カラムの圧縮率は、キーカラム (IsRobot, UserID, URL) をカーディナリティの昇順に並べたテーブルのほうが、明らかに高いことがわかります。
両方のテーブルに格納されているデータはまったく同じであるにもかかわらず (両方のテーブルに同じ 887 万行を insert しました) 、複合プライマリキーにおけるキーカラムの順序は、テーブルの 圧縮された データが カラムデータファイル 上で必要とするディスク容量に大きく影響します。
- 複合プライマリキー
(URL, UserID, IsRobot)を持ち、キーカラムをカーディナリティの降順に並べたテーブルhits_URL_UserID_IsRobotでは、UserID.binデータファイルは 11.24 MiB のディスク容量を使用します - 複合プライマリキー
(IsRobot, UserID, URL)を持ち、キーカラムをカーディナリティの昇順に並べたテーブルhits_IsRobot_UserID_URLでは、UserID.binデータファイルが使用するディスク容量は 877.47 KiB にすぎません
cl の値で並べられ、同じ cl 値を持つ行は ch の値で並べられます。そして、最初のキーカラム cl は low cardinality であるため、同じ cl 値を持つ行が存在する可能性が高くなります。その結果、ch の値も (同じ cl 値を持つ行の範囲内で局所的に) 順序付けられる可能性が高くなります。
あるカラム内で、たとえばソートによって似たデータが近くに配置されると、そのデータはより効率よく圧縮されます。
一般に、圧縮アルゴリズムはデータの連続長 (より長く連続したデータがあるほど圧縮に有利)
と局所性 (データ同士が似ているほど圧縮率が高くなる) の恩恵を受けます。
これに対して、次の図は、キーカラムをカーディナリティの降順に並べた主キーにおける、ディスク上の行の並び順の概略を示しています。
これでテーブルの行は、まず ch の値で並べ替えられ、同じ ch の値を持つ行は cl の値で並べ替えられます。
しかし、最初のキー・カラムである ch はカーディナリティが高いため、同じ ch の値を持つ行が現れる可能性は低くなります。そのため、cl の値についても (局所的に、つまり同じ ch の値を持つ行どうしで) 順序が整う可能性は低くなります。
したがって、cl の値はほぼランダムな順序になりやすく、その結果、局所性と圧縮率が悪化します。
要約
単一行を効率的に特定する
具体例
- 内容が変化したときの行の挿入順序 (たとえば、テキストエリアに文字を入力するキーストロークによる変化) と
PRIMARY KEY (hash)を使用した場合の、挿入された行のデータのディスク上での並び順:
hash カラムが主キーカラムとして使われるため、
- 特定の行は非常に高速に取得できますが、
- テーブルの行 (そのカラムデータ) は、ディスク上では (一意でランダムな) hash 値の昇順で保存されます。そのため、content カラムの値もデータ局所性のないランダムな順序で保存され、content カラムデータファイルの圧縮率は最適になりません。
- 上で述べた、異なるデータに対して異なる値になる内容のハッシュと、
- データに小さな変更があっても変化しない 局所性鋭敏型ハッシュ (フィンガープリント)
- 内容が変化したときの行の挿入順序 (たとえば、テキストエリアに文字を入力するキーストロークによる変化) と
- 複合
PRIMARY KEY (fingerprint, hash)を使用した場合の、挿入された行のデータのディスク上での並び順:
fingerprint で並べられ、同じ fingerprint 値を持つ行どうしでは、hash 値によって最終的な順序が決まります。
わずかな違いしかないデータには同じ fingerprint 値が与えられるため、似たデータどうしが content カラム内で近くに保存されるようになります。これは content カラムの圧縮率にとって非常に有利です。一般に圧縮アルゴリズムはデータ局所性の恩恵を受けるためです (データが似ているほど圧縮率は高くなります) 。
その代わり、複合 PRIMARY KEY (fingerprint, hash) によって得られるプライマリインデックスを最適に活用するには、特定の行を取得する際に 2 つのフィールド (fingerprint と hash) が必要になります。