Skip to content
Published on

ClickHouse の Lazy Materialization — LIMIT 10 の小技が FINAL と JOIN まで育った経緯

シェア
Authors

はじめに — 「同じSQLが1,576倍速くなった」をどう読むか

2025年4月、ClickHouse公式ブログにこんな一文が載りました。クエリを一行も変えていないのに219秒が0.139秒になった — 1,576倍。こういう数字を見ると反応は二つに分かれます。「おお」で済ませるか、どんな条件で出た数字なのかを分解するか。本稿は後者です。

この数字の主役はlazy materialization(遅延物質化)です。25.4で導入された当初は、LIMIT 10以下のクエリにしか適用されない、ゲートの非常に狭い最適化でした。ところが2025年末から2026年上半期にかけて、このゲートは目に見えて広がりました — 25.12で実行モデルが作り直され LIMIT 10,000まで、26.2でUNION ALLの全ブランチへ、26.4でReplacingMergeTreeのFINALへ、そして26.6(2026年6月25日リリース)ではJOIN後のselective LIMITまで。一つの小技がクエリプランナー全体の原則へと育っていく過程であり、そこにはリバートされたPRやバグ修正の跡もそのまま残っています。

ClickHouseのMergeTree構造やベクトル化実行がはじめてなら、先にClickHouse Internals Deep DiveリアルタイムOLAP最適化編を読んでおくと理解しやすいでしょう。本稿はその上で、2026年の各リリースでの変化だけを扱います。

lazy materializationとは正確には何か

公式ドキュメントは、この機能をClickHouseのI/O最適化スタックの最後の層として説明しています。下から積み上げるとこうなります。

  1. カラム指向ストレージ。 クエリに不要な列はそもそも読まない。
  2. スパース主キーインデックス、スキッピングインデックス、プロジェクション。 インデックス化された列へのフィルタで、グラニュール(デフォルト8,192行のブロック)を丸ごと除外する。
  3. PREWHERE。 インデックスにない列へのフィルタも、安い列から順に評価し、後続の列は生き残った行の分だけ読む。
  4. クエリ条件キャッシュ。 繰り返しクエリで、前回マッチしなかったグラニュールを記憶してスキップする。

ここまでには一つの共通する前提があります — フィルタを通過した行なら、SELECTにある列は、ソートであれ集約であれ、その前に一旦すべて読まなければならないという前提です。lazy materializationはこの前提を崩します。ソートに必要な列だけを先に読み、ソートとLIMITが終わって最終行が確定した後にはじめて、残りのSELECT列をその行の分だけ取ってきます。ORDER BY 小さい列 LIMIT 3に50GiBのテキスト列がSELECTでくっついてくるTop-Nクエリなら、そのテキスト列はたった3行分だけ読めば済みます。

適用されているかどうかは、実行計画で直接確認できます。

EXPLAIN actions = 1
SELECT helpful_votes, product_title, review_headline, review_body
FROM amazon.amazon_reviews
ORDER BY helpful_votes DESC
LIMIT 3;
Lazily read columns: review_headline, review_body, product_title
  Limit
    Sorting
      ReadFromMergeTree

Lazily read columns の行が見えれば適用されています。この行単位の遅延読み込みが可能なこと自体、カラム指向ストレージのおかげだとドキュメントも明記しています — 行指向DBではどのみち行全体がまとめて読まれるので、そもそも成立しない話です。

ベンダーベンチマークの条件 — 3分の正体はディスクだった

では、その1,576倍が出た条件を見ていきましょう。以下の数字はすべて、ClickHouseが自社ブログで公開したベンダー自己測定であり、測定環境もブログに明記されています — AWS m6i.8xlarge(32 vCPU、128GiB RAM)に、1TiB gp3 SSDをデフォルト設定のまま — 3,000 IOPS、最大スループット125MiB/s — 取り付け、毎回の実行前にOSのページキャッシュを消去しています(cold cache)。データはAmazonレビュー150.96M行、非圧縮で70.47GiB、ZSTD(1)圧縮後は30.05GiBです。

フィルタ付きクエリ(日付・カテゴリ・購入確認・星評価のフィルタ + ORDER BY helpful_votes DESC LIMIT 3)に最適化を一段ずつ積み上げた結果がこの段階表です。

段階実行時間処理データピークメモリ
フルスキャン(インデックス・PREWHERE・lazy全てオフ)219.508秒72.13GB953.25MiB
+ 主キーインデックス95.865秒27.67GB629.00MiB
+ PREWHERE61.148秒16.28GB583.30MiB
+ lazy materialization0.181秒807.55MB3.88MiB

フィルタを全部外した純粋なTop-Nクエリでは、219.071秒が0.139秒になります。処理データは71.38GB→1.81GB、ピークメモリは1.11GiB→3.80MiBです。これが1,576倍の出どころです。

この数字を解釈するのに一つ計算しておくべきことがあります。フィルタなしクエリが読む4つの列の圧縮サイズを、ブログに載っている表から合計すると、およそ26.8GiBになります(review_body 21.60GiB + product_title 3.53GiB + review_headline 1.58GiB + helpful_votes 72.11MiB)。これをディスクのスループット上限125MiB/sで割ると約219秒 — 実測値219.071秒とほぼ一致します。つまりベースラインの3分39秒は、ClickHouseが遅いのではなく、上限のかかったディスクから27GiBを読む物理的な時間なのです。ブログ自身もディスクのスペックの横にカタツムリの絵文字を添えています。lazy materializationの利得は「読まなかったバイト数 ÷ ディスク速度」なので、この設定はその利得が最も大きく見える設定でもあります。

公開されていないものについても明確にしておきます。hot cache(ページキャッシュにすでにデータがある状態)での比較数値は、このブログにはありません — 全区間cold cacheです。より高速なNVMeやローカルSSDでの倍率もありません。そしてこのクエリは、SELECT対象の圧縮ベースで約99.7%が遅延対象列(上記3つのテキスト列)という、この最適化にとって最も有利な形です。ブログ自身もこの点は正直に書いています — データセットやクエリの形によっては、インデックスやPREWHEREのほうが大きな利得を出す場合があると。あなたのクエリが狭い列を数個だけSELECTするものなら、そもそも遅延させるバイトがないという意味であり、倍率はまったく違う数字になります。

なぜ最初はLIMIT 10までだったのか — ゲートが10 → 100 → 10,000になった歴史

導入時点(25.4、2025年4月22日)のゲートは驚くほど保守的でした。query_plan_max_limit_for_lazy_materialization のデフォルト値は10 — LIMITが10を超えると最適化はまったく発動しません。理由は25.12リリースノートが事後にうまく説明しています。初期の実装は、Top-N行が確定した後、残りの列を行単位(row by row)で取得していました。LIMITが大きいと、このlookupが細かく散らばったランダム読み込みになり、遅延読み込みの利得をオーバーヘッドが食いつぶしてしまうのです。

ベンダー自身のデモがこれを定量化しています。ウェブ解析データセット(hits、100M行、同じm6i.8xlarge + gp3)で SELECT * FROM hits ORDER BY EventTime LIMIT 100000 をゲートをオフにしたまま(= 0)実行すると — 25.11の行単位方式は、非ソート列104個 × 100,000行、約1千万回の個別lookupになります。3回の実行で34.2秒から38.7秒。同じクエリでlazyを完全にオフにすると(eagerで全部先行読み込み)7.0秒から7.9秒です。つまりこの地点では、旧lazy方式はeagerより5倍近く遅かったのです — もっとも、処理データは1.20GB対56.83GB、ピークメモリは約1GiB対74–78GiBと、圧倒的に少なかったのですが。時間だけを見れば、なぜゲートが10だったのかがそのまま見えてきます。

25.12(2025年12月18日)で、この実行モデルが作り直されました(PR #90309、リリースノートの表現では「join-style execution model」)。行単位のlookupの代わりに、Top-N行の識別子集合を作り、ベーステーブルに結合するように一括で取得します — 通常のjoinと同じベクトル化・並列実行の利得をそのまま受けられます。同じデモクエリが0.513〜0.524秒まで下がり(ベンダーの表記では旧方式比で約75倍、eager比で約14倍 — ただしこのデモは3回繰り返した中の最速値ベースで、キャッシュの状態は明記されていません)、その結果としてゲートのデフォルト値が10,000まで引き上げられました。設定履歴ファイルにはこの軌跡がそのまま残っています — 25.4で10として導入、25.11で100(「More optimal」)、25.12で10,000(「Increase the limit after performance improvement」)。

10,000で止まった理由もリリースノートにあります。遅延物質化された列は結局、Top-N行の順序に合わせてソートする必要があり、LIMITが非常に大きくなると、このコストが再び目立ち始めるということです。ゲートはトレードオフの自白です — この最適化はタダではなく、Nが小さいときに勝つ賭けなのです。

もう一つ: 25.8以降、lazy materializationは新analyzerが有効な場合にのみ適用されます(#83791)。analyzerがデフォルトになってから久しいですが、旧バージョン互換のために enable_analyzer = 0 で運用している環境では、本稿のどれも適用されません。

2026年: 同じアイデアが新しいクエリ形状へ

ここまでが前史であり、2026年の各リリースは「ソート・LIMITの後まで読み込みを遅らせる」という同じアイデアを新しいクエリ形状へ移植する作業でした。以下はすべてchangelogとマージ済みPR原文で確認した内容です。

26.2(2026年2月26日) — UNION ALL全ブランチ。 これまでlazy materializationはUNION ALLの最初のブランチにしか適用されていませんでした。#96832以降、全ブランチに適用されます — 異なるMergeTreeテーブルからのソート・LIMIT付き読み込みを結合するクエリなら、ブランチごとに遅延読み込みのI/O削減を受けられます。ちなみにこの項目は公開changelogには載っておらず、26.2に入ったという事実はPRに残ったマージ記録(26.2.1.706)に基づいています。機能追加というより実装の穴を埋めた側面が強いですが、時系列を複数テーブルに分割しUNION ALLでまとめるという一般的なパターンには実質的な違いです。

26.4(2026年4月30日) — ReplacingMergeTreeのFINAL。 #101647がFINAL読み込みに遅延物質化を移植しました。メカニズムは設定の説明に要約されています — selectiveな条件のとき主キー集合を作り、それでインデックス分析をやり直す方式です。ただし注意点として、changelogには性能改善として載っていますが、masterブランチのSettings.cpp基準で query_plan_optimize_lazy_final はまだデフォルトfalse、つまりオプトインです。さらにガードレール設定が3つも一緒に入りました — PK集合が1,000万行または256MBを超えると通常のFINALへフォールバック(max_rows_for_lazy_finalmax_bytes_for_lazy_final)、インデックス分析がマークの半分以上を除外できなくてもフォールバック(min_filtered_ratio_for_lazy_final、デフォルト0.5)。ガードレールの数自体が信頼段階の表れです。実際、26.5のchangelogにはlazy FINAL経路の修正が2件載っています — パイプライン拡張中のロジカルエラー(#103230)と、PREWHEREとの組み合わせでリリースビルドがsegfaultで落ちるサーバーabort(#104177)です。FINALがボトルネックになっているReplacingMergeTreeワークロードなら、オンにしてA/Bする価値はありますが、デフォルトがオフになっているのには理由があると読むのが妥当です。

26.6(2026年6月25日) — JOIN後のselective LIMIT。 今回のリリースのJOIN最適化群の一つとして、JOINの後にselectiveなLIMIT・TopN・別のJOINが続く場合、左テーブルのpayload列(結合キーでない列)をすぐには物質化せず、selector/replicationインデックスとして持ち回ります(#106566)。行数が減ってから物質化するので、結合結果の大半がLIMITで捨てられるクエリでコピーのコストが減ります。ゲートは二つです — 左のpayload列が3個以上(query_plan_min_columns_for_join_lazy_indexing、0で無効)、LIMITが1,000以下(query_plan_max_limit_for_join_lazy_indexing、0で無制限)。本体の10,000よりずっと狭いゲートから始めるのも、導入期のパターンそのままです。

26.6のこの機能には注釈が一つあります。もともと#98883として2026年5月26日にマージされたのですが、3日後にCIが Bad cast from type DB::ColumnReplicated to DB::ColumnString というロジカルエラーを検出し(#106095)、リバートされたあと、ソート変換の段階で列を物質化する修正とともに6月19日に再導入されました。この一連の流れは26.5と26.6のリリースの間で起きたことで、壊れたバージョンがstableに載ったことはありませんが、「遅延された列の表現は、パイプラインの隅々まで本物の列のふりをしなければならない」という、この最適化系列に共通する構造的な難しさをよく示すエピソードです。

同じ時期に「lazy」という単語自体がコードベースの他の場所にも広がっているのも興味深い点です。26.3には、JSON列の型ヒント変更をデータの書き換えなしにメタデータ操作として処理する実験的機能が(allow_experimental_json_lazy_type_hints#97412)、26.6にはテキストインデックスのポスティングリストをRoaring Bitmapへ全部展開せず、カーソルベースで必要な分だけデコードする実験的機能が(allow_experimental_text_index_lazy_apply#100035)入りました。どちらもまだexperimentalですが、方向性は同じです — 最後の瞬間まで仕事を先延ばしにすれば、その仕事の相当部分は永遠にやらずに済む。

どこで通用せず、何に注意すべきか

まとめると、2026年半ば時点で、この最適化が発動するにはクエリが次の条件を満たす必要があります。

  • Top-N形状 — ORDER BY ... LIMIT N 系のクエリで、Nが10,000以下(デフォルトゲート基準)である必要があります。JOIN派生形はN 1,000以下 + payload列3個以上。FINAL派生形は自分で有効化する必要があります。
  • 新analyzerが有効になっている必要があります(デフォルト)。
  • arrayJoinがプランに含まれると適用されません — 26.5で意図的に除外されました(#101644)。LIMITが守られない可能性があるcorrectnessの問題であり、パフォーマンスではなく正確性の理由で外されたケースです。

利得が構造的にないケースもそのまま書いておきます。SELECT列がすべて狭ければ(ソート列とサイズが同程度なら)、遅らせるべきバイトがありません。結果を全部消費するクエリ(集計で終わるものや、LIMITのないexport系クエリ)はそもそも対象外です。そして、ワーキングセットがページキャッシュに全部載っているhot環境での利得幅は、ベンダーが公開したことがありません — cold cacheの1,576倍を、あなたのダッシュボードのレイテンシ改善幅にそのまま翻訳することはできない、ということです。計測は自分のデータで行う必要があります。幸い、A/Bはセッション設定一つでできます。

-- オフにして比較
SELECT ... SETTINGS query_plan_optimize_lazy_materialization = false;
-- ゲート外のLIMITで強制的にオンにしてみる (デフォルトゲートは10000)
SELECT ... SETTINGS query_plan_max_limit_for_lazy_materialization = 0;

最後に、バグの履歴について話しておかなければなりません。この機能は導入以来、ほぼ毎リリースで修正が続いています — Variant列の読み込み(25.8、#84400)、外部ソートと重なったときのCORRUPTED_DATA(25.8、#84738)、プロジェクションと重なったときのAMBIGUOUS_COLUMN_NAME(25.6、#80251)、ALTERで追加された列の古いパートの扱い(25.12、#91142)、そして26.6にも、ソート列を誤って指してTYPE_MISMATCHになるケースの修正(#107060)が載っています。怖がらせたいわけではありません — デフォルトでオンになっている最適化のバグは、あなたが一度もオンにした覚えのないコードパスのバグだ、という話です。ClickHouseをアップグレードするときにリリースノートで「lazy」が入った行を読むべき理由はここにありますし、クエリが妙に落ちたり遅くなったりしたときに query_plan_optimize_lazy_materialization = false が有効な最初の切り分け手段である理由でもあります。

おわりに

lazy materializationのこの1年ほどの軌跡を一行にまとめるとこうです — 25.4でLIMIT 10の保守的な小技として生まれ(PR自体は2023年10月に開かれ、1年半寝かされていました)、25.12で実行モデルをjoinスタイルに作り直してゲートを1,000倍広げ、2026年に入ってUNION ALL(26.2)、FINAL(26.4、オプトイン)、JOIN(26.6)へと適用範囲を広げている最中です。クエリを一行も変えなくてもアップグレードだけで速くなる類の改善だという点で、実務上の価値は明確です。

同時に、本稿で確認した境界も明確です。ヘッドラインの倍率は、cold cacheとスループット上限のかかったディスク、そして遅延対象列がバイトの大半を占めるクエリ形状から出た、ベンダー自己測定です。そのベンダー自身が、LIMITが大きくなると旧方式がeagerより5倍遅くなるという数値も一緒に公開しています。ゲートとフォールバックとオプトインのデフォルト値は、飾りではなく、この最適化のコスト構造についての正直な記録です。

最近の分析DBがそれぞれ違う層に手を入れているのを合わせて見るのも面白いところです — DuckDBがクライアント-サーバープロトコルを、Elasticsearchがベクトルインデックスのデフォルト値を入れ替えている間、ClickHouseは「読み込みをどこまで先延ばしできるか」という一つの問いを、クエリ形状を一つずつ広げながら掘り続けています。次のリリースでこれらのゲートがまたどう動くか — SettingsChangesHistory.cpp を眺めているだけでも、この機能の成熟度を追い続けられます。

参考資料