Skip to content

ClickHouseの列指向ストレージで高速なUPDATEを実現した方法 – パート2: SQLスタイルのUPDATE

tom schreiber headshot
2025年7月24日 · 40分で読む

TL;DR

ClickHouse の列指向ストレージに向けて、SQL スタイルの UPDATE を根本から再設計しました。本記事では、重量級のミューテーションから、スケールするパッチパートを活用した軽量な更新に至るまでの歩みを詳しく解説します。ベンチマーク結果はパート 3 で紹介します。

本記事は、ClickHouse における高速な UPDATE に関するシリーズ記事の一部です。
  • パート 1: 専用エンジン
    ReplacingMergeTree、CollapsingMergeTree、CoalescingMergeTree などの挿入ベースのエンジンを使用し、ClickHouse が低速な行レベルの更新をいかに回避しているかを解説します。
  • 本パート: 宣言的な SQL スタイルの UPDATE
    パッチパートを活用して最小限のオーバーヘッドで標準的な UPDATE 構文を ClickHouse に導入した方法を探ります。
  • パート 3: ベンチマーク
    実際の処理速度をご紹介します。宣言的 UPDATE を含むあらゆるアプローチをベンチマーク測定し、最大 1,000 倍の高速化を達成しました。
  • 番外編: ClickHouse と PostgreSQL の比較
    ClickHouse の新しい SQL UPDATE を、同一のハードウェアとデータ上で PostgreSQL と直接対決させました。ポイント更新では同等、バルク変更では最大 4,000 倍高速という結果が得られています。

不可能とされていた更新機能

列指向ストレージでは、更新を高速に行うことはできないとされてきました。

長年、分析向けに構築されたシステムは、読み取り速度のために更新性能を犠牲にしてきました。行レベルの変更は、スキャンに最適化された高スループットなアーキテクチャとは相容れないものと考えられていたためです。

ClickHouse も例外ではありませんでしたが、更新の本質を捉え直すことで、その常識を打ち破りました。

パート 1 では、更新を挿入へと変換するという、ClickHouse が採用した根本的に異なるモデルを紹介しました。ReplacingMergeTree や CollapsingMergeTree などの専用エンジンにより、超高速な取り込み速度を維持しながら、後からバックグラウンドで非同期にマージによって更新を解決できるようにしました。

しかし、誰もがマージのセマンティクスを意識して設計したいわけではありません。多くのユーザーは、シンプルに次のように書きたいだけです。

UPDATE orders
SET discount = 0.2
WHERE quantity >= 40;

そこで私たちは、ClickHouse 本来の高速性を損なうことなく、これを実現することを目指しました。

本記事では、その実現に至るプロセスを解説します。

ClickHouse における SQL スタイルの更新の進化を順に追っていきます。

  1. シンプルながら処理負荷の大きい、従来型のミューテーション。

  2. ミューテーションの完了を待たずに済む、一歩進んだ On-the-fly 更新。

  3. そして高頻度のワークロード向けに専用設計された、スケーラブルで列指向ネイティブな更新メカニズムであるパッチパートを活用した、高速で宣言的な SQL 更新。

パッチパートによる更新が実際にどれほど高速なのか気になる方は、ぜひご覧ください。
パート 3 でベンチマークを実施したところ、最大 1,000 倍、場合によっては 1,600 倍以上の高速化を確認できました。

まずは、ClickHouse が長年サポートしてきた、従来のミューテーションベースの UPDATE から見ていきましょう。

ステージ 1: 従来のミューテーションとカラムの書き換え

2018年以降、ClickHouse は ALTER TABLE ... UPDATE ステートメントによる SQL スタイルの更新をサポートしてきました。

ミューテーション経由で UPDATE がどのように機能するかを説明するため、パート 1 の例と同じ orders テーブルを使用します。

CREATE TABLE orders (
    order_id   Int32,
    item_id    String,
    quantity   UInt32,
    price      Decimal(10,2),
    discount   Decimal(5,2)
)
ENGINE = MergeTree
ORDER BY (order_id, item_id);

最初の挿入から始めて、シンプルな UPDATE が内部でどのようにミューテーションをトリガーするかを順を追って見ていきましょう。

初回挿入

まず、同じ注文の2商品を含むパートから始めます。

INSERT INTO orders VALUES
    (1001, 'kbd',  10, 45.00, 0.00),
    (1001, 'mouse', 6, 25.00, 0.00);

これにより、all_1_1_0 という名前のデータパートが作成されます。

Blog-updates Part 2.001.png

UPDATE によるミューテーションのトリガー

mouse の商品の数量と割引率を更新します。

ALTER TABLE orders
UPDATE quantity = 60, discount = 0.20
WHERE order_id = 1001 AND item_id = 'mouse';

ALTER TABLE ... UPDATE 構文が標準 SQL の UPDATE と意図的に異なっているのは、内部で何が起きているかを反映しているためです。ClickHouse では行をインプレースで変更するのではなく、データパートを書き換える(「ミューテーションする」)処理が行われます。

この UPDATE により、ClickHouse は内部でミューテーションを実行します。 これにより、次の3つの内部ステップがトリガーされます。

  1. 更新用に新しいブロック番号(例: 2)が割り当てられ、ClickHouse はこれを使用して書き換えが必要なパートを追跡します。

  2. ディスク上に all_1_1_0_2 という名前の新しいミューテーション済みパートが作成されます(末尾の 2 はミューテーションバージョンです)。

  3. ミューテーションは、元のパート all_1_1_0 のように、ブロック番号が 2 未満のパートにのみ適用されます。

以下の図は変更内容を示しています。更新されたカラム(quantity、discount)は完全に書き直され、変更されていないカラム(order_id、item_id、price)は**ハードリンク**されます。これらのカラムのデータはコピーされず、新しいパートはディスク上のハードリンクを介して基盤となる同一ファイルを単に再利用します。

Blog-updates Part 2.002.png

ミューテーションが完了すると、ミューテーション済みパート all_1_1_0_2 が元の all_1_1_0 を置き換え、元のパートは削除されます。これにより元のフォルダとファイルエントリは削除されますが、新しいパート all_1_1_0_2 がハードリンクしているカラムファイルはディスク上に安全に保持されます。参照するパートが完全になくなるまでデータが削除されないことがハードリンクによって保証されるためです。

まとめとトレードオフ

従来のミューテーションモデルは信頼性が高いものの、トレードオフも存在します。

  • 重量級の更新: UPDATE ごとに対象カラム全体が書き直されるため、大規模環境ではコストが高くなる可能性があります。

  • 可視化の遅延: バックグラウンドのミューテーションが完了するまで、クエリ結果に変更が反映されません。

  • マージへの依存: ミューテーションは、実行前に先行するマージやミューテーションの完了を待つ必要があります。(ここでは詳細を割愛しますが、これは実環境において予想外の制約となることがあります。)

デフォルトでは、ALTER TABLE … UPDATE ステートメントは非同期で実行されるため、ClickHouse は複数の更新を単一のミューテーションにまとめることができます。これにより、書き換えコストを償却できます。

On-the-fly ミューテーションに進む前に、このモデルに基づいて構築されたもう1つの過渡的な最適化である**軽量削除(Lightweight DELETE)**を見てみましょう。

ステージ 1.5: 軽量 DELETE(ミューテーションでありながら高速化を実現)

On-the-fly ミューテーションが登場する前、ClickHouse は従来のミューテーションモデルのもとで DELETE を高速化するためのよりシンプルな最適化を導入しました。

行を即座に削除する代わりに、DELETE は特別なマスクカラム _row_exists = 0 を設定する ALTER TABLE ... UPDATE に書き換えられます。これにより軽量ミューテーションがトリガーされ、_row_exists カラムのみが(再)書き込み*され、他のすべてのカラムはハードリンクされるため、不要な I/O を回避できます。

パートに対する初回の削除ミューテーションである場合、_row_exists カラムはミューテーション済みパート内に新規作成されます。パートがマージされる前の2回目以降の削除ミューテーションである場合は、_row_exists が再書き込み**されます。

次の図は、元のパートと軽量 DELETE によるミューテーション結果を示しています。以下で、プロセスの各ステップを順に説明します。

Blog-updates Part 2.003.png (わかりやすくするため、図では元のパートに _row_exists を記載していますが、前述のとおり、このカラムはまだ存在しておらず、初回の削除ミューテーションによって追加されます。)


1. 初回挿入: ステージ 1 と同じ orders の小さな例を再利用します。2行が挿入され、元のパート all_1_1_0 が作成されます。

INSERT INTO orders VALUES
    (1001, 'kbd',  10, 45.00, 0.00),
    (1001, 'mouse', 6, 25.00, 0.00);
  1. DELETE の発行: mouse の商品が削除されます。
DELETE FROM orders WHERE order_id = 1001 AND item_id = 'mouse';

内部的には、ClickHouse は _row_exists = 0 に更新します。

ALTER TABLE orders
UPDATE _row_exists = 0
WHERE order_id = 1001 AND item_id = 'mouse';
  1. ミューテーションの作成: 新しいパート all_1_1_0_2 が作成され、_row_exists のみが書き直されます。

  2. クエリの挙動: _row_exists = 0 の行は結果から除外されます。

  3. クリーンアップ: 次回の通常のバックグラウンドマージ中に行が完全に削除されます。

まとめとトレードオフ

このアプローチにより、中核となるミューテーションモデルを変更することなく DELETE が大幅に高速化されましたが、依然としてバックグラウンドでの書き換えは必要でした。

次の段階では、バックグラウンドでの書き換えそのものは解消されなかったものの、ミューテーションの実行完了を待たずに更新を即座に可視化できるようになりました。

ステージ 2: 即時可視化を実現する On-the-fly 更新

従来のミューテーションは重量級です。データカラム全体を書き直すため、特に大規模なデータセットでは完了までに時間がかかります。UPDATE を発行してから結果が確認できるようになるまでのレイテンシを短縮するため、ClickHouse はパートが書き直される前であっても更新を即座に可視化する最適化である On-the-fly ミューテーションを導入しました。

これは、パッチパートへの道筋における自然な第一歩でした。書き換えを回避するものではありませんでしたが、更新が即座に行われたように体感できるようになりました。

以下の図はそのメカニズムを示しています。UPDATE はメモリに保持されて読み取り時に適用され、実際のミューテーションはバックグラウンドで非同期に実行されます。

Blog-updates Part 2.004.png

  1. 挿入: 先ほどと同様に、2行が orders テーブルに ① 挿入され、② 初期パート all_1_1_0 が作成されます。

  2. UPDATE の発行: ③ mouse の行を更新します。ClickHouse は更新式をメモリに保持します。

  3. SELECT の発行: ④ クエリは元のデータパートを読み取りますが、ClickHouse はメモリ内で ⑤ オンザフライで更新を適用します。

  4. 結果: 更新された行は、⑥ クエリ結果に即座に反映されます。

  5. ミューテーションの実行: バックグラウンドで、ClickHouse はパートを all_1_1_0_2 として ⑦ 書き直し、古いパートを破棄します。

まとめとトレードオフ

On-the-fly ミューテーションによって応答性は向上しますが、バックグラウンドでの書き換え自体はなくなりません。また、更新が多数蓄積されると SELECT が低速になる可能性があり、サブクエリや非決定論的関数のサポートも限定的です。

これは実用的な過渡期のソリューションであり、より優れた機能を構築するための猶予を生み出す、手軽で迅速な最適化でした。本当のブレークスルーはその先にありました。頻繁な更新を大規模環境でも効率的に処理できるように設計された、根本的に異なるメカニズムであるパッチパートです。

ステージ 3: パッチパート – ClickHouse 流の更新

初期のアプローチには限界があったため、より優れた仕組みを一から構築しました。

なぜ従来のミューテーションでは不十分だったのか

On-the-fly 更新のような最適化を行っても、コアモデルには限界がありました。

  • 変更された行がごく少数であってもカラム全体が書き直されるため、大規模環境ではリソースの無駄が生じる点。

  • 先行するマージやミューテーションの完了待ちによってブロックされ、レイテンシが増大し予測困難になる点。

そこで、その役割から名付けられた新しいメカニズムであるパッチパートを一から構築しました。 マージ時にソースパートにパッチを適用し、変更されたデータのみを反映します。

実績ある機能の上に築かれたモデル

パッチパートは、専用エンジンで実証済みの高速な挿入とバックグラウンドマージというアイデアを取り入れ、柔軟な SQL スタイルの更新向けに完全にカプセル化して一般化したものです。

  1. 高速な挿入: ClickHouse は高スループットで挿入を処理します(例えばある本番環境では、ClickHouse は毎秒 10 億行以上を取り込んでいます)。この特性を利用して、更新や削除を軽量な挿入としてモデル化します。

  2. バックグラウンドマージ: MergeTree はすでにデータのスキャンと書き換えを行っています。このプロセス中についでに更新の適用や行の削除を行っても、オーバーヘッドはほぼゼロです。

これがどのように動作するか、シンプルな例で確認してみましょう。(直感的に理解できるようまずは小さな例から始め、内部構造については次のセクションで詳しく解説します。)

パッチパートの仕組み

パッチパートがどのように動作し、なぜこれほど効率的なのかを理解していきましょう。

前述の小さな orders テーブルを再利用して、シンプルな更新の流れを追ってみます。

CREATE TABLE orders (
    order_id   Int32,
    item_id    String,
    quantity   UInt32,
    price      Decimal(10,2),
    discount   Decimal(5,2)
)
ENGINE = MergeTree
ORDER BY (order_id, item_id);

パッチパートによる UPDATE は 25.7 の時点ではまだ実験的機能です
ClickHouse 25.7 で試すには、手動で機能を有効化する必要があります。

SET allow_experimental_lightweight_update = 1;
ALTER TABLE orders MODIFY SETTING enable_block_number_column = 1, enable_block_offset_column = 1;

(25.8 でベータ版になる予定です)

先ほどと同じ初期注文データ(kbd、mouse の2商品)も再利用します。これらの行は all_1_1_0 という名前のデータパートに挿入されます(後述の図を参照)。

INSERT INTO orders VALUES
    (1001, 'kbd',  10, 45.00, 0.00),
    (1001, 'mouse', 6, 25.00, 0.00);

その後、mouse の数量が 60 個に増やされ、20% のまとめ買い割引が適用されるようになったとします。行を更新します。

UPDATE orders
SET quantity = 60, discount = 0.20
WHERE order_id = 1001 AND item_id = 'mouse';

この UPDATE により、パッチパートを活用した**軽量更新(Lightweight UPDATE)**がトリガーされます。

パッチパートベースの更新には、標準の SQL UPDATE 構文を使用します。この新機能は、ClickHouse では軽量更新と呼ばれています。従来のミューテーションとは異なり、より行レベルの更新に近い挙動となり、小規模で頻繁な変更も効率的かつ高いパフォーマンスで処理できます。

この時点で、更新された値はすでにクエリから参照可能であり、バックグラウンド処理の完了を待つ必要はありません。この仕組みについては後ほど説明します。

パッチパートは置き換えではなく差分

従来のミューテーションとは異なり、ClickHouse はカラムやパート全体を書き直すことはしません。その代わり、以下のみを含む新しいコンパクトなパッチパートを作成します。

  • 変更されたカラム値(quantity = 60、discount = 0.20)

  • ソースパート内の元の行を特定するためのメタデータ

パッチパートは「diff」のようなものだと考えてください。「この行の、このカラムだけを更新する」と指示する小さな差分です。そのため、ClickHouse ではこれらを軽量更新と呼んでいます。極めてコンパクトで効率的です。

図解: パッチパートによる行の更新手順

上記の更新中に何が起きているかを視覚的に確認してみましょう。

(基本概念に焦点を当てるため、実際の実装を少し簡略化しています。完全な詳細は次のセクションで解説します。)

Blog-updates Part 2.005.png

① 元のデータパート:
キーボードとマウスの両方の注文を含み、テーブルのソートキー (order_id, item_id) で明示的にソートされています。各行には、パート内での位置を示す 仮想システムカラム _part_offset も存在するため、パートは _part_offset によっても暗黙的にソートされています。

② パッチパート:
UPDATE によって作成されます。ソースパート all_1_1_0 の2行目に対する変更された値(quantity、discount)と、メタデータシステムカラム(_part = all_1_1_0、_part_offset = 1)のみが含まれます。

③ マージ結果:
バックグラウンドマージ中に、ClickHouse は元のパートとパッチパートをマージし、一致する行を更新された値で置き換えます。

コンパクトなデータサイズ

パッチパートは、書き込まれるデータ量を最小限に抑えるよう設計されています。

  • 更新された値のみが書き込まれます。

  • 変更されていないカラム(例: order_id、item_id、price)は完全にスキップされ、パッチパートには一切含まれません。 (ReplacingMergeTree のような専用エンジンの場合、更新には変更されていない値も含めた行全体の再挿入が必要です。)

優れた処理効率

パッチパートは**バックグラウンドですでに実行されているマージに相乗り**します。ClickHouse が常時実行しているプロセスに組み込まれるため、オーバーヘッドはほぼゼロです。

  • すべてのパートが自然に整列している: 元のパートはテーブルのソートキー(および暗黙的に _part_offset)でソートされており、パッチは (_part, _part_offset) でソートされています。

  • そのため、マージで行をシームレスに整列可能: 追加のインデックス作成、再ソート、書き換えを行うことなく、_part_offset を使用して行を揃えられます。

  • 更新がスムーズに収まる: 1回の効率的なマージパスで適用されます。エンジンは、一時バッファやランダムアクセスを使用せず、*パートの単一の線形スキャンでパートのデータをインターリーブ(挟み込み)*するだけです。

(これは専用エンジンの仕組みと似ていますが、パッチパートはソートキーではなく _part_offset に依存します。)

パッチパートが単一の更新に対して原理的にどう機能するかに着目するため、意図的に内容を少し簡略化して説明しました。

それでは全体像を広げ、これがどのようにスケールするかを見ていきましょう。

大規模環境におけるパッチパート: 追跡、ターゲット特定、マージ

ClickHouse が本番環境に耐えうるスケーラブルな更新をどのようにサポートしているかを理解するために、パッチパートを支える内部システムカラムとメタデータ構造を詳しく見ていきます。

実環境の更新では、これらの内部構造を利用して複数パートにまたがる行を効率的にターゲットし、大規模環境でのノンブロッキングな更新をサポートしています。

詳細を見ていきましょう。

数量が 40 以上のすべての注文の全商品に 20% の割引を適用したいとします。

UPDATE orders
SET discount = 0.2
WHERE quantity >= 40;

このような SQL ステートメントは、複数のパートにまたがる多数の行に影響を与える可能性があります。しかし、その方法についてユーザーが心配する必要はありません。期待どおりに動作します。

宣言的更新の重要性
更新行ごとに行を再挿入する必要がある ReplacingMergeTree などの専用エンジンとは異なり、宣言的な SQL 更新では内部メカニズムが自動で処理されます。やりたい意図を記述するだけで十分です。

パッチパートが複数のパートにまたがる行をいかに効率的にターゲットするかを示すため、以下の図ではより多くの挿入と上記の更新を含む拡張例を使用しています。

注記: 理解しやすくするため、この例で使用されていないすべてのシステムカラムと構造は薄い色で表示しています。

• オレンジ色は使用されているシステムカラムを示します。
• 青色は使用されているインデックスを示します。
• 薄いグレーは未使用の構造を示します。

これにより、内部で何が存在しているかの全体像を把握しつつ、重要な部分に焦点を当てることができます。

Blog-updates Part 2.006.png

① 初回の注文: 2商品を含む基本の例。

② 別の注文: 新しい order_id で、別途挿入。

③ 拡張された注文: 初回の注文にさらに2商品を追加。

これらは通常の挿入であり、時間の経過とともに3つのデータパートが作成されます。

④ パッチパート: UPDATE によって作成されます。新しいカラム値(2行分の discount = 0.2)と、変更対象の正確な行をターゲットするための十分なメタデータのみが含まれます。

⑤ マージされたデータパート: 古い行とパッチの値を1つの出力パートに結合します。

クリーンアップ: マージ後、元のパート ① 〜 ③(そして最終的には ④ も)破棄されます。マージ結果である ⑤ のみが残ります。

それでは、上の図に描かれているシステムカラムと内部データ構造を順に見ていきましょう。

元の行に含まれるシステムカラム

パッチパートのメカニズムを実現するため、元のデータパート内のすべての行は3つのシステムカラムを保持しています。

システムカラム説明と目的
_part_offsetパート内での行の序数位置。マージ中に行を整列させ、パッチの更新を効率的に適用するために使用されます。
_block_number, _block_offset挿入時のブロック番号と、そのブロック内での行のオフセット。マージ後に _part_offset が無効になった際、行を特定するために使用されます。(この例では使用されません)

(これらのシステムカラムはデフォルトで仮想カラムです。先ほどの簡略化した例と同様に、ここではわかりやすくするために実体化されたものとして図示していますが、実際にはマージ後にのみ物理的に保存されます。)

パッチパート内の正確なターゲティングメタデータ

各パッチパートの行には、更新対象の行を見つけるための必要最小限のメタデータが含まれています。

システムカラム目的
_part, _part_offset元の行の特定
_block_number, _block_offsetマージをまたいだ追跡のサポート(ここでは未使用)
_data_version更新バージョンの追跡: 適用済みパッチのスキップや、他のパッチパートとのマージに使用(後述)

残りは以下のみです。

<更新されたカラム>古い値を置き換える新しい値(例: discount = 0.2)

パッチパートのインデックス

パッチパートが作成されると、ClickHouse はそれをいつ、どこに適用すべきかを把握する必要があります。そのために、ClickHouse は(パッチパートごとに)順方向と逆方向の2つの軽量インデックスを構築します。

インデックス名目的
ソースパートインデックス影響を受ける各ソースパートを最小/最大のデータバージョンにマッピング。このパッチをこのパートに適用すべきか? の判定に使用。
逆インデックスパッチのデータバージョンを、それを必要とするすべてのパートとブロックにマッピング。マージ後でもパッチの対象を見つけるために使用。

これらのインデックスにより、バックグラウンドでデータパートがマージされた場合でも、効率的なターゲティングが可能になります。

インデックスによる高速なソースパートのマッチング

ClickHouse は、パッチパートの ソースパートインデックス を使用して、更新がどのソースパートに適用されるかをすばやく判断します。今回の例では、all_1_1_0 と all_3_3_0 のみが一致し、更新は all_2_2_0 には触れません。

システムカラム主導のソートによる高速なパッチマージ

前述の通り、パッチパートはすでにバックグラウンドで実行されているマージに相乗りします。すべてのパートが(暗黙的または明示的に)_part_offset でソートされているため、ClickHouse は単一の効率的なマージパスでパッチを適用できます。

マージ中の行アイデンティティの保持

マージされたパート(⑤)では、各行の新しい位置を反映するように _part_offset の値が再作成されます。しかし、_block_number と _block_offset は元のパートから変更されずにそのままコピーされます。

この詳細は、対象となるパートのマージと同時に実行される更新をサポートする上で極めて重要です。

これがどのように機能するかを、次のセクションで見ていきましょう。

更新はマージを待たない

ClickHouse の更新はノンブロッキングです。マージの完了を待つことはありません。代わりに、各更新は UPDATE の開始時に存在するパートのスナップショットに対して実行されます。

従来のミューテーションでは、実行前に先行するマージやミューテーションの完了を待つ必要がありました。

ほとんどの場合、そのスナップショットは後でパッチパートが適用される時点でも有効です。しかし、パッチが適用される前にそれらのパートがマージによって消滅した場合、ClickHouse は自動的に別のマッチング戦略へとフォールバックします。

そのフォールバックがどのように動作するかを見てみましょう。

パッチが適用される前にパートがマージされると何が起きるか?

次の図は、前のセクションと同じ拡張された例を基にしていますが、パッチが適用される前にパートがマージされた場合に何が起こるかを示しています。

前の図では、_block_number と _block_offset を薄く表示していました。今回は、使用されない _part、_part_offset、およびパッチの ソースパートインデックス を薄いグレーで表示しています。

Blog-updates Part 2.007.png

このシナリオでは何が変わるのか?

①〜③: 前述と同様に同じ挿入と ⑤ の UPDATE が実行され、これらのステップは変更されません。

④: しかし今回は、パッチが適用される前にバックグラウンドマージが元のパートを1つのパートにまとめます。その後、マージの通常動作としてソースパート(①〜③)は削除されます。

⑤: パッチパートによって参照されていた元の _part と _part_offset の値は無効になります。そのため、ClickHouse はマージ中も保持されていた _block_number と _block_offset へとフォールバックします。

⑥〜⑧: ClickHouse は保持されていたそれらの値に基づいてハッシュ結合を使用しパッチを適用し、パッチが適用されたマージ済みデータパートを生成します。

このフォールバックのパッチ適用パスがどのように動作するか、詳しく見てみましょう。

逆インデックスによるソースパートのマッチング

逆インデックスは、このパッチがデータバージョン 4 に適用され、パート all_1_1_0 と all_3_3_0 を対象としていることを示しています。これらは(その名前に基づくと)ブロック番号 1 と 3 を含んでいました。

マージされたパート all_1_3_1 は(その名前に基づくと)ブロック番号 1 から 3 にまたがっているため、有効な一致となります。また、そのデータバージョンは 1(最小のブロック番号から推測)であり、パッチのバージョン 4 よりも小さいため、パッチを適用できます。

この逆マッピングにより、マージによって元のパート名が無効になった後でも、ClickHouse はデータをマッチングしてパッチを適用できます。

ブロックベースのシステムカラムの結合によるパッチ適用

ソースパートはすでに存在せず、元の値がマージ後の出力内の対応する行と一致しなくなっているため、マージでパッチを効率的に適用するために _part と _part_offset を使用することはできなくなっています。

代わりに、ClickHouse はハッシュ結合ベースのアルゴリズムを使用してパッチを適用します。パッチパートを (_block_number, _block_offset) をキーとするハッシュテーブルとしてメモリに読み込み、同じキーを使用してマージされたパートと結合して行を更新します。

このフォールバックは、前のセクションで説明した高速パスよりも低速で、メモリを多く消費します。パッチが完全にメモリに収まる必要がありますが、将来的にはメモリ制約のない full merge join をサポートする可能性があります(現時点では未実装です)。

幸いなことに、通常はソースデータパートが高速なパッチ適用に十分な時間存続するため、実際にこのフォールバックが発生することは稀です。

ClickHouse がマージ中であってもどのように UPDATE を処理するかを見てきたところで、次は DELETE がいかに同様の効率化を遂げたかについて説明します。

軽量な DELETE がさらに軽量に

ステージ 1.5 では、軽量な DELETE によってすでに改善が得られていました。ALTER UPDATE 経由で _row_exists 削除マスクのみを書き換えることで、行全体の書き換えを回避していました。

しかし、さらに軽量化できます。

lightweight_delete_mode = lightweight_update の場合、DELETE は ALTER でさえなくなります。ClickHouse は削除された行に対して単に _row_exists = 0 を設定するパッチパートを作成します。その後、次のバックグラウンドマージ中に行が破棄されます。

以下の図は、ステージ 1.5 と同じ例を再利用したものです。元の注文からマウスの商品を削除します。(わかりやすくするため、_block_number、_block_offset などの未使用のシステムカラムは省略しています。)

Blog-updates Part 2.008.png

① 初期のパートには2行が含まれています。

② パッチパートが、削除された行(マウス)に対して _row_exists = 0 を設定します。

③ 次のマージ中に、その行は消滅します。

ClickHouse はパッチを瞬時に適用します。マージが行われるまで、クエリは単に _row_exists = 0 の行を無視します。これが読み取り時パッチ適用(patch-on-read)によるクエリ実行の核心です。

読み取り時パッチ適用の仕組み

ClickHouse は更新された結果を返す前に、パッチパートが実体化されるのを待ちません。その代わりに、次のように処理します。

パッチパートは、暗黙的な FINAL のように、クエリ実行中にメモリ上で自動的かつオンザフライで適用されます。

この読み取り時パッチ適用のメカニズムは、パフォーマンスへの影響を最小限に抑えるように設計されています。

通常、クエリのために選択されたデータは(インデックス分析の後)、複数のデータパート内のいくつかのデータ範囲(連続した行ブロック)に配置されています。これらの範囲は、クエリエンジンによって ① 個別の並列ストリームステージ(データストリーム)へと動的に分散され、データをフィルタリング、集計、ソート、制限して最終結果を生成する ② 並列処理レーン によって処理されます。

Blog-updates Part 2.009.png

まだマージされていないパッチパートは、各データストリームのデータ範囲ごとに ③ マッチングされ、個別に適用されます。これにより、並列性を損なうことなく更新が正しく適用されます。

(必要に応じて、ALTER TABLE … APPLY PATCHES を実行して、蓄積された未マージの変更をすべて完全に実体化することもできますが、これは任意です。)

パッチパートを完全に理解するには、そのライフサイクル、つまり ClickHouse がバックグラウンドでどのようにマージ、重複排除、クリーンアップを行うかも見る必要があります。

パッチパートは時間の経過とともにどうなるか?

パッチパートは特殊に見えるかもしれませんが、内部的には ClickHouse の通常のパートと変わりません。つまり、以下の性質を持ちます。

  • _data_version をバージョンカラムとして使用し、ReplacingMergeTree アルゴリズムによって他のパッチパートとマージされます。これにより、各パッチパートには更新された各行の最新バージョンのみが保持されます。

  • 変更が影響を受けるすべてのデータパートに完全に実体化されるか、別のパッチパートとマージされると、自動的にクリーンアップされます。バックグラウンドのクリーンアップスレッドがこれを安全に処理します。

  • TOO_MANY_PARTS の閾値にカウント されます。これはパーティションごとに適用されます。この影響を緩和するため、パッチパートは更新されたカラムのセットに基づいて別々のパーティションに保存されます。そのため、SET x = …、SET y = …、SET x = …, y = … のように異なるカラムに影響を与える複数の UPDATE 文を実行した場合、それぞれ独自のパート数を持つ別々のパッチパーティションが作成されます。

この設計により、パッチパートの高速性と効率性が維持され、MergeTree のメカニズムと深く統合されています。

ここまでは、パッチパートが単独でどのように振る舞うかに焦点を当ててきました。しかし、複数の更新が同時に届いた場合はどうなるでしょうか?ClickHouse が同時実行される更新をどのように安全に調整するかを見てみましょう。

同時実行される更新の調整

ClickHouse はデフォルトで更新を並列実行します。 2つの更新が同じカラムに触れる場合、自動的に正しい順序で実行されます。何も設定する必要はなく、そのまま動作します。

いくつかの設定でこの動作を微調整できます。

  • update_parallel_mode:

    • auto(デフォルト): 依存関係のある更新(例: UPDATE a=3 WHERE b=2 と UPDATE b=2 WHERE a=1)を直列化します。その他は並列に実行します。

    • sync: すべての更新を1つずつ順番に実行します。

    • async: 調整を行わずにすべての更新を実行します。

  • update_sequential_consistency(デフォルトは無効): パフォーマンスへの影響と引き換えに、各更新が最新の可視状態を参照できるようにします。

しかし、ほとんどのワークロードでは、デフォルトの設定が高速で安全であり、適切に処理されます。

これらすべての調整は水面下で行われるため、外側からは更新が通常の SQL のように感じられます。ただそれだけで機能します。

この記事の最後に、一歩引いて全体像を振り返ってみましょう。

エンジン固有の工夫から使い慣れた SQL へ

パッチパートは、列指向ストレージのルールを破るのではなく、それを受け入れることで、効率的な SQL スタイルの UPDATE を ClickHouse にもたらします。

私たちは ClickHouse を高速にしている要素をそのまま活用しました。
挿入は高速である。マージは継続的である。パートはイミュータブルでソートされている。

そして挿入が極めて高速であるため、更新を挿入へと変換しました。
ClickHouse は水面下でコンパクトなパッチパートを挿入し、マージの際にそれらを効率的に適用します。

マージはすでに実行されているため、より多くの処理を行わせるようにしました。実質的な追加負荷をかけることなく。
エンジンはすでにバックグラウンドでデータパートをマージしているため、最小限のオーバーヘッドで更新を適用できるようになりました。

パッチパートはクエリのパフォーマンスにほとんど影響を与えることなく、瞬時に組み込まれます。
更新はすぐに反映され、未マージのパッチは並列性を損なわない方法で適用されます。

これが、現在 ClickHouse の軽量な更新を支えている中核のメカニズムです。

これらは ReplacingMergeTree のようなエンジンと同じ原則に基づきながらも、完全に汎用的な形で構築されており、柔軟で標準的な SQL 構文の背後にカプセル化されています。

UPDATE orders
SET discount = 0.2
WHERE quantity >= 40;

その結果、更新は瞬時に反映され、クエリは高速なまま維持され、何もブロックされません。これこそが ClickHouse 流の UPDATE であり、すべて使い慣れた SQL で実現されています。

パート 3 では、これをテストします。
ミューテーション、オンザフライミューテーション、軽量なパッチパートによる更新など、あらゆる更新手法のベンチマークを行い、ClickHouse の標準 SQL UPDATE がどれほど高速になるかを示します。
ネタバレ: 単に速いだけではありません。最大で 1,000 倍、場合によってはそれ以上に高速です。


この記事をシェア

  • Y Combinator icon
  • X icon
  • Bluesky icon
  • Facebook icon
  • LinkedIn icon

Subscribe to our newsletter

Stay informed on feature releases, product roadmap, support, and cloud offerings!

Aditya Chidurala, Bentsi Leviav and Alex Francoeur · 2026年9月17日
Aditya Chidurala, José Muñoz and Alex Francoeur · 2026年9月16日

Follow us

XBlueskySlackGithubTelegramMeetupRSS