前言
大数据的起源其实可以追溯到Google
在 2004
年前后发表的《The Google File System》
、 《MapReduce: Simplified Data Processing on Large Clusters》
、《Bigtable: A Distributed Storage System for Structured Data》
的三篇论文的发表。受到Goggle
论文的启发,程序员Doug Cutting
根据论文原理初步实现了类似 GFS
和 MapReduce
的功能,随后的时间里,Doug Cutting
启动了一个独立的项目专门开发维护大数据技术,这就是后来赫赫有名的 Hadoop
。
(注:Hadoop 2.x
相比于 Hadoop 1.x
将 Mapreduce
中的计算与调度进行了拆分)
通过上图我们可以看到,对于Hadopp
而言其中HDFS
和 MapReduce
是Hadoop
中两个核心组件。换言之,如果要学习理解Hadoop
那么HDFS
和 MapReduce
是难以绕过的难关。接下来,我们便来对Hadoop
中的HDFS
和 MapReduce
的工作原理进行一次深入剖析和拆解。
HDFS
分布式文件存储
如果我们将大数据计算比作烹饪,那么数据就是烹饪的食材,此时选如何选择烹饪这些食材的工具便成为困扰程序员的一个首要难题。不同于传统的数据处理,大数据时代的数据呈现出大量、高速、多样等特性,面对如此庞大的数据,采用原先的单节点手段肯定是无法处理的。而HDFS(Hadoop Distributed File System)
的出现恰好可以有效缓解这一问题。
我们完全可以将 HDFS
理解为烹饪"大数据"
这份食材的那口厨具,食材进进出出然后在厨师的操作下被烹饪为各种菜肴层出不穷。那HDFS
究竟使用了哪些黑科技
才能负担起如此庞大数据的存储及处理呢?
接下来,我们就从 HDFS
的原理说起,扒一扒HDFS
实现大数据高速、可靠的存储背后的秘密。
HDFS
架构
在介绍HDFS
之前,我们不妨思考这样一个问题,如果是我们来存储如此庞大的数据,我们又改如何处理呢?众所周知,当文件体较小时完全可以通过单机的方式来进行存储。这一点其实很像Java
应用中传统的单体应用,即当应用体量较小时完全可以采用单机部署的方式来实现服务的正常访问 。但是随着数据的爆炸性增长,原先的单体方式已然捉襟见肘,此时分布式
的理念应运而生。
事实上,在文件存储领域也有类似Java
分布式应用的概念,不过其被称为分布式文件系统(DFS)
。DFS
简单来看就是一种将文件存储和管理功能分布到多个物理节点上的文件系统。 其有别于传统的文件系统将所有文件和数据都存储在单一的服务器,在分布式文件系统中数据被切割成多个部分并分布存储在不同的服务器上。进而实现文件系统的高可用、扩展性和高性能的数据存储。
理解了分布式文件的系统对于文件存储的原理后,我们再来看HDFS
背后的存储逻辑。如下所示为HDFS
的架构图:
对于HDFS
而言DataNode
和NameNode
是其核心组成。其中,DataNode
主要负责文件数据的存储和读写操作 。当文件上传至客户端时,其会对上传的文件进行切分,进而将文件数据分割成若干数据块(Block)
,而这些被拆分后的(Block)
则会保存于DataNode
。这种分块机制使得文件能够分布在多个 DataNode
上,从而使得文件被分布性的存储在整个 HDFS
服务器集群中。
而NameNode
则负责整个分布式文件系统的元数据(MetaData)
管理,所谓的元数据也就是文件路径名、数据块的ID
以及存储位置等信息,这有点类似于操作系统中的文件分配表。
HDFS
的读写过程
知晓了HDFS
的机构设计后,我们接下来看其是如何来完成读写过程的。如下这张图完整的揭示了HDFS
中文件写入的整体逻辑。其中文件写入逻辑大致如下:
1. 客户端请求与 NameNode 通信
当希望在 HDFS
中写入一个新文件时,客户端首先与 NameNode
进行交互,查询文件系统中该文件是否已经存在。如果文件不存在,NameNode
会允许文件创建操作,并为这个文件分配一组数据块的位置。并将分配的空间信息返回至客户端,告知其将数据写入DataNode
的位置。
2. 数据分块与分配
正如我们之前所述,文件在客户端会被分割成多个Block
块。每个数据块都是 HDFS
存储的最小单元。这些数据块将被写入到不同的 DataNode
上。而HDFS
为了确保容错性,其会为每个数据块创建副本,以防止单一机架的故障导致数据丢失
3. 数据块确认
当每个DataNode
将客户端传来的这些数据存储到本地磁盘后,DataNode
会向客户端发送写入成功的确认信息。客户端接收到这些确认后,认为数据块写入成功。然后客户端会向 NameNode
发送文件写入完成的通知,告知 NameNode
文件的元数据已经更新,可以正式完成文件的创建。
而文件的读则可以认为是文件写入的逆过程,其逻辑如下图所示:
首先,客户端向NameNode
节点发送数据访问的请求。接着,Namenode
会根据文件信息收集所有数据块(Block)
的位置信息,并根据数据块在文件中的先后顺序,按次序组成数据块定位集合回应给客户端。随后,客户端拿到数据块定位集合后,会根据信息定位第一个数据块所在的位置,并读取Datanode
的数据流。之后根据读取偏移量依次定位下一个Datanode
数据,以此类推,直至完成对文件信息读取。
总结来看,HDFS
在存储数据时,数据被分割成若干数据块,并将这些块分布到集群中的多个节点,即DataNode
是HHDFS
内部负责实际存储数据块。然后,HDFS
内部通过NameNode
来负责管理文件系统的元数据(如文件名、文件大小、存储位置等),当客户端需要读取文件时,首先向NameNode
请求文件的位置信息,然后直接从相应的DataNode
读取数据。如此便是HDFS
内部的工作原理!
明白了HDFS
的工作原理后,我们接下来看Hadoop
内部组件另一个核心组件MapReduce
。
MapReduce
计算框架
MapReduce
原理
继续用烹饪为例来类比,如果Hdfs
是烹饪过程中的厨具,那MapReduce
则更像是烹饪手法的选择。MapReduce
主要由Map
阶段和 Reduce
阶段构成。
-
Map 阶段 :输入数据被分割成多个数据块,这些数据块分别由
Map
任务进行处理。每个Map
任务将数据映射成键值对(key-value pairs)
,这叫做"Map
操作"。 -
Reduce 阶段 :
Reduce
任务将根据Map
阶段输出的键值对进行合并、汇总或聚合。最终输出结果。
其实你完全将MapReduce
的思想与算法中分治
思想很类似,底层逻辑基本都是拆分
和合并
。
MapReduce
处理逻辑
为了更好的理解MapReduce
的处理逻辑,我们通过一个简单的词频统计
需求来对MapReduce
的处理逻辑进行深入介绍。
事实上,如果是要统计一篇几百kb
文章中对应单词出现的频率的话,其实是非常简单的。只需将文件读入内存,然后建一个 Hash
表记录每个词出现的次数就可以。此时代码大致如下:
java
// 假设文档以读入内容,并拆分为String数组形式
pubic void wordCount(String[] content) {
Map<String,Integer> wordCountMap = new HashMap<>();
for(int i = 0 ; i < content.length ; i ++ ) {
if (wordCountMap.contain(content[i])) {
wordCountMap.put(content[i],
wordCountMap.get([content[i])+1);
}else {
wordCountMap.put(content[i],1);
}
}
上述逻辑在处理小数据量时是完全没问题的,但是如果想统计一个几十GB
的文档中词频数时,此时将如此庞大的文件一次性读入内存显然是一件不太现实的事情。
此时,如果内将这庞大的文件拆分成多个细小的文件,然后统计各自小文件中单词出现频率,最后再将结果合并起来问题似乎就完美了。而这恰好就是MapReduce
的处理逻辑。当引入MapReduce
框架来统计文档单词出现频率时,此时代码如下:
Map
阶段
java
public class WordCountMapper extends Mapper<LongWritable, Text, Text, IntWritable> {
private final static IntWritable one = new IntWritable(1);
private Text word = new Text();
public void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
// 将输入的行分割成单词
String[] words = value.toString().split("\s+");
// 对每个单词发射键值对
for (String w : words) {
word.set(w);
context.write(word, one);
}
}
}
Reduce
阶段
java
public class WordCountReducer extends Reducer<Text, IntWritable, Text, IntWritable> {
public void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
int sum = 0;
// 对每个单词的计数求和
for (IntWritable val : values) {
sum += val.get();
}
context.write(key, new IntWritable(sum));
}
}
可以看到,在上述代码中map
函数中的value
是要统计的所有文本中的一行数据,其处理过大是map 函数将这行文本中的单词提取出来,针对每个单词输出一个 <word,1>
这样的 <Key, Value>
对。而MapReduce
计算框架会将这些 <word , 1>
收集起来,将相同的 word 放在一起,形成类似<word , <1,1,1,1,1,1,1...>>
这样的<Key, Value 集合 >
数据,然后将其输入给 reduce
进行进一步处理。而我们的reduce
的输入参数 values
就是由很多个 1
组成的集合,而 Key
就是具体的单词。
具体来看,reduce
在执行时,会将这个集合里的1
求和,再将单词的和组成一个 <Key, Value>
,也就是<word, sum>
输出。此时,每一个输出就是一个单词和它的词频统计总和。
以上就是 MapReduce
编程模型的主要计算过程和原理。总的来看,在Hadoop
架构中 HDFS
提供了一个分布式文件系统,负责将数据存储在集群中的多个节点上。进而它将大文件分割成多个小块,并在多个DataNode
上分布存储。而 MapReduce
任务会从HDFS
中读取输入数据,并将处理结果写回HDFS
。
在介绍完Hadoop
中核心组件的原理,回到我们的今天的主题,我们将基于Hadoop
进行编程,并编写一款简单的分布式文件处理系统,并将处理结果存储回HDFS
。
代码实战
在开始之前我们先对此次用到的maven
依赖进行一个介绍。对于一个hadoop
应用受到的hadoop-common
依赖当然是不能少的,而为了能编写程序的MapReduce
逻辑,还需引入hadoop-mapreduce-client-core
;除了上述两个构建hadoop
应用的核心依赖外,剩下就是数据处理工具了。由于我们输入文件主要为json
和csv
文件,所以笔者选择使用jackson
和commons-csv
来完成对数据的处理。
(注:此处数据处理的依赖其实并无太多限制,完全可以采用自己喜欢和擅长的。)
文件我们完全可以通过手动上传至HDFS
,到时只需为程序提供文件地址路径即可,所以我们没必要手写文件读入的代码。我们只需将主要精力集中于MapReduce
的任务构建即可。我们首先构建MapReduce
中的Map
阶段,此时map
处理逻辑如下:
构建
Map
java
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVRecord;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.Iterator;
public class FileProcessingMapper extends Mapper<Object, Text, Text, Text> {
private static final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void map(Object key, Text value, Context context) throws IOException, InterruptedException {
String line = value.toString().trim();
// 检查是否为空行
if (line.isEmpty()) {
return;
}
// 判断CSV还是JSON格式
if (line.startsWith("{")) {
// 处理JSON数据
JsonNode jsonNode = objectMapper.readTree(line);
String processedData = cleanJsonData(jsonNode);
context.write(new Text("json"), new Text(processedData));
} else {
// 处理CSV数据
Iterable<CSVRecord> records = CSVFormat.DEFAULT.parse(line);
for (CSVRecord record : records) {
String processedData = cleanCsvData(record);
context.write(new Text("name"), new Text(processedData));
}
}
}
/***
* 对json数据格式进行处理
* @param jsonNode
* @return
*/
private String cleanJsonData(JsonNode jsonNode) {
// 这里可以添加自定义的JSON数据清理逻辑,如果是json数据则仅读取其中的name属性
return jsonNode.get("name").asText();
}
/**
* 对csv文件数据进行处理
* @param record
* @return
*/
private String cleanCsvData(CSVRecord record) {
// 这里可以添加自定义的CSV数据清理逻辑,此处默认仅获取CSV的第一列
return record.get(0);
}
}
在MapReduce
中的Map
阶段中,我们主要定义了输入数据的处理逻辑,当前我们的程序主要支持json
格式和csv
文件的输入。其中所定义的cleanJsonData
和cleanCsvData
主要用于完成对数据的预处理及操纵。笔者
在此处所定义的逻辑较为简单,对于json
数据格式则获取其中的name
属性的内容,而对于csv
格式数据则仅读取其中的首列数据。
(注:此处cleanJsonData
和cleanCsvData
完全可以进行更深次的扩展,出于简单考虑笔者此处选择最简单的逻辑,感兴趣的读者完全可以对其进行替换~)
构建完Map
阶段后,接下来我们来构建其中的Reduce
逻辑。
Reduce
处理逻辑
java
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;
import java.io.IOException;
public class FileProcessingReducer extends Reducer<Text, Text, Text, Text> {
@Override
public void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException {
for (Text val : values) {
//
context.write(key, val);
}
}
}
我们的reduce
逻辑其实很简单,就是将map
阶段读取到的内容进行记录回写出即可。
(注:此处的逻辑完全可以扩展,笔者为了追求简单,所以才进行简化~)
Driver
驱动类
java
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
@Sl4j
public class FileProcessingDriver {
public static void main(String[] args) throws Exception {
log.info("Driver启动类执行,输入参数格式 <input path> <output path> .........");
if (args.length != 2) {
log.error("参数输入不合法,请重试!");
System.exit(-1);
}
// <1> 设定job信息
Configuration conf = new Configuration();
Job job = Job.getInstance(conf, "File Processing Job");
// <2> 设定处理任务处理信息
job.setJarByClass(FileProcessingDriver.class);
job.setMapperClass(FileProcessingMapper.class);
job.setReducerClass(FileProcessingReducer.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(Text.class);
// <3> 加载文件路径信息
FileInputFormat.addInputPath(job, new Path(args[0]));
FileOutputFormat.setOutputPath(job, new Path(args[1]));
// <4> 当任务执行成功,程序成功退出
System.exit(job.waitForCompletion(true) ? 0 : 1);
}
}
以上的代码编写完后,通过mvn clean package
命令将上述程序打打成jar
包,然后执行下面的命令
java
hadoop jar FileProcessingJob.jar FileProcessingDriver input/demo.json output/
进而就可以在yarn
调度平台看到上述任务执行情况。任务执行完毕后,我们可通过命令进行查看结果。如果我们是CSV
文件输入的输入,假设我们输入内容为:
csv
John,30,Engineer
Jane,25,Designer
.........
则我们的Mapper会
读取CSV
文件内容在进行cleanCsvData
方法进行清洗数据时,仅会个字段)。假设方法仅提取第一个字段即(name
)属性。此时预期输入结果如下:
csv
name John
name Jane
至此,我们就对hadoop
中的核心组件HDFS
和MapReduce
进行了深入的分析和介绍,在此基础上我们利用相关知识构建出一个简易版的分布式文件处理程序,希望上述的讲解对你入门理解大数据有所帮助!
总结
在未接触Hadoop
之前你可能觉得其是一个非常复杂的框架,但如果你有静下心来看一看Hadoop
的源码,你会发现这其实就是纯 Java
编写的软件。在实现过程中其并没有什么高深的技术难点,都是些基础的编程手法应用。阅读起来整体不算太难,对此感兴趣的同学,可下来进行研读一番~