10MapReduce原理与搭建

0 阅读25分钟

MapReduce原理与搭建

MapReduce介绍

Google发表了两篇论文《Google File System》 《Google MapReduce》,《Google File System》简称GFS,是Google公司用于解决海量数据存储的文件系统。《Google MapReduce》简称MapReduce,是Google的计算框架,基于GFS。

Map映射,Reduce汇集

比如:如何统计北京一共有多少栋楼房?

源数据:

Haidian 200 Haidian 230
Haidian 200 Haidian 230 Haidian 300 Haidian 330 Haidian 400 Haidian 420 Haidian 500 Haidian 540 Haidian 600 Haidian 120 Map: Haidian 200 Haidian 230 Haidian 200 ... ...

Reducer: Haidian 200,300,400,230,600 Haidian sum总和

image.png

数据以一条记录为单位经过map方法映射成KV,相同的key为一组,这一组数据调用一次reduce方法,在方法内迭代计算这一组数据。数据集一般是用迭代计算的方式,这样可以用有限的内存处理很大的数据集

  • 原语:相同的key为一组,这一组数据调用一次reduce方法,方法内迭代计算这一组数据
  • Map-Reduce:线性依赖关系,先执行map,再执行reduce,不一定非要等map全部执行完毕才可以执行reduce,这与需求相关
  • MapTask:保证原语中组的实现。map端的并行度对应split,一个split对应一个并行度,split的大小可调整,默认等于hdfs中block的大小。hdfs中一个文件多少个block,就会有多少个map task。在MapReduce底层有个map方法,负责读取数据,split中一条记录(record)调用一次map方法
  • ReduceTask:主要负责汇聚数据,将Map段处理后的数据,按照组group为单位,相同的组汇聚在一起(map阶段实际已经进行了汇聚,即将相同组的数据放到一起,这样大大方便了reduce阶段的总汇聚),汇聚过程在底层对应reduce方法,一组调用一次reduce方法。并行度,理想状态下多少组对应多少个reduceTask(reduceTask的数量最多等于分组的数量,再多就没有用了),但是其实一个reduceTask可以线性处理若干组。并行度默认为1,应该根据资源实际情况进行调整,reduce并行度在逻辑层面体现为分区,分区与reduceTask是一对一的关系,一个分区可以包含多个分组(即一个reduceTask可以处理多个分组),但是一个分组只能属于一个分区(即一个分组只能由一个reduceTask处理)

术语对比关系

block > split 1:1(默认) N:1 1:N split > map 1:1 map > reduce N:1 M:N 1:1 1:N group(key)>partition(redues task) 1:1 N:1 M:N 1:N? >违背了原语 redues task> outputfile 1:1

Hadoop Shuffle

系统执行排序、将map输出作为输入传给reducer的过程称为Shuffle

Shuffle这个定义并不准确。因为在某些语境中,它代表reduce任务获取map输出的这部分。我们应该理解为从map产生输出到reduce消化输入的整个过程

image.png

  1. 输入会切分成切片,每个切片对应一个map任务
  2. 切片会格式化出记录(默认以换行符分隔出记录,即一行一条记录),可以自定义记录分隔方式,以记录为单位调用map方法
  3. map的输出映射成kv(默认以"当前行字节偏移量"为key,以"读到的行字符串"为value,key一般都会自定义),kv会参与分区计算,拿着key算出分区号p(指定该键值对由哪个reducer进行处理),输出k,v,p
  4. map输出的kvp会写到环形缓冲区,该区默认大小100MB(mapreduce.task.io.sort.mb),一旦达到阈值0.8(mapreduce.map.sort.spill.percent),一个后台线程会把内容溢写(spill)到指定目录(mapreduce.cluster.local.dir)下的新建的一个溢写文件。溢写磁盘前,要进行partition、sort和combiner等操作;数据会进行两次排序,第一次按照分区号排序,将相同分区的数据放到一起(partition);第二次按照key排序,将相同分组的数据放到一起(sort);combiner是MapReduce的一种优化手段(可选),每一个map都可能会产生大量的本地输出,combiner的作用就是对map端的输出先做一次合并,以减少map和reduce节点之间的数据传输量,只有操作满足结合律的才可设置combiner,可以将combiner理解为map阶段就同时做了一次reduce。这样map执行过程中溢写到磁盘的文件是按照分区排序的,且分区内按照key排序。如果有后续的数据,将会继续写入环形缓冲区中,最终写入下一个溢写文件中
  5. 如果溢写的小文件达到了3个(默认),则进行归并,归并也要进行partition、sort和combiner等操作,归并出来的大文件也是按照分区排序的,且分区内按照key排序(这些小文件都是内部有序外部无序,通过一次归并即可变成一个有序的文件)
  6. reduce会拉取所有map产生的相应分区的数据进行处理,一个分区的数据还是按照分组为单位进行处理的。reduce拉取相应分区数据时,也会进行归并排序。reduce的归并排序可以和reduce方法的计算同时发生(需要满足一定条件),以尽量减少IO。这里用到了迭代器模式,迭代器模式是批量计算中非常优美的实现形式

map阶段做了partition、sort、combiner这些处理,成本也不是很高,最开始都是在内存中做归并排序的,文件合并时也只需遍历一次文件即可,这样做了之后可以指数级降低reduce阶段的复杂度

Yarn架构与MR执行流程

hadoop1.x MapReduce

hdfs已经暴露了数据的位置,要实现计算向数据移动。先确定哪些节点可以去,即哪些节点有计算资源且离数据比较近,需要有整体的资源把控(资源管理)。确定节点后对方怎么知道,如果任务执行失败了应该在哪个节点重试(任务调度)

还需要资源管理和任务调度

hadoop1.x中,JobTracker负责资源管理和任务调度,TaskTracker负责任务管理和资源汇报

client(客户端)会进行如下的操作

  1. 根据计算的数据咨询NameNode,得到一个split(切片)清单,map的数量就有了。split包含block、偏移量、节点信息等,这样map任务就知道可以移动到哪些节点了。这就支持了计算向数据移动了
  2. 生成计算程序未来运行时的相关配置文件(xml)
  3. 将jar包、split清单、配置文件,上传到hdfs中,这里副本数默认是10。未来拉取这些数据时应该是可靠的,这里利用了hdfs的可靠性
  4. 调用JobTracker,通知要启动一个计算程序,并告知启动文件在hdfs中的路径

JobTracker收到启动程序之后

  1. 从hdfs中取回"split清单"
  2. 根据自己收到的TaskTracker汇报的资源,最终确定每一个split对应的map应该去到哪一个节点(确定清单)
  3. 未来,TaskTracker再心跳的时候会取回分配给自己的任务信息

TaskTracker

  1. 在心跳时取回任务
  2. 取回任务后,从hdfs中下载jar包、配置文件到本地
  3. 最终启动任务描述中的MapTask/ReduceTask

最终代码在某一个节点被启动,是通过client上传,TaskTracker下载完成的

JobTracker有3个问题

  1. 单点故障
  2. 压力过大
  3. 集成了资源管理和任务调度,两者耦合,导致未来新的计算框架不能复用资源管理。如果新的计算框架要在相同的计算节点上执行任务,必须部署自己的资源管理系统,多套资源管理系统相互隔离,会造成资源的争抢(hadoop2.x新的资源管理系统yarn上就可以执行MapReduce、Spark、Flink等计算框架)

hadoop2.x MapReduce运行示意图

image.png

  1. 客户端提交MapReduce任务,首先向RM申请启动ApplicationMaster
  2. ApplicationMaster启动之后,向RM给MapReduce任务申请资源
  3. RM会找到NodeManager分配资源,最终在NodeManager中有对应的Container启动(core+内存)

Yarn

YARN:Yet Another Resource Negotiator,Hadoop 2.0新引入的资源管理系统,直接从MRv1演化而来的,其核心思想是将MRv1中JobTracker的资源管理和任务调度两个功能分开,分别由ResourceManager和ApplicationMaster进程实现。ResourceManager(主)和NodeManager(从)负责资源管理,ApplicationMaster负责任务调度。YARN的引入,使得多个计算框架可运行在一个集群中,比如MapReduce、Spark、Storm、Flink等都可以运行在Yarn上。Yarn中核心如下:

  1. ResourceManager,核心,负责整个集群的资源管理和调度
  2. NodeManager
    • 通过心跳,向RM汇报资源
    • 管理Container生命周期(计算框架中的角色都以Container表示)
  3. ApplicationMaster(是一个Container)
    • 负责应用程序相关的事务,比如任务调度、任务监控和容错等
    • 每个job对应一个ApplicationMaster
  4. Container容器(不是docker,但是可以整合docker),每个Container分到节点NM的一部分CPU、MEM、I/O等资源
    • NM会有线程监控container使用资源情况,超额则会被NM直接kill掉
    • 支持Linux内核级Cgroup技术,启动任务进程时,由kernel约束死

MapReduce On YARN(MRv2)

将MapReduce作业直接运行在YARN上,而不是由JobTracker和TaskTracker构建的MRv1系统中。这里所说的MapReduce on Yarn指的是基于Yarn运行MapReduce任务,当MR任务由客户端提交任务后,会向Yarn中的ResourceManager申请资源,进而会向NodeManager节点上分配对应的资源。

基本功能模块

  1. YARN:负责资源管理和调度
  2. ApplicationMaster:负责任务切分、任务调度、任务监控和容错等
  3. MapTask/ReduceTask:任务驱动引擎,与MRv1一致

每个MapRduce作业对应一个ApplicationMaster

  1. ApplicationMaster任务调度
  2. YARN将资源分配给ApplicationMaster
  3. ApplicationMaster进一步将资源分配给内部的任务

ApplicationMaster容错

  1. 失败后,由YARN重新启动
  2. 任务失败后,ApplicationMaster重新申请资源

基本执行流程如下

  1. Client 与hadoop1.x中一样,会将切片清单、配置文件、jar包上传到HDFS。然后访问RM申请ApplicationMaster
  2. RM选择一个不忙的NM节点,通知该节点启动一个container,在里面反射一个ApplicationMaster
  3. 启动ApplicationMaster,从hdfs下载切片清单,向RM申请资源
  4. 由RM根据自己掌握的资源情况得到一个确定清单,通知NM来启动container
  5. container启动后会反向注册到已经启动的ApplicationMaster
  6. ApplicationMaster(曾经的JobTracker阉割版不带资源管理)最终将任务Task发送给container
  7. container会反射相应的Task类为对象,调用方法执行,其结果就是我们的业务逻辑代码的执行
  8. 计算框架都有Task失败重试的机制

JobTracker3个问题在这里得到了解决

  1. 单点故障

    JobTracker是全局的,它挂了整个计算层就没有了调度。yarn中每个application由一个自己的ApplicationMaster调度(计算程序级别),yarn还支持ApplicationMaster失败重试

  2. 压力过大

    yarn中每个计算程序有自己的ApplicationMaster,每个ApplicationMaster只负责自己计算程序的任务调度,轻量了。ApplicationMaster是在不同的节点中启动的,默认有了负载的光环

  3. 集成了资源管理和任务调度两者耦合

    yarn只负责资源管理,不负责具体的任务调度,是公立的,只要计算框架继承yarn的AppMaster,不同的计算框架就可以使用一个统一视图的资源层

hadoop1.x中JobTracker和TaskTracker都是MR的常服务,hadoop2.x中MR没有后台常服务

YARN RM-HA搭建

hadoop1.x中hdfs没有ha,没有yarn。hadoop2.x中hdfs增加了ha,为了向前兼容,没有过多修改NN,而是通过新增角色zkfc来支持ha;hadoop2.x中有了yarn,直接在RM中增加了ha的模块,让yarn原生就支持ha

Yarn集群的搭建不需要依赖于HDFS集群,可以只启动yarn集群,供其它计算框架使用,MapReduce计算框架依赖hdfs。加入Yarn(ResourceManager、NodeManager)之后的集群划分如下:

在上面Hadoop NameNode HA的基础上,搭建RM和NM即可

节点NNDNZKZKFCJNRMNM
node01
node02
node03
node04

YarnHA搭建可以参照官网地址:

hadoop.apache.org/docs/r3.2.4…

image.png

在node01节点上修改配置,然后发送到其它节点,注意切换到bigdata用户

$HADOOP_HOME/etc/hadoop/mapred-site.xml

<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<configuration>
  <!-- 让MapReduce任务运行时使用Yarn资源调度框架进行调度 -->
  <property>
    <name>mapreduce.framework.name</name>
    <value>yarn</value>
  </property>
  <!-- 起MapReduce任务时需要下面的配置 -->
  <property>
    <name>yarn.app.mapreduce.am.env</name>
    <value>HADOOP_MAPRED_HOME=${HADOOP_HOME}</value>
  </property>
  <property>
    <name>mapreduce.map.env</name>
    <value>HADOOP_MAPRED_HOME=${HADOOP_HOME}</value>
  </property>
  <property>
    <name>mapreduce.reduce.env</name>
    <value>HADOOP_MAPRED_HOME=${HADOOP_HOME}</value>
  </property>
</configuration>

$HADOOP_HOME/etc/hadoop/yarn-site.xml

<?xml version="1.0"?>
<configuration>
  <!-- 让yarn的容器支持mapreduce的shuffle,开启shuffle服务 -->
  <property>
    <name>yarn.nodemanager.aux-services</name>
    <value>mapreduce_shuffle</value>
  </property>
  <!-- 启用resourcemanager的HA -->
  <property>
    <name>yarn.resourcemanager.ha.enabled</name>
    <value>true</value>
  </property>
  <!-- 指定zookeeper集群的各个节点地址和端口号 -->
  <property>
    <name>hadoop.zk.address</name>
    <value>node02:2181,node03:2181,node04:2181</value>
  </property>
  <!-- 标识集群,以确保RM不会接管另一个集群的活动,与core-site.xml中保持一致 -->
  <!-- 这个会反应在zk中,可以用于在zk中隔离不同的yarn集群 -->
  <property>
    <name>yarn.resourcemanager.cluster-id</name>
    <value>mycluster</value>
  </property>
  <!-- RM HA的两个resourcemanager的名字 -->
  <property>
    <name>yarn.resourcemanager.ha.rm-ids</name>
    <value>rm1,rm2</value>
  </property>
  <!-- 指定rm1的reourcemanager进程所在的主机名称 -->
  <property>
    <name>yarn.resourcemanager.hostname.rm1</name>
    <value>node03</value>
  </property>
  <!-- RM HTTP访问地址,不配置的话在shell中提交MR任务时会报错"org.apache.hadoop.mapreduce.v2.app.client.MRClientService: Webapps failed to start. Ignoring for now:java.lang.NullPointerException" -->
  <!-- 默认为 ${yarn.resourcemanager.hostname}:8088 -->
  <property>
    <name>yarn.resourcemanager.webapp.address.rm1</name>
    <value>node03:8088</value>
  </property>
  <!-- 指定rm2的reourcemanager进程所在的主机名称 -->
  <property>
    <name>yarn.resourcemanager.hostname.rm2</name>
    <value>node04</value>
  </property>
  <property>
    <name>yarn.resourcemanager.webapp.address.rm2</name>
    <value>node04:8088</value>
  </property>
  <!-- 关闭虚拟内存检查,生产环境不能关闭,测试环境资源不够应该关闭,否则后面对yarn的操作可能会报内存不足 -->
  <property>
    <name>yarn.nodemanager.vmem-check-enabled</name>  
    <value>false</value>  
  </property>
</configuration>
# node01
su - bigdata
# 在start-yarn.sh和stop-yarn.sh脚本中加入两行配置,指定操作Yarn的用户
cat > user_tmp.txt << EOF
YARN_RESOURCEMANAGER_USER=bigdata
YARN_NODEMANAGER_USER=bigdata
EOF

sed -i '17r user_tmp.txt' $HADOOP_HOME/sbin/start-yarn.sh
sed -i '17r user_tmp.txt' $HADOOP_HOME/sbin/stop-yarn.sh
rm -f user_tmp.txt

# NM节点配置,修改这个配置文件 $HADOOP_HOME/etc/hadoop/workers,与DN节点配置公用一个配置文件,即NM与DN一一对应且在同一个节点,上面启动HDFS时已经配置过了(如果只启动yarn这里不要忘记配置)

# yarn配置文件和启停脚本发送到所有其它节点
for i in 2 3 4; do scp $HADOOP_HOME/etc/hadoop/mapred-site.xml node0$i:$HADOOP_HOME/etc/hadoop/; scp $HADOOP_HOME/etc/hadoop/yarn-site.xml node0$i:$HADOOP_HOME/etc/hadoop/; scp $HADOOP_HOME/sbin/*-yarn.sh node02:$HADOOP_HOME/sbin/;done

# 启动yarn(需要先启动hdfs)
start-yarn.sh

启动之后,可以通过浏览器访问:http://node03:8088http://node04:8088。ResourceManager是standby状态会自动跳转到ResourceManager是active状态的节点。这个到底是node03或者node04节点是active不一定,由争夺zookeeper锁决定

image.png

我们可以手动停止Active ResourceManager进程,然后可以观察另外的standby ResourceManager节点状态,会自动切换成Active状态

# node03 bigdata用户
# 停止 active ResourceManager
yarn --daemon stop resourcemanager

我们可以通过查看Yarn页面找到Active ResourceManager到底是哪个节点:

image.png

也可以在zookeeper中查看对应的ResourceManager高可用信息

# node02 bigdata用户
zkCli.sh
# 进入zk客户端shell
get /yarn-leader-election/mycluster/ActiveBreadCrumb

        myclusterrm2

resourcemanager和namenode在同一个节点,可以使用start-all.sh和stop-all.sh,启动或停止hdfs和yarn集群,否则需要单独启动hdfs和yarn集群

# node01 bigdata用户

# 修改脚本,同时启停hdfs和yarn集群
cp ~/bin/hdfs_ha_start.sh ~/bin/hadoop_all_start.sh
cp ~/bin/hdfs_ha_stop.sh ~/bin/hadoop_all_stop.sh
sed -i 's/start-dfs.sh/start-all.sh/' ~/bin/hadoop_all_start.sh
sed -i 's/stop-dfs.sh/stop-all.sh/' ~/bin/hadoop_all_stop.sh

# 停止zk、hdfs、yarn
/bin/bash ~/bin/hadoop_all_stop.sh
# 启动zk、hdfs、yarn
/bin/bash ~/bin/hadoop_all_start.sh

MapReduce实践

简单入门

MapReduce执行,一般都是通过java开发MapReduce程序打成jar包,上传至服务器,然后通过hadoop jar命令执行。也可以在本地idea中直接运行代码,代码需要做一些修改,实际还需要上传jar包,这种方式一般很少使用。还可以直接在本地运行,输入输出使用hdfs,在本地直接运行MapReduce而不是在yarn中,这种方式一般用于本地调试和测试

# node01 bigdata用户
hdfs dfs -mkdir -p /data/test/input
for i in `seq 100000`;do echo "hello hadoop $i" >> data.txt;done
hdfs dfs -D dfs.blocksize=1048576 -put data.txt /data/test/input
cd $HADOOP_HOME/share/hadoop/mapreduce

hadoop jar hadoop-mapreduce-examples-3.2.4.jar wordcount /data/test/input /data/test/output
2024-01-29 14:24:59,265 INFO client.ConfiguredRMFailoverProxyProvider: Failing over to rm2
2024-01-29 14:24:59,323 INFO mapreduce.JobResourceUploader: Disabling Erasure Coding for path: /tmp/hadoop-yarn/staging/bigdata/.staging/job_1706509432574_0001
2024-01-29 14:25:00,008 INFO input.FileInputFormat: Total input files to process : 1
2024-01-29 14:25:00,095 INFO mapreduce.JobSubmitter: number of splits:2
2024-01-29 14:25:00,208 INFO mapreduce.JobSubmitter: Submitting tokens for job: job_1706509432574_0001
2024-01-29 14:25:00,209 INFO mapreduce.JobSubmitter: Executing with tokens: []
2024-01-29 14:25:00,368 INFO conf.Configuration: resource-types.xml not found
2024-01-29 14:25:00,368 INFO resource.ResourceUtils: Unable to find 'resource-types.xml'.
2024-01-29 14:25:00,521 INFO impl.YarnClientImpl: Submitted application application_1706509432574_0001
2024-01-29 14:25:00,558 INFO mapreduce.Job: The url to track the job: http://node04:8088/proxy/application_1706509432574_0001/
2024-01-29 14:25:00,558 INFO mapreduce.Job: Running job: job_1706509432574_0001
2024-01-29 14:25:05,630 INFO mapreduce.Job: Job job_1706509432574_0001 running in uber mode : false
2024-01-29 14:25:05,631 INFO mapreduce.Job:  map 0% reduce 0%
2024-01-29 14:25:10,708 INFO mapreduce.Job:  map 100% reduce 0%
2024-01-29 14:25:14,729 INFO mapreduce.Job:  map 100% reduce 100%
2024-01-29 14:25:15,745 INFO mapreduce.Job: Job job_1706509432574_0001 completed successfully
...
        File Input Format Counters
                Bytes Read=1892991
        File Output Format Counters
                Bytes Written=788922

# _SUCCESS 标志成功的文件,part-r-00000 数据文件(r表示MapReduce之后产生的数据,m表示只有map没有reduce产生的数据)
hdfs dfs -ls /data/test/output/
Found 2 items
-rw-r--r--   3 bigdata supergroup          0 2024-01-29 14:25 /data/test/output/_SUCCESS
-rw-r--r--   3 bigdata supergroup     788922 2024-01-29 14:25 /data/test/output/part-r-00000

# 查看输出文件最后10行数据
hdfs dfs -get /data/test/output/part-r-00000
# data.txt上传会切割成2个block,单词被切割开了,计算结果依然是正确的
tail -10 part-r-00000
99992   1
99993   1
99994   1
99995   1
99996   1
99997   1
99998   1
99999   1
hadoop  100000
hello   100000

image.png

编写MapReduce的maven程序

统计每个单词的数量

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.example</groupId>
    <artifactId>hadoop-test</artifactId>
    <version>1.0-SNAPSHOT</version>
    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.apache.hadoop</groupId>
            <artifactId>hadoop-client</artifactId>
            <version>3.2.4</version>
        </dependency>
    </dependencies>
    <build>
        <finalName>${project.name}</finalName>
        <resources>
            <resource>
                <directory>src/main/resources</directory>
                <filtering>true</filtering>
            </resource>
        </resources>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <target>${maven.compiler.target}</target>
                    <source>${maven.compiler.source}</source>
                    <encoding>UTF-8</encoding>
                    <skip>true</skip>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
package org.example.mr;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
import java.io.IOException;
import java.util.StringTokenizer;

// hadoop是分布式架构,数据需要在网络中传输,这就需要序列化和反序列化,MapReduce过程中还涉及到排序
// 因此Mapper和Reducer中的泛型参数都必须实现hadoop的序列化和反序列化接口以及比较器接口
public class MyMapper extends Mapper<Object, Text, Text, IntWritable> {
    private final IntWritable one = new IntWritable(1);
    private final Text word = new Text();
    /**
     * @param key 是每一行字符串自己第一个字节面向源文件的偏移量(这个是默认的,可以自定义)
     * @param value 每行的数据(默认按照换行符分隔记录,可以自定义),例如:hello hadoop 1
     * @param context 上下文用于map阶段的输出
     */
    @Override
    public void map(Object key, Text value, Context context) throws IOException, InterruptedException {
        StringTokenizer itr = new StringTokenizer(value.toString());
        while (itr.hasMoreTokens()) {
            word.set(itr.nextToken());
            // 需求是统计各个单词出现的数量,因此输入reduce的是 单词-数量
            context.write(word, one);
        }
    }
}
package org.example.mr;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;
import java.io.IOException;

public class MyReducer extends Reducer<Text, IntWritable, Text, IntWritable> {
    private final IntWritable result = new IntWritable();
    /**
     * 相同的key为一组 ,这一组数据调用一次reduce
     * @param key     单词
     * @param values  单词数量列表,没有执行combinbe的话这里列表中每个元素都是1
     * @param context 上下文用于将结果输出到文件
     */
    @Override
    public void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
        int sum = 0;
        for (IntWritable val : values) {
            sum += val.get();
        }
        result.set(sum);
        context.write(key, result);
    }
}
package org.example.mr;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.TextOutputFormat;
import org.apache.hadoop.util.GenericOptionsParser;

public class MyWordCount {
    /**
     * hadoop command [genericOptions] [commandOptions]
     * 例如:hadoop jar hadoop-test.jar ooxx -D ooxx=ooxx inpath outpath
     * args: 2类参数 genericOptions(-D参数) commandOptions(其它参数)
     */
    public static void main(String[] args) throws Exception {
        // true 表示加载配置文件
        Configuration conf = new Configuration(true);
        // 工具类帮我们把所有-D属性直接set到conf,会留下commandOptions
        GenericOptionsParser parser = new GenericOptionsParser(conf, args);
        // commandOptions
        String[] otherArgs = parser.getRemainingArgs();
        Job job = Job.getInstance(conf);
        job.setJarByClass(MyWordCount.class);
        job.setJobName("myTest");
        // 设置输入路径
        Path inFile = new Path(otherArgs[0]);
        TextInputFormat.addInputPath(job, inFile);
        // 设置输出路径
        Path outFile = new Path(otherArgs[1]);
        FileSystem outFileFileSystem = outFile.getFileSystem(conf);
        if (outFileFileSystem.exists(outFile)) {
            outFileFileSystem.delete(outFile, true);
        }
        TextOutputFormat.setOutputPath(job, outFile);
        // 设置Mapper类型
        job.setMapperClass(MyMapper.class);
        // 设置Combiner类型
        job.setCombinerClass(MyReducer.class);
        // 设置输出的键类型
        job.setMapOutputKeyClass(Text.class);
        // 设置输出的值类型
        job.setMapOutputValueClass(IntWritable.class);
        // 设置Reducer类型
        job.setReducerClass(MyReducer.class);
        // 设置reduce并行度
        // job.setNumReduceTasks(2);
        // 提交作业,然后轮询进度,直到作业完成
        job.waitForCompletion(true);
    }
}

mvn clean install打包,然后将jar包上传到node01节点

# node01 bigdata用户
# 删除输出文件
hdfs dfs -rm -R /data/test/output
rm -f part-r-00000
# 执行自己编写的MapReduce程序
hadoop jar hadoop-test.jar org.example.mr.MyWordCount /data/test/input/ /data/test/output/

# 查看输出文件最后10行数据
hdfs dfs -get /data/test/output/part-r-00000

tail -10 part-r-00000
99992   1
99993   1
99994   1
99995   1
99996   1
99997   1
99998   1
99999   1
hadoop  100000
hello   100000

代码也可以在idea中运行,修改启动类如下

public static void main(String[] args) throws Exception {
    Configuration conf = new Configuration(true);
    GenericOptionsParser parser = new GenericOptionsParser(conf, args);
    String[] otherArgs = parser.getRemainingArgs();
    
    // 设置属性,覆盖配置文件中的配置
    // 让框架知道是windows异构平台运行
    conf.set("mapreduce.app-submission.cross-platform", "true");
    // 下面的几个设置主要用于测试
    // 设置在本地运行
    // conf.set("mapreduce.framework.name", "local");
    // 设置reduce数量,也是分区的数量
    conf.setInt("mapreduce.job.reduces", 2);
    // 设置环形缓冲区大小
    conf.setInt("mapreduce.task.io.sort.mb", 1);
    // 执行map阶段溢写文件合并时,每次最多处理几个文件
    conf.setInt("mapreduce.task.io.sort.factor", 2);
    Job job = Job.getInstance(conf);
    // 设置jar包路径,需要先执行 mvn clean install 生成jar包(本地运行,不需要传jar包)
    job.setJar("D:\\code\\test\\hadoop-test\\target\\hadoop-test.jar");
    job.setJarByClass(MyWordCount.class);
    job.setJobName("myTest");
    ...
}

还需要做如下修改

  1. 在本机部署hadoop,并设置HADOOP_HOME=D:\dev\hadoop-3.2.4和HADOOP_USER_NAME=bigdata环境变量
  2. 将hadoop相关配置拷贝到maven项目的resources目录。core-site.xml、hdfs-site.xml、mapred-site.xml、yarn-site.xml
  3. windows系统专用,在github.com/cdarlint/wi…
  4. 在idea中设置启动类的配置Program arguments/data/test/input/ /data/test/output/,即设置启动参数中的输入和输出路径
TopN
2019-6-1 22:22:22	1	31
2019-5-21 22:22:22	3	33
2019-6-1 22:22:22	1	33
2019-6-2 22:22:22	2	32
2018-3-11 22:22:22	3	18
2018-4-23 22:22:22	1	22
1970-8-23 22:22:22	2	23
1970-8-8 22:22:22	1	32
1	beijing
2	shanghai
3	guangzhou

原始数据是:"日期时间\t城市编号\t温度",辅助数据是:"城市编号\t城市名称",需求:每个月气温最高的2天,输出是"年-月-日@城市名称"。该需求可以按照年月分组,然后将组内的数据按照温度倒序,取出每组前两条即可。对应到MapReduce就是,map阶段按照年月排序,reduce阶段按照年月分组,然后在reduce中取出温度最高的前两条,这样做在reduce阶段还要做排序,性能很差。MapReduce中排序是在map阶段做的,reduce阶段的排序只是对所有map结果做的一次归并排序。因此这里温度的排序应该设计在map阶段,map阶段按照"年月正序温度倒序"的顺序排序,reduce阶段按照年月分组,这样reduce中只需取出前两条数据即可(这里需要对天做去重,可能需要多取几条)。这样做消除了reduce阶段额外的排序,而map阶段本来就是要做排序的,性能会高很多。

这里原始数据中城市存储的是编号,输出中需要的是城市名称,需要做类似sql中的join操作。当这种映射数据集很大时,可以通过两次MR完成(利用MR的分布式能力),当映射数据集不是很大时,可以直接在内存中完成映射。MR中在提交job前可以添加映射数据集对应的缓存文件,在MR中可以拿到缓存文件然后读取到内存中做映射。

public class MyTopN {

    public static void main(String[] args) throws Exception {
        Configuration conf = new Configuration(true);
        GenericOptionsParser parser = new GenericOptionsParser(conf, args);
        String[] otherArgs = parser.getRemainingArgs();
        // 让框架知道是windows异构平台运行
        // conf.set("mapreduce.app-submission.cross-platform", "true");
        // MR中需要拿缓存文件,不能再本地运行,只能在集群中运行
        //conf.set("mapreduce.framework.name", "local");
        Job job = Job.getInstance(conf);
        // job.setJar("D:\\code\\test\\hadoop-test\\target\\hadoop-test.jar");
        job.setJarByClass(MyTopN.class);
        job.setJobName("TopN");
        // 将join的右表(映射数据集)cache到mapTask出现的节点上
        job.addCacheFile(new Path("/data/topn/dict/dict.txt").toUri());
        // 设置输入路径
        Path inFile = new Path(otherArgs[0]);
        TextInputFormat.addInputPath(job, inFile);
        // 设置输出路径
        Path outFile = new Path(otherArgs[1]);
        FileSystem outFileFileSystem = outFile.getFileSystem(conf);
        if (outFileFileSystem.exists(outFile)) {
            outFileFileSystem.delete(outFile, true);
        }
        TextOutputFormat.setOutputPath(job, outFile);
        // map
        job.setMapperClass(TMapper.class);
        job.setMapOutputKeyClass(TKey.class);
        job.setMapOutputValueClass(IntWritable.class);
        // 设置自定义分区器,按照年分区
        job.setPartitionerClass(TPartitioner.class);
        // 设置自定义排序比机器,按照年月正序温度倒序
        job.setSortComparatorClass(TSortComparator.class);
        // reduce
        job.setReducerClass(TReducer.class);
        // 设置自定义分组比较器,按照年月分组
        job.setGroupingComparatorClass(TGroupingComparator.class);
        job.waitForCompletion(true);
    }
}
// 分组比较器,在reduce中需要计算出每个月气温最高的2天,所以需要按照年月分组
public class TGroupingComparator extends WritableComparator {
    public TGroupingComparator() {
        super(TKey.class, true);
    }

    @Override
    public int compare(WritableComparable a, WritableComparable b) {
        TKey k1 = (TKey) a;
        TKey k2 = (TKey) b;
        // 按照年月分组
        int c1 = Integer.compare(k1.getYear(), k2.getYear());
        if (c1 == 0) {
            return Integer.compare(k1.getMonth(), k2.getMonth());
        }
        return c1;
    }
}
// 自定义类型必须实现序列化/反序列化和比较器接口
public class TKey implements WritableComparable<TKey> {
    private int year;
    private int month;
    private int day;
    private int wd;
    private String location;
	// 省略get/set方法
    // ...
    // 我们为了让这个案例体现api开发,所以下面的逻辑是一种通用的逻辑,按照时间正序
    // 但是我们目前业务需要的是,按照年月正序温度倒序,所以还得开发一个排序比较器(TSortComparator),将排序比较器中的逻辑移到这里,可以不用开发排序比较器
    @Override
    public int compareTo(TKey that) {
        int c1 = Integer.compare(this.year, that.getYear());
        if (c1 == 0) {
            int c2 = Integer.compare(this.month, that.getMonth());
            if (c2 == 0) {
                return Integer.compare(this.day, that.getDay());
            }
            return c2;
        }
        return c1;
    }

    @Override
    public void write(DataOutput out) throws IOException {
        out.writeInt(year);
        out.writeInt(month);
        out.writeInt(day);
        out.writeInt(wd);
        out.writeUTF(location);
    }

    @Override
    public void readFields(DataInput in) throws IOException {
        this.year = in.readInt();
        this.month = in.readInt();
        this.day = in.readInt();
        this.wd = in.readInt();
        this.location = in.readUTF();
    }
}
public class TMapper extends Mapper<LongWritable, Text, TKey, IntWritable> {
    // 因为map会被调用很多次,定义在外面减少gc,不管map方法执行多少次,map输出的key/value对象只有一个,每次都重新设置该对象中字段的值,MR源码中也是这样做的
    TKey mKey = new TKey();
    IntWritable mValue = new IntWritable();
    public Map<String, String> dict = new HashMap<>(); // 存放job缓存中的数据


    @Override
    protected void setup(Mapper<LongWritable, Text, TKey, IntWritable>.Context context) throws IOException, InterruptedException {
        // 读取job缓存中的数据,在reduce中也可以这样做
        URI[] files = context.getCacheFiles();
        Path path = new Path(files[0].getPath());
        BufferedReader reader = new BufferedReader(new FileReader(path.getName()));
        String line = reader.readLine();
        while (line != null) {
            String[] split = line.split("\t");
            dict.put(split[0], split[1]);
            line = reader.readLine();
        }

    }

    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, TKey, IntWritable>.Context context) throws IOException, InterruptedException {
        // value: 2019-6-1 22:22:22	1	31,处理原始的每条数据,提取年月日温度等信息构建输出的key和value,然后调用context.write(mKey, mValue)
        // strs: ["2019-6-1 22:22:22:, "1", "31"]
        String[] strs = StringUtils.split(value.toString(), '\t');
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
        try {
            Date date = sdf.parse(strs[0]);
            Calendar cal = Calendar.getInstance();
            cal.setTime(date);
            mKey.setYear(cal.get(Calendar.YEAR));
            mKey.setMonth(cal.get(Calendar.MONTH) + 1);
            mKey.setDay(cal.get(Calendar.DAY_OF_MONTH));
            int wd = Integer.parseInt(strs[2]);
            mKey.setWd(wd);
            // 可以在这里不做映射,输入位置的原始信息,在reduce中输出时再替换位置实际信息,因为位置原始信息是索引比实际信息小
            mKey.setLocation(dict.get(strs[1]));
            mValue.set(wd); // 温度信息在key中有,设置到value中没有实际用处
            context.write(mKey, mValue);
        } catch (ParseException e) {
            e.printStackTrace();
        }
    }
}
// 自定义分区器,逻辑不能太复杂,只要满足相同的key获得相同的分区号就可以,相同的key是指通过分组比较器比较相等的key。
// 因此分区只要不比分组小(即同一个分组一定属于同一个分区)就可以,这里是按照年月分组,当然可以按照年月分区,为了简单可以只按照年分区
public class TPartitioner extends Partitioner<TKey, IntWritable> {
    @Override
    public int getPartition(TKey key, IntWritable value, int numPartitions) {
        // 按照年分区,如果不同年份的数据量差距很大会有数据倾斜
        return key.getYear() % numPartitions;
    }
}
public class TReducer extends Reducer<TKey, IntWritable, Text, IntWritable> {
    Text rKey = new Text();
    IntWritable rValue = new IntWritable();

    @Override
    protected void reduce(TKey key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
        Iterator<IntWritable> iterator = values.iterator();
        // 是否是一组数据中的第一个key,
        boolean isFirstKey = true;
        // 一组数据中第一个key中对应的日(天)
        int firstDay = 0;
        while (iterator.hasNext()) {
            // value值没有使用,调用next是为了迭代一组数据,每迭代一次都会得到key/value(实际是更新key/value,每次使用的都是同一个对象),
            // key与这里reduce方法传入的key是同一个对象,因此对values进行遍历时这里传入的key是同一个对象但是里面的内容已经变了
            iterator.next();
            if (isFirstKey) { // 是第一个key,即是这组数据的第一条
                rKey.set(key.getYear() + "-" + key.getMonth() + "-" + key.getDay() + "@" + key.getLocation());
                rValue.set(key.getWd());
                context.write(rKey, rValue);
                isFirstKey = false;
                firstDay = key.getDay();
            }
            // 不是该组数据中的第一条,且与第一条数据的日(天)不相等,那么该条数据就是我们需要的Top2数据中的第二条
            // 因为我们需要的是每个月气温最高的2天,数据中可能有同一天的数据,需要去重
            else if (firstDay != key.getDay()) {
                rKey.set(key.getYear() + "-" + key.getMonth() + "-" + key.getDay() + "@" + key.getLocation());
                rValue.set(key.getWd());
                context.write(rKey, rValue);
                break;
            }
        }
    }
}
// 自定义排序比较器,按照年月正序温度倒序
public class TSortComparator extends WritableComparator {

    // 比较器比较数据时,需要反序列化
    public TSortComparator() {
        super(TKey.class, true);
    }

    @Override
    public int compare(WritableComparable a, WritableComparable b) {
        TKey k1 = (TKey) a;
        TKey k2 = (TKey) b;
        // 年月正序温度倒序
        int c1 = Integer.compare(k1.getYear(), k2.getYear());
        if (c1 == 0) {
            int c2 = Integer.compare(k1.getMonth(), k2.getMonth());
            if (c2 == 0) {
                return Integer.compare(k2.getWd(), k1.getWd());
            }
            return c2;
        }
        return c1;
    }
}
# 将上面的TopN程序打包,上传到node01节点
# node01
# 准备数据,将上面的"原始数据"放到data.txt中,"辅助数据"放到dict.txt中

# 上传到hdfs
hdfs dfs -mkdir -p /data/topn/input
hdfs dfs -mkdir -p /data/topn/dict
hdfs dfs -put data.txt /data/topn/input
hdfs dfs -put dict.txt /data/topn/dict
# 执行MR程序
hadoop jar hadoop-test.jar org.example.topn.MyTopN /data/topn/input/ /data/topn/output/
# 查看执行结果
hdfs dfs -cat /data/topn/output/part-r-00000
1970-8-8@beijing        32
1970-8-23@shanghai      23
2018-3-11@guangzhou     18
2018-4-23@beijing       22
2019-5-21@guangzhou     33
2019-6-1@beijing        33
2019-6-2@shanghai       32
好友推荐

每个用户都有自己的好友列表,给每个用户推荐好友的好友,即推荐的好友与该用户至少有一个共同的好友

原始数据格式是:"用户名 好友列表",好友列表也是以空格分割的,即原始数据是空格分割的用户名,第一个是用户自己后面的是好友列表

詹姆斯 韦德 波什 雷阿伦
韦德 詹姆斯 波什
波什 詹姆斯 韦德 库里 汤普森
雷阿伦 詹姆斯 库里
库里 波什 雷阿伦
汤普森 波什 保罗
保罗 汤普森

image.png

将原始数据处理成,"姓名-姓名"的格式并用0表示直接关系(已经是好友)1表示间接关系(有共同的好友)。"詹姆斯 韦德 波什 雷阿伦"处理之后是:"詹姆斯-韦德 0","詹姆斯-波什 0","詹姆斯-雷阿伦 0","韦德-波什 1","韦德-雷阿伦 1","波什-雷阿伦 1"。这样只要从间接关系(有共同好友)的数据中去掉直接关系的数据(已经是好友的去掉),得到的就是推荐的好友(不是好友但是有共同的好友),直接关系数据和间接关系数据去重,因此需要对"姓名"数据按照姓名排序之后再拼接"姓名-姓名"。MR设计是,map阶段的输出key是"姓名-姓名"value是0或1,默认对key进行字符排序,reduce阶段拿到的一组数据是两个用户的一组关系(好友或有共同好友),遍历这一组关系出现0表示已经是好友,没有出现0将value相加,得到的结果就是共同好友的数量

public class FMapper extends Mapper<LongWritable, Text, Text, IntWritable> {

    Text mKey = new Text();
    IntWritable mValue = new IntWritable();

    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
        // value: 詹姆斯 韦德 波什 雷阿伦
        // 处理成:"詹姆斯-韦德 0","詹姆斯-波什 0","詹姆斯-雷阿伦 0","韦德-波什 1","韦德-雷阿伦 1","波什-雷阿伦 1"
        String[] strings = StringUtils.split(value.toString(), ' ');
        for (int i = 1; i < strings.length; i++) {
            mKey.set(getFof(strings[0], strings[i]));
            mValue.set(0); // 已经是好友
            context.write(mKey, mValue);
            for (int j = i + 1; j < strings.length; j++) {
                mKey.set(getFof(strings[i], strings[j]));
                mValue.set(1); // 有共同好友
                context.write(mKey, mValue);
            }

        }
    }
    // 后面需要对key做去重,因此将姓名排序之后再拼接
    private String getFof(String s1, String s2) {
        if (s1.compareTo(s2) > 0) {
            return s1 + "-" + s2;
        } else {
            return s2 + "-" + s1;
        }
    }
}
public class FReducer extends Reducer<Text, IntWritable, Text, IntWritable> {

    IntWritable rValue = new IntWritable();

    @Override
    protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
        // 韦德-波什 0
        // 韦德-波什 1
        // 韦德-波什 1
        int sum = 0;
        for (IntWritable v : values) {
            if (v.get() == 0) {
                // 这两个人已经是好友,不需要处理了,退出
                return;
            }
            sum += v.get();
        }
        rValue.set(sum);
        context.write(key, rValue);
    }
}
public class MyFof {
    public static void main(String[] args) throws Exception {
        Configuration conf = new Configuration(true);
        String[] otherArgs = new GenericOptionsParser(conf, args).getRemainingArgs();
        // conf.set("mapreduce.framework.name", "local");
        // conf.set("mapreduce.app-submission.cross-platform", "true");
        Job job = Job.getInstance(conf);
        job.setJarByClass(MyFof.class);
        job.setJobName("fof");
        TextInputFormat.addInputPath(job, new Path(otherArgs[0]));
        Path outPath = new Path(otherArgs[1]);
        if (outPath.getFileSystem(conf).exists(outPath)) {
            outPath.getFileSystem(conf).delete(outPath, true);
        }
        TextOutputFormat.setOutputPath(job, outPath);
        job.setMapperClass(FMapper.class);
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(IntWritable.class);
        job.setReducerClass(FReducer.class);
        job.waitForCompletion(true);
    }
}
# 将上面的fof程序打包,上传到node01节点
# node01
# 准备数据,将上面的"原始数据"放到data.txt中

# 上传到hdfs
hdfs dfs -mkdir -p /data/fof/input
hdfs dfs -put data.txt /data/fof/input
# 执行MR程序
hadoop jar hadoop-test.jar org.example.fof.MyFof /data/fof/input/ /data/fof/output/
# 查看执行结果
hdfs dfs -cat /data/fof/output/part-r-00000
汤普森-库里     1
波什-保罗       1
詹姆斯-库里     2
詹姆斯-汤普森   1
雷阿伦-波什     2
韦德-库里       1
韦德-汤普森     1
韦德-雷阿伦     1