はじめに: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つあります。

  1. GA4のエクスポートは日次シャード events_YYYYMMDD(と当日分の events_intraday_)として作られる。 すべての日付がまとまった単一の events パーティションテーブルが存在するわけではありません。複数日をまとめて読むには events_* のようなワイルドカードを使うことになります。
  2. 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';

UNNESTevents_* のワイルドカードは、このステップ(=普通のクエリ/スケジュールドクエリ)では自由に使えます。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つです。

  1. MVの参照元はワイルドカードでもビューでもない、実在する単一テーブル(中間テーブル)にする。UNNEST を含む複雑な定義は避け、配列展開はステップ①で済ませておく。
  2. ユニークユーザー数は厳密な 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 や一部のウィンドウ関数、HAVINGORDER BYLIMIT などは定義に使えない/制限がある。
  • 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が合わない「複雑な集計を毎日作り直す」ケースは スケジュールドクエリで毎日データマートを更新する にまとめているので、あわせてどうぞ。

なお、対応する集計関数や増分更新の条件は変わることがあります。実際に導入する際は、本記事の例をたたき台にしつつ、最新の公式ドキュメントで仕様を確認してから進めてください。