はじめに:GA4集計が毎回重い・コストがかかる
GA4(Google アナリティクス4)をBigQueryにエクスポートしている方は多いと思います。GA4の管理画面だけだと「サンプリングがかかる」「思った切り口で見られない」といった不満が出てくるので、生ログをBigQueryに流して自分でSQLを書く、という運用に行き着くわけですね。
ところがこの運用、しばらく続けると地味な悩みが出てきます。
- 毎朝ダッシュボードを開くたびに、同じ集計クエリが走って数十秒〜数分待たされる
- GA4のエクスポートは日付ごとの
events_YYYYMMDDテーブル(日次シャード)として作られ、1日分でも数百MB〜数GBになることがある。events_*のワイルドカードで何日分もまとめてスキャンすると、スキャン量(=課金対象)が積み上がる - 月末に請求を見て「あれ、思ったより高い」となる
中小ECや個人事業主の規模だと、専属のデータエンジニアがいるわけでもないので、「とりあえず動くクエリ」をそのまま使い続けてしまいがちです。今回は、この「毎回重い・毎回お金がかかる」問題に対して、BigQueryの**マテリアライズドビュー(Materialized View、以下MV)**で手当てした話を書きます。
この記事は「GA4のデータがすでにBigQueryへエクスポートされている」前提で進めます。エクスポート設定そのものは GA4 管理画面の「BigQuery のリンク」から行えます。
マテリアライズドビューとは
マテリアライズドビューは、ざっくり言うと「クエリ結果をあらかじめ計算して保存しておく仕組み」です。普通のビューが「クエリの別名(実行のたびに中身を計算する)」なのに対して、MVは結果を実体として持っています。
しかもBigQueryのMVには、自分で手を動かさなくても済むうれしい特徴があります。
- 自動更新:元テーブルが更新されると、BigQueryが差分を見て裏側でMVを更新してくれる
- 自動クエリ書き換え(スマートチューニング):元テーブルに対して投げたクエリでも、BigQueryが「これはMVで答えられる」と判断すれば自動的にMVを使ってくれることがある
つまり、ダッシュボード側のSQLを書き換えなくても、MVを1つ用意しておくだけで裏側が速く・安くなる可能性がある、ということです。
通常ビュー・中間テーブルとの違い
似たような選択肢として「通常ビュー」と「自分で作る中間テーブル」があります。違いを整理しておきます。
| 方式 | 結果の保持 | 更新 | スキャン量 |
|---|---|---|---|
| 通常ビュー | 持たない(毎回計算) | 常に最新 | 元テーブルをその都度スキャン |
| 中間テーブル(手動 or スケジュール) | 持つ | 自分で更新する必要あり | 作成時にスキャン、参照時は中間テーブルのみ |
| マテリアライズドビュー | 持つ | BigQueryが自動更新 | 増分のみ更新、参照時はMV |
中間テーブルでも速くはなりますが、「更新を自分で回す」運用コストがかかります。スケジュールドクエリで毎日作り直す方法もあって、これはこれで定番なのですが(後述)、MVは更新の面倒を見なくていいのが大きな違いです。
ただし後述するように、GA4のエクスポートにはMVを直接作れないため、実際には「GA4日次シャードをフラット化する中間テーブル」+「その上に張るMV」という組み合わせになります。中間テーブルとMVは二者択一ではなく、役割分担で併用するのが現実解です。
GA4エクスポートには直接MVを作れない、という前提
ここで最初にハマりやすい落とし穴を共有しておきます。「GA4のエクスポートテーブルに直接MVを張ろう」とすると、たいてい失敗します。理由は2つあります。
- GA4のエクスポートは日次シャード
events_YYYYMMDD(と当日分のevents_intraday_)として作られる。 すべての日付がまとまった単一のeventsパーティションテーブルが存在するわけではありません。複数日をまとめて読むにはevents_*のようなワイルドカードを使うことになります。 - BigQueryのMVはワイルドカードテーブルやビューを参照元にできない。 さらに、GA4のスキーマで必須になる
UNNEST(event_params)のような配列展開や、複雑なサブクエリを含む定義にも制約があります。
つまり「events_* に対してMVを作る」は構文的に通りませんし、「単一の events テーブルにMVを作る」は、そもそもそのテーブルが無いので前提が成り立ちません。
そこで現実的には、いったん自分でフラットな中間テーブルを作り、その中間テーブルに対してMVを作る(または中間テーブル+スケジュール集計で代替する)という二段構えにします。
ステップ①:GA4日次シャードをフラット化した中間テーブルを作る
ここでは「日付別・ページ別のページビュー数」という、ダッシュボードで頻出の集計の素材を作ります。events_* を _TABLE_SUFFIX で日付範囲を絞って読み、UNNEST で必要な列をフラット化したパーティションテーブルに落とします。この更新はスケジュールドクエリで毎日回す前提です。
-- ① GA4日次シャードを読み、必要列をフラット化した中間テーブルを作る
-- (スケジュールドクエリで毎日 MERGE / INSERT する想定。初回は CREATE TABLE)
CREATE TABLE IF NOT EXISTS `your_project.mart.ga4_pageview_flat`
(
event_date DATE,
page_location STRING,
user_pseudo_id STRING
)
PARTITION BY event_date;
-- 毎日の追記分(前日分のみを対象にしてスキャン量を抑える)
INSERT INTO `your_project.mart.ga4_pageview_flat`
SELECT
PARSE_DATE('%Y%m%d', event_date) AS event_date,
(SELECT value.string_value
FROM UNNEST(event_params)
WHERE key = 'page_location') AS page_location,
user_pseudo_id
FROM `your_project.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX = FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY))
AND event_name = 'page_view';
UNNEST や events_* のワイルドカードは、このステップ(=普通のクエリ/スケジュールドクエリ)では自由に使えます。MVで使えないものを、ここで先に消化してしまうイメージです。中間テーブルを event_date でパーティション分割しておくと、後段の参照も安くなります。
ステップ②:中間テーブルに対してMVを作る
フラットな中間テーブルができたら、ここに対してMVを作ります。中間テーブル側で配列展開を済ませてあるので、MVの定義は素直な GROUP BY だけで書けます。
-- ② フラット化済みの中間テーブルに対してマテリアライズドビューを作る
CREATE MATERIALIZED VIEW `your_project.mart.mv_daily_pageviews`
OPTIONS (
enable_refresh = true, -- 自動更新を有効化
refresh_interval_minutes = 60 -- 最短更新間隔の目安(要件に合わせて調整)
)
AS
SELECT
event_date,
page_location,
COUNT(*) AS page_views,
APPROX_COUNT_DISTINCT(user_pseudo_id) AS users
FROM `your_project.mart.ga4_pageview_flat`
GROUP BY event_date, page_location;
ポイントは2つです。
- MVの参照元はワイルドカードでもビューでもない、実在する単一テーブル(中間テーブル)にする。
UNNESTを含む複雑な定義は避け、配列展開はステップ①で済ませておく。 - ユニークユーザー数は厳密な
COUNT(DISTINCT ...)だとMVの増分更新の対象にならないことがあるため、APPROX_COUNT_DISTINCTを使っています(後述の制約を参照)。厳密値が要る用途なら、MVではなくスケジュールドクエリでの集計を検討します。
ダッシュボード側は、このMVを直接参照します。
-- ダッシュボード側はMVを直接参照する
SELECT event_date, SUM(page_views) AS pv
FROM `your_project.mart.mv_daily_pageviews`
WHERE event_date BETWEEN '2026-06-01' AND '2026-06-28'
GROUP BY event_date
ORDER BY event_date;
なお、中間テーブルに対して投げた集計クエリが条件を満たせば、BigQueryが自動でMVに書き換えてくれることもあります。書き換わったかどうかは、クエリ実行後の「ジョブ情報」や実行計画で確認できます。スキャン量(処理されたバイト数)が中間テーブル直読みより明らかに小さくなっていれば、MVが効いているサインです。
もしMVの制約に引っかかって作れない集計(複雑な式や厳密な
COUNT(DISTINCT)を含むものなど)であれば、MVはあきらめて、ステップ①の中間テーブルに対してスケジュールドクエリで集計テーブルを作り直すほうが確実です。この「中間テーブル+スケジュール集計」は、MVが使えないケースの定番の代替策です。
効果(自動更新・スキャン削減)と向き不向き
実際に運用してみて感じた効果はシンプルです。
- スキャン量が減る:毎回
events_*を何日分も舐めていたところが、中間テーブルの増分追記+MVの増分更新+小さなMV参照に置き換わるので、課金対象のバイト数がぐっと下がりました。 - 待ち時間が減る:ダッシュボードの初回表示が体感で速くなりました。事前に計算済みの結果を読むだけなので当然ですね。
- 運用が楽:いちばん効いたのはここでした。中間テーブルだと「更新ジョブが落ちていた」という事故が起きますが、MVは自動更新なので見張る対象が減りました。
一方で、なんでもMVにすればいいわけではありません。向き不向きがあります。
MVが向いているケース
- 集計の形が固定で、毎日(あるいは何度も)同じ切り口を見る
- 元テーブルが頻繁に追記されるが、過去データはあまり書き換わらない(GA4のエクスポートはまさにこれ)
SUM/COUNT/AVGのような素直な集約が中心
MVが向いていないケース
- 集計軸が毎回バラバラで、固定の集計結果を使い回せない
- ウィンドウ関数や複雑な結合をふんだんに使った分析
- リアルタイム性が最優先(MVには更新の遅延があります)
「探索的にいろいろな角度から掘る分析」よりも、「決まったKPIを定点観測するダッシュボード」のほうがMV向きだと考えるとイメージしやすいと思います。
注意:MVの制約(対応する集計・増分更新の条件)
MVは便利ですが、書けるSQLにわりとはっきりした制約があります。導入前にここを確認しておかないと、CREATE の時点でエラーになって戸惑うことになります。
⚠️ 注意:代表的な制約(2026年時点の一般的な傾向)
- ワイルドカードテーブル(
events_*)やビューは参照元にできない。 だからGA4エクスポートに直接MVは作れず、本記事のように実在する単一の中間テーブルを挟む必要がある。UNNESTや複雑なサブクエリを含む定義には制約がある。 GA4のevent_params展開はMVの定義側ではなく、前段の中間テーブル作成で済ませておく。- サポートされる集約関数が限られる。
SUM/COUNT/MIN/MAX/APPROX_COUNT_DISTINCTなどは使えるが、厳密なCOUNT(DISTINCT ...)はそのままでは増分更新の対象にならないことがある。OUTER JOINや一部のウィンドウ関数、HAVING、ORDER BY、LIMITなどは定義に使えない/制限がある。SELECTには集約かGROUP BYのキーに使う列のみが書ける(自由な式が何でも書けるわけではない)。- 増分更新が効くのは、元テーブルへの変更が主に「追記」である場合。過去パーティションを大きく書き換えると、増分ではなくフルリフレッシュ(=スキャン増)が走ることがある。
- 元テーブルが削除・変更されるとMVが無効化されることがある。
対象や式によっては、そもそもMVを作成できないことがあります。その場合は無理にMV化せず、中間テーブル+スケジュールドクエリでの集計で代替してください。これらの可否や対応関数は更新されることがあるので、実際に作る前に必ず最新の公式ドキュメントを確認してください。ここで「使えるはず」と書いた関数でも、環境やバージョンで挙動が変わる可能性があります。
特にGA4集計でつまずきやすいのが COUNT(DISTINCT user_pseudo_id)(ユニークユーザー数)です。厳密値が増分更新と相性が悪い場合は、APPROX_COUNT_DISTINCT(user_pseudo_id) に置き換えると、わずかな誤差と引き換えにMVの恩恵を受けやすくなります。ダッシュボード用途なら近似値で十分なことも多いはずです。
また、MVは「魔法のスイッチ」ではなく、更新そのものにもコスト(スキャン)がかかります。ほとんど参照されないのに頻繁に更新されるMVは、かえって割高になることもあります。refresh_interval_minutes を実態に合わせて調整し、「どれくらいの鮮度が必要か」を一度決めておくとよいです。
まとめ
GA4 × BigQueryの集計が「毎回重い・毎回お金がかかる」状態になっていたら、マテリアライズドビューは有力な一手です。要点を振り返ります。
- MVはクエリ結果を保存して自動更新してくれる仕組み。通常ビューより速く、中間テーブルより運用が楽。
- ただしGA4エクスポートに直接MVは作れない。
events_*のワイルドカードやビューは参照できず、UNNESTを含む定義にも制約があるため、①フラット化した中間テーブルを作る → ②その中間テーブルにMVを作る、という二段構えにする。 - GA4の固定KPI(日付別PV、ユニークユーザーなど)のように、形が決まっていて何度も見る集計と相性がよい。
- 使える集計・SQLにも制約がある。特に厳密な
COUNT(DISTINCT)は要注意で、APPROX_COUNT_DISTINCTへの置き換えや、MVが作れない場合のスケジュールドクエリ集計も検討する。 - 更新にもコストはかかるので、鮮度要件に合わせて更新間隔を決める。
MVは「決まった集計を安く速く」する道具なので、コスト全体の見直しや、そもそもの集計設計とあわせて考えると効果が出やすいです。コストを抑える小ワザは BigQueryのコストを月1万円以下に抑えるための実践的な工夫 に、MVが合わない「複雑な集計を毎日作り直す」ケースは スケジュールドクエリで毎日データマートを更新する にまとめているので、あわせてどうぞ。
なお、対応する集計関数や増分更新の条件は変わることがあります。実際に導入する際は、本記事の例をたたき台にしつつ、最新の公式ドキュメントで仕様を確認してから進めてください。