2024年8月、ClickHouse v24.8 では 強力な JSON データ型 が導入されました。それ以来、私たちは新機能の追加や最適化を継続的に行ってきました。
本記事では、ClickHouse v25.8 でさらにどのように進化させたかをご紹介します。
ClickHouse はすでに分析性能において業界をリードしていますが、v25.8 における最新の変更により、ClickHouse は JSON データに対する分析においてもリーダーとなりました。
v24.8 における JSON 型の仕組み
まずは、ClickHouse v24.8 で設計された MergeTree パート内での JSON データの格納方法 を簡単におさらいしましょう。
以下の図では、文字列型の値を持つ K 個の一意なパスからなる JSON データを扱っています。先頭の N 個のパス は dynamic paths としてサブカラムに格納されます。残りの K – N 個のパス はすべて shared data 構造に格納され、これはパスと値の双方を含む Map(String, String) カラムとして表現されます。

例えば、パス key1 のデータをクエリする場合、ClickHouse はまずメタデータをチェックし、key1 が dynamic paths にあるか shared data にあるかを確認します。この例ではパス key1 は dynamic paths にあるため、その値は専用のデータファイルに格納されており、直接かつ効率的に読み取ることができます。

一方、key_n+1 をクエリする場合、メタデータによりこれが dynamic paths に含まれていないことがわかります。代わりに shared data 内に存在します。その値を抽出するためには、ClickHouse は Map(String, String) カラム全体を読み取り、メモリ上でフィルタリングしなければなりません。これははるかに効率が落ちます。

デフォルトでは、dynamic paths の上限は 1024 です。より多くのパスを扱うためにこの上限を引き上げることは、特に S3 のようなリモートストレージを使用している場合には通常おすすめできません。パートあたり数千ものファイルが作成されることで、マージ時のメモリ使用量が増加し、読み取り処理も複雑になるためです。
その結果、数千から数万の一意な JSON パスを持つワークロードでは、パフォーマンスの低下に悩まされることになります。
これこそが、私たちが解決しようと取り組んだ課題です。
v25.8 における shared data の新しいシリアライゼーション
ClickHouse v25.8 では、特定のパスを読み取る効率を劇的に向上させる、shared data 向けの 2 つの新しいシリアライゼーション形式 が導入されました。
-
バケット化された shared data
1 つ目の新しいシリアライゼーションは、shared data を N 個のバケット に分割します。各バケットには独自の Map(String, String) カラムが含まれ、パスは確定的な割り当てルールに従ってバケットに配置されます。

key_m1 のようなパスをクエリする際、ClickHouse は要求されたパスがどのバケットに含まれているかを即座に特定できます。そしてそのバケットのみが読み取られ、残りのバケットはスキップされます。

これにより、shared data 全体を読み取る場合と比べてスキャンされるデータ量が削減され、パフォーマンスが向上します。ただし、バケット数を増やしすぎるとファイル数が過剰になり、ファイルシステムのオーバーヘッドが再び問題になります。
-
高度な shared data
2 つ目の、より強力なアプローチが高度なシリアライゼーション形式(advanced serialization format)です。
この形式では、各バケットに 3 つのファイルが含まれます。
.structure– 各グラニュールのメタデータ。行数、グラニュール内のパスの一覧、.paths_marksファイル内のオフセットを保持します。.data– グラニュールごとに列指向形式で格納された実際のパスデータ。.paths_marks–.dataファイル内における各パスのデータ開始位置を指すオフセット。

パス key_m1 をクエリする際、ClickHouse はまず .structure ファイルをチェックし、そのグラニュールに key_m1 が含まれているかを確認します。含まれていなければ、グラニュール全体がスキップされます。含まれている場合、ClickHouse は .paths_marks ファイル内のオフセットを参照し、.data ファイル内の key_m1 パスのデータへ直接ジャンプして読み取ります。

これにより、無関係なパスのデータをメモリに読み込むことが回避され、クエリパフォーマンスが大幅に向上します。
ネストされたパスのサポート
多くの JSON ドキュメントには、オブジェクトの配列などのネストされた構造が含まれます。上記のアプローチを用いてそのような構造からネストされたパスを抽出する場合、依然として配列全体を読み取る必要があり、大きなペイロードでは非効率的です。
これに対処するため、高度な形式はサブカラムの処理用に追加ファイルを持つよう拡張されました。
この場合、各バケットには 6 つのファイルが存在します。
.structure– 各グラニュールのメタデータ。行数、グラニュール内のパスの一覧、ファイル.paths_marksおよび.substreams_metadata内のオフセットを保持します。.data– グラニュールごとに列指向形式で格納され、サブストリームに分割された実際のパスデータ。1 つのパスが複数のサブストリームを持つ場合があります。この構造により、ClickHouse はパスの値全体をスキャンすることなく、要求されたサブカラムの再構築に必要なサブストリームのみを読み取ることができます。.paths_marks–.dataファイル内における各パスのデータ開始位置を指すオフセット。.substreams_marks–.dataファイル内における各パスのサブストリーム開始位置を指すオフセット。.substreams– グラニュール内の各パスに存在するサブストリームの一覧。これはグラニュールごとに異なる場合があります(例: 一部の配列に異なるネストフィールドを持つオブジェクトが含まれている場合など)。.substreams_metadata– パスごとに.substreamsファイルおよび.substreams_marksファイル内のオフセットを格納し、パスをそのサブカラムおよびデータの格納位置へと実質的に紐付けます。

パス key_m1 のサブカラムをクエリする際、ClickHouse はまず .structure ファイルからデータを読み取り、このグラニュールに要求されたパス key_m1 が含まれているかをチェックします。含まれていなければ、グラニュール全体がスキップされます。含まれている場合、ClickHouse は .structure ファイルに格納されたオフセットを使用して .substreams_metadata 内の対応するエントリを読み取り、key_m1 のオフセットを取得します。
続いて、1 つ目のオフセットを使用して、グラニュール内のこのパスのサブストリーム一覧を読み取ります。要求されたサブカラムに必要なサブストリームがこの一覧に含まれていない場合、グラニュールはスキップされます。必要なサブストリームが存在する場合、ClickHouse は 2 つ目のオフセットを使用して .substreams_marks からそれらの位置を読み取り、.data ファイルからそれらのサブストリームのデータのみを読み取ります。
要求されたサブカラムを再構築した後、ClickHouse は次のグラニュールへと進みます。

これにより、グラニュール内の無関係なパスのデータや、要求されたパスの無関係なサブストリームのデータをメモリに読み込むことを防ぎます。これにより、ネストされたサブカラムの読み取りパフォーマンスが大幅に向上します。
効率性と互換性のバランス
高度な形式は選択的な読み取り(selective reads)に非常に優れていますが、トレードオフがないわけではありません。shared data のインメモリ表現(Map(String, String))とそのストレージレイアウトが大きく異なるため、JSON カラム全体の読み取り時にコストの高い変換が発生し、JSON カラム全体の読み取りやマージの実行が遅くなります。
これに対処するため、ClickHouse v25.8 では高度な形式と並行して元の形式のデータのコピーも保持するというトレードオフを採用しました。これにより必要なストレージ容量は実質的に 2 倍になりますが、前述の犠牲を払うことなく新しい形式のあらゆるメリットを享受できます。
下図に示すように、コピーは 3 つの追加ファイルに格納されます。
.copy.offsets– 元のMap(String, String)カラムのオフセット。.copy.indexes– バケット全体をまたぐ結合されたパス一覧へのインデックス(すべてのパスを再格納することを回避)。.copy.values- 元のMap(String, String)カラムの値。

これにより、高度な形式が持つ選択的な読み取りのメリットを維持したまま、JSON カラム全体の読み取りやマージの実行を従来通りの高速さで行えるようになります。
パフォーマンス
私たちは、10 個、100 個、1,000 個、10,000 個の一意なパスを含む JSON データを用いて新しいシリアライゼーション形式のベンチマークを実施しました。
新しい高度な shared data シリアライゼーションは、クエリ速度とメモリ使用量の双方において、数万の一意なパスへとスケールさせながらも、すべてのパスを動的サブカラムとして格納した場合に近いパフォーマンスを発揮します。
以下に、v25.8 の新しいシリアライゼーション形式による改善を示す、10,000 パスのテスト結果のハイライトをいくつか紹介します。
パフォーマンステスト 1(選択的な読み取り)
このテストでは、Wide パートを使用し、各行に 1 万個のパスを持つ JSON ドキュメントが含まれる 20 万行のテーブルから、単一の JSON キーを読み取る処理を比較します。
高度なシリアライゼーション形式を使用することで、従来の JSON シリアライゼーションと比較して、処理時間(約 58 倍)とメモリ性能(約 3,300 分の 1)の双方が劇的に改善しています。
| データ型 | 処理時間(秒) | メモリ使用量(MiB) |
|---|---|---|
| JSON no shared data | 0.027 | 18.64 MiB |
| JSON shared data "advanced" | 0.063 | 3.89 MiB |
| JSON shared data "map_with_buckets" | 0.087 | 403.55 MiB |
| String | 3.216 | 582.70 MiB |
| Map | 3.594 | 538.37 MiB |
| JSON shared data "map" | 3.63 | 12.53 GiB |
パフォーマンステスト 2(JSON オブジェクト全体の読み取り)
このテストでは、Wide パートを使用し、各行に 1 万個のパスを持つ JSON ドキュメントが含まれる 20 万行のテーブルから、JSON ドキュメント全体を読み取る処理を比較します。
高度なシリアライゼーションを使用した結果は、実質的に従来の JSON シリアライゼーションと同等です。これは、高度なシリアライゼーションがドキュメント全体の読み取りパフォーマンスを犠牲にすることなく、選択的な読み取りに対して莫大な利益をもたらすことを示しています。
| table_type | time_sec | memory_usage |
|---|---|---|
| String | 2.734 | 581.15 MiB |
| Map | 3.869 | 550.14 MiB |
| JSON shared data "map" | 4.182 | 618.78 MiB |
| JSON shared data "advanced" | 4.774 | 683.27 MiB |
| JSON shared data "map_with_buckets" | 15.271 | 1.45 GiB |
| JSON no shared data | - | OOM |
まとめ
新しい shared data シリアライゼーションにより、ClickHouse における JSON サポートは次のレベルへと引き上げられました。選択的な読み取りにおける優れたパフォーマンスを維持しながら、数万の一意なパスを持つ JSON ドキュメントを効率的にクエリできるようになりました。
この強化により、ClickHouse は大規模な半構造化 JSON データを扱うワークロードにとって、さらに強力な選択肢となります。



