Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save hdknr/b32313de621a699d6b0e80a47d982cbf to your computer and use it in GitHub Desktop.

Select an option

Save hdknr/b32313de621a699d6b0e80a47d982cbf to your computer and use it in GitHub Desktop.
BigQuery ARRAY/STRUCT で速度3倍・コスト25%削減 --- JOINを消す「データの持ち方」最適化

BigQuery ARRAY/STRUCT で速度 3 倍・コスト 25% 削減 --- JOIN を消す「データの持ち方」最適化

@yoshitake_l 氏が X で共有した、BigQuery のデータ構造変更による劇的な改善結果が注目を集めています。

BigQuery でデータの持ち方を変えるだけで、クエリ処理速度を 3 倍に、クエリコストを 25% 削減できたので共有。試したクエリは、1:N の 2 つのテーブルの N 側を集計し、1 側と JOIN するシンプルなもの。使ったのは、ARRAY と STRUCT というデータ構造です。

「データの持ち方を変えるだけ」で速度 3 倍・コスト 25% 削減。SQL のチューニングではなく、テーブル設計の変更でこの結果を得ています。本記事では、なぜ ARRAY/STRUCT が JOIN より高速でコストが低いのか、その技術的な仕組みと実践方法を解説します。

なぜ JOIN は遅くて高いのか

BigQuery の分散処理とシャッフル

BigQuery の課金と速度の問題を理解するには、まず分散処理の仕組みを知る必要があります。

BigQuery の JOIN 処理の流れ:

  1. テーブル A を複数のスロット(ワーカーノード)に分散読み込み
  2. テーブル B を複数のスロットに分散読み込み
  3. JOIN キーに基づいて、データを適切なスロットに「再配置」
     → これが「シャッフル」
  4. 各スロットでマッチング処理を実行
  5. 結果を統合

  問題:
    ステップ 3 のシャッフルが最大のボトルネック
    ├── スロット間のネットワーク通信が発生
    ├── 大量の中間データが移動
    └── 通信待ちの間、スロットが遊休状態になる

BigQuery のオンデマンド課金は「スキャンしたバイト数」に比例します。JOIN では両方のテーブルのキー列と必要列をすべてスキャンするため、スキャン量が増えます。さらに、JOIN に必要なシャッフル処理が実行時間を大幅に伸ばします。

1:N の JOIN は特に影響が大きい

@yoshitake_l 氏が試したのは「1:N の 2 つのテーブルの N 側を集計し、1 側と JOIN する」クエリです。これは典型的な親子関係のデータ構造です。

典型的な 1:N テーブル構造:

  orders テーブル(1 側):
    order_id | customer_name | order_date
    ---------+--------------+-----------
    1001     | 田中太郎     | 2026-03-01
    1002     | 鈴木花子     | 2026-03-02

  order_items テーブル(N 側):
    item_id | order_id | product  | amount
    --------+---------+---------+-------
    1       | 1001    | 商品 A   | 3000
    2       | 1001    | 商品 B   | 5000
    3       | 1001    | 商品 C   | 2000
    4       | 1002    | 商品 D   | 8000
    5       | 1002    | 商品 E   | 1000

  よくあるクエリ:
    SELECT o.order_id, o.customer_name,
           SUM(i.amount) AS total_amount,
           COUNT(i.item_id) AS item_count
    FROM orders o
    JOIN order_items i ON o.order_id = i.order_id
    GROUP BY o.order_id, o.customer_name

このクエリでは、orders テーブルと order_items テーブルの両方をフルスキャンし、order_id でシャッフルして結合します。データ量が増えるほど、シャッフルのコストが支配的になります。

ARRAY/STRUCT による解決

ARRAY と STRUCT とは

BigQuery は、列にネストされたデータ構造を格納できます。

BigQuery のデータ型:

  STRUCT(構造体):
    複数のフィールドを 1 つのカラムにまとめる
    → SQL の「行」のようなもの
    例: STRUCT<product STRING, amount INT64>

  ARRAY(配列):
    同じ型の値を複数格納する
    → 1 つのセルに複数の値を持てる
    例: ARRAY<INT64>

  ARRAY<STRUCT>(配列×構造体):
    構造体の配列 = 「テーブルの中にテーブル」
    → 1:N の関係を 1 行にまとめられる
    例: ARRAY<STRUCT<product STRING, amount INT64>>

テーブル設計の変換

1:N の 2 テーブルを、ARRAY を使った 1 テーブルに変換します。

-- 変換前: 2 つのフラットテーブル
-- orders テーブル + order_items テーブル(JOIN が必要)

-- 変換後: ARRAY<STRUCT> で 1 テーブルに統合
CREATE TABLE orders_nested AS
SELECT
  o.order_id,
  o.customer_name,
  o.order_date,
  ARRAY_AGG(
    STRUCT(i.item_id, i.product, i.amount)
  ) AS items  -- N 側を ARRAY<STRUCT> として格納
FROM orders o
JOIN order_items i ON o.order_id = i.order_id
GROUP BY o.order_id, o.customer_name, o.order_date

変換後のテーブル構造はこうなります。

orders_nested テーブル:

  order_id | customer_name | order_date | items(ARRAY<STRUCT>)
  ---------+--------------+-----------+------------------------
  1001     | 田中太郎     | 2026-03-01 | [{1, "商品A", 3000},
           |              |            |  {2, "商品B", 5000},
           |              |            |  {3, "商品C", 2000}]
  1002     | 鈴木花子     | 2026-03-02 | [{4, "商品D", 8000},
           |              |            |  {5, "商品E", 1000}]

変換後のクエリ

JOIN が不要になり、UNNEST で配列を展開するだけで同じ結果が得られます。

-- 変換後: JOIN なし、UNNEST で展開
SELECT
  order_id,
  customer_name,
  SUM(item.amount) AS total_amount,
  COUNT(*) AS item_count
FROM orders_nested,
UNNEST(items) AS item
GROUP BY order_id, customer_name

なぜ速くなるのか --- 3 つの技術的理由

理由 1: シャッフルの排除

最大の要因はシャッフルの排除です。

JOIN ありの場合:
  スロット A: orders の一部を読む
  スロット B: order_items の一部を読む
  → order_id でシャッフル(ネットワーク通信)
  → マッチング処理
  → 結果統合

ARRAY/STRUCT の場合:
  スロット A: orders_nested の一部を読む
  → そのスロット内で UNNEST して集計(ローカル処理)
  → 結果統合

  シャッフルが発生しない
  → ネットワーク通信ゼロ
  → スロットの遊休時間ゼロ

Google 公式ドキュメントも明確にこの点を述べています。

Denormalization localizes the data to individual slots, so that execution can be done in parallel. (非正規化によってデータが個別スロットに局在化され、並列実行が可能になる)

--- Use nested and repeated fields | BigQuery | Google Cloud

理由 2: 列指向ストレージの最大活用

BigQuery は列指向(カラム型)ストレージを採用しています。クエリで参照した列だけをスキャンする仕組みです。

列指向ストレージと ARRAY/STRUCT:

  フラットテーブルの場合:
    orders テーブル → order_id, customer_name をスキャン
    order_items テーブル → order_id, amount をスキャン
    → 2 テーブル分のスキャンが発生

  ARRAY<STRUCT> の場合:
    orders_nested テーブル → order_id, customer_name,
                             items.amount だけをスキャン
    → 1 テーブルの必要列のみ

  さらに:
    ARRAY<STRUCT> 内の個別フィールドを指定できる
    → items.amount だけ読んで items.product は読まない
    → 不要なサブフィールドのスキャンを回避

ARRAY の中のサブフィールドも列指向で格納されるため、items.amount だけを参照すれば items.product はスキャンされません。これがスキャン量(= コスト)の削減に直結します。

理由 3: 行数の大幅削減

1:N の関係をネスト化すると、テーブルの行数が劇的に減ります。

行数の比較:

  フラットテーブル:
    orders: 100 万行
    order_items: 500 万行(平均 5 アイテム/注文)
    → 合計 600 万行をスキャン

  ARRAY<STRUCT>:
    orders_nested: 100 万行(items は配列として格納)
    → 100 万行のみスキャン

  行数が 6 分の 1 に削減
  → メタデータのオーバーヘッドも減少
  → 行ごとの JOIN キー重複もゼロ

Towards Data Science の実例では、テーブルサイズ 35.5% 削減、行数 93.1% 削減という結果が報告されています。

コスト削減の仕組み

スキャン量の削減

BigQuery のオンデマンド課金は「スキャンしたバイト数 × $5/TB」です。ARRAY/STRUCT でスキャン量が減れば、そのまま課金額が減ります。

比較項目 フラット + JOIN ARRAY/STRUCT
スキャンテーブル数 2 テーブル 1 テーブル
JOIN キー列のスキャン 両テーブルで必要 不要
N 側のキー重複 N 行分のキーをスキャン なし
サブフィールド選択 テーブル単位 フィールド単位

実際のベンチマーク

公開されているベンチマーク結果をまとめます。

ソース フラット + JOIN ARRAY/STRUCT 改善率
Google 公式ブログ 1.88 GB スキャン 1.28 GB スキャン 32% 削減
Google 公式ブログ 実行時間ベースライン 約 60% 高速化 60% 改善
Towards Data Science 427.88 GB / $2.67 112.28 GB / $0.70 74% 削減
@yoshitake_l 氏 ベースライン 3 倍高速、25% コスト減 ツイートの結果

@yoshitake_l 氏の「速度 3 倍・コスト 25% 削減」は、上記のベンチマーク群と比較しても控えめな方です。データの特性(1:N の比率、カラム数、データ量)によっては、さらに大きな改善が見込めます。

実践ガイド

ARRAY/STRUCT が有効なケース

有効なケース:

  1. 1:N の親子関係:
     受注ヘッダー + 受注明細
     ユーザー + 行動ログ
     商品 + レビュー

  2. 常に一緒にクエリされるデータ:
     注文の集計で必ず明細を参照する
     ユーザー分析で行動履歴を参照する

  3. N 側のデータ量が多い:
     1 注文に 5〜50 の明細がある
     1 ユーザーに数百のイベントがある

  4. 10 GB 以上のテーブル:
     小さなテーブルではメリットが薄い
     大きいほど効果が大きい

ARRAY/STRUCT が不向きなケース

不向きなケース:

  1. 独立したエンティティの結合:
     顧客テーブル × 商品テーブル(N:M)
     → 親子関係ではないため ARRAY 化できない

  2. 頻繁に個別行を更新する:
     BigQuery は UPDATE が高コスト
     N 側を個別に更新するワークロードには不向き

  3. N 側を単独でクエリする:
     明細テーブルだけを集計したい場合
     → ネスト構造だと親テーブル全体を読む必要がある

  4. N 側のデータ量が極端に大きい:
     1 親に対して数万件の子レコード
     → 1 行が巨大になり、別の問題が発生

変換の手順

-- ステップ 1: 変換先テーブルを作成
CREATE OR REPLACE TABLE dataset.orders_nested AS
SELECT
  o.order_id,
  o.customer_name,
  o.order_date,
  ARRAY_AGG(
    STRUCT(
      i.item_id AS item_id,
      i.product AS product,
      i.amount AS amount
    )
  ) AS items
FROM dataset.orders o
JOIN dataset.order_items i ON o.order_id = i.order_id
GROUP BY o.order_id, o.customer_name, o.order_date;

-- ステップ 2: 変換後のクエリ例(集計)
SELECT
  order_id,
  customer_name,
  (SELECT SUM(item.amount) FROM UNNEST(items) AS item) AS total,
  ARRAY_LENGTH(items) AS item_count
FROM dataset.orders_nested;

-- ステップ 3: 条件付きフィルタリング
SELECT
  order_id,
  customer_name,
  item.product,
  item.amount
FROM dataset.orders_nested,
UNNEST(items) AS item
WHERE item.amount > 5000;

GA4 データでの実例

Google Analytics 4 の BigQuery エクスポートは、ARRAY を標準で採用しています。event_params フィールドがその代表例です。

-- GA4 の event_params は ARRAY<STRUCT> 形式
SELECT
  event_name,
  (SELECT value.string_value
   FROM UNNEST(event_params)
   WHERE key = 'page_location') AS page_url,
  (SELECT value.int_value
   FROM UNNEST(event_params)
   WHERE key = 'engagement_time_msec') AS engagement_ms
FROM `project.analytics_XXXXXX.events_*`
WHERE _TABLE_SUFFIX = '20260306'

Google が GA4 のデータ形式に ARRAY を選んだこと自体が、この設計パターンの有効性を示しています。

RDB の常識を捨てる

正規化 vs 非正規化

RDB(リレーショナルデータベース)の世界では「正規化」が基本原則です。データの重複を排除し、テーブルを分割して JOIN で結合する。これは OLTP(トランザクション処理)では正しい設計です。

しかし BigQuery のような分析基盤(OLAP)では、正規化の前提が崩れます。

OLTP(RDB)と OLAP(BigQuery)の違い:

  OLTP(MySQL, PostgreSQL 等):
    ├── 行単位の読み書きが主
    ├── UPDATE が頻繁
    ├── トランザクションの整合性が重要
    ├── JOIN は B-Tree インデックスで高速
    └── → 正規化が正解

  OLAP(BigQuery):
    ├── 列単位の大量スキャンが主
    ├── INSERT のみ(UPDATE は高コスト)
    ├── 分析クエリの速度とコストが重要
    ├── JOIN はシャッフルを伴い高コスト
    └── → 非正規化(ARRAY/STRUCT)が正解

Google 公式ドキュメントも明確に非正規化を推奨しています。

The recommended way to denormalize data in BigQuery is to use nested and repeated fields. (BigQuery でデータを非正規化する推奨方法は、ネストされた繰り返しフィールドを使用すること)

ETL パイプラインへの影響

ARRAY/STRUCT 化は、ETL(データ取り込み)パイプラインの設計にも影響します。

ETL 設計の変更:

  従来:
    ソースDB → orders テーブルに INSERT
    ソースDB → order_items テーブルに INSERT
    → 分析時に JOIN

  ARRAY/STRUCT 化:
    ソースDB → 中間テーブルに INSERT
    → スケジュールクエリで ARRAY_AGG して
      orders_nested テーブルに変換
    → 分析時は JOIN 不要

一度 ETL パイプラインを変更すれば、その後のすべての分析クエリが高速化・低コスト化されます。初期の移行コストに対して、長期的なリターンが大きい投資です。

まとめ

  • JOIN を消すことが最大の最適化: BigQuery では JOIN のシャッフル処理がボトルネック。ARRAY/STRUCT で 1:N を 1 テーブルにまとめれば、シャッフルがゼロになる
  • 速度 3 倍・コスト 25% 削減は控えめな結果: Google 公式ブログでは 60% 高速化・32% スキャン削減、Towards Data Science では 74% コスト削減の報告もある。データ特性次第でさらに改善可能
  • 3 つの技術的理由: (1) シャッフル排除(スロット間通信ゼロ)、(2) 列指向ストレージの最大活用(サブフィールド単位のスキャン制御)、(3) 行数の大幅削減(メタデータオーバーヘッド減少)
  • Google 公式が非正規化を推奨: 「BigQuery でデータを非正規化する推奨方法は、ネストされた繰り返しフィールドを使用すること」と明記。GA4 のデータ形式が ARRAY を標準採用していること自体が証明
  • RDB の常識を捨てる: OLTP では正規化が正解だが、OLAP(BigQuery)では非正規化が正解。SQL のチューニングより先に「データの持ち方」を見直すべき
  • 適用判断が重要: 1:N の親子関係、常に一緒にクエリされるデータ、10 GB 以上のテーブルが有効。独立エンティティの N:M 結合や頻繁な個別行更新には不向き

参考

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment