HN 日本語サマリー

← 一覧へ戻る
インフラ・DevOps

ミリ秒単位の最適化

Every Millisecond Counts (jordivillar.com)

11 pointsby theanonymousone1 コメント

要約

この記事では、ClickHouseのクエリパフォーマンスを劇的に改善するために、4ヶ月にわたって行われた段階的な最適化の道のりを解説しています。ReplacingMergeTreeエンジンの利用、パーティションキーの変更、ソートキーの並び替え、不要なJOINの削除、そしてアプリケーションレベルでのデータ事前計算といった手法により、1分以上かかっていたクエリをサブ秒レベルにまで高速化しました。

全文翻訳

新しいプロジェクトに参加し、ClickHouseのパフォーマンスを向上させて、最大の顧客が合理的な時間内にクエリを実行できるようにするという明確な目標があると想像してみてください。これは以前にも何度も経験したことです。通常、最も影響が大きいであろう最も高価なクエリを求め、いくつかの簡単な改善策を見つけます。それを実行すると、完了までに1分以上かかります。1 row in set. Elapsed: 85.715 sec. Processed 1.96 billion rows, 198.69 GB Peak memory usage: 23.05 GiB. そのクエリは、並列実行されている12個のクエリのうちの1つで、それぞれが独自のメトリックやスパークラインを計算しています。そして、それらはクライアントがアプリケーションにアクセスしたときに最初に表示される画面にあります。ログイン直後に少なくとも数分間スピナーを見つめているクライアントを想像してみてください。これは、過去4ヶ月間に行った変更、学習、最適化のすべてを記録したもので、そのクエリをサブ秒レイテンシにまで引き下げました。変更はシンプルで段階的でした。どれも巧妙なトリックではなく、基本的な改善が互いに積み重なっただけです。 ReplacingMergeTreeの置き換えは楽しくない セットアップはシンプルで、イベントはKafkaから直接ClickHouseテーブルに書き込まれます。イベントはミュータブルであり、いつでも更新または削除できます1。そのため、過去の履歴を変更するイベントは、新しいイベントと同等かそれ以上の速度で受信されます。まさに、イミュータブルストレージに基づいたデータベースには不向きなことです。私たちはReplacingMergeTreeエンジンを使用せざるを得ず、FINALを多用しています。そこにほとんどの時間が費やされます。さらに、クライアントのイベント履歴全体を調べる必要があるメトリックがいくつかあります。履歴は常に増加しており、クライアントは毎日アクティビティを行い、イベントを生成するためです。このため、この記事の一部の数値は変動する可能性があります。クエリを200GBから150GBに改善した最適化は、次の最適化と次の最適化の間に受信されたデータ量のために、後で210GBを読み込んでいるように見えるかもしれません2。 奇妙なパーティションキー テーブルは当初、月ごとにパーティション化されていました: PARTITION BY toYYYYMM(event_created_at) これは明白な選択ですが、このケースでは多くの点で間違っていました。イベントはバッチで到着し、それらのバッチはあらゆる時点に影響を与えます。単一のバッチには複数の月のイベントが含まれ、挿入ごとに多くのファイルが作成され、通常の雪だるま効果を引き起こします。つまり、リアルタイムクエリが読み取る必要があるファイルの数が増加し、マージに圧力がかかります。また、FINALは行をマージし、その並列性はキーがパーティションにどのように分散され、どのように重複するかによって大きく異なります。最悪のシナリオは、単一のスレッドが最終マージを行い、多くの時間を費やすことです。時間によるパーティション化は、クライアントのイベントがテーブルのすべてのパーティションに分散されていることを意味するため、ClickHouseはパイプラインの構築と範囲の分割にかなりの時間を費やす必要があり、交差を減らします。FINALが単一のスレッドで実行される原因となるこのパーティション化のパフォーマンスへの影響の例: FINALパフォーマンス(単一スレッド使用時) FINALパフォーマンス(適切な並列性使用時) 両方の問題を解決しつつ、パフォーマンスを少し向上させるために、次のパーティション戦略を適用しました: PARTITION BY (client_customer_id % 36) client_customer_idはクライアントのIDではなく、クライアントのクライアントIDであり、クエリではこの列を使用しません。この戦略は直感に反しますが3、私たちのユースケースではうまく機能します。 - 挿入ごとに「わずか」36個のパートを作成することで、挿入パフォーマンスを制御下に置きます。 - パーティションは均等に分散されます。巨大なパーティションにクジラクライアントが含まれておらず、同じパーティションに着地したすべてのクライアントのクエリを遅くすることはありません。 - ClickHouseが範囲の交差を計算する必要なしに、FINALの並列性を確保します。これは、split_parts_ranges_into_intersecting_and_non_intersecting_finalおよびsplit_intersecting_parts_ranges_into_layers_finalを無効にできることを意味し、データパイプライン構築ステップでさらに数秒を節約できます。 - client_customer_idでフィルタリングすることは決してないため、パーティションプルーニングを完全に放棄します。コストはかかりますが、クライアントの履歴全体を読み取る必要があるメトリックには影響しません。月ごとのパーティション化も何もプルーニングしていませんでした。そして前述のように、並列性で多くのものを得ます。 Order By のわずかな変更 各メトリックに各イベントタイプがどのように貢献しているかを確認したところ、いくつかのイベントタイプが貢献していないにもかかわらず、それらを読み込んで処理していることがわかりました。それらはテーブルの行の約16%でした。それほど大きな違いではありませんが、クエリパフォーマンスを向上させることができる明白な変更です。イベントタイプをより前の位置に昇格させることはALTERではありません。ソートキーはテーブル作成時に固定されるため、これは新しいテーブルと完全な書き直しを意味しました。イベントタイプはすでにキーに含まれており、その位置だけが移動したため、動作は同じままで、ReplacingMergeTreeは以前と同じ行をマージしました。変更後、クエリパフォーマンスに顕著な改善が見られました: Before4 1 row in set. Elapsed: 13.43 sec. Processed 256.67 million rows, 18.82 GiB Peak memory usage: 18.78 GiB. After 1 row in set. Elapsed: 11.44 sec. Processed 237.86 million rows, 15.67 GiB Peak memory usage: 14.04 GiB. クエリレイテンシは約15%向上し、読み取る行数は約7%減少しました。これは、完全な約16%が見られない理由を説明しています。ソートキーはグラニュールをフィルタリングし、グラニュールに興味のあるイベントタイプの行が1つでも含まれている場合、そのグラニュールから残りの行を読み取る必要があるためです。 高価なJOINの削除 クエリにはいくつかの高価なJOINがありましたが、それらのほとんどは、JOINを削除せずに実際のテーブルをスキップできるようにするトリックの背後にありました。右側はパラメータでゲートされたサブクエリであるため、ブランチが必要ない場合、フィルタはfalseになり、何も返しません: ANY LEFT JOIN ( SELECT ... FROM table_2 WHERE {has_currency:Boolean} = 1 AND ... ) USING ... これを実行すると数ミリ秒かかり、解析と計画にさらに数ミリ秒かかります。それがテンプレートセクションについてです。しかし、すべての呼び出しで実際のテーブルに対して実行されていたJOINがありました。そのJOINは、メトリックを計算するために使用される中間値をいくつか取得するだけでした。ANY LEFT JOIN ( SELECT client_id, internal_id, latest_a_field, latest_another_field FROM table_derived_from_original_table FINAL PREWHERE client_id IN {client_ids:Array(UInt64)} ) USING client_id, internal_id 2つのフィールドの最新値を取得するために、元のテーブルから派生したテーブルに対して2回目のFINALを実行していました。ClickHouseは右側のハッシュテーブルを構築し、メモリに配置します。JOINは比較的高速ですが、コストは読み取りだけでなくメモリフットプリントにもあります。修正は見た目よりも簡単でした。これらの2つの値で計算された中間列は、アプリケーションレベルで事前計算され、イベントの一部として送信できます。クエリは現在よりシンプルになり、JOINは完全に削除できます。 1 row in set. Elapsed: 2.46 sec. Processed 49.86 million rows, 5.60 GiB Peak memory usage: 1.09 GiB メモリは約13倍減少し、クエリ時間は約5倍改善しました。 より少ない列の読み取り アプリケーションレベルで列を事前計算することが、多くのメモリとCPUを節約できることを確認しました。なぜそこで止まる必要があるのでしょうか?メトリックの中には、クライアントの履歴全体を読み取る必要があるものがあり、9つの列を使用していました。行ベースのデータベースでは大きな問題ではありませんが、ClickHouseはカラムナーであり、多数の列を読み取るということは、回避できたはずの追加データにアクセスすることを意味します。各行のメトリックへの貢献度をアプリケーションレベルで事前計算した後5、その影響は明らかでした。 1 row in set. Elapsed: 1.38 s. Processed 49.69 million rows, 3.46 GiB Peak memory usage: 582.40 MiB 9つの列は、行あたり約38バイトでした。それらを8バイトの列に置き換えるだけで、1.5GBの読み取りが節約されます。読み取る追加ファイル、インデックス、マーク、および保存するシークを考慮してください。そして、さらに約2倍の改善が得られます。テンプレート この時点で、13.43秒から...