Orders know the item, the menu knows the category
The canteen manager wants revenue per category: drinks, snacks, breakfast, meals. The 5 lakh orders in student_orders.csv only say dosa or chai; the category lives in a second file, menu.csv. In SQL this is one JOIN. In MapReduce there is no JOIN keyword, and the two files are read by different map tasks on different workers. Riya has to find a way to bring every order and its menu row to the same place.
Sir, MapReduce only groups by one key. How do I combine two different files?
Make the item the key for both files. The shuffle doesn't care which file a pair came from: every pair with key dosa goes to the same reduce call. You only have to tag each value so the reducer knows which side it came from.
And I removed coffee from the menu on purpose. Let's see what a join does with orders that have no menu row.
Good. We will also catch something that surprises most people: the menu row does not always arrive first. Our counters will prove it.
Reduce-side join
A reduce-side join (also called a repartition join) combines two or more data sets on a common key inside the reduce phase. Each input is read by its own mapper (configured with MultipleInputs), which emits the join key as the map output key and the record, tagged with its source, as the value. The shuffle partitions and groups all pairs by the join key, so a single reduce call receives every record of every data set that shares that key. The reducer separates the values by tag, typically buffers one side in memory, and emits the combined records according to the join type (inner, left outer, right outer or full outer). It works for inputs of any size and needs no pre-sorting or co-partitioning, at the cost of shuffling all records of all inputs across the network.
Both files are mapped to (join key, labelled record). Hadoop's shuffle brings everything with the same key to one reducer, and the reducer glues the matching records together.
Words you'll meet in this lesson
item.O| order, M| menu.From our lab (Hadoop 3.5.0, 3 workers, 5 lakh orders + 7-line menu)
| Inputs | student_orders.csv (3 splits) + menu_nocoffee.csv (1 split) = 4 map tasks, 500,007 map input records |
|---|---|
| Shuffle | 500,007 records, 24,448,645 bytes, 8 reduce groups (one per item, coffee included) |
| Menu row position | MENU_FIRST=3, MENU_NOT_FIRST=4: for 4 of 7 items the menu row arrived after some orders |
| Result | ORDERS_JOINED=459,636, ORDERS_WITHOUT_MENU=40,364 (every coffee order); 20,851,062 bytes written; 24.8 s |
Joins at three levels
Beginner Matching name tags
- Two lists: the bill book (who ordered what) and the price board (which item is which category).
- Every slip from both lists gets a coloured sticker (the tag) and is dropped into a box labelled with the item name (the key).
- One person opens the
dosabox, finds the one price-board slip and writes “meal” on every order slip in that box. - A box with order slips but no price-board slip (coffee) is the interesting case: throw them away (inner join) or keep them with a blank (outer join).
Intermediate How the job is wired
MultipleInputs.addInputPath(job, orders, TextInputFormat.class, OrderMapper.class)and the same for the menu withMenuMapper. NosetMapperClass.- Both mappers must emit the same key and value types:
(Text item, Text tagged). - The reducer loops over the values once: menu values set
category, order values go into a list. After the loop it writes each order with the category. - Values arrive in no guaranteed order. In our run the menu row came first for only 3 of 7 items, so the order list is not optional.
Advanced Costs and refinements
- Everything is shuffled: all 5 lakh orders crossed the network just to meet 7 menu rows. That is the price of a reduce-side join.
- Memory: the reducer buffers one side. Buffer the smaller side per key; here that would be the menu, but you can only do that if the menu arrives first.
- Secondary sort fixes the order: put the tag in a composite key (item, tag), sort the menu tag before the order tag, and partition and group by item only. Then the menu row is always first and orders can be streamed without a list (Lesson 9.7).
- Skew: chai has 149,669 orders, thali 30,292. One reducer gets the chai group whatever you do; salting the key or a map-side join (Lesson 9.6) avoids that.
Exam tip: For “Explain reduce-side join”, write: both mappers emit (join key, tagged record); the shuffle groups by join key; the reducer separates by tag, buffers one side and emits combinations. Add one drawback (all data is shuffled; buffering can run out of memory) and when to use it (both data sets are large).
Join types in the reducer
The map side is the same for every join type. Only the last few lines of the reducer change.
| Join type | Reducer emits | Coffee (orders, no menu row) | Menu item never ordered |
|---|---|---|---|
| Inner | orders × menu, only when both exist | dropped (our job: 40,364) | dropped |
| Left outer (orders left) | every order, category or blank | kept, category empty | dropped |
| Right outer (menu right) | every menu row, with its orders or blanks | dropped | kept with no order |
| Full outer | everything from both sides | kept | kept |
| Anti join | orders whose key has no menu row | the only output | dropped |
The three ways to join in MapReduce
Reduce-side join
Any sizes, any layout. Shuffles everything. This lesson.
generalMap-side join (broadcast)
One side is small enough for every mapper's memory; ship it with the distributed cache. No shuffle at all.
Lesson 9.6Sorted, partitioned join
Both inputs already sorted and split the same way by the key (CompositeInputFormat); mappers merge them directly.
special caseHive and Spark pick between the same strategies: a “shuffle join” (Spark's sort-merge join) is a reduce-side join, a “broadcast join” or Hive “map join” is the map-side join of Lesson 9.6.
Joining 5 lakh orders with the menu
Run on the Module 8 lab cluster with join_lab.sh (in “Run it on your PC”). The data comes from make_student_orders.py.
Prepare a menu with a missing item
Real menus go out of date. Removing coffee lets us see what an inner join silently does with orders that have no match.grep -v coffee menu.csv > menu_nocoffee.csv hdfs dfs -put -f menu_nocoffee.csv /user/hadoop/data/chai,drink,15,A samosa,snack,18,B vada pav,snack,20,B poha,breakfast,25,C idli,breakfast,30,C dosa,meal,45,D thali,meal,80,DTwo mappers, one key
Each input path gets its own mapper class. The tag is just a prefix on a Text value; production code often uses a small custom Writable (Lesson 9.3) instead.// orders: (item, "O|whole line") item.set(line.toString().split(",")[3]); val.set("O|" + line); // menu: (item, "M|category") item.set(f[0]); val.set("M|" + f[1]); MultipleInputs.addInputPath(job, orders, TextInputFormat.class, OrderMapper.class); MultipleInputs.addInputPath(job, menu, TextInputFormat.class, MenuMapper.class);Run the job
Three splits of orders plus one split of menu: 4 map tasks. Every record was emitted and shuffled, 24.4 MB of it, to bring 7 menu rows next to 5 lakh orders. 8 groups: 7 menu items plus coffee.hadoop jar mr9.jar JoinReduceSide /user/hadoop/data/student_orders.csv \ /user/hadoop/data/menu_nocoffee.csv /out/JoinReduceSideJobSubmitter:203 - number of splits:4 Launched map tasks=4 Launched reduce tasks=1 Map input records=500007 Map output records=500007 Reduce input groups=8 Reduce shuffle bytes=24448645Proof that the reducer must buffer
The reducer counted whether the menu row was the first value of its group. For 4 of 7 items it was not, because values from 4 map tasks are merged in no particular order. Code that assumed “menu comes first” would have lost thousands of orders without any error.MENU_FIRST=3 MENU_NOT_FIRST=4Matched and unmatched
459,636 + 40,364 = 500,000. The 40,364 are exactly the coffee orders: an inner join dropped them, and only the counter tells you. Always count unmatched records.ORDERS_JOINED=459636 ORDERS_WITHOUT_MENU=40364 Bytes Written=20851062The joined rows
Each order now carries its category. The next lesson produces exactly the same 459,636 rows with a map-side join in 18.9 s and no shuffle at all.hdfs dfs -cat /out/JoinReduceSide/part-r-00000 | head -21,S1413,2026-09-14 15:30,dosa,45,meal 2,S0973,2026-09-26 20:11,samosa,18,snack
If a single key has millions of records on both sides (for example a NULL or default join key like “unknown”), the reducer's list can exhaust the task's heap and the job fails with an OutOfMemoryError. Filter such keys out, buffer only the small side, or use secondary sort to stream.
One item's journey through the join
Six steps for the item dosa, and what happens to coffee.
- The job reads two inputs: 3 splits of orders and 1 split of menu.
- Every record, from both files, becomes one map output pair.
- All 24.4 MB are shuffled; the reducer sees 8 item groups.
- The menu row was first in only 3 of 7 groups.
- 459,636 orders joined; 40,364 coffee orders had no menu row.
- Each output row is an order with its category appended.
- Each input has its own mapper: three order map tasks and one menu map task.
- Both emit the item as key and a tagged value: O| for orders, M| for menu.
- The shuffle merges pairs from all four map tasks into one group per item.
- The menu row can be anywhere in the group, so the orders are kept in a list until it shows up.
- Once the category is known, every buffered order is written with it. Coffee has no menu row and is dropped.
- The output is one file of joined rows, one per order with a matching menu row.
Reduce-side joins at work
1. Transactions with customer accounts
A bank joins a day's card transactions (hundreds of millions) with the accounts table (crores of rows) on account number. Both sides are too large for memory, so the join is shuffled.
2. Clickstream with user profiles
E-commerce sites join page-view logs with user profiles to attribute views to segments (city, device, plan). Hive's default “common join” for two big tables is exactly this pattern.
3. Data quality checks
An anti join (records with no match) finds orphans: orders for items missing from the master list, payments for closed accounts. The unmatched count is reported every night.
4. Inside SQL engines
A JOIN between two large tables in Hive or Spark SQL becomes a shuffle join: both sides repartitioned by the join key, then joined per partition. Reading “Exchange hashpartitioning” in a Spark plan means this lesson.
Simplified; organisations differ in tools and policies.
Simulate a reduce-side join
Two small inputs, tagged map output, a shuffle that mixes the order of values (as Hadoop does), and a reducer that joins. Change JOIN to "left" to keep orders without a menu row.
import random
random.seed(4)
orders = ["1,S1413,dosa,45", "2,S0973,samosa,18", "3,S0007,coffee,20", "4,S0001,dosa,45", "5,S0002,chai,15"]
menu = ["chai,drink", "samosa,snack", "dosa,meal", "thali,meal"]
JOIN = "inner"
pairs = [(o.split(",")[2], "O|" + o) for o in orders] # OrderMapper
pairs += [(m.split(",")[0], "M|" + m.split(",")[1]) for m in menu] # MenuMapper
groups = {}
for k, v in pairs: # shuffle: group by item
groups.setdefault(k, []).append(v)
for k in groups:
random.shuffle(groups[k]) # values arrive in no fixed order
for item in sorted(groups): # reducer
category, buffered = None, []
for v in groups[item]:
if v.startswith("M|"):
category = v[2:]
else:
buffered.append(v[2:])
for o in buffered:
if category is not None:
print(o + "," + category)
elif JOIN == "left":
print(o + ",") # no menu row: empty categorypairs = [(o.split(",")[2], "O|" + o) for o in orders] # OrderMapperThe join key (item) becomes the map output key; the tag O| says “this came from orders”.random.shuffle(groups[k]) # values arrive in no fixed orderHadoop merges values from several map tasks; in our lab the menu row was first in only 3 of 7 groups.buffered.append(v[2:])Orders wait in a list until the category is known. This list is the memory risk of reduce-side joins.
Run it on your PC: join orders with the menu
Use the Module 8 lab cluster (Lesson 8.1). Create the data with make_student_orders.py (it writes student_orders.csv and menu.csv), copy the files and JoinReduceSide.java into /tmp/mr9 in the namenode container, compile into mr9.jar and run join_lab.sh. The script also runs the map-side join of Lesson 9.6 for comparison.
Reduce-side join
// JoinReduceSide.java: reduce-side join of orders with the menu.
// Both inputs are mapped to (item, tagged record); the reducer meets them per item and writes
// every order with its category added.
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.MultipleInputs;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.util.GenericOptionsParser;
public class JoinReduceSide {
enum Join { ORDERS_JOINED, ORDERS_WITHOUT_MENU, MENU_FIRST, MENU_NOT_FIRST }
// orders: order_id,student_id,time,item,amount -> (item, "O|whole line")
public static class OrderMapper extends Mapper<LongWritable, Text, Text, Text> {
private final Text item = new Text(), val = new Text();
@Override
protected void map(LongWritable k, Text line, Context ctx) throws IOException, InterruptedException {
item.set(line.toString().split(",")[3]);
val.set("O|" + line);
ctx.write(item, val);
}
}
// menu: item,category,price,counter -> (item, "M|category")
public static class MenuMapper extends Mapper<LongWritable, Text, Text, Text> {
private final Text item = new Text(), val = new Text();
@Override
protected void map(LongWritable k, Text line, Context ctx) throws IOException, InterruptedException {
String[] f = line.toString().split(",");
item.set(f[0]); val.set("M|" + f[1]);
ctx.write(item, val);
}
}
// For one item: one menu record and thousands of order records, in NO particular order
public static class JoinReducer extends Reducer<Text, Text, Text, NullWritable> {
private final Text out = new Text();
@Override
protected void reduce(Text item, Iterable<Text> vals, Context ctx) throws IOException, InterruptedException {
String category = null;
List<String> orders = new ArrayList<>(); // must buffer: the menu record may arrive last
boolean first = true;
for (Text v : vals) {
String s = v.toString();
if (s.startsWith("M|")) {
category = s.substring(2);
ctx.getCounter(first ? Join.MENU_FIRST : Join.MENU_NOT_FIRST).increment(1);
} else orders.add(s.substring(2));
first = false;
}
if (category == null) { ctx.getCounter(Join.ORDERS_WITHOUT_MENU).increment(orders.size()); return; }
for (String o : orders) { out.set(o + "," + category); ctx.write(out, NullWritable.get()); }
ctx.getCounter(Join.ORDERS_JOINED).increment(orders.size());
}
}
public static void main(String[] args) throws Exception {
Configuration conf = new Configuration();
String[] rest = new GenericOptionsParser(conf, args).getRemainingArgs(); // orders menu out
Job job = Job.getInstance(conf, "reduce-side join");
job.setJarByClass(JoinReduceSide.class);
MultipleInputs.addInputPath(job, new Path(rest[0]), TextInputFormat.class, OrderMapper.class);
MultipleInputs.addInputPath(job, new Path(rest[1]), TextInputFormat.class, MenuMapper.class);
job.setReducerClass(JoinReducer.class);
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(Text.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(NullWritable.class);
FileOutputFormat.setOutputPath(job, new Path(rest[2]));
System.exit(job.waitForCompletion(true) ? 0 : 1);
}
}
// In the mr-lab folder:
// python3 make_student_orders.py
// docker exec lab8-namenode-1 mkdir -p /tmp/mr9
// docker cp JoinReduceSide.java lab8-namenode-1:/tmp/mr9/
// docker cp student_orders.csv lab8-namenode-1:/tmp/mr9/
// docker cp menu.csv lab8-namenode-1:/tmp/mr9/
// Inside the container, in /tmp/mr9:
// hdfs dfs -D dfs.blocksize=8388608 -put -f student_orders.csv /user/hadoop/data/
// mkdir -p classes && javac -cp $(hadoop classpath) -d classes *.java && jar cf mr9.jar -C classes .The 8 MB block size gives 3 splits for the orders, so you can see values from several map tasks being merged.
Join lab: reduce-side vs map-side
# join_lab.sh: join 5 lakh orders with the 8-line menu, reduce-side and map-side, and compare
cd /tmp/mr9
O=/user/hadoop/data/student_orders.csv; MENU=/user/hadoop/data/menu.csv
hdfs dfs -put -f menu.csv $MENU
echo "== menu.csv (minus 'coffee', to show an order without a menu row)"
grep -v coffee menu.csv > menu_nocoffee.csv; hdfs dfs -put -f menu_nocoffee.csv /user/hadoop/data/
cat menu_nocoffee.csv
for j in JoinReduceSide JoinMapSide; do
hdfs dfs -rm -r -f -skipTrash /out/$j > /dev/null 2>&1
start=$(date +%s%N)
hadoop jar mr9.jar $j $O /user/hadoop/data/menu_nocoffee.csv /out/$j > /tmp/$j.log 2>&1
echo "== $j $(( ($(date +%s%N) - start) / 1000000 )) ms"
grep -E "number of splits|Launched (map|reduce) tasks|Map input records|Map output records|Reduce shuffle bytes|Reduce input groups|ORDERS_JOINED|ORDERS_WITHOUT_MENU|MENU_(NOT_)?FIRST|Bytes Written" /tmp/$j.log | sed -E 's/^[0-9-]+ [0-9:]+ INFO //; s/^\s*/ /'
hdfs dfs -ls /out/$j | grep part | awk '{print " " $5, $8}'
done
echo "== a joined row"
hdfs dfs -cat /out/JoinMapSide/part-m-00000 2>/dev/null | head -2
echo "== same rows from both joins?"
diff <(hdfs dfs -cat /out/JoinReduceSide/part-* | sort) <(hdfs dfs -cat /out/JoinMapSide/part-* | sort) > /dev/null && echo "identical (459,636 rows each, compared after sorting)"
echo "== map task log of JoinMapSide: the cached menu"
app=$(grep -o "application_[0-9_]*" /tmp/JoinMapSide.log | head -1); sleep 5
yarn logs -applicationId $app -log_files stderr 2>/dev/null | grep -E "cache files|menu loaded" | head -2 | cut -c1-200
# Inside the namenode container: bash /tmp/mr9/join_lab.shReal output. MENU_FIRST/MENU_NOT_FIRST will differ between runs because the merge order depends on which map task finishes first; the joined rows never do. The JoinMapSide part is explained in Lesson 9.6.
The reducer for one item
The values for dosa arrive with the menu row in the middle. Watch the list grow until the category is known.
Worked examples: real problems, solved and traced in memory
Three reduce-side join problems: the shuffle cost, the memory risk, and choosing the join type.
Join students with their hostel
Problem statement
orders.csv: order_id,student_id,item,amount. students.csv: student_id,name,hostel. The warden wants revenue per hostel. What is the join key, what do the two mappers emit, and what does the reducer write?
3 orders · 2 students
S1 Ganga [45, 80] S2 Yamuna [15]
Approach
- Join key = student_id, in both files.
- Order mapper: (student_id, "O|amount"); student mapper: (student_id, "S|hostel").
- Reducer: find the hostel, write (hostel, amount) for each order; a second job (or step) sums per hostel.
Solution: run it live
What happens in memory, step by step
Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.
What does a reduce-side join cost?
Problem statement
Join 200 GB of orders with a 50 MB menu using a reduce-side join on a 20-node cluster where each node can receive shuffle data at 100 MB/s. Roughly how long does the shuffle alone take, and how much of it is the menu?
200 GB + 50 MB · 20 × 100 MB/s
shuffle ≈ 1.7 min; menu is 0.024% of it
Approach
- Everything is shuffled: both inputs.
- Total bandwidth = nodes × per-node rate.
- Compare with the menu size.
Solution: run it live
What happens in memory, step by step
Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.
Will the reducer run out of memory?
Problem statement
A reducer buffers all orders of one item as Java Strings of about 40 characters (≈ 120 bytes each in memory). chai has 149,669 orders in our data; a real chain has 4 crore chai orders a month. The reduce container has a 300 MB heap. Does each case fit?
149,669 vs 4 crore · 120 B · 300 MB heap
our lab : 17 MB -> fits real chain: 4578 MB -> OutOfMemoryError
Approach
- Memory = records × bytes per record.
- Compare with the heap.
- Decide: buffer the small side instead, or stream with secondary sort.
Solution: run it live
What happens in memory, step by step
Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.
Check yourself
Quick revision
Lesson 9.5 in one screen
- Reduce-side join: both mappers emit (join key, tagged record); the shuffle groups by key; the reducer separates by tag and combines.
MultipleInputs.addInputPath(job, path, InputFormat, Mapper)for each input.- Values arrive in no fixed order: lab counters MENU_FIRST=3, MENU_NOT_FIRST=4. Buffer one side, or use secondary sort to put the small side first.
- Lab: 500,007 records and 24.4 MB shuffled, 8 groups, 459,636 joined, 40,364 coffee orders dropped, 24.8 s.
- Join types: inner, left outer, right outer, full outer, anti; only the reducer's output rule changes.
- Costs: everything is shuffled; buffering a hot key (chai 149,669 orders) can exhaust memory.
- Use when both sides are big; if one side is small, use a map-side join.
Interview and viva questions
What is a reduce-side join in Hadoop?
A join performed in the reduce phase. Each input has a mapper that emits the join key as the map output key and the record, tagged with its source, as the value. The shuffle sends all records with the same key to one reduce call, where the reducer separates them by tag and emits the joined records. It handles inputs of any size but shuffles all data.
What is the difference between a map-side join and a reduce-side join?
A reduce-side join shuffles both data sets by the join key and joins in the reducer; it works for any sizes. A map-side join loads the small data set into every mapper's memory (via the distributed cache) and joins while reading the large one, with no shuffle; it needs one side small enough to fit in memory. In our lab both produced the same 459,636 rows; the map-side join took 18.9 s instead of 24.8 s.
Why do reduce-side joins need buffering?
The values for a key come from several map tasks and are merged in no guaranteed order, so the reducer may see many records from one side before the matching record from the other side. It must keep them in memory until the loop is complete, unless secondary sort is used to make one side arrive first.
How do you do an outer join in MapReduce?
The map side is unchanged. In the reducer, after separating the values by tag, emit unmatched records too: for a left outer join, emit every left-side record with empty fields if the right side is missing; for a right outer join, the reverse; for a full outer join, both.
How do you handle skew in a reduce-side join?
A key with very many records sends them all to one reducer, which becomes slow or runs out of memory. Options: filter out junk keys like NULL, buffer only the smaller side and stream the larger with secondary sort, split (salt) the hot key across several reducers while replicating the small side, or use a map-side join when one side is small.
Practice problems
Reduce-side join exercises using the real numbers from the lab.
Check the counters
ORDERS_JOINED=459,636 and ORDERS_WITHOUT_MENU=40,364. Do they add up to all orders?
two counters
True
Show solution and explanation
print(459636 + 40364 == 500000)How it works: True: every order was either joined or counted as unmatched; none was lost silently.
Where do 500,007 records come from?
Map input records were 500,007. Explain the 7.
orders + menu
500007
Show solution and explanation
print(500000 + 7)How it works: 5 lakh orders plus the 7 rows of menu_nocoffee.csv; both inputs go through map and shuffle.
Bytes per shuffled record
24,448,645 shuffle bytes for 500,007 records. What is the average size of one tagged record?
bytes / records
48.9 bytes
Show solution and explanation
print(round(24448645 / 500007, 1), "bytes")How it works: About 49 bytes: the whole order line plus the “O|” tag and the item key, plus framing.
Full outer join
Write the reducer output for a full outer join: orders without a menu row get an empty category, menu items without orders get one line “,,item,category”.
3 groups
3,coffee, 1,dosa,meal ,,thali,meal
Show solution and explanation
groups = {"coffee": ["O|3,coffee"], "dosa": ["O|1,dosa", "M|meal"], "thali": ["M|meal"]}
for item, vals in sorted(groups.items()):
cat = next((v[2:] for v in vals if v.startswith("M|")), None)
ords = [v[2:] for v in vals if v.startswith("O|")]
for o in ords:
print(o + "," + (cat or ""))
if not ords:
print(",," + item + "," + cat)How it works: coffee kept with a blank, dosa joined, thali printed once with no order.
Menu first with a composite key
Sort the values by a tag rank so the menu row (rank 0) always comes before orders (rank 1), then stream without a list.
mixed values
1,dosa,45,meal 4,dosa,45,meal
Show solution and explanation
values = ["O|1,dosa,45", "M|meal", "O|4,dosa,45"]
values.sort(key=lambda v: 0 if v.startswith("M|") else 1)
category = None
for v in values:
if v.startswith("M|"):
category = v[2:]
else:
print(v[2:] + "," + category)How it works: In Hadoop the sort happens in the shuffle via a composite key (item, tag) with grouping on item only: Lesson 9.7.
Reducer load
With one reducer per item, which reducer has the most work? Use the real order counts.
orders per item
chai 149669 = 30% of all orders
Show solution and explanation
counts = {"chai": 149669, "coffee": 40364, "samosa": 69884, "vada pav": 69754, "poha": 45336, "idli": 44774, "dosa": 49927, "thali": 30292}
top = max(counts, key=counts.get)
print(top, counts[top], f"= {counts[top] / sum(counts.values()) * 100:.0f}% of all orders")How it works: chai: 30% of all records go to one reduce call. That is join skew.
Unmatched means lost money
The finance report used an inner join and missed every coffee order (40,364 orders at ₹20). How much revenue was missing from the report?
40,364 × ₹20
Rs 807,280
Show solution and explanation
print(f"Rs {40364 * 20:,}")How it works: ₹8,07,280 missing for one month. Count unmatched records, or use a left outer join, in every financial join.
Salting a hot key
Split chai into 3 reducers by appending a salt to the key on the order side, and replicate the menu row to all 3 salts. Show that every chai order still gets its category.
5 chai orders
chai#0 ['1,chai,15,drink', '4,chai,15,drink'] chai#1 ['2,chai,15,drink', '5,chai,15,drink'] chai#2 ['3,chai,15,drink']
Show solution and explanation
orders = [f"O|{i},chai,15" for i in range(1, 6)]
groups = {}
for n, o in enumerate(orders):
groups.setdefault(f"chai#{n % 3}", []).append(o) # salt orders
for s in range(3):
groups.setdefault(f"chai#{s}", []).append("M|drink") # replicate menu row
for key in sorted(groups):
cat = next(v[2:] for v in groups[key] if v.startswith("M|"))
print(key, [v[2:] + "," + cat for v in groups[key] if v.startswith("O|")])How it works: Three smaller groups instead of one; the small side is copied to every salt. Spark's skew-join handling does the same.
Real-world scenario questions (asked in company interviews)
Joins are among the most asked MapReduce and Spark interview topics: how they work, what they cost and what goes wrong.
“How do you join two datasets in MapReduce?”
Asked right after word count.
The interviewer is checking: key choice, tagging and the shuffle.
Show a strong answer
- Reduce-side: both mappers emit (join key, tagged record) via MultipleInputs; the shuffle groups by key; the reducer separates by tag and combines.
- Map-side: if one side is small, load it in every mapper from the distributed cache and join while reading; no shuffle.
- Mention the trade-off: reduce-side works for any sizes but shuffles everything.
The joined output has fewer rows than the orders table
Finance says revenue in the joined report is lower than in the raw orders.
The interviewer is checking: inner join semantics and counters.
Show a strong answer
- An inner join drops records without a match: check a counter for unmatched keys (our lab: 40,364 coffee orders, no menu row).
- Fix the master data, or switch to a left outer join so unmatched rows appear with blanks.
- Add a reconciliation check: joined + unmatched = input.
“My join reducer works in testing but loses rows in production”
The reducer writes orders as soon as it has the category, and assumes the dimension row comes first.
The interviewer is checking: value order is not guaranteed.
Show a strong answer
- Values for a key are merged from many map tasks in no fixed order; with one map task in testing it looked ordered.
- Buffer one side until the loop ends, or make the order deterministic with secondary sort: composite key (join key, tag), partition and group on the join key.
- Add a counter for “dimension not first” to detect it.
One reducer of a join runs for hours; the other 199 finish in minutes
Joining page views with users; one user_id is the anonymous value “0”.
The interviewer is checking: skew diagnosis and fixes.
Show a strong answer
- Check reduce input records per task: one key holds most records.
- Filter or handle the junk key separately; salt hot keys and replicate the small side; or use a map-side join if the users table fits in memory.
- In Spark, adaptive query execution can split skewed partitions automatically.