概要
BI Engineは「予約」して初めて効くキャッシュ
capacity 0GBのままではBI Engineは一切機能しない。列指向のインメモリキャッシュとして予約容量を確保し、参照する列を絞ることで初めて高速化の恩恵を受けられる。
ダッシュボードに必要な粒度まで事前集計する
商品×倉庫のような不要な粒度を持ったMaterialized Viewは大きくなりすぎて予約容量に乗り切らない。5分バケット×チャネルまで集計すれば桁違いに小さく保てる。
イベント時のキャパシティ調整は運用として設計する
平常時の固定サイズのままセールを迎えると、キャッシュから溢れたデータへのクエリがフルスキャンに戻る。tfvars切替でレビュー可能な形にしておく。
ヒット率・使用率の監視で劣化に気づく
INFORMATION_SCHEMA.JOBS_BY_PROJECTのbi_engine_statisticsとCloud Monitoringを組み合わせ、キャッシュ効果が薄れたことをクレーム前に検知する。
問題
ECサイト MOps チームでは、セール当日に経営陣・現場責任者向けに Looker Studio で「リアルタイム売上ダッシュボード」(5分ごとに自動リフレッシュ、時間帯×購入チャネル別の売上・件数を表示)を提供している。直近のセールで、ダッシュボードの1クエリあたりのレイテンシが8〜12秒まで悪化し、かつ月間BigQueryコストも高騰した。調査すると、以下のTerraform(BI Engine予約構成)・dbtモデル(Materialized View)・Looker Studioが発行するクエリには7つの設計・コスト上の問題が潜んでいる。
問題点を全て洗い出し、BigQuery BI Engine capacity reservation(列指向インメモリキャッシュ)× Materialized Viewによる事前集計 × ダッシュボードに必要な粒度への集計設計 × パーティションプルーニングを効かせるWHERE句設計 × セール時のBI Engine予約サイズの動的引き上げ × BI Engineヒット率/スロット消費の監視を活用したBad→Goodリファクタリングを行ってください。
制約・前提条件
- BigQuery Standard SQL(GA機能のみ。BI Engine, Materialized View は共にGA機能)
- dbt Core 1.8+(Materialized Viewは
materialized='materialized_view'で定義可能) - Terraform 1.8+(
googleプロバイダー 5.x、google_bigquery_bi_reservationリソース使用可) - ソーステーブル:
orders.order_items(order_id,order_date DATE,order_ts TIMESTAMP,product_id,warehouse_id,channel,qty,unit_price。partition:order_date)。セール当日は日次数千万〜億行規模に達する - BI Engine reservationは現在未作成(capacity 0GB)で、ダッシュボードの集計クエリは毎回フルスキャン相当のスロット処理が発生している
- Looker Studio側のクエリは
orders.order_itemsを直接SELECT *で参照し、WHERE order_dateの範囲指定が無い - BigQueryはオンデマンド課金($5/TB)。セール当日はダッシュボードの閲覧者数(同時アクセス)も平常時の10倍に増える
- ダッシュボードで実際に必要な集計粒度は「5分バケット × 購入チャネル」の売上合計・件数のみで、商品別・倉庫別の内訳は不要
悪いコード (Before)
-- Looker Studioが5分ごとに発行するクエリ
-- 問題①: BI Engine reservationがcapacity 0GBで未作成
-- 問題②: SELECT *で不要列(product_id,warehouse_id,unit_price等)も読込
-- 問題③: 事前集計されたMaterialized Viewが存在せず生テーブルを直接集計
-- 問題⑥: order_dateの範囲指定が無くパーティションプルーニングが非効
SELECT *
FROM `my-project.orders.order_items`;
{{ config(materialized='materialized_view') }}
-- 問題④: 商品×倉庫まで持つ細かすぎる粒度
-- ダッシュボードは5分バケット×チャネルしか使わないのに
-- BI Engine予約容量(平常時10GB)に乗り切らずキャッシュミス多発
SELECT
order_date,
product_id,
warehouse_id,
channel,
SUM(qty * unit_price) AS revenue,
COUNT(DISTINCT order_id) AS order_count
FROM {{ source('orders', 'order_items') }}
GROUP BY order_date, product_id, warehouse_id, channel
-- 問題⑤: BI Engine予約サイズが平常時想定の固定値のまま
-- (infra/bi_engine.tf の size = 10GB 固定、セール向けの変数化なし)
-- 問題⑦: ヒット率・使用率を監視する仕組みが無い
ヒント(段階的開示)
ヒント1 — 方向性
SELECT * や範囲指定の無いWHERE句は、BI Engine・パーティションプルーニングいずれの恩恵も受けられない。加えて、セール当日はデータ量・閲覧者数が跳ね上がるため、予約サイズを固定値のまま放置すると効果が薄れる、という「イベント時に合わせて調整する運用」自体も設計に含める必要がある。
ヒント2 — アプローチ
- 問題①: BI Engine reservationがcapacity 0GBで未作成 →
google_bigquery_bi_reservationで対象ロケーションにキャパシティを確保する - 問題②: Looker Studioのクエリが
SELECT *で不要列(product_id, warehouse_id, unit_price等)まで読み込んでいる → ダッシュボードに必要な列のみに絞る(BI Engineは列指向キャッシュなので、参照する列が少ないほど同じ予約容量でより多くの行を保持できる) - 問題③: 生の
orders.order_items(日次数千万〜億行)を毎回直接集計しており、事前集計されたMaterialized Viewが存在しない →mart_sales_5minをMaterialized Viewとして定義し、ベーステーブルの差分のみを増分リフレッシュさせる - 問題④: MVの集計粒度が「注文明細×商品×倉庫」と細かすぎてBI Engine予約容量に乗り切らずキャッシュミスが多発する → ダッシュボードが実際に必要とする粒度(5分バケット×チャネル)まで事前集計し、行数を数桁小さくする
- 問題⑤: BI Engine予約サイズが平常時のデータ量を前提にした固定値のままで、セール当日のデータ増加・閲覧者増を考慮していない → セール開催カレンダーに合わせて予約サイズを一時的に引き上げる仕組み(Terraform変数化+運用手順)を用意する
- 問題⑥: Looker Studioのクエリに
WHERE order_dateの範囲指定が無く、パーティションプルーニングが効かずMV全体をスキャンしている → 「直近24時間」など明示的な範囲指定をダッシュボード側のクエリテンプレートに組み込む - 問題⑦: BI Engineのヒット率・スロット消費を監視する仕組みが無く、キャッシュ効果が薄れても誰も気づけない →
INFORMATION_SCHEMA.JOBS_BY_PROJECTのBI Engine関連統計情報+Cloud MonitoringでBI Engineのメモリ使用率・ヒット率を可視化し、閾値を割ったらアラートする
ヒント3 — コードの骨格
-- Materialized View 集計粒度の骨格(5分バケット×チャネル)
{{ config(materialized='materialized_view') }}
SELECT
TIMESTAMP_TRUNC(order_ts, MINUTE, 'Asia/Tokyo') AS minute_ts,
channel,
order_date,
SUM(qty * unit_price) AS revenue,
COUNT(DISTINCT order_id) AS order_count
FROM {{ source('orders', 'order_items') }}
GROUP BY 1, 2, 3
# BI Engine reservation の骨格
resource "google_bigquery_bi_reservation" "dashboard" {
project = var.project_id
location = "asia-northeast1"
size = var.bi_engine_capacity_bytes # セール時は変数を引き上げてapply
}
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | BI Engine reservationが未作成(capacity 0GB) | キャッシュ層 | google_bigquery_bi_reservationで予約作成 |
| 2 | SELECT *で不要列まで読込 | キャッシュ層 | 必要列のみに絞りキャッシュ効率最大化 |
| 3 | 事前集計(Materialized View)が無く生テーブル直接集計 | コスト | mart_sales_5minをMV化 |
| 4 | MV集計粒度が商品×倉庫まで細かすぎる | コスト | 5分バケット×チャネルまで事前集計 |
| 5 | BI Engine予約サイズがセール時のデータ増加を非考慮 | 運用設計 | tfvars切替で一時引き上げ |
| 6 | WHERE句に範囲指定が無くパーティションプルーニング非効 | クエリ設計 | 直近24時間の範囲指定を必須化 |
| 7 | ヒット率・使用率の監視が無い | 監視 | JOBS_BY_PROJECT+Cloud Monitoringアラート |
BI Engineキャッシュの仕組み — Bad vs Good の比較
模範解答
# infra/bi_engine.tf
variable "bi_engine_capacity_bytes" {
description = "BI Engine予約容量(バイト単位)。平常時は10GB、セール開催時は変数を引き上げてapplyする運用"
type = number
default = 10 * 1024 * 1024 * 1024 # 10GB(平常時)
}
# 修正①: BI Engine capacity reservationを作成
resource "google_bigquery_bi_reservation" "dashboard" {
project = var.project_id
location = "asia-northeast1"
size = var.bi_engine_capacity_bytes
}
# 修正⑤: セール開催カレンダーに合わせて容量を引き上げる運用は
# tfvarsの切り替え(例: bi_engine_capacity_sale.tfvars で 50GB を指定)で対応。
# Terraformコード自体は変数化しておくことで、セール前後の
# capacity変更をコードレビュー可能な変更として履歴に残す。
# 修正⑦: BI Engineのメモリ使用率が予約容量に対して逼迫していないか監視
resource "google_monitoring_alert_policy" "bi_engine_utilization" {
project = var.project_id
display_name = "BI Engine 予約容量の使用率が閾値超過"
combiner = "OR"
conditions {
display_name = "bi_engine/reservation/used_bytes が予約容量の90%を超過"
condition_threshold {
filter = <<-EOT
resource.type="bigquery_biengine_reservation"
metric.type="bigquery.googleapis.com/biengine/reservation/used_bytes_ratio"
EOT
comparison = "COMPARISON_GT"
threshold_value = 0.9
duration = "300s"
}
}
notification_channels = [var.oncall_notification_channel_id]
}
# 修正⑦: BI Engineでアクセラレートされなかったクエリの割合を監視
# (INFORMATION_SCHEMA.JOBS_BY_PROJECT の bi_engine_statistics を
# スケジュールドクエリで日次集計しモニタリングメトリクスにexport)
resource "google_bigquery_data_transfer_config" "bi_engine_hit_rate_report" {
display_name = "bi-engine-hit-rate-daily"
data_source_id = "scheduled_query"
schedule = "every day 07:00"
destination_dataset_id = "monitoring"
params = {
query = <<-EOT
SELECT
DATE(creation_time) AS report_date,
COUNTIF(bi_engine_statistics.bi_engine_mode = 'FULL') AS full_accel_count,
COUNTIF(bi_engine_statistics.bi_engine_mode = 'DISABLED') AS disabled_count,
COUNT(*) AS total_query_count
FROM `region-asia-northeast1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP(CURRENT_DATE() - 1)
AND statement_type = 'SELECT'
GROUP BY report_date
EOT
}
}
{{ config(materialized='materialized_view') }}
SELECT
order_date, product_id, warehouse_id, channel,
SUM(qty * unit_price) AS revenue,
COUNT(DISTINCT order_id) AS order_count
FROM {{ source('orders', 'order_items') }}
GROUP BY order_date, product_id, warehouse_id, channel
-- BI Engine予約容量(10GB)に乗り切らずキャッシュミス多発
-- models/marts/mart_sales_5min.sql
-- 修正③④: 生テーブルへの直接集計をやめ、ダッシュボードに
-- 必要な粒度(5分バケット×チャネル)まで事前集計した
-- Materialized Viewとして定義する。商品・倉庫の内訳は
-- 持たないため、同じBI Engine予約容量により多くの
-- 期間のデータを収めることができる。
{{ config(
materialized='materialized_view',
on_configuration_change='apply',
options={
'enable_refresh': true,
'refresh_interval_minutes': 5, -- ダッシュボードのリフレッシュ間隔に合わせる
'max_staleness': 'INTERVAL "0-0 0 0:10:0" YEAR TO SECOND',
}
) }}
SELECT
order_date,
-- 5分バケットに丸める(ダッシュボードの表示粒度そのもの)
TIMESTAMP_TRUNC(order_ts, MINUTE, 'Asia/Tokyo')
AS minute_ts,
DIV(EXTRACT(MINUTE FROM order_ts), 5) * 5
AS bucket_offset_minutes, -- 5分単位のオフセット
channel,
SUM(qty * unit_price) AS revenue,
COUNT(DISTINCT order_id) AS order_count
FROM {{ source('orders', 'order_items') }}
GROUP BY order_date, minute_ts, bucket_offset_minutes, channel
version: 2
models:
- name: mart_sales_5min
description: >
リアルタイム売上ダッシュボード専用の事前集計マート。
5分バケット×購入チャネル単位のみを持ち、商品・倉庫の
内訳は持たない(BI Engine予約容量に収まるサイズを維持するため)。
columns:
- name: order_date
data_type: date
constraints: [{ type: not_null }]
- name: minute_ts
data_type: timestamp
constraints: [{ type: not_null }]
- name: channel
data_type: string
constraints: [{ type: not_null }]
- name: revenue
data_type: numeric
- name: order_count
data_type: int64
tests:
- dbt_utils.unique_combination_of_columns:
combination_of_columns: [order_date, minute_ts, channel]
SELECT * FROM `my-project.orders.order_items`;
-- 修正②: revenue/order_count/channel/minute_tsのみを参照し、
-- BI Engineのキャッシュ効率を最大化する
-- 修正⑥: order_dateの範囲指定でパーティションプルーニングを効かせ、
-- MV全体ではなく直近分のみをスキャン対象にする
SELECT
minute_ts,
channel,
revenue,
order_count
FROM `my-project.marts.mart_sales_5min`
WHERE order_date >= DATE_SUB(CURRENT_DATE('Asia/Tokyo'), INTERVAL 1 DAY)
ORDER BY minute_ts;
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① BI Engine未予約 | google_bigquery_bi_reservationで予約作成 | 列指向インメモリキャッシュが機能開始 |
| ② SELECT *で不要列読込 | 必要列のみに絞る | 同じ予約容量でより多くの行を保持 |
| ③ 事前集計マート無し | mart_sales_5minをMV化 | 生テーブル直接集計を回避しコスト削減 |
| ④ 粒度が細かすぎる | 5分バケット×チャネルまで集計 | MVサイズが桁違いに小さくなり予約容量に収まる |
| ⑤ 予約サイズ固定 | tfvars切替で一時引き上げ | セール時のデータ急増にも対応 |
| ⑥ 範囲指定なし | 直近24時間の範囲指定を必須化 | パーティションプルーニングでスキャン量削減 |
| ⑦ 監視なし | JOBS_BY_PROJECT+Cloud Monitoringアラート | キャッシュ劣化をクレーム前に検知 |
実行例(input → output)
input(改善前・生テーブル直接集計、セール当日のデータ量):
orders.order_items は当日だけで8,000万行。Looker Studioは SELECT * を5分ごとに実行し、BI Engine reservationも存在しない。
output(Before・問題のある実装):
毎回8,000万行に対して集計クエリのフルスキャンが走り、レイテンシ8〜12秒・1クエリあたり数百MB〜数GBのスキャン課金が発生する。閲覧者が10倍(同時100人)に増えると、同じ集計が並列に100回実行され、コストとスロット消費がほぼ線形に増加する。
output(After・改善後):
mart_sales_5min は5分バケット×チャネル単位まで事前集計されているため、当日分でも数千〜数万行程度に収まる。10GB(平常時)〜50GB(セール時)のBI Engine予約容量に十分収まり、WHERE order_date >= ... の範囲指定と合わせてBI Engineの列指向インメモリキャッシュがヒットし、レイテンシは数百ms〜1秒程度まで改善する。同時閲覧100人がほぼ同一のクエリ(同じ集計・同じ範囲)を投げるため、BI Engineのキャッシュ共有効果によりスキャンコストの増加も抑えられる。
ポイント解説
INFORMATION_SCHEMA.JOBS_BY_PROJECT の bi_engine_statistics)を可視化し、閾値割れをアラートすることで、キャッシュ効果の劣化をユーザーからのクレーム前に検知できる。実務への応用
MOpsチームが扱うダッシュボードの多くは「同じ集計クエリを、多数の閲覧者が短い間隔で繰り返し実行する」という特性を持つ。このパターンでは、個々のクエリを個別最適化するより、「事前集計した小さなマート × BI Engineのようなキャッシュ層」を用意し、集計クエリそのものを軽量化する設計の方が費用対効果が高い。特にセール・キャンペーン当日のようにアクセスが急増するタイミングでは、平常時の設計のまま迎えるとレイテンシ悪化とコスト急増が同時に起きやすいため、「イベント時にキャパシティを一時的に引き上げる」運用まで事前に仕組み化しておくことが実務上重要になる。
今日のまとめ
次のステップ
- 発展問題: 現在の設計は「5分ごとにLooker StudioがBigQueryへ直接クエリを投げる」ポーリング型だが、真にリアルタイム(秒単位)な更新が必要な場合、
orders.order_itemsへのINSERTをトリガーにPub/Sub経由でダッシュボード側にプッシュ通知する設計に変えるとどうなるか、BI Engine/Materialized Viewとの役割分担も含めて整理せよ。また、Materialized Viewのmax_stalenessを極端に短く(例: 30秒)設定した場合、ベーステーブルの増分リフレッシュ頻度とコストにどう影響するかを試算せよ。 - 参考: BigQuery BI Engine(capacity reservation) / BigQuery Materialized View(
max_staleness, 自動リフレッシュ) /INFORMATION_SCHEMA.JOBS_BY_PROJECTのbi_engine_statistics/ パーティション分割テーブルとプルーニング / Looker Studio のBigQueryコネクタ