Databricks Certified Data Engineer Associate 教科書
第3章 データ変換とモデリング(Data Transformation and Modeling, 22%)
🎯 この節の学習目標
シルバー層のデータは複数のテーブルに分かれています(例:注文 orders と顧客 customers)。これらを突き合わせて1つの DataFrame にするのが結合(join)です。PySpark では df1.join(df2, 結合条件, 結合タイプ) の形で書きます。
orders = spark.read.table("silver.orders")
customers = spark.read.table("silver.customers")
# inner join(既定):両方に存在するキーの行だけ残る
df = orders.join(customers, on="customer_id", how="inner")
# left join:左(orders)は全行残り、相手がなければ null で埋まる
df = orders.join(customers, on="customer_id", how="left")
-- SQL 版
SELECT o.*, c.customer_name, c.region
FROM silver.orders o
INNER JOIN silver.customers c
ON o.customer_id = c.customer_id;
SELECT o.*, c.customer_name
FROM silver.orders o
LEFT JOIN silver.customers c
ON o.customer_id = c.customer_id;
| 結合タイプ | how の指定 | 残る行 | 典型的な用途 |
|---|---|---|---|
| inner | "inner"(既定) | 両方にキーが存在する行のみ | マスタに存在する取引だけを分析対象にする |
| left(left outer) | "left" | 左の全行+一致した右の列(不一致は null) | 全注文を残しつつ顧客属性を付与する |
| right(right outer) | "right" | 右の全行+一致した左の列 | left の左右逆。実務では left に書き換えることが多い |
| full(full outer) | "full" | 両方の全行(不一致側は null) | 2つのデータの差分・突合チェック |
| cross | crossJoin() | 全行×全行(直積) | 全組み合わせの生成(日付×店舗のカレンダー表など)。行数が爆発するため要注意 |
| left semi | "left_semi" | 右に一致がある左の行のみ(右の列は付かない) | 「存在チェック」でのフィルタ |
| left anti | "left_anti" | 右に一致がない左の行のみ | 「マスタに存在しないレコード」の抽出 |
💡 具体例:複数キーでの結合と cross join
# 複数キー結合:リストで渡す
df = orders.join(returns, on=["order_id", "line_no"], how="left")
# 列名が左右で異なる場合は条件式で書く
df = orders.join(
customers,
orders["customer_id"] == customers["cust_id"],
"inner"
)
# cross join:全組み合わせ(明示的に crossJoin を使う)
calendar = dates.crossJoin(stores) # 日付 × 店舗の全組み合わせ
結合条件を on="customer_id" のように列名で渡すとキー列は1つにまとまりますが、条件式(==)で渡すと両方の DataFrame のキー列が残り、列名の曖昧さエラーの原因になります。条件式で結合した後は不要な側を drop するのが定石です。
通常の結合(shuffle join)では、同じキーの行を同じワーカーに集めるために、両方のテーブルをネットワーク越しに再配置(シャッフル)します。シャッフルはネットワーク転送とディスクI/Oを伴う高コストな処理です。
ここで片方のテーブルが十分小さい場合、小さいテーブル全体を全ワーカーにコピー(ブロードキャスト)してしまえば、大きいテーブルは動かさずその場で結合できます。これが broadcast join(ブロードキャスト結合)です。
図:shuffle join と broadcast join のデータ移動の違い
💡 具体例:broadcast ヒントの付け方
from pyspark.sql.functions import broadcast
orders = spark.read.table("silver.orders") # 数億行の大テーブル
regions = spark.read.table("silver.region_master") # 数百行の小テーブル
# 小さい側を broadcast() で包む
df = orders.join(broadcast(regions), on="region_code", how="left")
-- SQL ではヒント構文で指定
SELECT /*+ BROADCAST(r) */ o.*, r.region_name
FROM silver.orders o
LEFT JOIN silver.region_master r
ON o.region_code = r.region_code;
実は明示しなくても、テーブルサイズが spark.sql.autoBroadcastJoinThreshold(既定 10MB)以下なら Spark が自動で broadcast join を選びます。この閾値の調整は 3-5 で扱います。
📝 試験のポイント
「大きなファクトテーブルと小さなディメンションテーブル(マスタ)の結合を高速化したい」→ 小さい側を broadcast するが正解の型です。理由まで問われたら「シャッフルを回避できるから」。逆に、大きいテーブルを broadcast するのは誤りです。全ワーカーのメモリに載せることになり、OOM(メモリ不足)を招きます。broadcast はドライバ経由で配布されるため、ドライバメモリにも負荷がかかる点も覚えておきましょう。データスキュー(偏り)対策として broadcast join が効く場面は 6-2 で再登場します。
join が「横に列を増やす」操作なら、union は「縦に行を積む」操作です。スキーマが同じ2つの DataFrame(例:2026年の注文と2025年の注文)を1つにまとめます。
df_all = df_2026.union(df_2025)
# 列の「順序」ではなく「名前」で対応付けたい場合
df_all = df_2026.unionByName(df_2025)
# 片方にしかない列を null で許容する場合
df_all = df_2026.unionByName(df_2025, allowMissingColumns=True)
-- SQL 版:UNION ALL は重複を残す、UNION は重複を除去する
SELECT * FROM sales_2026
UNION ALL
SELECT * FROM sales_2025;
| 操作 | 対応付け | 重複行の扱い | 注意点 |
|---|---|---|---|
union() | 列の位置(名前は見ない) | 残す(SQL の UNION ALL 相当) | 列順が違うと黙って誤った対応付けになる |
unionByName() | 列名 | 残す | 列順の違いに安全。列集合が違う場合は allowMissingColumns=True |
unionAll() | 列の位置 | 残す | DataFrame API では union の別名(同義)。非推奨で、union を使う |
📝 試験のポイント
混同しやすいのが SQL と DataFrame API の差です。SQL の UNION は重複を除去し、UNION ALL は残します。一方 DataFrame API の union() は重複を除去しません(UNION ALL 相当)。かつて存在した unionAll() は現在 union() と同義の別名になっています。重複を除去したければ union() の後に distinct() や dropDuplicates()(3-4)を明示的に呼びます。また、「列の順序が異なる2つの DataFrame を安全に連結する」→ unionByName() が正解の型です。
結合は「1対1」とは限りません。キーが左に m 行・右に n 行あると、結果は m×n 行になります。たとえば注文テーブルに customer_id=101 が3行、顧客テーブルに誤って同じ顧客が2行入っていると、結合後はその顧客の注文が 3×2=6 行に「膨張」します。集計すると売上が2倍にカウントされる、という典型的な事故です。
💡 具体例:膨張の検知と予防
# 結合前:右テーブルのキーの一意性を確認する
customers.groupBy("customer_id").count().filter("count > 1").show()
# 一意でなければ先に重複排除(3-4 で詳述)してから結合する
customers_unique = customers.dropDuplicates(["customer_id"])
df = orders.join(customers_unique, on="customer_id", how="left")
# 結合後:行数が変わっていないか検算する
assert df.count() == orders.count()
「left join なのに行数が増えた」と感じたら、まず右側のキー重複を疑います。left join が保証するのは「左の行が消えない」ことだけで、「増えない」ことは保証しません。
SQL の比較演算では NULL = NULL は真になりません。そのためキーが null の行は、どの行とも結合されません。inner join では null キーの行が黙って消え、left join では右側が全て null のまま残ります。「結合したのに件数が減った」「マスタの値が付かない行がある」ときは、キーの null を疑ってください(null 処理は 3-1 で学んだとおりです)。
# 結合前に null キーの件数を把握する
orders.filter(F.col("customer_id").isNull()).count()
# 要件に応じて:除外する / 代替キーで埋める / left join で残す
orders_valid = orders.filter(F.col("customer_id").isNotNull())
✅ この節のまとめ
on=["k1","k2"])で結合。条件式結合ではキー列が両側に残るので後で drop する。broadcast() 関数または SQL ヒントで明示でき、閾値(既定 10MB)以下なら自動適用される。大きいテーブルの broadcast は OOM のもと。union() は列の位置で対応付け、重複は除去しない(SQL の UNION ALL 相当)。unionAll() は union の同義の別名。列名で安全に連結するなら unionByName()。問1. 全ての注文レコードを保持しつつ、顧客マスタに存在する場合のみ顧客名を付与したい。顧客マスタに存在しない注文も分析対象に残す必要がある。適切な結合はどれか。
orders.join(customers, "customer_id", "inner")orders.join(customers, "customer_id", "left")orders.join(customers, "customer_id", "left_semi")orders.crossJoin(customers)正解:B
「左(注文)の全行を残し、一致すれば右の列を付与、しなければ null」は left join そのものです。Aの inner ではマスタに存在しない注文が消えてしまい、要件に反します。Cの left semi は「右に一致がある左の行」だけを返すフィルタであり、顧客名の列は付与されないうえ不一致の注文も消えます。Dは全組み合わせの直積で、要件と無関係に行数が爆発します。
問2. 数億行のファクトテーブルと数百行の地域マスタの結合が遅い。最も効果的な改善策はどれか。
broadcast() を付けて結合するbroadcast() を付けて結合し、シャッフルを回避するcollect() でドライバに集めて Python で結合する正解:B
broadcast join は「小さい側」を全ワーカーに配布することで、大テーブルのシャッフル(ネットワーク越しの再配置)を丸ごと回避します。Aは巨大テーブルを全ワーカーのメモリに配ることになり OOM を招く誤りです。Cの cross join は直積を作ってから絞る最悪の手で、計算量が桁違いに増えます。Dはドライバのメモリに全データを載せる操作で、分散処理の利点を捨てるうえドライバの OOM で失敗します。
問3. PySpark の DataFrame API における union() の説明として正しいものはどれか。
unionAll() とは異なる結果を返す正解:C
DataFrame API の union() は列の位置で対応付け、重複を除去しません。重複を除きたい場合は distinct() を明示的に呼びます(Aは SQL の UNION との混同)。Bは unionByName() の説明です。Dについて、unionAll() は現在 union() の同義の別名であり、結果は同じです。
問4. 注文テーブル(100万行)と顧客テーブルを customer_id で left join したところ、結果が103万行になった。最も可能性の高い原因はどれか。
customer_id が重複する行があり、一致した注文行が複数行に膨張したcustomer_id に null が含まれており、null 同士が結合された正解:B
キーが左に m 行・右に n 行あると結合結果は m×n 行になるため、右(顧客)側のキー重複は行の膨張として現れます。left join が保証するのは左の行が「消えない」ことだけで、「増えない」ことではありません(Aは仕様の誤解)。Cについて、null キーはどの行とも一致しないため行は増えません。Dのパーティション数は性能に影響しても結果の行数は変えません。対策は、結合前に右側を dropDuplicates(["customer_id"]) 等で一意化することです。