株式会社COMPASS
🗂

BigQuery の dry run と実際のスキャン量が異なるケースまとめ

に公開

記事の内容

BigQuery の dry run は実行前にクエリスキャン量を測定することができます。
ただし様々なケースで dry run で提示されるスキャン量と実際のスキャン量が異なります。
この記事では dry run と実際のスキャン量が乖離しているケースを検証してまとめました。

記事を書いた動機

最近 AI エージェントに BigQuery を使ったデータ分析を任せようとしているのですが、高コストなクエリを連投されると困るため、コストに応じて処理を分岐することを考えています。
その時に dry run を使用することでクエリを実行する前にある程度コストを予測することができます。
しかし dry run と実際のコストが乖離する場合があることを経験しており、これを機に一通り調べておきたいと思い、ついでに調べた内容を記事として公開することにしました。

dry run とは

簡単に dry run の機能を紹介します。

BigQuery の公式ドキュメントによると次のとおりです。

  • オンデマンド モードでの料金の見積もり
  • クエリの検証
  • キャパシティ モードでクエリによって処理されたおおよそのバイト数

ドライランはクエリスロットを使用しないため、ドライランの実行に対しては課金されません。ドライランによって返された見積もりを料金計算ツールで使用すると、クエリの費用を計算できます。

このようにクエリを実行する前にクエリを実行できるか検証したり、料金を見積もることができるため、AI の生成したクエリを検証するのにもってこいの機能です。

dry run は BigQuery コンソールで このクエリを実行すると、◯◯ B が処理されます。 と表示される他、 bq コマンドや各プログラミング言語のクライアントライブラリから実行することが可能です。

下準備

まずはデータセットを作ります。

- データセット作成
CREATE SCHEMA IF NOT EXISTS `verification-475911.dryrun_test`
OPTIONS(
  location = 'asia-northeast1',
  default_partition_expiration_days = NULL
);

続いてサンプルの基本となるテーブルを作成します。
後ほど検証に使用するため、パーティションのあるクラスタリングテーブルにします。

-- サンプルとしてクラスタリングテーブル作成
CREATE OR REPLACE TABLE
  `verification-475911.dryrun_test.partitioned_clustered_table`
PARTITION BY
  usage_date
CLUSTER BY category_id
OPTIONS (partition_expiration_days = NULL) AS
SELECT DATE(TIMESTAMP_ADD(TIMESTAMP '2025-01-01 00:00:00', INTERVAL x SECOND)) AS usage_date,
  TIMESTAMP_ADD(TIMESTAMP '2025-01-01 00:00:00', INTERVAL x SECOND) AS usage_timestamp,
  MOD(x, 5) AS category_id,
  CONCAT('value_', CAST(MOD(x,100) AS STRING)) AS value_text FROM UNNEST(GENERATE_ARRAY(1, 1000000)) AS x 
CROSS JOIN (
  SELECT
    y
  FROM
    UNNEST(GENERATE_ARRAY(1, 100)) AS y ) 
 ;

これで1億行の 3GB くらいのテーブルが出来上がりました。

テーブルの概要

プレビューを見るとこんな感じです。
テーブルのプレビュー

usage_date にパーティションを、category_id にクラスタリングを入れています。

この後も必要なサンプルがあれば都度テーブル等を作成します。

なお、スクショでプロジェクトIDが書いてあったり隠されたりしてますが検証後プロジェクトは削除したので書いてあっても問題ありません。

dry run > 実際

クラスタ化テーブル

公式ドキュメントのクラスタ化テーブルの概要に次の記載があります。

クラスタ化テーブルに対してクエリを実行する場合、クエリを実行する前はスキャンされるストレージ ブロック数が不明なため、正確なクエリ費用の見積りを得られません。最終的な費用は、クエリの完了後に、スキャンされた特定のストレージ ブロックに基づいて求められます。

上記の通り実際の見積もりが得られません。

ということで実際にクエリを発行してみます。

SELECT
  *
FROM
  `verification-475911.dryrun_test.partitioned_clustered_table`
WHERE
  category_id = 1

dry run では全量スキャンになっています。

実際には1つのクラスタのみが課金され5分の1程度になっています。

数分経過後に再度クエリを実行すると、dry runの結果にクラスタリングが考慮されるようになります。

存在しないクラスタを指定しようとすると 0B になります。

category_id = 1 に対してもともとこのクラスタに入っていたのと同じ量のデータを入れてみます。

INSERT INTO
  `verification-475911.dryrun_test.partitioned_clustered_table`
  SELECT
  DATE(TIMESTAMP_ADD(TIMESTAMP '2025-01-01 00:00:00', INTERVAL x SECOND)) AS usage_date,
  TIMESTAMP_ADD(TIMESTAMP '2025-01-01 00:00:00', INTERVAL x SECOND) AS usage_timestamp,
  MOD(x, 5) AS category_id,
  CONCAT('value_', CAST(MOD(x,100) AS STRING)) AS value_text
FROM
  UNNEST(GENERATE_ARRAY(1, 1000000)) AS x
CROSS JOIN (
  SELECT
    y
  FROM
    UNNEST(GENERATE_ARRAY(1, 100)) AS y )
WHERE MOD(x, 5) = 1 ;

この直後に dry run で category_id = 2 を見てみると、もともと category_id = 2 に入っていたデータ量 + category_id = 1 に入れたデータ量が表示されています。

存在しない category_id を指定すると、先程 category_id = 1 に入れたデータ量が表示されています。

しばらく時間をおいてから再度見ると実際の値が表示されています。

おそらく公式ドキュメントのBigQuery の内部: 列メタデータ インデックス(CMETA)の力に記載しているようなことが関係しているのではないかと思います。

INNER JOIN でパーティション列が絞り込まれる

INNER JOIN でパーティション列が絞り込まれる場合、パーティションが効いて実際のスキャン量は減ります。

しかし dry run ではそれが反映されません。

-- INNER JOIN でパーティション列で絞り込むためのテーブルを作成する
CREATE OR REPLACE TABLE `verification-475911.dryrun_test.join_right_small`
AS
SELECT
  x AS category_id,
  DATE_ADD(DATE('2025-01-01'), INTERVAL x DAY) AS usage_date
FROM
  UNNEST(GENERATE_ARRAY(0, 4)) AS x;
-- INNER JOIN でパーティション列で絞り込んだクエリ
SELECT
  *
FROM
  `verification-475911.dryrun_test.partitioned_clustered_table` AS t1
INNER JOIN 
   `verification-475911.dryrun_test.join_right_small` AS t2
USING(usage_date)
;

dry run では全量分が表示されています。
INNER JOIN でパーティション列で絞り込んだクエリの dry run

実際にはパーティション列で絞られている分はコストがかかっていません。
INNER JOIN でパーティション列で絞り込んだクエリの実際の結果

キャッシュが効いている

一度次のクエリを実行しておいて、もう一度実行しようとすると dry run はキャッシュを考慮せずに見積もりを出しますが、実際には課金されるバイト数は 0 B になります。

SELECT
  *
FROM
  `verification-475911.dryrun_test.partitioned_clustered_table`;

dry run < 実際

dry run よりも実際のコストのほうが高いので特に注意が必要です。

外部テーブル

外部テーブルに対しては 0 B と推定されますが実際には課金されます。

公式ドキュメントのクエリの実行のドライランにも次の記載があります。

注: 外部データソースを使用する連携クエリのドライランで、行が返されても 0 バイトの下限が報告される場合があります。これは、実際のクエリが完了するまで、外部テーブルで処理されるデータの量が確定できないためです。連携クエリを実行すると、このデータの処理に引き続き費用がかかります。

適当なCSVファイルを作ります。

usage_date,category_id,user_id,value
2025-01-01,1,u001,100
2025-01-02,2,u002,200
2025-01-03,1,u003,300

外部テーブルを作成します

CREATE OR REPLACE EXTERNAL TABLE `verification-475911.dryrun_test.external_csv_example`
OPTIONS (
  format = 'CSV',
  uris = ['gs://verification-dryrun-test/sample.csv'],
  skip_leading_rows = 1
);

クエリを実行します。

SELECT
  *
FROM
  `verification-475911.dryrun_test.external_csv_example`
;

0 Bと表示されました。

dry run の結果

実際には課金されます。

実行結果

DDL ステートメントを使用している

DDL ステートメントを使用しているとその後は推定が実行されません。
一例として以下のように途中に CREATE TEMP TABLE 実行するクエリを試します。

SELECT
  *
FROM
  `verification-475911.dryrun_test.partitioned_clustered_table`;

SELECT
  *
FROM
  `verification-475911.dryrun_test.partitioned_clustered_table`;

CREATE TEMP TABLE `temp_table`(num INT64) AS
SELECT
  1 AS num;

SELECT
  *
FROM
  `verification-475911.dryrun_test.partitioned_clustered_table`;

本来であれば3回呼び出しているので10GB程度のスキャンが想定されます(キャッシュの考慮なし)が、dry run では 7 GB 程度になっており、 CREATE TEMP TABLE 後は dry run が走っていないことがわかります。

他にも公式ドキュメントのマルチステートメント クエリのドライランにはDMLを使用したケースやCALLステートメントを使用した場合などのケースが記載されています。ぜひご確認ください。

終わりに

今回検証した以外にも dry run と実際が乖離しているケースはあると思います。
dry run の結果だけでクエリ利用量を完全にコントロールすることはできないため、BigQuery の API 制限をかけたり定期的に実際の利用量を確認することが重要です。

他にも dry run と実際が乖離するケースがあればぜひコメント下さい。

株式会社COMPASS
株式会社COMPASS

Discussion