- BigQuery でネスト・繰り返しフィールドによる非正規化が推奨される理由を、カラムナストレージと JOIN コストの観点で説明できる
- STRUCT 型と ARRAY 型を宣言・参照し、1 対多の関係を 1 列に埋め込んだスキーマを組み立てられる
- UNNEST の動作原理(相関 CROSS JOIN)を理解し、LEFT JOIN UNNEST で空配列を持つ行を落とさずに展開できる
- ARRAY_AGG / ARRAY_LENGTH / OFFSET / SAFE_OFFSET などの配列関数を使いこなせる
- JSON 型と STRUCT 型のどちらを選ぶべきかを、スキーマの安定性という観点で判断できる
前提: サンドボックスで完走可
なぜ BigQuery では非正規化が有利なのか
これまでの章では、テーブルの列はすべて単純型(STRING や INT64 など)でした。しかし BigQuery では、列そのものが「入れ子のレコード」や「繰り返しの値」を持てます。これが GoogleSQL の STRUCT(構造体)と ARRAY(配列)です。リレーショナル DB の経験があると、1 対多の関係は親テーブルと子テーブルに分けて JOIN で結合するのが自然に思えますが、BigQuery ではむしろ「子テーブルの行を親テーブルの 1 列に埋め込んでしまう」非正規化が推奨されます。
理由はデータ調整のコストです。JOIN はキーの一致するデータをノード間で突き合わせる処理で、内部的にシャッフル(ノード間でのデータ再配置)が発生し、通信帯域が消費されます。関連するデータをあらかじめ 1 行の中に持たせておけば、この突き合わせ自体が不要になります。BigQuery の非正規化ベストプラクティスの解説では、Stack Overflow の公開データセットを使った検証で、フラットな構造(JOIN + GROUP BY)が約 25 秒・1.88 GB の処理に対し、同じ内容をネスト構造で持たせたテーブルでは約 10 秒・1.28 GB まで短縮されたと報告されています。約 60% の高速化、約 32% のデータ削減です。
ただしすべてのケースで非正規化が勝つわけではありません。同じ解説では「スタースキーマはすでに最適化されたスキーマであり、さらに非正規化してもパフォーマンスが大きく改善しない場合がある」とも注意されています。子側の件数が非常に多く、独立して更新・参照されるならテーブルを分けたままの方が扱いやすいこともあります。
STRUCT と ARRAY の宣言・参照
ネスト・繰り返しフィールドは、テーブルのスキーマでは「RECORD 型」「REPEATED モード」として表現されますが、GoogleSQL からは STRUCT 型・ARRAY 型としてアクセスします。
CREATE TABLE handson_ch05.orders (
order_id INT64,
customer STRUCT<name STRING, email STRING>,
items ARRAY<STRUCT<item STRING, quantity INT64, unit_price INT64>>
);
STRUCT は角括弧ではなく <>(山括弧)で型を宣言し、フィールド名は省略できます。ARRAY は「同じ型の値の順序付きリスト」で、要素が STRUCT でもかまいません。ここで組んだ ARRAY<STRUCT<...>> は、1 対多の関係を 1 列に埋め込む定番パターンです。フィールドへのアクセスはドット記法(customer.name)で行います。なお、配列の配列は作れません。ネストした配列が必要な場合は、間に STRUCT を 1 段挟みます。ネストの深さにも上限があり、RECORD のネストは 15 段階まで(16 段階以上は不可)です。
配列を直接操作する関数も豊富です。よく使うものを次に挙げます。
| 関数 | 用途 | NULL・範囲外の扱い |
|---|---|---|
ARRAY_LENGTH(arr) | 要素数を返す | arr が NULL なら NULL |
ARRAY_AGG(expr) | 複数行の値を 1 つの配列に集計する | 順序は保証されないため ORDER BY を明示する |
arr[OFFSET(n)] | 0 始まりの位置で要素を取得 | 範囲外はエラー |
arr[SAFE_OFFSET(n)] | 0 始まりの位置で要素を取得 | 範囲外は NULL |
arr[ORDINAL(n)] / arr[SAFE_ORDINAL(n)] | 1 始まりの位置で要素を取得 | SAFE 版は範囲外で NULL |
ARRAY_TO_STRING(arr, sep) | 配列を区切り文字で 1 本の文字列にする | 既定では NULL 要素を無視する |
UNNEST の仕組み — 相関 CROSS JOIN と LEFT JOIN UNNEST
ARRAY 列そのままでは、要素ごとに集計したり条件を付けたりできません。そこで使うのが UNNEST です。UNNEST は配列を「複数行のテーブル」に変換するテーブル値関数で、次のように書きます。
SELECT order_id, customer.name, i.item, i.quantity
FROM handson_ch05.orders, UNNEST(items) AS i;
この FROM table, UNNEST(table.array_column) AS elem という形は、実質的に「各行に対して、その行が持つ配列だけを CROSS JOIN する」という動作をします。これを相関 CROSS JOINと呼びます。同じ意味を CROSS JOIN UNNEST(items) AS i と明示的に書いても構いません。
ここで注意が必要なのが、配列が空([])または NULL の行の扱いです。CROSS JOIN UNNEST は INNER JOIN と同じ理屈で動くため、展開対象の配列が空の行は結果からまるごと消えます。この行も残したい場合は LEFT JOIN UNNEST を使います。展開列は NULL になりますが、元の行自体は 1 行として保持されます。
-- 空配列の行を落とさない
SELECT order_id, customer.name, i.item, i.quantity
FROM handson_ch05.orders
LEFT JOIN UNNEST(items) AS i;
JSON 型と STRUCT の使い分け
ネスト構造をスキーマ側で持つ STRUCT に対し、BigQuery には JSON 型という選択肢もあります。JSON 型はスキーマ・オン・リードで扱えるデータ型で、列を作る際に内部構造を確定させる必要がありません。
CREATE TABLE handson_ch05.events (
event_id INT64,
payload JSON
);
STRUCT はフィールド名と型をスキーマとして固定するため、型チェックが効き、列指向ストレージの恩恵(使わないフィールドを読み飛ばす)を最大限に活かせます。一方 JSON 型は構造が事前に決まっていない、あるいは頻繁に変わるデータ(外部 API のレスポンスをそのまま保存する場合など)に向きます。
JSON の値を取り出す関数は複数あり、用途で使い分けます。JSON_VALUE はスカラー値を SQL の STRING に変換して返し、非スカラー(オブジェクトや配列)が指定された場合は NULL になります。JSON_QUERY は JSON 値のまま返すため、ネストしたオブジェクトや配列も取り出せます。配列専用には JSON_VALUE_ARRAY(スカラー配列を ARRAY<STRING> に)、JSON_QUERY_ARRAY(JSON の配列を ARRAY<JSON> に)があります。
型があいまいな JSON 値を柔軟に SQL 型へ変換したいときは LAX 系関数(LAX_BOOL / LAX_INT64 / LAX_STRING / LAX_FLOAT64 など)を使います。通常の CAST と異なり、変換できない場合はエラーではなく NULL を返すのが特徴です。例えば LAX_INT64(JSON '"42"') は文字列 "42" から整数 42 を返します。
所要時間の目安: 5 分。サンドボックスで可・無料枠内(作成するテーブルは数百バイト程度)。
- データセット
handson_ch05(ロケーション US)を作成します。コンソールの場合はエクスプローラの「データセットを作成」から、bq の場合は次のコマンドです。
bq mk --dataset --location=US PROJECT_ID:handson_ch05
PROJECT_ID は自分のプロジェクト ID に置き換えます。
- customer を STRUCT、items を ARRAY<STRUCT> にしたテーブルを CTAS(CREATE TABLE AS SELECT)で作成します。2 件目はあえて明細が 0 件の注文にします。
CREATE TABLE handson_ch05.orders AS
SELECT
1001 AS order_id,
STRUCT('田中 太郎' AS name, 'tanaka@example.com' AS email) AS customer,
[STRUCT('ノート PC' AS item, 2 AS quantity, 128000 AS unit_price),
STRUCT('マウス' AS item, 1 AS quantity, 3200 AS unit_price)] AS items
UNION ALL
SELECT
1002,
STRUCT('鈴木 花子', 'suzuki@example.com'),
CAST([] AS ARRAY<STRUCT<item STRING, quantity INT64, unit_price INT64>>);
- UNNEST で明細行に展開します。
SELECT order_id, customer.name AS customer_name, i.item, i.quantity, i.unit_price
FROM handson_ch05.orders, UNNEST(items) AS i
ORDER BY order_id;
期待される出力(order_id 1002 は明細が無いため現れません):
order_id | customer_name | item | quantity | unit_price
1001 | 田中 太郎 | ノート PC | 2 | 128000
1001 | 田中 太郎 | マウス | 1 | 3200
後片付け: ハンズオン 5-3 まで同じデータセットを使うため、ここでは削除しません。
所要時間の目安: 5 分。サンドボックスで可・無料枠内(bigquery-public-data.github_repos.languages は約 195 MiB。列を絞ってもフルスキャンになりますが無料枠 1 TiB には十分収まります)。
github_repos.languages は repo_name STRING, language ARRAY<STRUCT<name STRING, bytes INT64>> という、1 リポジトリに対して複数の言語構成を持つ典型的なネストテーブルです。まず dry run でスキャン量を確認します。
bq query --use_legacy_sql=false --dry_run '
SELECT repo_name, language
FROM `bigquery-public-data.github_repos.languages`
WHERE repo_name = "torvalds/linux"'
続けて UNNEST で言語ごとの行に展開し、バイト数の多い順に並べます。
-- 処理: 約 195 MiB(列を絞ってもテーブル全体をスキャンする)
SELECT repo_name, lang.name AS language, lang.bytes
FROM `bigquery-public-data.github_repos.languages`,
UNNEST(language) AS lang
WHERE repo_name = 'torvalds/linux'
ORDER BY lang.bytes DESC
LIMIT 5;
実行すると、C や Makefile など、Linux カーネルらしい言語が上位に並びます(構成比はリポジトリの更新により変動するため、具体的な数値はここでは示しません)。全リポジトリを対象に「言語別の総リポジトリ数」を集計することもできます。
SELECT lang.name AS language, COUNT(DISTINCT repo_name) AS repo_count
FROM `bigquery-public-data.github_repos.languages`,
UNNEST(language) AS lang
GROUP BY language
ORDER BY repo_count DESC
LIMIT 10;
後片付け: 公開データセットを読んだだけなので後片付けは不要です。
所要時間の目安: 3 分。サンドボックスで可・無料枠内(数百バイトの自作テーブルへの参照)。
- ハンズオン 5-1 で作った
handson_ch05.ordersに対し、まず CROSS JOIN UNNEST(既定の UNNEST の書き方と同じ動作)で注文件数をカウントします。
SELECT COUNT(DISTINCT order_id) AS orders_with_items
FROM handson_ch05.orders, UNNEST(items) AS i;
結果は 1(order_id 1002 の空配列は展開されず消えるため)です。
- LEFT JOIN UNNEST に書き換えて同じ集計をします。
SELECT COUNT(DISTINCT order_id) AS all_orders
FROM handson_ch05.orders
LEFT JOIN UNNEST(items) AS i;
結果は 2 になり、明細の無い注文も母数に含まれます。「注文全体に対する明細ありの割合」のような分析では、この違いが結果を左右します。
後片付け:
bq rm -r -f -d PROJECT_ID:handson_ch05
この時点で handson_ch05 データセットは不要になるため削除します。次のハンズオンでは新しいデータセットを作り直します。
所要時間の目安: 5 分。サンドボックスで可・無料枠内(数百バイトの自作テーブル)。
- データセットを作り直し、JSON 型の列を持つテーブルを作成します。
bq mk --dataset --location=US PROJECT_ID:handson_ch05_json
CREATE TABLE handson_ch05_json.events AS
SELECT 1 AS event_id,
JSON '{"user": {"id": 42, "plan": "pro"}, "tags": ["beta", "mobile"]}' AS payload
UNION ALL
SELECT 2,
JSON '{"user": {"id": 43, "plan": "free"}, "tags": []}';
- スカラー値は JSON_VALUE、数値への変換には LAX_INT64、配列は JSON_QUERY_ARRAY で取り出します。
SELECT
event_id,
JSON_VALUE(payload, '$.user.plan') AS plan,
LAX_INT64(JSON_QUERY(payload, '$.user.id')) AS user_id,
JSON_QUERY_ARRAY(payload, '$.tags') AS tags
FROM handson_ch05_json.events
ORDER BY event_id;
期待される出力:
event_id | plan | user_id | tags
1 | pro | 42 | ["beta", "mobile"]
2 | free | 43 | []
後片付け:
bq rm -r -f -d PROJECT_ID:handson_ch05_json
つまずきポイント
, UNNEST(...))は空配列・NULL 配列の行を静かに除外します。集計の分母がいつの間にか小さくなる典型的な原因なので、明細 0 件の行も数えたい場面では最初から LEFT JOIN UNNEST を使う習慣にしましょう。arr[OFFSET(n)] は範囲外アクセスでエラーになります。配列長が行によって異なる場合は SAFE_OFFSET(n) を使い、範囲外は NULL として扱いましょう。JSON_VALUE はオブジェクトや配列などの非スカラー値を指すと NULL を返します。ネストしたオブジェクトごと取り出したいときは JSON_QUERY を使い、スカラー値だけを取り出したいときは JSON_VALUE を使う、と用途で切り分けます。ARRAY<STRUCT<inner_array ARRAY<...>>> のように組み立てます。理解度チェック
Q1. BigQuery でネスト・繰り返しフィールドによる非正規化が推奨されるのはなぜですか?
JOIN はキーの一致するデータをノード間で突き合わせるためシャッフル(データ再配置)が発生し通信コストがかかりますが、関連データをあらかじめ 1 行にまとめておけば JOIN 自体が不要になるためです。ただしスタースキーマがすでに最適化されている場合は効果が小さいこともあります。
Q2. CROSS JOIN UNNEST と LEFT JOIN UNNEST の違いは何ですか?
CROSS JOIN UNNEST は展開対象の配列が空または NULL の行を結果から除外します。LEFT JOIN UNNEST はそのような行も 1 行として保持し、展開された列は NULL になります。明細 0 件の行も母数に含めたい集計では LEFT JOIN UNNEST を使います。
Q3. OFFSET(n) と SAFE_OFFSET(n) の違いは何ですか?
どちらも 0 始まりのインデックスで配列要素にアクセスしますが、OFFSET(n) は範囲外アクセスでエラーになるのに対し、SAFE_OFFSET(n) は範囲外の場合に NULL を返します。行ごとに配列長が異なる場合は SAFE_OFFSET が安全です。
Q4.(シナリオ)外部 API から受け取るイベントログを保存したい。フィールドは API の仕様変更によっていつ増減するか分からない。最も適した列の型はどれか?
A. STRUCT 型で固定スキーマを定義する B. JSON 型で保存する C. フィールドごとに個別の STRING 列へ分解する D. すべての値を 1 本の REPEATED STRING 列に詰め込む
B。JSON 型はスキーマ・オン・リードで、フィールドの増減があっても列定義を変更せずに扱えます。A の STRUCT は固定スキーマを強制するため頻繁な変更に弱く、C は列追加のたびに ALTER TABLE が必要でスケールしません。D はキーと値の対応が失われ、後から特定フィールドを取り出せなくなります。
Q5. STRUCT 型を使う主なメリットは何ですか?
フィールド名と型をスキーマとして固定できるため型チェックが効き、列指向ストレージの恩恵(使わないフィールドを読み飛ばす)を JSON 型より活かしやすくなります。構造が安定しているデータに向いています。
まとめと次章へ
- BigQuery はネスト・繰り返しフィールドによる非正規化で最高のパフォーマンスを発揮するように設計されている
- STRUCT(RECORD)と ARRAY(REPEATED)を組み合わせた
ARRAY<STRUCT<...>>は 1 対多の関係を 1 列に埋め込む定番パターン - UNNEST は配列を行に変換する相関 CROSS JOIN。空配列を持つ行を落としたくない場合は LEFT JOIN UNNEST を使う
- JSON 型はスキーマ・オン・リード、STRUCT 型は固定スキーマ。構造が安定しているなら STRUCT、変わりやすいなら JSON を選ぶ
次章では、フルスキャン課金のもとでスキャン量そのものを桁で減らすパーティショニングとクラスタリングを学びます。