Query Cache
はじめに
分析ワークロードでは、変更されていないデータに対して同じ集約クエリが繰り返し実行されることがよくあります。例えば:
SELECT region, SUM(revenue) FROM orders WHERE dt = '2024-01-01' GROUP BY region;
SELECT region, SUM(revenue) FROM orders WHERE dt = '2024-01-01' GROUP BY region;
各実行では、同一のタブレットを再スキャンし、同一の集約結果を再計算するため、CPUとI/Oリソースが無駄になります。
これを解決するために、Apache DorisはQuery Cacheメカニズムを提供しています。パイプライン実行エンジン内で生成された中間集約結果をキャッシュし、同一の実行コンテキストを共有する後続のクエリに直接提供することで、クエリレイテンシを大幅に削減します。
- Query Cacheは内部OLAPテーブルでの集約クエリのみに適用されます。非集約クエリ(プレーンスキャン、結合、ソートなど)はQuery Cacheを使用しません。
- Query Cacheは外部テーブル(Hive、JDBC、Iceberg、Hudi、Paimonなど)では動作しません。
動作原理
適用可能なクエリパターン
Query Cacheは集約クエリ用に設計されています。具体的には、プランツリーが以下のパターンのいずれかと一致するフラグメントのみが対象となります:
AggregationNode → OlapScanNode(スキャンでの直接単一フェーズ集約)AggregationNode → AggregationNode → OlapScanNode(スキャンでの二段階集約)
集約ノードとスキャンノード間には、FilterNodeやProjectNodeなどの中間ノードが許可されます。ただし、プランツリーは、キャッシュ対象のサブツリー内にJoinNode、SortNode、UnionNode、WindowNode、またはExchangeNodeを含んではいけません。
キャッシュキー
キャッシュキーは3つの部分で構成されます:
-
SQL Digest — 正規化されたプランツリー(集約関数、グループ化式、非パーティション フィルタ述語、投影、および結果に影響するセッション変数)から計算されたSHA-256ハッシュ。正規化プロセスでは、すべての内部識別子に標準IDが割り当てられるため、意味的に同一の2つのクエリは、異なる内部プランノード/スロットIDを持つ場合でも、同じダイジェストを生成します。
-
Tablet ID — 現在のパイプラインインスタンスに割り当てられたタブレットIDのソート済みリスト。
-
Tablet Range — パーティション述語から導出される各タブレットの有効スキャン範囲(パーティションとフィルタの動作を参照)。
キャッシュ無効化
以下のいずれかが発生すると、キャッシュエントリは無効になります:
- データ変更: INSERT、DELETE、UPDATE、またはCompactionによりタブレットバージョンがインクリメントされます。次回のクエリ時に、タブレットバージョンがキャッシュされたバージョンと比較され、不一致の場合はキャッシュミスとなります。
- スキーマ変更: ALTER TABLE操作によりテーブル構造が変更され、プランが変更されるため、ダイジェストが変更されます。
- LRU退避: キャッシュメモリが設定制限を超えると、最も最近使用されていないエントリが退避されます。キャッシュはLRU-K(K=2)アルゴリズムを使用し、キャッシュが満杯の場合、新しいエントリがキャッシュに受け入れられるには、少なくとも2回アクセスされる必要があります。
- 期限切れスイープ: 24時間を超えるエントリは、定期的な整理により自動的に削除されます。
- 強制リフレッシュ:
query_cache_force_refresh = trueの場合、キャッシュされた結果は無視され、クエリが再実行されます。
実行フロー
初回実行(キャッシュミス):
- スキャンオペレータは通常通りタブレットからデータを読み取ります。
- 集約オペレータは結果を計算します。
- 結果は下流のコンシューマーに送信され、同時にキャッシュ挿入用に蓄積されます。
- 完了時、蓄積された結果がエントリあたりのサイズ/行制限を超えない場合、結果がキャッシュに挿入されます。
後続実行(キャッシュヒット):
- スキャンオペレータはキャッシュヒットを検出し、スキャン範囲の追加をスキップします — タブレットデータは読み取られません。
- 集約オペレータは何も生成しません(入力データなし)。
- キャッシュソースオペレータが、キャッシュされたブロックを直接提供します。
- 列の順序がキャッシュされたエントリと異なる場合(例:同じダイジェストで
SELECT a, b対SELECT b, a)、列は自動的に並べ替えられます。
パーティションとフィルタの動作
パーティション述語とフィルタ式がQuery Cacheとどのように相互作用するかを理解することは、良好なヒット率を達成するために不可欠です。
パーティション述語
単一列RANGEパーティショニングを持つテーブルでは、パーティション述語は特別な扱いを受けます:
- パーティション述語はダイジェストから抽出されます。代わりに、有効範囲(述語範囲と各パーティションの実際の範囲境界の積集合)が計算され、タブレット範囲文字列としてキャッシュキーに追加されます。
- これにより、パーティションフィルタ範囲のみが異なる2つのクエリは、共通するタブレットについてキャッシュエントリを共有できます。
例:
dtによる日次パーティションを持つテーブルordersを考えます:
-- Query A
SELECT region, SUM(revenue) FROM orders
WHERE dt >= '2024-01-01' AND dt < '2024-01-03' GROUP BY region;
-- Query B
SELECT region, SUM(revenue) FROM orders
WHERE dt >= '2024-01-02' AND dt < '2024-01-04' GROUP BY region;
- Query Aはパーティション
2024-01-01と2024-01-02からタブレットをスキャンします。 - Query Bはパーティション
2024-01-02と2024-01-03からタブレットをスキャンします。 - パーティション
2024-01-02のタブレットは同じdigestと同じタブレット範囲を持つため、Query Bは2024-01-02パーティションに対してQuery Aのキャッシュを再利用できます。パーティション2024-01-03のみ新しく計算する必要があります。
マルチカラムRANGEパーティション、LISTパーティション、またはUNPARTITIONEDテーブルの場合、パーティション述語を抽出できないため、digestに直接含まれます。この場合、パーティション述語のわずかな違いでも異なるdigestが生成され、キャッシュミスが発生します。
非パーティションフィルター式
非パーティションフィルター式(例:WHERE status = 'active')は正規化されたplan digestに含まれます。2つのクエリがキャッシュエントリを共有できるのは、正規化後に非パーティションフィルター式が意味的に同一である場合のみです。
WHERE status = 'active'とWHERE status = 'active'— 同じdigest、キャッシュヒット。WHERE status = 'active'とWHERE status = 'inactive'— 異なるdigest、キャッシュミス。WHERE status = 'active' AND region = 'ASIA'とWHERE region = 'ASIA' AND status = 'active'— 正規化プロセスが連言をソートするため、同じdigestが生成され、キャッシュヒットできます。
セッション変数
クエリ結果に影響するセッション変数(time_zone、sql_mode、sql_select_limitなど)はdigestに含まれます。クエリ間でこれらの変数を変更すると、異なるキャッシュキーが生成され、キャッシュミスが発生します。
Query Cacheを無効にする条件
以下の条件により、plannerはフラグメントに対してQuery Cacheを完全にスキップします:
| 条件 | 理由 |
|---|---|
| フラグメントがランタイムフィルターの対象 | ランタイムフィルター値は動的でplan時に不明。キャッシュすると不正な結果が生成される |
非決定的式(rand()、now()、uuid()、UDFなど) | 同一の入力でも実行毎に結果が変わる |
| プランのキャッシュサブツリーにJOIN、SORT、UNION、WINDOWノードが含まれる | aggregation-over-scanパターンのみサポート |
スキャンノードがOlapScanNodeでない(例:外部テーブルスキャン) | キャッシュはタブレットIDとバージョンに依存するが、外部テーブルには存在しない |
Query Cacheが外部テーブルで動作しない理由
Query Cacheは内部OLAPテーブル固有の3つのプロパティに依存しています:
-
タブレットベースのデータ構成 — キャッシュキーにはタブレットIDとタブレット毎のスキャン範囲が含まれます。外部テーブルは外部システム(HDFS、S3、JDBCなど)にデータを格納し、タブレットの概念がありません。
-
バージョンベースの無効化 — 各内部タブレットには単調増加するバージョン番号があり、データ変更時に変更されます。キャッシュはこのバージョンを使用して古いデータを検出します。外部テーブルはDorisにそのようなバージョニングを公開しません。
-
OlapScanNode要件 — plan正規化ロジックは、aggregationキャッシュポイント下の有効なスキャンノードとして
OlapScanNodeのみを認識します。外部テーブルスキャンノードは認識されません。
外部テーブルでのキャッシュニーズについては、代わりにSQL Cacheの使用を検討してください。
設定
セッション変数(FE)
| パラメータ | 説明 | デフォルト |
|---|---|---|
enable_query_cache | Query Cacheを有効または無効にするマスタースイッチ | false |
query_cache_force_refresh | trueの場合、キャッシュされた結果を無視してクエリを再実行。新しい結果は依然としてキャッシュに書き込まれる | false |
query_cache_entry_max_bytes | 単一キャッシュエントリの最大サイズ(バイト)。aggregation結果がこの制限を超える場合、そのフラグメントのキャッシュは放棄される | 5242880 (5 MB) |
query_cache_entry_max_rows | 単一キャッシュエントリの最大行数。aggregation結果がこの制限を超える場合、そのフラグメントのキャッシュは放棄される | 500000 |
BE設定(be.conf)
| パラメータ | 説明 | デフォルト |
|---|---|---|
query_cache_size | 各BE上のQuery Cacheの総メモリ容量(MB) | 512 |
be.confのパラメータquery_cache_max_size_mbとquery_cache_elasticity_size_mbは、ここで説明しているパイプラインレベルのQuery Cacheではなく、古いSQL Result Cacheを制御します。両者を混同しないでください。
使用例
Query Cacheを有効にする
SET enable_query_cache = true;
典型的なシナリオ
-- First execution: cache miss, results are computed and cached
SELECT region, SUM(revenue), COUNT(*)
FROM orders
WHERE dt = '2024-01-15' AND status = 'completed'
GROUP BY region;
-- Second execution: cache hit, results are served directly from cache
SELECT region, SUM(revenue), COUNT(*)
FROM orders
WHERE dt = '2024-01-15' AND status = 'completed'
GROUP BY region;
Profile でのキャッシュヒットの確認
クエリを実行した後、クエリ profile を確認してください。CacheSourceOperator セクションを探します:
HitCache: true— クエリはキャッシュから結果を提供しました。HitCache: false,InsertCache: true— クエリはキャッシュをミスしましたが、結果の挿入に成功しました。HitCache: false,InsertCache: false— クエリはキャッシュをミスし、結果が大きすぎてキャッシュできませんでした。
Profile には、どの tablet が関与したかを示す CacheTabletId も表示されます。
強制更新
-- Force the next query to bypass cache and re-compute results
SET query_cache_force_refresh = true;
SELECT region, SUM(revenue) FROM orders WHERE dt = '2024-01-15' GROUP BY region;
-- Reset
SET query_cache_force_refresh = false;
適用シナリオ
Query Cacheは以下のケースで最も効果的です:
- 繰り返し集計クエリ: ダッシュボードクエリ、レポートクエリ、または同じ集計SQLを繰り返し発行するBIツール。
- T+1レポート: データは1日1回ロードされ、その後の同日のクエリはキャッシュにヒットします。
- 重複する範囲を持つパーティションベースクエリ: 重複する日付範囲でのクエリは、パーティション/tabletレベルでキャッシュエントリを部分的に共有できます。
Query Cacheは以下には適していません:
- 非集計クエリ: 単純なSELECTスキャン、JOIN、SORT、WINDOW関数。
- 外部テーブル: Hive、JDBC、Iceberg、Hudi、Paimonなど。
- 頻繁に更新されるテーブル: 高い取り込み率によりtabletバージョンが急速に変化し、キャッシュヒット率が低下します。
- 非決定的関数を含むクエリ:
now()、rand()、uuid()、およびUDFはキャッシュを無効にします。 - ランタイムフィルターに依存するクエリ: スキャンフラグメントのランタイムフィルターを生成するJOINは、そのフラグメントでのキャッシュを無効にします。
注意事項
- キャッシュは永続化されません: Query CacheはBEメモリに存在し、BE再起動時にクリアされます。
- メモリ消費: キャッシュされたブロックはBEメモリを消費します。使用量を監視し、必要に応じて
query_cache_sizeを調整してください。 - LRU-K許可: キャッシュが満杯の場合、新しいエントリは少なくとも2回アクセスされないと許可されません(K=2のLRU-K)。これにより、低頻度のクエリがキャッシュを汚染することを防ぎます。
まとめ
Query CacheはDorisにおけるパイプラインレベルの最適化メカニズムで、tabletごとに中間集計結果をキャッシュします。主な特徴:
- 内部OLAPテーブルでの集計クエリのみに適用
- 自動キャッシュ無効化にtabletバージョンを使用
- パーティション述語をキャッシュダイジェストから知的に分離し、重複するパーティション範囲を持つクエリ間でのキャッシュ共有を可能にする
- エントリごとのサイズと行制限により、過大な結果がキャッシュメモリを消費することを防ぐ
- LRU-K排出を使用して高品質なキャッシュを維持