Join操作
MapReduce Join 操作:大表关联小表用 Map 端,大表关联大表用 Reduce 端
数据处理中,多表关联是家常便饭——订单关联用户、日志关联商品、销售关联库存。在 SQL 里一个 JOIN 就搞定,在 MapReduce 里得自己实现。
MapReduce 提供了两种 Join 方案:
- Reduce 端 Join:通用,任何规模都能用,但要走 Shuffle,性能一般
- Map 端 Join:快,但要求一张表足够小,能塞进内存
怎么选?就一句话:小表能装进内存就用 Map 端 Join,装不下就用 Reduce 端 Join。
Join 的核心难点:怎么让相同 Key 的数据到同一个地方
两个表关联,关键是把相同关联键的数据凑到一起。
- 订单表有商品 ID → 需要找到商品表里相同商品 ID 的信息
- 问题是:订单数据分散在不同节点,商品数据也分散在不同节点
- 怎么让同一个商品 ID 的订单数据和商品数据落到同一个节点?
这就是 Join 的核心挑战。MapReduce 用两种方式解决:
| 方案 | 怎么凑到一起 | 前提 |
|---|---|---|
| Reduce 端 Join | 通过 Shuffle,相同 Key 自动分到同一个 Reduce | 无限制 |
| Map 端 Join | 小表提前加载到每个 Map 任务的内存里 | 小表能装进内存 |
Reduce 端 Join:通用方案,任何规模都能用
实现原理
flowchart TD
A[表 A 数据] -->|Map 阶段| B["打标签 <Key, (A, ValueA)>"]
C[表 B 数据] -->|Map 阶段| D["打标签 <Key, (B, ValueB)>"]
B --> E[Shuffle 按 Key 分组]
D --> E
E --> F["Reduce 阶段合并 <Key, (A, B)>"]
F --> G[输出关联结果]
思路:
- Map 阶段:从不同表读数据,统一输出
<关联键, 来源标记 + 数据> - Shuffle 阶段:相同关联键自动分到同一个 Reduce
- Reduce 阶段:把同一 Key 的两张表数据合并
关键点:Map 阶段一定要标记数据来源。 Reduce 阶段收到的是同一个 Key 的 value 列表,不知道哪些来自订单表、哪些来自商品表,所以需要标记(比如用 “order” 和 “product” 前缀)。
代码骨架:
Map 阶段:
public void map(LongWritable key, Text value, Context context) {
FileSplit split = (FileSplit) context.getInputSplit();
String fileName = split.getPath().getName();
String[] fields = value.toString().split(",");
if (fileName.contains("order")) {
// 订单表:关联键是商品ID(第3列)
outKey.set(fields[2]); // 商品ID
outValue.set("order," + fields[0] + "," + fields[1] + "," + fields[3]);
context.write(outKey, outValue);
} else if (fileName.contains("product")) {
// 商品表:关联键是商品ID(第1列)
outKey.set(fields[0]); // 商品ID
outValue.set("product," + fields[1]); // 商品名称
context.write(outKey, outValue);
}
}
Reduce 阶段:
public void reduce(Text key, Iterable<Text> values, Context context) {
List<String> orders = new ArrayList<>();
String productName = "";
for (Text value : values) {
String[] parts = value.toString().split(",");
if (parts[0].equals("order")) {
orders.add(parts[1] + "," + parts[2] + "," + parts[3]); // 订单ID,用户ID,金额
} else if (parts[0].equals("product")) {
productName = parts[1];
}
}
// 内连接:只输出有商品信息的订单
for (String order : orders) {
if (!productName.isEmpty()) {
context.write(null, new Text(order + "," + productName));
}
}
}
优缺点:
| 优点 | 缺点 |
|---|---|
| 数据量无限制,什么都能 Join | 所有数据都要走 Shuffle,网络传输大 |
| 支持内连接、左连接、外连接 | Reduce 端需要排序,开销大 |
| 实现直观,好理解 | 大表 Join 大表时,Reduce 是瓶颈 |
Map 端 Join:快,但小表得能装进内存
实现原理
flowchart TD
A[小表数据] -->|Map 初始化| B["加载至内存 Map<Key, Value>"]
C[大表数据] -->|Map 阶段| D["读取大表记录,通过内存 Map 关联"]
D --> E[直接输出关联结果]
思路: 把小表加载到每个 Map 任务的内存里,Map 读大表时直接在内存里查,不需要 Shuffle 和 Reduce。
前提条件: 小表必须能装进 MapTask 内存(通常 < 1GB)。如果装不下,Map 端 Join 不适用。
代码骨架:
setup 阶段加载小表:
private HashMap<String, String> productMap = new HashMap<>();
protected void setup(Context context) {
// 从分布式缓存读取小表
URI[] cacheFiles = context.getCacheFiles();
Path productPath = new Path(cacheFiles[0]);
FileSystem fs = FileSystem.get(context.getConfiguration());
BufferedReader reader = new BufferedReader(new InputStreamReader(fs.open(productPath)));
String line;
while ((line = reader.readLine()) != null) {
String[] fields = line.split(",");
productMap.put(fields[0], fields[1]); // 商品ID → 商品名称
}
reader.close();
}
Map 阶段查内存:
public void map(LongWritable key, Text value, Context context) {
String[] fields = value.toString().split(",");
String productId = fields[2]; // 订单表的商品ID
String productName = productMap.get(productId);
if (productName != null) {
String result = fields[0] + "," + fields[1] + "," + fields[3] + "," + productName;
context.write(null, new Text(result));
}
}
Driver 里把小表放进分布式缓存:
job.addCacheFile(new URI("hdfs:///input/product.txt"));
job.setNumReduceTasks(0); // 不需要 Reduce
优缺点:
| 优点 | 缺点 |
|---|---|
| 无 Shuffle,无 Reduce,性能极高 | 小表必须能装进内存,否则 OOM |
| 网络传输少 | 只适合”一大一小”的场景 |
| 代码比 Reduce 端 Join 简单 | 不支持大表 Join 大表 |
实际项目中的经验:
- 小表 < 100MB → 无脑 Map 端 Join
- 小表 100MB - 500MB → Map 端 Join,把 MapTask 内存调到 2GB 以上
- 小表 > 500MB → 先用 Map 端 Join 试,不行换 Reduce 端
- 两张都大 → Reduce 端 Join,同时用 Combiner 和压缩优化