TT Lab
はじめる
学ぶ 学習パス コース

ClickHouse — 列指向分析 DB を中身から

SummingMergeTree と AggregatingMergeTree — マージが集計を引き継ぐ

TT Labで続きを見る

一言でいうと

2つのエンジンは、ソートキーが同じ行をマージのときに1つに畳みながら、値をまとめます。合計で減らせる値(件数・合計)は、SummingMergeTreeがそのまま足し、合計で減らせない値(異なるユーザー数・平均)は、AggregatingMergeTreeが数値の代わりに集計の中間状態を持ち歩きます。マージがいつ終わるかはわからないので、クエリはいつも、GROUP BYとsum()・-Mergeで仕上げます。

なぜ必要なのか

ダッシュボードが「サイトごとの1日の訪問数」を表示するとします。元のイベントが1日に数億行あるなら、画面を開くたびに元データを全部走査するのは無駄です。答えは1日に数百行あれば足りるのに。そこで、あらかじめ集計しておきます。問題は、データが入り続けることです。今日の午前の集計を入れておいたのに、午後のデータが来たら、集計行を探して直す必要があります。ところが前のモジュールで見たとおり、MergeTreeのパートは書き換えません。

ReplacingMergeTreeが「新しいバージョンを追記し、マージのときに古いものを捨てる」だったとすれば、この2つのエンジンは「新しい断片を追記し、マージのときに足す」です。午後の集計をもう1行入れるだけで、同じキーの行がマージで1つにまとまります。書き込みは、相変わらず追記だけです。

どう動くのか

api.exampleの1日分が3回のINSERTで入ってきて、SummingMergeTreeでは、ビュー数2194・2198・2213の3行が、マージかsum()で6605になります。AggregatingMergeTreeでは、3行がユーザーの集合という状態を持っているので、それぞれ仕上げてから足すと5322人になって重なった人を二重に数えますが、uniqExactMergeでまとめてから仕上げると、元データと同じ3671人になります。この状態は、uniqExactMergeStateで日単位にもう一度まとめられます

SummingMergeTreeについて、リファレンスドキュメントのルールは短いです。ソートキーが同じ行を、数値型の列の値を足した1行に置き換えます。足す列をエンジンの引数に書かないと、ソートキーにないすべての数値列を足します。足す列がすべて0になった行は削除します。足さない列は、あった値のうち任意のものが残ります。

この「すべての数値列」が落とし穴です。ラボのサーバーで(k, a Int32, mx UInt32)のテーブルに(1, 5, 10)と(1, −5, 20)を別々に入れてまとめたら、(1, 0, 30)になりました。最大値として入れたmxが足されて30になり、aが0になってもmxが0ではないため、行は残りました。最大値が必要なら、AggregatingMergeTreeのSimpleAggregateFunction(max, UInt32)の列を使います(同じ2行が20にまとめられました)。

ラボのデータ(100万行、サイト5個×30日=150個のキー)を時間帯ごとに3回入れてマージを止めると、集計テーブルは450行になります。ドキュメントが「合算が完全ではないことがあるので、クエリにはsum()とGROUP BYを使うように」と述べる理由が、この状態です。SELECT *はキーごとに3行を返し、GROUP BY site, dayで足し直せば、マージされたかどうかに関係なく、元データとまったく同じになります。

AggregatingMergeTreeについて、異なるユーザー数は足せません。午前に来た1,772人と午後に来た1,771人の間に、同じ人がいるからです。そこで、数値の代わりに状態を保存します。コンビネーターのドキュメントの表現では、-Stateは結果の値ではなく「集計の中間状態(uniqならハッシュテーブル)」を返し、-Mergeは状態をまとめて結果の値を出します。

users AggregateFunction(uniqExact, UInt64)      -- 열 타입: 어떤 함수의 상태인가
INSERT ... SELECT uniqExactState(user_id) ...    -- 넣을 때 -State
SELECT uniqExactMerge(users) ... GROUP BY ...    -- 읽을 때 -Merge

-Ifは条件を付けます(countIfState(is_bot = 0)の型はAggregateFunction(countIf, UInt8)でした)。平均も状態として持ちます。avgStateは合計と個数を持っているので、まとめてから割ると、元データの平均とちょうど同じになります。状態は人間が読める値ではないので、JSONで取り出すと、バイナリのバイトが出力されます。

最大の利点は、状態をもう一度まとめられることです。-MergeStateは、状態をまとめて、結果の値ではなく、また状態を返します。サイトごとの日次の状態を日付ごとの状態に畳めるので、数値だけを残していたら不可能な「サイトをまとめた日次の訪問者」を、元データなしで求められます。

現場での姿

最もよくあるバグは、行ごとに仕上げた数値を足してしまうことです。ラボで、マージ前の450個の状態の行をfinalizeAggregationでそれぞれ仕上げて足すと807,524、キーごとにまとめてから仕上げて足すと552,634でした。ダッシュボードの「日次ユーザーの合計」が異様に大きいなら、この間違いを疑います。平均も同じです。平均の平均は、平均ではありません。

2番目は、SummingMergeTreeに集計してはいけない数値列(最大値、識別子、比率)が混ざってしまうことです。ドキュメントは、元データ全体はMergeTreeに置き、SummingMergeTreeは集計用にだけ使うよう勧めています。ソートキーを誤って選んでも、元データから作り直せるようにするためです。

3番目は、このテーブルを誰が埋めるかです。ラボではINSERT ... SELECTを手で3回実行しますが、現場では、元データにINSERTが入るたびに自動で集計を入れる、マテリアライズドビューと組み合わせます。それが次のモジュールです。もう1つあります。1回のINSERTの中に同じキーが何度もあると、入れた瞬間にまとめられます(optimize_on_insert)。100万行をhits = 1で1回に入れたら、すぐに150行になりました。

次のラボですること

元データのagg.hitsに100万行を入れ、(site, day)の集計をSummingMergeTreeに時間帯ごとに3回に分けて入れて、マージ前の行数を確認します。GROUP BY+sum()のクエリがマージ前でも元データと同じになるかを突き合わせてから、自分でまとめてみます。続いて、異なるユーザー数・平均・人間だけを数えたビュー数を-StateでAggregatingMergeTreeに入れて-Mergeで仕上げ、行ごとに仕上げて足した値がどれだけ間違うかを記録します。最後に、日次の状態を-MergeStateで日付単位にもう一度まとめます。