引言
首先先明白在關係型資料庫中Join的用法。
Join在MapReduce中的用法也是用於兩個檔案之間的連線。
使用MR程式解決兩張表的join問題,有兩種解決方案 à MR程式的join應用
1. reduce端join
在map端將資料封裝成Java物件 à 兩張表的複合Java物件
在reduce端根據物件值的不同進行join操作
2. map端join
通過緩衝流將小檔案儲存起來,在map階段根據物件值的不同進行join操作
關係型資料庫MySQL中Join的用法
(MySQL中JOIN的用法均整理於https://www.cnblogs.com/fudashi/p/7491039.html)
在關係型資料庫中,Join主要用於兩張表的連線,就如同英語單詞“join”一樣,可以分為內連線、外連線、左連線、右連線、自然連線。
笛卡爾積:CROSS JOIN
笛卡爾積是將A表中的資料和B表中的資料組合在一起,假設A表中有n條記錄,B表中有m條記錄,經過笛卡爾積組合後就會產生n * m條記錄。
內連線:INNER JOIN
內連線INNER JOIN是最常用的連線操作(即求兩個表的交集),從笛卡爾積的角度來說就是從笛卡爾積中挑出ON子句成立的記錄。有INNER JOIN,WHERE(等值連線),STRAIGHT_JOIN,JOIN(省略INNER)四種寫法。
左連線:LEFT JOIN
左連線就是求兩個表的交集以及左表的其餘資料。從笛卡爾積的角度講,就是先從笛卡爾積中跳出ON字句條件成立的記錄,然後加上左表中剩餘的記錄。
右連線:RIGHT JOIN
與左連線相同,就是求兩個表的交集以及右表的其餘資料。
外連線:OUTER JOIN
外連線就是求兩個集合的並集。從笛卡爾積的角度講就是從笛卡爾積中挑出ON字句條件成立的記錄,然後加上左表中剩餘的記錄,再加上右表中剩餘的記錄。MySQL不支援OUTER JOIN,但是可以對左連線和右連線的結果做UNION操作來實現。
Reduce Join
Reduce Join介紹
案例
需求:
訂單資料表t_order:
id |
pid |
amount |
1001 |
01 |
1 |
1002 |
02 |
2 |
1003 |
03 |
3 |
商品資訊表t_product
pid |
pname |
01 |
小米 |
02 |
華為 |
03 |
格力 |
要求將商品資訊表中資料根據商品pid合併到訂單資料表中。
最終資料形式:
id |
pname |
amount |
1001 |
小米 |
1 |
1004 |
小米 |
4 |
1002 |
華為 |
2 |
1005 |
華為 |
5 |
1003 |
格力 |
3 |
1006 |
格力 |
6 |
如果想要實現這一功能,使用MySQL可以利用Select id, b.pname, a.amount from t_order as a left join t_product as b on a.pid=b.pid 輕鬆實現。但是如果資料量龐大的話,利用MySQL來實現將會耗費非常久的時間,無法即時完成需求。而使用MapReduce可以解決這一問題。
實現思路:reduce端表合併(資料傾斜)
通過將關聯條件作為map輸出的key,將兩表滿足join條件的資料並攜帶資料所來源的檔案資訊,發往同一個reduce task,在reduce中進行資料的串聯。
步驟
第一步:JavaBean物件的編寫
先宣告一個JavaBean物件---OrderBean,該JavaBean物件是一個複合的JavaBean,其中涵蓋了兩張表涉及到的所有資訊。order表會用到這個bean,product也會用到一個bean。所以為了區分兩個表,應該設定一個變數flag,用於定義該表是order表還是product表。同時,為了將這個bean作為一個序列化資訊進行中間值傳遞與輸出,還需要將這個bean繼承Writable類實現自定義序列化,並且實現其中的序列化和反序列化方法。
第二步:Mapper類的編寫
通過Mapper類實現檔案切片資訊的讀取,並且根據檔名不同,封裝不同的OrderBean物件。根據之前所學的經驗,在Mapper階段需要將切片資料中獲取到的資訊傳遞到JavaBean物件中,然後通過context,write()方法傳遞給Reducer階段。然後在Mapper階段需要考慮一個問題:怎麼獲取到的切片資訊是哪個檔案的?怎麼把切片資訊和所屬檔案對應起來?只要解決了這兩個問題,向Reduce階段傳輸資料就迎刃而解了。
首先需要明白MapReduce程式預設的切片機制是TextInputFormat,而在案例需求中可以發現這裡不需要再自定義切片機制。所以根據TextInputFormat的切片機制:以檔案對資料進行切割,也就是說每個檔案最少有一個切片,可以輕鬆的看出來每一個切片資料都對應著一個固定的檔案,不會出現兩個檔案的部分資料出現在一個切片的現象。所以可以根據切片來獲取到檔案的路徑,之後再將路徑和該切片的資訊對應起來即可了。
有了上述思想準備,再補充一個方法:setup()方法,該方法可以理解為一個初始化方法,其作用類似於Junit單元測試的@Before,用在Mapper階段表示在每執行一次map()方法之前都必須執行一次setup()方法。所以可以通過setup()方法,在setup()方法中實現查詢切片對應檔案路徑的操作,以便於map在每進行一次切片時都能標記出他們所處的檔案是哪個檔案。
知道了切片資訊處於哪一個檔案之後,還需要考慮的問題就是該如何將切片資訊和所屬檔案的資料資訊對應起來?每一行切片資料經過split()方法將行資料進行切割後都會生成一個陣列檔案,這個陣列陣列檔案可以有三項,那麼它就是order表中的資料,也可能有兩項,那麼他就是product表中的資料,所以該怎麼確定陣列中有三項還是有兩項呢?可以根據setup中定義的操作去得到該切片資訊的檔名,然後根據檔名傳出對應的值就可以了。此時要回憶起JavaBean物件中定義的flag。可以根據flag值的不同,區分出兩個檔案資料的不同。
總而言之,map階段可以分為以下幾步:
1、 MR程式讀進來的是兩個檔案:order.txt、product.txt。通過InputFormat將資料讀入後,先按照不同的檔案執行不同的資料封裝
2、 讀取檔案切片資訊,根據檔名不同,封裝不同的OrderBean物件(主要的含義還是在Mapper階段對資料打上你是那個表的資料的標籤)
3、 資料封裝完成後,如果做join邏輯,那麼需要將兩張表的連線欄位作為key,物件當做value,傳送給Reducer
Map階段處理完成的資料格式是:
key |
value |
1 |
OrderBean(1001 1 1 order) |
1 |
OrderBean(1 小米 product) |
1 |
OrderBean(1002 1 3 order) |
也就是將pid(產品id)作為key值,兩張表對應key值的其他資訊作為value值傳給Reduce階段。
第三步:Reducer類的編寫
通過Map階段,可以得到如上的key-value鍵值對,通過對錶的分析可以得出兩表之間的關係為:一個產品可以對應多個訂單,而一個訂單隻能對應一個產品。而Reduce階段的任務就是將Map階段得到的資料合起來,將product表中的pname(產品名)和order表中的oid(訂單編號)、count(訂單數量)作為結果值輸出到結果檔案中。
此時可以以pid為key,讀取到pid一樣的OrderBean的集合資料。毫無疑問的是 pid一樣的OrderBean集合中,只有一條產品資訊的OrderBean,但是會有很多個訂單的OrderBean。此時可以設定一個OrderBean物件用於儲存Map階段得到的product表中的資料,設定一個OrderBean物件的集合來儲存map階段得到的order表中的資料,這個可以通過在Map階段中定義的flag值來區分什麼時候該儲存什麼值。最後通過遍歷將訂單中的產品id修改為產品資訊中的名字,輸出到結果檔案中,即可以完成該案例需求。
第四步:Driver類的編寫
Driver類還是按照常規編寫,需要注意的是此處傳入的檔案是兩個檔案。
原始碼
a) OrderBean.java
@Data public class OrderBean implements Writable { private String orderId = ""; private String pid = ""; private int amount; private String pname = ""; /** * 用來區分是order表的封裝物件還是product表的封裝物件 */ private String flag = ""; @Override public void write(DataOutput dataOutput) throws IOException { dataOutput.writeUTF(orderId); dataOutput.writeUTF(pid); dataOutput.writeInt(amount); dataOutput.writeUTF(pname); dataOutput.writeUTF(flag); } @Override public void readFields(DataInput dataInput) throws IOException { this.orderId = dataInput.readUTF(); this.pid = dataInput.readUTF(); this.amount = dataInput.readInt(); this.pname = dataInput.readUTF(); this.flag = dataInput.readUTF(); } @Override public String toString() { return orderId + " " + pname + " " + amount; } }
b) OrderMapper.java
public class OrderMapper extends Mapper<LongWritable, Text, Text, OrderBean> { /** * 當前切片所在檔名字 */ String fileName; @Override protected void setup(Context context) throws IOException, InterruptedException { // 獲得切片 FileSplit fileSplit = (FileSplit) context.getInputSplit(); // 獲取切片資料所在檔案路徑 Path path = fileSplit.getPath(); // 獲取檔名 fileName = path.getName(); } @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); String[] array = line.split("\t"); OrderBean ob = new OrderBean(); String file = "order"; if (fileName.contains(file)) { // 如果是訂單表,只需要向OrderBean物件中封裝:orderId、pid、amount、flag ob.setOrderId(array[0]); ob.setPid(array[1]); ob.setAmount(Integer.parseInt(array[2])); ob.setFlag("order"); } else { // 如果是產品表,只需要向OrderBean物件中封裝:pid、flag ob.setPid(array[0]); ob.setPname(array[1]); ob.setFlag("product"); } context.write(new Text(ob.getPid()), ob); } }
c) OrderReduce.java
public class OrderReducer extends Reducer<Text, OrderBean, NullWritable, OrderBean> { /** * @param key 產品id * @param values 產品id相同的訂單資訊和產品資訊 * @param context * @throws IOException * @throws InterruptedException */ @Override protected void reduce(Text key, Iterable<OrderBean> values, Context context) throws IOException, InterruptedException { // 訂單有多個 List<OrderBean> orders = new ArrayList<>(); // 產品資訊只有一個 OrderBean product = new OrderBean(); /** * 在使用增強的for迴圈時,前面的變數永遠都是同一個物件,只不過指向的地址不一樣了。 * 如果直接把value的值賦給其他變數,那麼value的地址值變化,那麼賦值給的變數也就發生變化了 */ for (OrderBean value: values) { // 如果是訂單資料,將訂單資料加到集合中 if ("order".equals(value.getFlag())) { OrderBean orderBean = new OrderBean(); // 將value中的資料copy到orderBean中。增強的for迴圈中每一次獲取的物件都複製給同一個值,如果加到集合中只能加上一個,所以需要通過這種方式將兩個物件區分開 orderBean.setOrderId(value.getOrderId()); orderBean.setPid(value.getPid()); orderBean.setAmount(value.getAmount()); orderBean.setFlag(value.getFlag()); orders.add(orderBean); } else { // 如果是產品資訊,那麼將產品資訊資料賦值給product物件 product.setPid(value.getPid()); product.setPname(value.getPname()); product.setFlag(value.getFlag()); } } // 將訂單中的產品名字修改為產品資訊中的名字 for (OrderBean order: orders) { order.setPname(product.getPname()); context.write(NullWritable.get(), order); } } }
d) OrderDriver.java
public class OrderDriver { public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); conf.set("fs.defaultFS", "hdfs://192.168.218.55:9000"); Job job = Job.getInstance(conf); job.setJarByClass(OrderDriver.class); job.setMapperClass(OrderMapper.class); job.setMapOutputKeyClass(Text.class); job.setMapOutputValueClass(OrderBean.class); job.setReducerClass(OrderReducer.class); job.setOutputKeyClass(NullWritable.class); job.setOutputValueClass(OrderBean.class); FileInputFormat.setInputPaths(job, new Path("/school/join/*")); FileSystem fs = FileSystem.get(new URI("hdfs://192.168.218.55:9000"), conf, "root"); Path output = new Path("/school/join/test_reduce"); if (fs.exists(output)) { fs.delete(output, true); } FileOutputFormat.setOutputPath(job, output); boolean b = job.waitForCompletion(true); System.exit(b ? 0 : 1); } }
執行截圖
Reduce Join缺點
這種方式中,介於產品表中有可能有幾百M資料,但是訂單表有可能有幾T資料,合併的操作是在reduce階段完成,reduce端的處理壓力太大,map節點的處理邏輯比較簡單,運算負載很低,容易導致資源利用率不徹底,且在reduce階段極易產生資料傾斜。
解決方案:Map Join
Map Join
使用場景
Map Join適用於一張表十分小、一張表很大的場景。
優點
思考:在Reduce端處理過多的表,非常容易產生資料傾斜。怎麼辦?
在Map端快取多張表,提前處理業務邏輯,這樣增加Map端業務,減少Reduce端資料的壓力,儘可能的減少資料傾斜。
具體辦法:採用DistributedCache
(1)在Mapper的setup階段,將檔案讀取到快取集合中。
(2)在驅動函式中載入快取。
// 快取普通檔案到Task執行節點。
job.addCacheFile(new URI("file://e:/cache/pd.txt"));
Map階段的join主要用到了一個技術:MR快取小檔案的技術----在Mapper的setup中去處理快取的小檔案即可
案例一:訂單資訊表
需求
同Reduce Join案例
思路
可以將小表分發到所有的map節點,這樣,map節點就可以在本地對自己所讀到的大表資料進行合併並輸出最終結果,可以大大提高合併操作的併發度,加快處理速度。
這裡要求將所有資料都放到Map端處理,處理邏輯為:
1. 在setup()階段把快取的小資料讀取到記憶體的集合中,快取備用
2. 在map()階段讀取大檔案(大表 order.txt),根據我們快取的小表資料進行join操作即可
原始碼
1) OrderDriver.java
public class OrderDriver { public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); conf.set("fs.defaultFS", "hdfs://192.168.218.55:9000"); Job job = Job.getInstance(conf); job.setJarByClass(OrderDriver.class); job.setMapperClass(OrderMapper.class); job.setOutputKeyClass(NullWritable.class); job.setOutputValueClass(Text.class); job.setNumReduceTasks(0); // 需要把小檔案提前快取下來-----需要在map階段的setup方法中讀取快取進行資料處理 job.addCacheFile(new URI("/school/join/product.txt")); // 輸入檔案只需要指定大檔案,即訂單檔案的輸入路徑即可 FileInputFormat.setInputPaths(job, new Path("/school/join/order.txt")); FileSystem fs = FileSystem.get(new URI("hdfs://192.168.218.55:9000"), conf, "root"); Path output = new Path("/school/join/test_map"); if (fs.exists(output)) { fs.delete(output, true); } FileOutputFormat.setOutputPath(job, output); boolean b = job.waitForCompletion(true); System.exit(b ? 0 : 1); } }
2) OrderMapper.java
public class OrderMapper extends Mapper<LongWritable, Text, NullWritable, Text> { Map<String, String> products = new HashMap<>(); /** * 快取的小檔案資料讀取、儲存集合備用。 * 小檔案是產品表,一個欄位是pid,一個欄位是pname * @param context * @throws IOException * @throws InterruptedException */ @Override protected void setup(Context context) throws IOException, InterruptedException { // 獲取快取的小檔案 URI[] cacheFiles = context.getCacheFiles(); URI cacheFile = cacheFiles[0]; // 獲取快取檔案的路徑 String path = cacheFile.getPath(); try { // 建立一個檔案系統,用來獲取快取檔案的io流 FileSystem fs = FileSystem.get(new URI("hdfs://192.168.218.55:9000"), context.getConfiguration(), "root"); // 獲取快取檔案的io流 FSDataInputStream open = fs.open(new Path(path)); // 將io流轉換為字元緩衝流,可以實現一次讀取檔案的一行資料 BufferedReader br = new BufferedReader(new InputStreamReader(open)); String line = null; while ((line = br.readLine()) != null) { // 把一行資料切割,得到[1, 小米] String[] array = line.split("\t"); // 將資料傳到集合中 products.put(array[0], array[1]); } } catch (URISyntaxException e) { e.printStackTrace(); } } /** * map只需要處理訂單表的資料即可 * @param key * @param value * @param context * @throws IOException * @throws InterruptedException */ @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { // order.txt中的一行資料 [1001, 01, 5] String[] array = value.toString().split("\t"); String orderId = array[0]; String pid = array[1]; String amount = array[2]; // 獲取產品名---根據訂單檔案的pid去剛剛快取下來的產品集合中獲取值即可 String pname = products.get(pid); String line = orderId + "\t" + pname + "\t" + amount; context.write(NullWritable.get(), new Text(line)); } }
執行截圖
案例二:學生資訊表(三表連線)
需求
總共有三張表:一張表是student表,裡面儲存了學生id,學生所在班級id以及學生的分數;一張表是class表,裡面記錄了班級id、班級所在系的id、班級名稱;一張表是dept表,裡面儲存了系id以及系名稱。
最後要求以“學生id 班級名稱 系名稱 分數”這樣的格式輸出到結果檔案中。
思路
同案例一,分析需求可以將class表和dept表視作小標,通過緩衝流儲存在兩個Map集合中,鍵值都為他們的id,然後在map()方法中讀取學生表資訊,將學生表中儲存的id和Map集合中對應起來,獲得到對應的值,最後輸出到結果檔案中
原始碼
1) SchoolDriver.java
public class SchoolDriver { public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); conf.set("fs.defaultFS", "hdfs://192.168.218.55:9000"); Job job = Job.getInstance(conf); job.setJarByClass(SchoolDriver.class); job.setMapperClass(SchoolMapper.class); job.setOutputKeyClass(NullWritable.class); job.setOutputValueClass(Text.class); job.setNumReduceTasks(0); job.addCacheFile(new URI("/school/stu/class.txt")); job.addCacheFile(new URI("/school/stu/dept.txt")); FileInputFormat.setInputPaths(job, new Path("/school/stu/student.txt")); FileSystem fs = FileSystem.get(new URI("hdfs://192.168.218.55:9000"), conf, "root"); Path path = new Path("/test/school/school_map"); if (fs.exists(path)) { fs.delete(path, true); } FileOutputFormat.setOutputPath(job, path); boolean b = job.waitForCompletion(true); System.exit(b ? 0 : 1); } }
2) SchoolMapper.java
public class SchoolMapper extends Mapper<LongWritable, Text, NullWritable, Text> { Map<String, String> dept = new HashMap<>(); Map<String, String[]> cla = new HashMap<>(); @Override protected void setup(Context context) throws IOException, InterruptedException { URI[] cacheFiles = context.getCacheFiles(); URI file1 = cacheFiles[0]; URI file2 = cacheFiles[1]; String path1 = file1.getPath(); String path2 = file2.getPath(); FileSystem fs = FileSystem.get(context.getConfiguration()); FSDataInputStream open1 = fs.open(new Path(path1)); FSDataInputStream open2 = fs.open(new Path(path2)); BufferedReader br1 = new BufferedReader(new InputStreamReader(open1)); BufferedReader br2 = new BufferedReader(new InputStreamReader(open2)); String line = null; while ((line = br1.readLine()) != null) { String[] split = line.split("\t"); cla.put(split[0], new String[]{split[1], split[2]}); } while ((line = br2.readLine()) != null) { String[] split = line.split("\t"); dept.put(split[0], split[1]); } } @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); String[] split = line.split("\t"); String[] classInfo = cla.get(split[1]); String sid = split[0]; String className = classInfo[1]; String deptName = dept.get(classInfo[0]); String score = split[2]; String result = sid + "\t" + className + "\t" + deptName + "\t" + score; context.write(NullWritable.get(), new Text(result)); } }
執行截圖