
料金モデルの全体像 — オンデマンド vs Editions
BigQueryの請求は大きくコンピュート(クエリ処理)とストレージの2軸です。 実務の体感ではコンピュートが総額の85〜90%を占めるケースが多く、 「なぜか突然請求が跳ねた」の9割はクエリ側の事故です。まずは2026年時点の料金モデルを整理します。
コンピュートの2モデル
| モデル | 課金単位 | 単価(2026年、US / asia-northeast1 は+約20%) | 向き/不向き |
|---|---|---|---|
| On-Demand | スキャンTiB | $6.25 / TiB(毎月1 TiB無料) | 変動の大きい/軽いワークロード、PoC、EC中規模まで |
| Editions (Standard) | slot-hour | $0.04 / slot-hour(Pay-as-you-go) | アドホック・開発・小規模スケジュールクエリ |
| Editions (Enterprise) | slot-hour | $0.06 / slot-hour(PAYG) | 本番ワークロード・BI Engine・99.99% SLO |
| Editions (Enterprise Plus) | slot-hour | $0.10 / slot-hour(PAYG) | ミッションクリティカル、Assured Workloads、CMEK等 |
Editionsは1年コミットで20%オフ、3年コミットで40%オフ。 スロットは1分単位・autoscale対応で、アイドルなら自動で0にスケールダウンします。 旧Flat-rate/Flexの後継で、2023年のGA以降このモデルに統一されました。
ストレージの2モデル
- Logical storage(デフォルト) — 論理バイト数で課金。Active $0.02/GB/月、Long-term(90日未変更)$0.01/GB/月。
- Physical storage(オプション) — 実ディスクバイト(圧縮後)で課金。Active $0.04/GB/月、Long-term $0.02/GB/月。 圧縮率が高いテーブルは物理課金のほうが安くなるケースが多い。ただし
time_travel_physical_bytesとfail_safe_physical_bytesも課金対象になる点に注意。
出典: Google Cloud BigQuery Pricing / BigQuery Editions 公式ドキュメント
スロット予約の判断基準(月間スキャンTBの目安)
「On-Demand と Editions、どちらが得か?」は月間スキャンTB × クエリの山谷で決まります。 単純化した損益分岐は次の通りです。
| 月間スキャン量 | On-Demand 月額 | Enterprise 100 slot autoscale 概算 | 推奨 |
|---|---|---|---|
| 10 TiB | 約 $56 | 最小課金でも約 $45〜 | On-Demand |
| 50 TiB | 約 $306 | 約 $300〜400(稼働率次第) | ちょうど分岐点。混在運用がおすすめ |
| 200 TiB | 約 $1,244 | 約 $600〜900 | Editions(+コミット割引でさらに縮小) |
| 1 PiB | 約 $6,400 | 約 $2,000〜3,500 | Editions + 1yr commit |
ざっくり月間スキャンが50 TiB前後を安定して超えるようになったら Editions 検討。 さらに、On-Demand には「1プロジェクトあたり最大2,000スロット」という同時実行の上限があり、 BI/Looker Studioの同時アクセスが増えてくると「急にクエリが詰まる」症状が出ます。 そのタイミングも乗り換えシグナルです。
一方、開発プロジェクトは On-Demand、本番は Editionsと分けるのがコスト上も運用上も王道です。 Editionsはプロジェクトではなく「予約」という単位でスロットを買い、assignmentsで複数プロジェクトに割り当てる構成が可能です。
パーティション戦略と4,000パーティション制限
パーティションは物理的にファイルを分割して保存する仕組みで、WHERE句で該当パーティションだけをスキャンできれば費用は1/365にも下がります。 しかし設計を誤ると、恩恵を受けられないどころか「小さすぎるパーティションが大量発生 → メタデータ肥大 → 逆に遅くなる」罠に嵌ります。
パーティションの4種類
- Ingestion time — 取り込み時刻ベース。疑似列
_PARTITIONTIME/_PARTITIONDATE。 - TIMESTAMP/DATE/DATETIME列 — 任意の時刻列でパーティション。ほとんどのETLで推奨。
- Integer range — 整数列をバケット分割。ユーザーID hash等。
- Time-unit: HOUR / DAY / MONTH / YEAR — DAYが基本。HOURは4,000制限に166日で到達するので非推奨。
SQL 1: DAY パーティション + クラスタリングでの CREATE TABLE
CREATE TABLE `mart.orders_daily` PARTITION BY DATE(order_ts) CLUSTER BY shop_id, customer_id OPTIONS ( partition_expiration_days = 1095, require_partition_filter = TRUE, description = 'Shopify orders (daily partition, 3y retention)' ) AS SELECT order_id, shop_id, customer_id, total_price, created_at AS order_ts FROM `raw.shopify_orders`;
require_partition_filter = TRUEは必殺技。 これを付けておくと、WHERE order_ts >= '2026-03-01'のようなパーティションフィルタがないクエリをBigQueryが拒否してくれます。 「新人がSELECT *で全期間スキャンしてしまう」事故をDDLで防げる、最強のガードレール。
SQL 2: パーティション枝刈りが効く書き方
-- OK: リテラルで絞るので枝刈りが効く(スキャン量が小さい)
SELECT COUNT(*) FROM `mart.orders_daily`
WHERE DATE(order_ts) BETWEEN '2026-04-01' AND '2026-04-11';
-- NG: 関数でラップするとパーティション列と認識されず、全パーティションスキャン
SELECT COUNT(*) FROM `mart.orders_daily`
WHERE FORMAT_DATE('%Y-%m', DATE(order_ts)) = '2026-04';
-- NG: JOIN先のサブクエリを条件に入れるとプルーニングが効かないことがある
SELECT * FROM `mart.orders_daily`
WHERE DATE(order_ts) IN (SELECT DATE(event_ts) FROM `mart.events`);4,000パーティション制限と回避策
BigQueryの1テーブルあたりのパーティション上限は4,000。 DAY粒度なら約11年分で到達しますが、以下のケースではクラスタリング併用 or 別テーブル分割を検討します。
- 時間粒度をHOURにした → 166日で到達。基本やらない
- Integer rangeで細かくバケット分割した → 4,000バケットを超えない設計に
- マルチテナントで
tenant_idを列パーティションにした → 典型的なアンチパターン。クラスタリングで分離するのが正解
公式推奨は「各パーティションが10 GB以上になる粒度」。 これより細かいと、パーティションプルーニングのメタデータ処理オーバーヘッドのほうが重くなります。
クラスタリング — 効く列・効かない列
クラスタリングは同一パーティション内でブロックを並べ替える仕組み。 1テーブルあたり最大4列まで指定でき、先頭列ほど効果が大きい(プレフィックス順)です。 Shopifyが事例として公開した「75 GB → 508 MB(150倍削減)」はこれで実現しています。
クラスタに適する列の条件
- 高カーディナリティ — ID系(
customer_id,product_id)は最適 WHEREの等価・範囲条件で頻繁に使うGROUP BYやORDER BYでもヒット- BOOL・日付・数値・STRING 対応。配列・JSON・GEOGRAPHY は不可
SQL 3: 既存テーブルにクラスタリングを追加
-- 新規データから有効化。既存データには再書き込みが必要 ALTER TABLE `mart.orders_daily` SET OPTIONS ( clustering_fields = ['shop_id', 'customer_id', 'status'] ); -- 既存データもクラスタリングしたい場合は書き戻す CREATE OR REPLACE TABLE `mart.orders_daily` PARTITION BY DATE(order_ts) CLUSTER BY shop_id, customer_id, status AS SELECT * FROM `mart.orders_daily`;
SQL 4: クラスタリング効果の検証(dry run)
-- クライアントで --dry_run を付けるか、EXPLAIN を使う -- Before: shop_id で絞るが全件スキャン SELECT COUNT(*) FROM `mart.orders_daily` WHERE DATE(order_ts) = '2026-04-10' AND shop_id = 'shop_8f2a'; -- ジョブ履歴で total_bytes_processed を確認 SELECT total_bytes_processed / POW(1024,3) AS gb_scanned, total_slot_ms / 1000.0 AS slot_seconds, query FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 HOUR) AND job_type = 'QUERY' ORDER BY creation_time DESC LIMIT 10;
効果測定はスキャンバイト(total_bytes_processed)で必ず前後比較。 クラスタリング後でも、先頭列でない列だけをWHEREに使うと効きません。 たとえばCLUSTER BY shop_id, customer_idでWHERE customer_id = ?だけだと枝刈りが甘くなります。
マテリアライズドビューの活用と制約
マテリアライズドビュー(MV)は基底テーブルの更新に追従して差分更新される「半自動キャッシュ」。 集計系ダッシュボードの繰り返しクエリを、スキャンバイトもレイテンシも1桁以上削減できます。 ただし書ける集計に強い制約があり、何でも貼れるわけではありません。
MVが得意なパターン
SUM,COUNT,AVG,MIN,MAX,APPROX_COUNT_DISTINCTといった集約関数GROUP BYで日次/週次集計- 1つのベーステーブルに対する集計(複雑なJOINは非対応)
MVで使えない構文(よくある落とし穴)
UNNEST,ARRAY_AGG, ウィンドウ関数、自己結合、HAVINGLIMIT,ORDER BY,COUNT(DISTINCT)(APPROX_COUNT_DISTINCTは可)- サブクエリ内の非決定的な関数(
CURRENT_TIMESTAMP()等)
したがってGA4のようにevent_paramsをUNNESTする系は、一度フラット化した中間テーブルに対してMVを貼るのが定石です。
SQL 5: MVを作る(日次売上サマリ)
CREATE MATERIALIZED VIEW `mart.mv_daily_revenue` PARTITION BY order_date CLUSTER BY shop_id OPTIONS ( enable_refresh = TRUE, refresh_interval_minutes = 30, max_staleness = INTERVAL '1' HOUR ) AS SELECT DATE(order_ts) AS order_date, shop_id, COUNT(*) AS orders, COUNT(DISTINCT customer_id) AS unique_buyers, SUM(total_price) AS revenue, APPROX_COUNT_DISTINCT(customer_id) AS approx_unique FROM `mart.orders_daily` WHERE DATE(order_ts) >= '2025-01-01' GROUP BY order_date, shop_id;
max_stalenessを指定すると「古くていい許容範囲」を明示でき、 範囲内なら実クエリがMVに置き換えられ、範囲を超えるとベーステーブルにフォールバックしてくれます。 これを使わないと、ストリーミングバッファの未取込データでMVが古く見えるケースで事故ります。
クエリ最適化テクニック(SELECT *撲滅・APPROX系・ウィンドウ)
1. SELECT * 撲滅が一撃で効く
BigQueryはカラムナストレージなので、参照しない列のバイトはスキャンされません。SELECT *の代わりに必要な列だけ指定するだけで、GA4 Exportのような幅広テーブルではスキャン量が90%減することも。SELECT * EXCEPT(大きい列)も現実的です。
SQL 6: EXCEPT を使って巨大列だけ除外
-- user_properties と items は巨大なのでダッシュボード用途では除外 SELECT * EXCEPT (user_properties, items) FROM `analytics_123.events_*` WHERE _TABLE_SUFFIX BETWEEN '20260401' AND '20260410';
2. APPROX_* 関数で劇的に軽くする
UU・ユニークセッション・ユニーク商品点数など、厳密な重複排除が要らない指標は HyperLogLog ベースのAPPROX_COUNT_DISTINCTに置き換えるだけでスロット消費が1/3〜1/10になります。 誤差は約±2%で、ビジネスダッシュボード用途では無視できます。
-- 遅い SELECT DATE(order_ts) AS d, COUNT(DISTINCT customer_id) AS uu FROM `mart.orders_daily` WHERE DATE(order_ts) BETWEEN '2026-01-01' AND '2026-03-31' GROUP BY d; -- 速い(± 2%誤差) SELECT DATE(order_ts) AS d, APPROX_COUNT_DISTINCT(customer_id) AS uu FROM `mart.orders_daily` WHERE DATE(order_ts) BETWEEN '2026-01-01' AND '2026-03-31' GROUP BY d;
PercentileもAPPROX_QUANTILES、頻出トップNもAPPROX_TOP_COUNTを使うと、 ソート無しで高速に計算できます。
3. CTE は「名前付けのみ」で再評価される
重要な落とし穴。BigQueryのCTE(WITH)は毎回インライン展開されます。 同じCTEを3回参照すると3回スキャンされる。巨大な中間結果を使い回したいなら、一時テーブル化(CREATE TEMP TABLE)に逃がすのが安上がりです。
SQL 7: CTEの罠を TEMP TABLE で回避
-- NG: base を 3 回使う → 3 回スキャン
WITH base AS (
SELECT customer_id, total_price FROM `mart.orders_daily`
WHERE DATE(order_ts) BETWEEN '2026-01-01' AND '2026-03-31'
)
SELECT (SELECT SUM(total_price) FROM base) AS gmv,
(SELECT COUNT(DISTINCT customer_id) FROM base) AS uu,
(SELECT AVG(total_price) FROM base) AS aov;
-- OK: TEMP TABLE に一度だけマテリアライズ
CREATE TEMP TABLE base AS
SELECT customer_id, total_price FROM `mart.orders_daily`
WHERE DATE(order_ts) BETWEEN '2026-01-01' AND '2026-03-31';
SELECT SUM(total_price) gmv,
COUNT(DISTINCT customer_id) uu,
AVG(total_price) aov
FROM base;4. ウィンドウ関数は PARTITION BY で絞る
ROW_NUMBER() OVER (ORDER BY ...)のようにPARTITIONなしで書くと、 全データを1スロットでソートするため激重になります。PARTITION BYを付けて並列化し、フレームも必要最小限にする。
SQL 8: ウィンドウ関数のチューニング
-- NG: PARTITION なしで全件ソート → slot 不足で詰まる
SELECT customer_id, order_ts,
ROW_NUMBER() OVER (ORDER BY order_ts) AS global_rn
FROM `mart.orders_daily`;
-- OK: 顧客単位で並列化
SELECT customer_id, order_ts,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_ts
) AS nth_order
FROM `mart.orders_daily`
WHERE DATE(order_ts) BETWEEN '2026-01-01' AND '2026-03-31';5. JOIN の順序と BROADCAST ヒント
BigQueryのオプティマイザは優秀ですが、小さい側を右に置く原則は今も有効。小さいマスタテーブル(店舗・カテゴリ等)は右側にJOINし、 必要なら/*+ BROADCAST(shops) */でブロードキャストヒントを与えます。
6. WITH RECURSIVE は必要最小限に
アトリビューションの経路探索や組織ツリーなどで便利ですが、再帰は増幅しやすく、深さ制限を付けないと爆発します。 必ずWHERE depth < 10のようなストッパーを入れ、 可能なら非再帰のSQL(LAG/ウィンドウ)で代替できないか先に検討する。
ストレージ最適化 — Long-term storage・有効期限・物理課金
ストレージはコンピュートに比べて安いとはいえ、GA4 × Shopify × 広告ログをまとめると年間数十GB〜数TBに膨らみ、 放置すると「使っていないのに毎月課金され続ける」状態になります。
1. Long-term storage は自動で半額になる
パーティション or テーブルが90日間変更されないと、該当データが自動的にLong-term扱いになり、 ストレージ単価が半額(Active $0.02/GB → Long-term $0.01/GB)になります。 「古いデータを別プロジェクトに退避」みたいな運用は不要。触らずに置いておけば勝手に安くなる。
ただしパーティション単位で判定されるので、MERGE/UPDATEでテーブル全体を触ると過去パーティションまでActive扱いに戻ります。 洗替え(DELETE + INSERT)系のETLはLong-term化の大敵です。
2. テーブル・パーティションの有効期限
データセットにdefault_table_expiration_msを設定するか、 テーブルにpartition_expiration_daysを設定すると、 期限切れのテーブル/パーティションが自動削除されます。 ETLで日次ファイルを貯め続けるプロジェクトでは必須。
SQL 9: データセットとテーブルに期限を設定
-- データセット全体のデフォルト期限(365日) ALTER SCHEMA `raw` SET OPTIONS ( default_table_expiration_ms = 365 * 24 * 60 * 60 * 1000, default_partition_expiration_ms = 90 * 24 * 60 * 60 * 1000 ); -- 個別テーブルで上書き ALTER TABLE `raw.events` SET OPTIONS ( partition_expiration_days = 400 ); -- 有効期限なしに戻す ALTER TABLE `raw.events` SET OPTIONS (partition_expiration_days = NULL);
3. Physical storage billing で圧縮率を生かす
データセット単位で物理課金に切り替えると、圧縮後サイズで請求されます。 文字列中心でカーディナリティが低い(繰り返しが多い)テーブルは4〜10倍圧縮されるので、 単価が倍でもトータル安くなるケースが多い。Time Travel 7日分の物理バイトも課金される点だけ要注意です。
ALTER SCHEMA `mart` SET OPTIONS (storage_billing_model = 'PHYSICAL'); -- 効果見積: 論理 vs 物理の差を把握 SELECT table_schema, table_name, ROUND(total_logical_bytes/POW(1024,3), 2) AS logical_gb, ROUND(total_physical_bytes/POW(1024,3), 2) AS physical_gb, ROUND(SAFE_DIVIDE(total_logical_bytes, total_physical_bytes), 2) AS compression_ratio FROM `region-us`.INFORMATION_SCHEMA.TABLE_STORAGE WHERE table_schema = 'mart' ORDER BY logical_gb DESC;
4. 不要テーブルの棚卸し
-- 30日以上アクセスされていないテーブル Top 20
SELECT
t.table_schema,
t.table_name,
ROUND(t.total_logical_bytes/POW(1024,3), 2) AS gb,
TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), MAX(j.creation_time), DAY) AS days_since_last_read
FROM `region-us`.INFORMATION_SCHEMA.TABLE_STORAGE t
LEFT JOIN `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT j,
UNNEST(j.referenced_tables) r
ON r.table_id = t.table_name
AND r.dataset_id = t.table_schema
AND j.creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY t.table_schema, t.table_name, t.total_logical_bytes
ORDER BY gb DESC
LIMIT 20;監査と可視化 — INFORMATION_SCHEMA.JOBS_BY_*
「誰が」「いつ」「どのクエリで」「何GBスキャンしたか」はINFORMATION_SCHEMA.JOBS_BY_*で全部わかります。データ保持は過去180日。毎月これで棚卸しするだけで事故の9割は防げます。
SQL 10: 直近30日の高コストクエリ Top 20
SELECT user_email, query, ROUND(total_bytes_billed / POW(1024, 4), 3) AS tib_billed, ROUND(total_bytes_billed / POW(1024, 4) * 6.25, 2) AS cost_usd, total_slot_ms / 1000.0 AS slot_seconds, creation_time FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY) AND job_type = 'QUERY' AND state = 'DONE' AND statement_type != 'SCRIPT' ORDER BY total_bytes_billed DESC LIMIT 20;
SQL 11: ユーザー別の月次コスト
SELECT user_email, DATE_TRUNC(DATE(creation_time), MONTH) AS ym, ROUND(SUM(total_bytes_billed) / POW(1024, 4) * 6.25, 2) AS cost_usd, COUNT(*) AS jobs, ROUND(AVG(total_slot_ms) / 1000.0, 1) AS avg_slot_sec FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY) AND job_type = 'QUERY' GROUP BY user_email, ym ORDER BY ym DESC, cost_usd DESC;
SQL 12: キャッシュヒット率とリトライ率
SELECT DATE(creation_time) AS d, COUNT(*) AS jobs, COUNTIF(cache_hit) AS cache_hits, SAFE_DIVIDE(COUNTIF(cache_hit), COUNT(*)) AS cache_hit_rate, COUNTIF(error_result IS NOT NULL) AS errors, COUNTIF(statement_type = 'SELECT' AND total_bytes_billed = 0) AS free_queries FROM `region-us`.INFORMATION_SCHEMA.JOBS_BY_PROJECT WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 14 DAY) AND job_type = 'QUERY' GROUP BY d ORDER BY d DESC;
キャッシュヒット率は20〜40%が目安。 0%に近い場合、ダッシュボードが毎回別パラメータを送っていて使い回しが効いていない可能性大。 Looker Studioなら「データの更新」を頻度下げる、BIの接続をライブ接続からエクストラクトに切り替える等で改善します。
Looker Studio ダッシュボード化
上記のSQLをベースに、Looker Studio公式の BigQuery Admin / Cost Monitoring テンプレートを使うと、ユーザー別・データセット別・日次の支出を10分でダッシュボード化できます。 毎週1回確認する運用を作るだけで、コスト感度が全く変わります。
出典: JOBS view
ガードレール — Query Quota・Maximum Bytes Billed
最悪のケース(SELECT *で数TB、BIの無限ポーリングで数万クエリ)を防ぐには、DDL・クオータ・クエリオプションの3レイヤーでガードレールを敷きます。
1. Custom Cost Controls(プロジェクト/ユーザー別)
GCP Console → IAM & Admin → Quotas でQueryUsagePerDay(プロジェクト全体、デフォルト200 TiB)とQueryUsagePerUserPerDay(ユーザー単位、デフォルト無制限)を設定できます。 On-Demand専用の機能で、上限に達するとそれ以降のクエリは拒否されます。
- 本番プロジェクト — QueryUsagePerDay = 5 TiB(暴走時の保険)
- 開発プロジェクト — QueryUsagePerUserPerDay = 500 GB(個人の事故防止)
- サービスアカウント — 別途高めの枠を付与(ETLが詰まらないように)
2. Maximum Bytes Billed(クエリ単位)
個別クエリ or セッションで「このクエリは最大X GBまで。超えたら即時失敗」を強制できます。 ダッシュボード接続用のサービスアカウントに仕込んでおくと鬼のように効きます。
SQL 13: クエリ単位でバイト上限を設定
-- セッション全体に 1 GB 上限 SET @@query_plan_cache_enabled = TRUE; -- bq コマンド -- bq query --maximum_bytes_billed=1073741824 \ -- --use_legacy_sql=false 'SELECT ...' -- Python / Node SDK では QueryJobConfig.maximum_bytes_billed に渡す -- BigQuery Studio で実行する場合は Query Settings > Advanced から設定 SELECT COUNT(*) FROM `mart.orders_daily` WHERE DATE(order_ts) BETWEEN '2026-04-01' AND '2026-04-11'; -- ↑ 1 GB を超えるとエラー: "Query exceeded limit for bytes billed: 1073741824"
3. require_partition_filter をテーブル側で強制
前述のDDLオプション。これが付いているテーブルではパーティションフィルタなしのクエリが拒否されます。 ETLの成果物、GA4 Exportのミラーテーブル、広告ログ等、全件スキャンが事故にしかならないテーブルには必須設定です。
4. 予算アラート(Billing Budget)
GCPプロジェクト単位で月次予算を設定 → 50% / 90% / 100%でメール/Pub/Sub通知。 閾値を超えたら自動でクエリ停止、まではBigQuery側では不可ですが、Cloud FunctionでAPI経由のQuota調整をPub/Subトリガで仕込むパターンが広く使われています。
削減事例 — 月45万円→月8万円の現場
実際に筆者が改善に入ったEC/D2Cプロジェクトで、月額$3,000(約45万円)→ $550(約8万円)まで82%削減できた事例のポイントをまとめます。
| 施策 | Before | After | 削減額 |
|---|---|---|---|
| Looker Studioが毎時GA4 Exportを全期間スキャン | 月 40 TiB | 日次マート化 + MV化で月 1.2 TiB | 約 ¥32万 |
dbtモデルでSELECT * | 月 8 TiB | 必要列のみに絞って月 1.5 TiB | 約 ¥6万 |
| クラスタ未設定の注文テーブル | 分析1回 12 GB | shop_id+customer_idクラスタで 400 MB | 約 ¥2万 |
| 古いPoCデータセット放置 | 2.3 TB active storage | 削除 + physical billing 切替で 300 GB相当 | 約 ¥1万 |
進め方のテンプレ
- Week 1:
JOBS_BY_PROJECTで月次Top 20クエリ抽出 → 8割がLooker Studioと判明 - Week 2: ダッシュボード用日次マート(
mv_daily_*)を5本作成 → Looker接続を差し替え - Week 3: 既存テーブルに
PARTITION BYとCLUSTER BYを再設計 →CREATE OR REPLACEで書き戻し - Week 4:
require_partition_filter=TRUEとCustom Quota (5 TiB/day) を設定 → 再発防止
一発の魔法ではなく、可視化 → 犯人特定 → 構造修正 → 再発防止を順番に回すだけで、 たいていのプロジェクトは半分〜8割は落ちます。「BigQueryが高い」の正体は、9割がクエリ設計と運用設計の問題です。
参考になる公開事例: Spotify Engineering (BigQuery learnings) / Shopify Engineering Blog
まとめ
- コンピュートが総額の85〜90%。月50 TiBを安定して超えたらEditions検討、それまではOn-Demand
PARTITION BY+CLUSTER BY+require_partition_filterの3点セットで、スキャン量は平気で10倍以上変わる- MVは「UNNEST/ウィンドウ不可」の制約付き。フラット化した中間テーブル上で貼るのが定石
SELECT *と「CTEの3回再評価」は絶対NG。APPROX系関数とTEMP TABLEで体感1桁変わる- ストレージは90日で自動Long-term化。洗替え型のETLはLong-term化を壊す最大の敵
INFORMATION_SCHEMA.JOBS_BY_PROJECTで月次棚卸しを回せば、事故の9割は未然に防げる- Custom Quota + Maximum Bytes Billed + Partition Filter強制 の3重ガードレールを本番で必ず敷く
BigQueryのコスト最適化は「設計した人の知識量」がそのまま月次請求書に反映される世界です。GA4 BigQuery Exportや Shopify × BigQueryのように入れた瞬間は無料でも、クエリの書き方1つで月数十万円変わるのがBigQueryの怖さでもあり、 正しく設計すれば「分析し放題かつ定額同等」にできる面白さでもあります。
DecisionFlowは、BigQueryの基盤構築(パーティション/クラスタ設計・MV・マート層・監査SQL一式)から、 毎朝のKPI自動監視・LLMによる自動分析・意思決定ログ化まで一気通貫で提供します。 「BigQueryの請求が見えないまま膨らんでいる」「データはあるのにダッシュボード止まりで判断に繋がらない」 という状態を解消したい方は、無料相談で現状の クエリ/テーブル設計/運用を診断します。
