Broadcast and Skew Joins

Spark tutorial · PySpark.in

Broadcast and Skew Joins

1. Broadcast Join

In distributed computing, a Broadcast Join is a technique where the smaller dataset is sent to all worker nodes, so that a join can be performed without shuffling the larger dataset. In Spark, broadcast joins can dramatically speed up join operations by avoiding the expensive shuffle of a large DataFrame across the cluster. PySpark can automatically decide to broadcast a small DataFrame (based on a size threshold, e.g. tens of MB), but you can also hint or explicitly mark a DataFrame to be broadcast.

How Broadcast Joins Work

Normally, a join between two big tables is a shuffle join (also called a sort-merge join in Spark) – both DataFrames are shuffled and partitioned by the join key, so that matching keys end up on the same partition. Shuffle joins are expensive when data is large.

With a broadcast join, Spark will distribute the entire smaller DataFrame to every partition of the larger DataFrame. Then each partition of the large DataFrame can join with the local copy of the small DataFrame without further network shuffle. This is very effective when one DataFrame is relatively tiny and the other is huge.

When to use: A common guideline is to broadcast the smaller side if it’s on the order of tens of megabytes or less (say < 100MB), though the exact threshold can be tuned and depends on your cluster memory. Spark’s config spark.sql.autoBroadcastJoinThreshold (default 10MB in many versions) controls automatic broadcasting. You can override or force it with hints.

Example Usage

In our running example, the Departments DataFrame is very small (only 3 records). In a real scenario, imagine df_departments is a small dimension table and df_employees is huge (millions of rows). A broadcast join would be beneficial. We can force a broadcast join in PySpark like this:

```

from pyspark.sql.functions import broadcast

from pyspark.sql import SparkSession

spark = SparkSession.builder.appName("JoinsTutorial").getOrCreate()

# Create Employees DataFrame

employees = [

(1, "Alice", 101),

(2, "Bob", 102),

(3, "Charlie", None), # Charlie has no dept (null)

(4, "David", 104), # David's dept_id 104 doesn't exist in Departments

(5, "Eve", 101)

]

df_employees = spark.createDataFrame(employees, ["emp_id", "name", "dept_id"])

df_employees.show()

# Create Departments DataFrame

departments = [

(101, "HR"),

(102, "IT"),

(103, "Marketing")

]

df_departments = spark.createDataFrame(departments, ["dept_id", "dept_name"])

broadcast_join_df = df_employees.join(broadcast(df_departments), on="dept_id", how="inner")

broadcast_join_df.show()

```

This code explicitly marks df_departments to be broadcast to all nodes. The result of broadcast_join_df will be the same as an inner join we did before (in fact, we still specify the join type as "inner"). Broadcasting does not change the result of the join, only the way Spark executes it. In our small example, the output is identical to the earlier inner join result:

But behind the scenes, Spark did not shuffle the employees by dept_id. Instead, it broadcast the tiny departments DataFrame to every executor, and each executor joined its portion of employees with that broadcasted data. This is much faster when df_employees is large, because shuffling a huge DataFrame is avoided.

Alternative: Join Hints

PySpark also allows join hints. For example:

```

hide

df_employees.hint("broadcast").join(df_departments, "dept_id")

```

would hint to Spark to broadcast the employees DataFrame (not what we want in this case, we’d rather broadcast the smaller one). You can also hint both or neither, but Spark will decide which side to broadcast or neither. The broadcast() function from pyspark.sql.functions is more explicit and guarantees broadcasting the specified DataFrame.

Use Cases

Pitfalls

Best Practices

2. Skew Join Handling

Data skew in joins occurs when certain join key values are very common (disproportionately large number of records), causing an imbalance in the data distribution. For example, imagine joining on country_id and 50% of your data has country_id = USA – then in a normal shuffle join, one partition might end up handling 50% of the data (all the USA records land in the same partition for matching), becoming a performance bottleneck. Skew can lead to some tasks taking much longer than others, or even running out of memory, while other tasks finish quickly.

Spark has a few strategies to handle skewed joins:

2.1 Identify and Manage Skew

Detecting skew: A first step is to identify if skew exists. You can do this by computing group counts on the join key (e.g., df.groupBy("key").count() and looking at the distribution) or by looking at the Spark UI’s task metrics (if one reducer task is dramatically larger). Some keys having significantly more records than others is a sign of skew.

Basic mitigation strategies:

2.2 Salting Technique

When broadcasting isn’t possible (e.g., both tables are large) and skew is severe, a common technique is salting. Salting means adding a random "salt" value to the join key to artificially distribute what would have been one large group into several smaller groups. Both DataFrames must be salted in a coordinated way so that matches can still happen.

How salting works:

  1. Augment keys with a random number: For the skewed key values, append or prepend a random integer (from 0 to N-1) to create a modified key. N could be based on how many splits you want for the largest skewed keys.
  2. Replicate the other side’s data for those keys: The table on the other side needs to have matching salted keys. One way is to do a cross join between the small set of salt values and the portion of the data with the skewed keys, effectively duplicating the skewed key’s rows across N different keys (key_0, key_1, ... key_N).
  3. Join on the salted key: Perform the join using the new salted key. The formerly single heavy key is now treated as N distinct keys, which go to N different partitions, relieving the single-partition bottleneck.
  4. Post-processing: After the join, you may need to drop the salt and aggregate results back on the original key (if you want to get rid of the artificial distinction). For example, if you were joining and then aggregating, you can aggregate by the original key after the join.

Example scenario: Suppose dept_id 101 was extremely common in both DataFrames. We could salt it by, say, splitting into 3 keys: treat them as 101_0, 101_1, 101_2. For each employee with dept 101, assign a random salt 0-2 and form a composite key like 101_0. For each department 101 entry (there’s just one in our case), replicate it 3 times as 101_0, 101_1, 101_2. Then join on this composite key. Now the load of dept 101 is spread across 3 partitions. This is a simplistic illustration — in practice you'd only salt the problematic keys and choose the number of salts based on how skewed it is.

Pitfalls of salting:

Despite these pitfalls, salting is an effective last resort when you cannot avoid a skewed shuffle. It ensures the job makes progress (avoiding one straggler task or out-of-memory error) by distributing the work more evenly, even if some tasks are still a bit heavier than others.

2.3 Other Approaches and Best Practices for Skew

Summary: Handling skew often requires a combination of techniques. The key is to redistribute data more evenly. Broadcasting small tables, salting large keys, and using Spark's adaptive optimizations are all tools to address the challenge of skewed joins. By applying these, you can prevent a single hot partition from dragging down your job.

More Spark tutorials

All tutorials · Try the free PySpark compiler · Practice challenges