"Why Did That Work?"

Concepts covered: Broadcast Join

The follow-up that decides the answer

Most war stories end with some version of 'why did that work?' It separates a fix you understood from one you copied. The answer is a mechanism with 3 parts: where the data moved before, where it moves now, and what that movement cost.

Before: both tables travel

A sort-merge join needs each order line on the same task as its store. With broadcasting off, Spark got there by hash-partitioning both tables on store_id into 200 partitions. Each scan task wrote its rows to local disk, split by partition. Each of the 200 reducer tasks then fetched its partition from every scan task, sorted it by store_id and merged it with the matching stores rows. For orders that meant 140 GB written, 140 GB fetched and roughly 700 MB sorted per task, which spilled.

After: only the small table travels

A broadcast hash join brings the small table to the data. The driver collects stores, sends a copy to each of the 40 executors, and each executor builds an in-memory hash table keyed by store_id. Every orders scan task probes that hash table as its rows stream past. No order line is written or sent anywhere until the groupBy, and by then each task has collapsed its rows into a few partial sums.

Sort-merge join, before
  • Moves both tables: 140 GB of orders, plus stores
  • Adds 2 exchanges for the join
  • Sorts every order line by store_id
  • Risk: reducer tasks spill when partitions are large
  • Fits: 2 large tables
Broadcast hash join, after
  • Moves stores only: a copy per executor
  • Adds no exchange for the join
  • Sorts nothing; probes a hash table
  • Risk: the small side must fit in driver and executor memory
  • Fits: a small table that stays small

The questions that come next

Expect 2 or 3 of these after the mechanism, and prepare a 2-sentence answer to each for your own story.

Predict the plan before you claim it

Interviewers sometimes change your code and ask what happens. Practice that by predicting the plan before running it. This version has no hint and no threshold setting, and it sorts the result by revenue. The stores sample is far below 10MB. Decide before you press play: how many shuffle exchanges, and how many stages?

revenue = (
    orders.join(stores, "store_id")
    .groupBy("region")
    .agg(F.sum("amount").alias("revenue"))
    .orderBy(F.desc("revenue"))
)
revenue.collect()
orders
order_idstore_idamount
110140
210225
310160
410315
510480
610235
710350
810120
910445
1010230
1110310
1210455
stores
store_idregion
101North
102South
103North
104West

Two exchanges and 3 stages. The join broadcasts without a hint because stores is under the default threshold, so it adds no shuffle. The groupBy shuffles partial sums by region. The orderBy adds a range shuffle and a stage: Spark samples the revenue values to pick boundaries, then sends each row to the task that owns its range, so the collected rows arrive in order. Cutting the sorted result to its first few rows would let Spark run a top-N and skip that range shuffle.

About this lesson

This section is part of the The War Story lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.