増分マテリアライズドビュー — 保存されたクエリではなく INSERT トリガー
一言でいうと
ClickHouseの(増分)マテリアライズドビューは、結果を保存しておくスナップショットではなく、ソーステーブルにINSERTが入るたびに、入ってきたブロックだけにSELECTを実行して、結果をターゲットテーブルに追加するトリガーです。ビューができる前の行、右側のJOINテーブルの変化、ソースのUPDATE・DELETEは、ビューには見えません。
なぜ必要なのか
前のモジュールで、SummingMergeTreeとAggregatingMergeTreeがマージのときに行をまとめることを見ました。ところが、毎日たまるソースログについて、ダッシュボードが「店舗ごとの日次売上」を毎秒尋ねるとしたら、そのたびにソースの数億行を集計し直すのは無駄です。計算をクエリ実行時から挿入時へ移したいのです。公式ドキュメントがマテリアライズドビューの動機として挙げる文が、まさにこれです。
PostgreSQLのマテリアライズドビューは、REFRESH MATERIALIZED VIEWを実行したときにクエリ全体を再実行するスナップショットです。ClickHouseは別の道を選びました。ソースが数十億行でも、コストが「新しく入ったブロックのサイズ」にだけ比例するように、ビューをINSERTの経路にかけたトリガーにしたのです。代償ははっきりしています。ビューは自分が見たブロックしか知りません。この限界を知らずに使うと、数値が黙って間違います。
どう動くのか
CREATE MATERIALIZED VIEW mv.daily_sales_mv TO mv.daily_sales AS
SELECT toDate(ts) AS day, shop, count() AS orders, sum(qty * price) AS revenue
FROM mv.orders GROUP BY day, shop;
TOは、結果を送るテーブルです。CREATE VIEWのドキュメントは、動作を1文で述べています。ソーステーブルにINSERTするとき、挿入されたデータの一部がこのSELECTで変換されて、ビューに入ります。そしてGROUP BYがあっても、挿入されたブロック1つの中でだけ集計され、それ以上はまとめられません。このラボのPodで、20万行を1回のINSERTで入れたら、ターゲットテーブルには(店舗8×日付15)=120行ができました。何回も入れると、同じ(shop, day)が何行にもなります。実測では、キー240個に対して行が720行ありました。そのため、ターゲットテーブルは、同じキーをマージのときにまとめてくれるエンジン(SummingMergeTree・AggregatingMergeTree)にし、ソートキーをビューのGROUP BYに合わせ、クエリでも必ずsum()でもう一度足します。マージがいつ起きるかは、決まっていないからです。
ビューの生涯で見落としやすい3つのこと:
| 状況 | ビューの動作 |
|---|---|
| ビューを作る前にソースにあった行 | 何もしません。ターゲットテーブルは0行から始まります |
| ソースのALTER UPDATE・DELETE、DROP PARTITION | ターゲットテーブルを修正しません |
| JOINの右側のテーブルに行が追加される | ビューは動きません。トリガーになるのは、一番左のテーブルだけです |
1つ目の欄を埋める作業を、バックフィル(backfill)と呼びます。方法は2つです。POPULATEを付けて作るか、ビューを作ったあとに同じSELECTでINSERT INTO 대상 SELECT ... FROM 원천 WHERE (뷰가 못 본 구간)を直接実行します(プレースホルダーは、ターゲットテーブル名、ソーステーブル名、ビューが見ていない区間です)。26.8のドキュメントによると、通常のCREATEのPOPULATEは、現在はソースに短い排他ロックをかけて、同時INSERTを1回ずつだけ通します(設定materialized_views_populate_atomicallyのデフォルトは1で、Podで確認しました)。それでも落とし穴は残ります。TOテーブルにすでに行があると、バックフィルした行が追加されてしまい、失敗したCREATEをもう一度実行すると、すでに入った行がまた入り、CREATE OR REPLACEとReplicatedデータベースでは、古い非アトミックな方式になるか、そもそも禁止されます。そのため、現場では、たいてい区間を決めて直接INSERTします。核心は、「ビューがすでに見た区間を、もう一度入れないこと」です。
加算できない値は、結果の代わりに中間状態を保存します。毎日数えたユニークユーザー数を足すと、複数日に来た人が重複して数えられます。uniqExactState(user_id)で状態をAggregateFunction(uniqExact, UInt32)の列に入れ、クエリ時にuniqExactMergeでまとめれば、ソースを数え直したのと同じになります。
カスケードもできます。ターゲットテーブルもテーブルなので、そこにINSERTが入ると、そのテーブルをソースにしたビューがまた動きます。生データを保存したくないときは、ソースをENGINE = Nullにします。Nullテーブルは何も保存しませんが、ビューは起こします。入口としてだけ使うテーブルです。
JOINは、公式ドキュメントが別に警告しています。一番左のテーブルが挿入されたブロックに置き換えられ、右側のテーブルはまるごと読まれます。そのため、注文が商品より先に入ると、INNER JOINはその注文を捨て、商品があとから入っても復活させません。1つのソースにビューが複数あると、デフォルト(parallel_view_processing = 0)では、ビューのuuidの順に1つずつ動きます。
定期的にクエリ全体を再実行するリフレッシュビュー(REFRESH EVERY 1 HOUR)もあります。INSERTのトリガーがなく、JOINやUNIONに制約がない代わりに、結果が最後のリフレッシュ時点の分だけ遅れます。時刻によって結果が変わるので、このラボでは扱いません。
現場での姿
最もよくある事故は、「ビューをデプロイしたら、ダッシュボードの先月の数値が空だった」です。ビューはデプロイの瞬間からのINSERTしか見ないので、バックフィルが抜けていたのです。急いでバックフィルしてデプロイ後の区間まで入れてしまい、直近数日が2倍になるのが、2番目の事故です。バックフィルするときは、境界の時刻を決めて、その手前だけを入れます。
3番目は、ディメンションテーブルのJOINです。注文に商品カテゴリを付けるビューを作ったのに、新商品の注文がカテゴリ集計から消えます。商品マスターが注文より遅れて同期される日は、いつも黙って抜けます。ドキュメントが勧める道は、JOINをクエリ実行時に遅らせるか、ディクショナリやリフレッシュビューを使うことです。
4番目は、ターゲットテーブルをSELECT orders FROM daily_sales WHERE ...のように足さずに読むコードです。マージが終わった日は正しく、INSERTが入ったばかりの日は2行が出て間違います。再現できないバグになります。
次のラボですること
mv.ordersに1回目の分として20万件を入れ、SummingMergeTreeのターゲットとビューを作ります。2回目の分を入れて、ソースとターゲットの差を記録し、1回目の分だけをバックフィルして、2つのテーブルを一致させます。ターゲットテーブルをsumで読むクエリを書き、AggregatingMergeTreeにユニーク購入者の状態を入れます。Nullテーブルからpaidの注文だけを流してカスケードを確認し、最後に、商品テーブルが遅れて埋まるときにJOINビューが失う注文数を数えます。