ClickHouse 26.9で、テーブルデータの既定の圧縮方式がLZ4からZSTD(3)に変わりました(後方互換性のない変更として告知されています)。ClickHouse Cloudは、もともと既定がZSTD(1)です。
これまで、ClickHouseのストレージを小さくする定番の助言は「列の型を正しく選ぶ」「コーデックを列ごとに設定する」「LZ4をZSTDに替える」の3つでした。このうち3つ目は、新しいバージョンやCloudでは何もしなくても手に入ります。では、残りの2つはまだ効くのでしょうか。
実在のWebアクセスログ1,000万行を、型とコーデックの組み合わせだけ変えて何通りも入れ直し、サイズとクエリ速度を測りました。先に結論を書いておきます。
- 型を直しても、ディスク上のサイズはほとんど変わらない(5%)。ただし集計は18〜37%速くなる(クエリによって幅がある)
- ZSTDの上にさらに効くのは、時刻の列に付ける
Deltaくらい。全列を総当たりで最適化しても、単純な規則との差は0.7% - コーデックを間違えると逆に大きくなる。ハッシュ値の列に
DeltaやT64を付けると3〜9割増える - 26.9に上げても、既存のデータはマージで書き直されるまでLZ4のまま
Cloudのストレージ料金は圧縮後のサイズで計算されます。この記事の数字は、OSSならディスク容量、Cloudなら月々のストレージ代にそのまま効きます。
検証の条件
データはClickBenchのhitsから先頭の1,000万行です(105列、Parquetで約1.2GB)。乱数で作ったデータは使っていません。圧縮の効き方はデータの偏りで決まるので、乱数では実際より悪い結果が出るからです。
並び順(ORDER BY)はすべての構成で(CounterID, EventDate, UserID, EventTime, WatchID)にそろえました。並び順は圧縮率を大きく左右しますが、今回比べたいのは型とコーデックだけです。
サイズはOPTIMIZE TABLE ... FINALで1パートにまとめてから、system.columnsとsystem.partsで測っています。記事中の「圧縮後サイズ」は、列データを圧縮した後のバイト数(data_compressed_bytes)です。インデックスなども含むディスク上の占有量(bytes_on_disk)とは別の値ですが、今回のテーブルでは差が0.1%未満でした。サーバーはClickHouse 26.8 LTS(既定がLZ4の最後のLTS)と26.9(既定がZSTDになった版)で、どちらもmacOS arm64版です(Apple M4、10コア、メモリ16GB)。
比べた構成は次のとおりです。
| 構成 | 内容 |
|---|---|
| A | Parquetから自動推論した型のまま(全105列がNullable、整数はInt16〜Int64、時刻はUNIX秒のInt64) |
| B1 | AからNullableを外す(NULLは1件もなかった) |
| B2 | B1に加えて、種類が1万未満の文字列22列をLowCardinality(String)にする |
| B3 | B2に加えて、フラグや区分コードの整数をUInt8などに狭め、時刻をDateTime、日付をDateにする |
| B3+各コーデック | B3の全列に同じコーデックを付ける(ZSTDのレベル違い、Delta、DoubleDelta、T64) |
B3で型を狭めるとき、サンプルの最大値で決めると危ない列は対象から外しています。たとえばサイトIDのCounterIDは、この1,000万行では最大4,679なのでUInt16に収まります。しかし本番で同じことをすると、いずれ値があふれます。狭めたのは、値の種類が300以下のフラグや区分コードだけです。
ZSTDにするだけで35%減る。中身の3分の2は文字列
まず、既定がLZ4の26.8で、型を直した構成(B3)に汎用の圧縮だけを替えた結果です。
| 圧縮 | 圧縮後サイズ | B3(LZ4)比 | 取り込み時間 |
|---|---|---|---|
| LZ4(26.8までの既定) | 1,365MiB | — | 4.5秒 |
| ZSTD(1) | 944MiB | −31% | 5.4秒 |
| ZSTD(3)(26.9からの既定) | 884MiB | −35% | 5.7秒 |
| ZSTD(9) | 809MiB | −41% | 21.7秒 |
(取り込み時間は、手元にある構成AのテーブルからINSERT ... SELECTで1,000万行を入れ直すのにかかった時間です)
LZ4からZSTD(3)に替えるだけで35%減りました。日を改めて測り直しても、削減率は同じ35%でした。26.9で既定がZSTD(3)になったのは、この差が大きいからでしょう。
取り込み時間は、どの構成も1回ずつしか測っていません。日を改めてZSTD(3)を測り直すと10.7秒かかり、表の5.7秒から倍近くぶれました。表の取り込み時間は目安と考えてください。そのうえで、ZSTD(9)はほかの構成の4〜5倍かかっています。
減った分の大半は文字列です。B3(LZ4)の1,365MiBのうち、文字列の列が895MiB、全体の3分の2を占めていました。Title、Referer、URLの3列だけで780MiBあります。これらはZSTD(3)で4割ほど縮みました(Titleは293MiBから164MiB)。
26.9に上げれば、何もしなくても縮むのか
縮みます。ただし、すぐには縮みません。
26.9で同じデータを既定の設定のまま入れると、取り込んだ直後の22個のパート(1個あたり34〜70MB)はすべてLZ4でした。26.9の既定は「パートが100MB以上ならZSTD(3)、未満ならLZ4」で、新しく取り込むときは判定上のサイズが0として扱われるためです。
その後バックグラウンドのマージに任せると、パートは7個にまとまり、3個がZSTD(3)、4個がLZ4のまま止まりました。合計は959MiBで、4分待っても変わりませんでした。OPTIMIZE TABLE ... FINALで1パートにまとめると884MiBになり、26.8でZSTD(3)を明示した場合と同じ値になります。
26.8以前から上げた場合も同じです。既存のパートは、マージで書き直されるまでLZ4のまま残ります。「バージョンを上げたのにディスクが減らない」というときは、system.partsのdefault_compression_codec列で、どのパートがまだLZ4かを確認できます。
SELECT default_compression_codec, count() AS parts,
formatReadableSize(sum(data_compressed_bytes)) AS size
FROM system.parts
WHERE active AND table = 'hits'
GROUP BY default_compression_codec;
型を直してもサイズは5%。ただし集計が速くなる
次に、型の見直しです。A(自動推論のまま)からB3まで段階的に直したときのサイズです(いずれもLZ4)。
| 構成 | 圧縮後 | 圧縮前 |
|---|---|---|
| A自動推論のまま | 1,434MiB | 8,798MiB |
B1 Nullableを外す |
1,412MiB | 5,991MiB |
B2 LowCardinality
|
1,389MiB | 5,441MiB |
B3整数を狭める・時刻をDateTimeに |
1,365MiB | 5,105MiB |
圧縮後のサイズは5%しか減りません。整数の上位の0や、NULLかどうかの印(すべて0)は、もともと圧縮でほぼ消えているからです。26.9でZSTD(3)のまま比べると、差はさらに縮みます。AとB3は、どちらも884MiBでした。
ディスク代だけを考えるなら、型の見直しはあまり効きません。
一方で、圧縮前のサイズは8.8GBから5.1GBへ、4割減りました。圧縮前のサイズは、クエリが展開後にメモリ上で扱うデータの量です。これはクエリの速さに出ます。
| クエリ | A | B3 | 差(4セットの幅) |
|---|---|---|---|
| 検索語の上位10件(文字列の集計) | 46.1ms | 35.1ms | −19〜−24% |
| ユーザー別PVの上位10件(整数の集計) | 25.3ms | 17.3ms | −30〜−37% |
| 国別・時間帯別のユニークユーザー | 67.1ms | 54.9ms | −18% |
| URLの部分一致の件数 | 20.0ms | 19.1ms | 0〜−5% |
(AとB3の時間は1セット目の値で、9回測った中央値です。2回空打ちしてから測ったので、データはページキャッシュに載った状態です。日を改めて同じ計測を3セット繰り返し、計4セットの差の幅を右の列に載せました)
集計は2割から4割弱速くなりました。ただし、速くなる幅はクエリによって違います。URLの部分一致のように長い文字列を全件なめるクエリは、型を直してもほとんど変わりません。
効き方が大きいのはLowCardinalityです。国名や言語など5列の圧縮前サイズは、636MiBから48MiBになりました。文字列を毎行持つ代わりに、辞書の番号を持つ形になるためです。
ZSTDの上にさらに効くのは、時刻の列のDeltaだけ
ZSTDを前提にしたうえで、列ごとの専用コーデックはどれくらい上乗せできるのでしょうか。B3の全列に同じコーデックを付けた結果と、そこから列ごとに一番小さくなるものを選んだ結果です(汎用の圧縮はすべてZSTD(1))。
| 構成 | 圧縮後サイズ |
|---|---|
| 全列ZSTD(1) | 944MiB |
全列Delta, ZSTD(1)
|
974MiB |
全列T64, ZSTD(1)
|
1,059MiB |
全列DoubleDelta, ZSTD(1)
|
1,095MiB |
| 列ごとに最小のものを選ぶ(28列が専用コーデック) | 921MiB |
全列ZSTD(1)、時刻の3列だけDelta, ZSTD(1)
|
927MiB |
専用コーデックを全列に付けると、どれもZSTD(1)だけのときより大きくなりました。
列ごとに総当たりで選んだ構成と、「時刻の3列だけDelta」という単純な規則との差は0.7%です。105列を1列ずつ詰めても、ほとんど得はありません。
時刻の列だけは、はっきり効きます。EventTimeはZSTD(3)だけだと20.6MiBで、Delta, ZSTD(3)にすると14.9MiBまで縮みました(−28%)。並び順の中で時刻が近い値どうしで並ぶので、隣との差を取ると小さな数になり、よく縮むためです。
間違ったコーデックは逆効果
逆に、付けると悪化する列もありました。
| 列 | ZSTD(1)だけ | Delta, ZSTD(1) |
T64, ZSTD(1) |
|---|---|---|---|
URLHash(ハッシュ値) |
41.6MiB | 55.8MiB | 75.6MiB |
RefererHash(ハッシュ値) |
35.6MiB | 50.5MiB | 69.2MiB |
UserID |
11.8MiB | 12.1MiB | 21.7MiB |
ハッシュ値は隣の行との関係がないので、差を取っても小さくなりません。T64は、値の最小と最大の間で使われていない上位ビットを削るコーデックです。ハッシュ値は全範囲に散っているので、削れるビットがありません。どちらも縮める材料がないまま前処理だけが入り、ZSTDだけのときより3割から9割増えました。
既定のLZ4にT64を全列付けた構成は1,444MiBで、何も付けないLZ4(1,365MiB)より大きくなりました。「時系列向け」と紹介されるコーデックを、よく考えずに全列に付けるのは避けたほうがよさそうです。
ZSTDのレベルを上げると、読む速さは変わらず書く時間が延びる
時刻の列にDeltaを付けたうえで、ZSTDのレベルを変えた結果です。
| 構成 | 圧縮後サイズ | 取り込み時間 | 検索語の上位 | ユニークユーザー | URLの部分一致 |
|---|---|---|---|---|---|
| LZ4(B3) | 1,365MiB | 4.5秒 | 35.1ms | 54.9ms | 19.1ms |
ZSTD(1)+時刻にDelta
|
927MiB | 7.1秒 | 39.1ms | 65.1ms | 24.4ms |
ZSTD(3)+時刻にDelta
|
867MiB | 6.7秒 | 39.3ms | 61.3ms | 23.7ms |
ZSTD(9)+時刻にDelta
|
791MiB | 18.1秒 | 39.5ms | 61.9ms | 20.1ms |
(クエリの時間は1セット目の値です。下の文章の幅は、日を改めて測った3セットを合わせた計4セットの値です)
ページキャッシュに載った状態では、ZSTDはLZ4より展開に手間がかかる分、クエリが遅くなりました(ディスクから読む量が減る効果は、この測り方には出ません)。遅くなる幅は、クエリの種類で大きく違います。
- URLの部分一致(長い文字列を全件なめる): ZSTD(1)とZSTD(3)で24〜35%遅い
- 検索語の上位、国別・時間帯別のユニークユーザー: 5〜19%遅い
- ユーザー別PV(整数の集計): ほぼ変わらない(−4〜+7%)
長い文字列を全件検索するクエリが多いテーブルでは、ZSTDにすると、サイズが減る代わりに検索が遅くなる点に注意が必要です。
ZSTDのレベルを1から9に上げても、読む速さはほぼ変わりません。ZSTDは、レベルを上げても展開の速さがほとんど落ちない方式だからです。URLの部分一致では、むしろZSTD(9)のほうが速く、LZ4より5〜17%遅いだけでした。
レベルを上げる代償は書き込み側で払います。ZSTD(9)は取り込みに約2.7倍の時間がかかり、そのかわりサイズはZSTD(3)よりさらに9%減りました。
書き込みが多いテーブルに常にZSTD(9)を使うのは重すぎます。ただ、古くなって書き換えなくなったデータだけを強く圧縮し直す方法があります。MergeTreeのTTL ... RECOMPRESSです(今回は効果を測っていません)。
ALTER TABLE hits
MODIFY TTL EventDate + INTERVAL 30 DAY RECOMPRESS CODEC(ZSTD(9));
自分のテーブルで、どこに手を入れるか
今回の結果から、手を入れる順番は次のようになります。
- 大きい列を調べる。効くのは大きい列だけで、今回は上位3列が全体の半分以上を占めていた
- 26.8以前を使っているなら、大きい文字列の列をZSTDにする(26.9以降とCloudでは既定でZSTD)。ただし、その列を
LIKEなどで全件検索することが多いなら、検索が遅くなる分と比べて決める - 時刻の列に
Deltaを付ける - 種類の少ない文字列を
LowCardinalityにし、NULLが入らない列のNullableを外す。サイズより速さのため - ハッシュ値やランダムなIDには専用コーデックを付けない
1はsystem.columnsで一度に見られます。
SELECT name, type,
formatReadableSize(data_compressed_bytes) AS compressed,
formatReadableSize(data_uncompressed_bytes) AS uncompressed,
round(data_uncompressed_bytes / data_compressed_bytes, 1) AS ratio
FROM system.columns
WHERE database = currentDatabase() AND table = 'hits'
ORDER BY data_compressed_bytes DESC
LIMIT 10;
コーデックはALTER TABLE ... MODIFY COLUMNで後から変えられます。ただし、変えた時点で既存のデータが書き直されるわけではありません。今回EventTimeで確かめたところ、ALTER直後は20.6MiBのままで、OPTIMIZE TABLE ... FINALで書き直したあとに14.9MiBになりました。大きいテーブルでOPTIMIZE ... FINALを流すと、そのぶん重いマージが走ります。新しいパートから順に切り替わっていくのを待つか、負荷の低い時間帯にパーティション単位で書き直すかを決めておきましょう。
ALTER TABLE hits MODIFY COLUMN EventTime DateTime CODEC(Delta, ZSTD(3));
OSSとCloudで違うところ
テーブルの保存形式(MergeTree)は同じなので、ここまでの手順はOSSでもCloudでも同じように使えます。違うのは既定値と、サイズが何に跳ね返るかです。
| OSS 26.8以前 | OSS 26.9以降 | ClickHouse Cloud | |
|---|---|---|---|
| 既定の圧縮 | LZ4 | 100MB以上のパートはZSTD(3)、未満はLZ4 | ZSTD(1) |
| サイズが効くもの | ディスク容量 | ディスク容量 | ストレージ料金(圧縮後のサイズで課金。バックアップもストレージとして課金) |
| 手を入れる余地 | ZSTD化、時刻のDelta、型 |
時刻のDelta、型、古いデータの再圧縮 |
時刻のDelta、型(古いデータの再圧縮は、Cloudでの対応を公式ドキュメントで確認できていない) |
Cloudでは、ストレージ料金が圧縮後のサイズで決まります。今回のデータでいえば、時刻の3列にDeltaを付けるだけでテーブル全体が2%縮み、ストレージ代もその割合だけ下がります。26.8以前のOSSからCloudへ移る場合は、既定がLZ4からZSTDに変わるので、移行前にLZ4のディスク容量で見積もった費用より小さくなる見込みです。Cloudの既定はZSTD(1)なので、今回のデータでいえば31%の削減にあたります(OSSの26.8でZSTD(1)を指定して測った値で、Cloud上では測っていません)。
まとめ
既定の圧縮がZSTDになったことで、「LZ4をZSTDに替える」という一番大きな改善は、何もしなくても手に入るようになりました。今回のデータでは35%の削減です。ただし、既存のデータがマージで書き直されるまでは縮みません。
そのうえで残っている手は、思ったより少数でした。
- 時刻の列に
Deltaを付ける(その列で−28%) - 種類の少ない文字列を
LowCardinalityにし、不要なNullableを外す(サイズはほぼ変わらないが、集計が18〜37%速い) - 古いデータだけ強く圧縮し直す(ZSTD(9)で、さらに約9%減る)
列ごとに総当たりで最適なコーデックを選んでも、単純な規則との差は1%未満でした。「時系列向け」のコーデックを全列に付けると、逆にサイズが増えます。
大きい列から順に見て、時刻と種類の少ない文字列にだけ手を入れる。それで十分です。
検証環境: ClickHouse 26.8.25.14 LTS(既定LZ4)と26.9.17.8(既定ZSTD(3))、macOS arm64ネイティブビルド、Apple M4(10コア、メモリ16GB)。データはClickBench hitsの先頭1,000万行(hits_0.parquet〜hits_9.parquet)。サイズはOPTIMIZE TABLE ... FINALで1パートにまとめた後の、列データの圧縮後サイズ(data_compressed_bytes)。クエリ時間はページキャッシュに載った状態で9回測った中央値で、日を改めて計4セット測りました。ディスクから読む場合の差は含んでいません。取り込み時間は各構成1回の計測です。DDLの生成スクリプト、計測スクリプト、列ごとの全結果を同梱しています。