0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ClickHouseの圧縮最適化は、ZSTDが既定になっても必要か?1,000万行で検証

0
Last updated at Posted at 2026-10-15

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));

自分のテーブルで、どこに手を入れるか

今回の結果から、手を入れる順番は次のようになります。

  1. 大きい列を調べる。効くのは大きい列だけで、今回は上位3列が全体の半分以上を占めていた
  2. 26.8以前を使っているなら、大きい文字列の列をZSTDにする(26.9以降とCloudでは既定でZSTD)。ただし、その列をLIKEなどで全件検索することが多いなら、検索が遅くなる分と比べて決める
  3. 時刻の列にDeltaを付ける
  4. 種類の少ない文字列をLowCardinalityにし、NULLが入らない列のNullableを外す。サイズより速さのため
  5. ハッシュ値やランダムな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の生成スクリプト、計測スクリプト、列ごとの全結果を同梱しています。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?