Loading

MapReduce

date: 2020-04-22 20:15:00
updated: 2020-04-24 09:40:00

MapReduce

1.1 输入输出

pic1

首先都是 k, v 的形式

map 到 reduce 端是通过网络来传输,所以 k, v 都需要序列化和反序列化,Java 基本数据类型不支持序列化,所以需要用 MR 自己封装的类型,比如 LongWritable, Text, IntWritable 等等

reduce 的输入顺序默认是按照 key 的大小顺序(字符串的字典顺序)来进行处理,如果 map 输出的 key 是一个对象,可以 implements WritableComparable 接口,自定义 key 的顺序来交给 reduce

map 输出的内容会被序列化,所以如果是输出一个对象的话,也不会有影响

ArrayList<OrderBean> beans = new ArrayList<>();
OrderBean bean = new OrderBean();
bean.set(1);
beans.add(bean); // 此时保存的只是bean对象的一个地址引用
bean.set(2);
beans.add(bean); // 由于是地址引用,在原先地址上修改了bean对象的值,那么beans最后呈现出来的值也是最新的值,打印出来都是 2

class xxMapper extends Mapper<>{
    OrderBean bean = new OrderBean();
    Text k = new Text();
    @Override
    protected void map(){
        // 每一个 maptask 都会调用 map 方法,如果在 map 方法里创建对象,就会占用大量资源,由于对象写入到上下文的时候要经过序列化,所以并不需要担心多个maptask 在频繁赋值造成前后影响
        context.write(k, bean);
    }
}

reduce 里虽然只有一个key,但是迭代器values每迭代一次,key也会跟着变

运行机制:
map task(其实是叫 yarn child),拿到的是内容是 TextInputFormat,读取每一行用的是 LineRecordReader,返回一对kv,k是偏移量,v是行的内容。频繁的IO会降低效率,所以context(k, v) 会把结果写入到一个 MapOutputCollector 的环形内存缓冲区,当内容写到一定程度之后(缓冲区默认大小100M,spill的阈值是0.8,可以通过mapreduce.map.io.sort.spill.percent来设置),有一个 Spiller 溢出监控的线程,去把缓冲区的内容通过轮询的方式写入到mapreduce.cluster.local.dir属性指定的目录中,在写入的同时会根据key排序和合并(也就是combiner执行的地方),整个map输出完毕之后,会对所有临时文件终会执行一个合并任务,将生产的小文件全部合并为一个文件,相同分区的会拼接在一起,并且分区内保证key有序(map过程的输出是写入本地磁盘而不是HDFS,但是一开始数据并不是直接写入磁盘而是缓冲在内存中,缓存的好处就是减少磁盘I/O的开销,提高合并和排序的速度。在编写map函数的时候要尽量减少内存的使用,为shuffle过程预留更多的内存,这个过程最耗时)。文件路径会保存在NodeManager里,reduceTask 通过网络下载,文件可能存放在多个服务器,每个文件只能保证在本服务器内是分区且有序的,所以 reduceTask 在拿到文件分区后还需要再执行一次合并使key有序。reduce 里的迭代器 values 每迭代一次都会调用 groupingComparator 来判断下一次和上一次的key是不是一个。reduce 里的 context 会通过TextOutputFormat 类下的 LineRecordWriter 写入文件。map的输出到reduce的输入的过程叫 shuffle。

写磁盘时压缩map端的输出,因为这样会让写磁盘的速度更快,节约磁盘空间,并减少传给reducer的数据量。默认情况下,输出是不压缩的(将mapreduce.map.output.compress设置为true即可启动)

sequenceFile 里面保存的是kv形式,这样就不需要split文本内容了。 job.setOutputFormatClass(xx); 默认是 TextOutputFormat.class(同理输入),改成 SequenceFileOutputFormat.class 就可以修改输出文件的类型,直接将内容转换成二进制

1.2 combiner

combiner 是先在 map 端做一次合并,减少传输到 reduce 端的数据量,因为 combiner 的输出是 reduce 的输入,所以 combiner 是不能对 map 的输出进行修改,所以适用的场景比如累加、求最大值等,但是求平均数就不行了。

combiner 操作发生在 map 端的,处理一个任务所接收的文件中的数据,做局部聚合,不能跨 map 任务执行;只有 reduce 可以接收多个 map 任务处理的数据(也就是通过 key 的 hash 值取模分配到对应的 reduce上)

继承的也是 reduce 父类,实现的是对 reduce 方法的覆写,然后在启动类添加 job.setCombinerClass();

可以用来解决一定的数据倾斜问题

1.3 启动类配置

设置 hadoop 用户
System.setProperty("HADOOP_USER_NAME", "root"); // 这样写可以在本地运行程序的时候以 root 用户去执行 jar,从而拥有对输入输出路径下文件的读写权限

job 执行时提交请求给 resource manager,分配一定的 node manager 的资源来运行 job

Configuration conf = new Configuration();
conf.set("fs.defaultFS", "hdfs://9000"); // 对应linux下hadoop安装目录下 core-site.xml 里面的配置项
conf.set("mapreduce.framework.name", "yarn"); // 对应linux下hadoop安装目录下 mapred-site.xml 里面的配置项,不写的话默认值是 local
conf.set("yarn.resourcemanager.hostname", "yarn-01");
// 如果从 windows 运行的话,需要跨平台提交
conf.set("mapreduce.app-submission.cross-platform", true);

Job job = Job.getInstance(conf);
job.setJarByClass(类名.class) | ("d:/xxx.jar");
// 还需要设置 map reduce 的 key value 的类型,以及 map reduce 对应的类,还有输入输出路径,输出路径必须不存在
// 设置 reduceTask 个数
job.setNumReduceTasks(2);
// 提交到 yarn 并保持通信,打印 resource manager 返回的日志信息
job.waitForCompletion(true);

以上是在 windows 下启动运行,以下是在 linux 下运行

hadoop jar xx.jar 启动类 // hadoop jar 会把本机器上 hadoop 安装目录下的所有jar包和配置文件(core-site.xml)加载到执行语句的classpath下,这样jvm就可以读取到想要的配置

1.4 yarn

客户端提交一个job,在yarn的resourcemanager中请求一个容器来运行,在启动 mapreduce 的时候会首先启动一个 MRAppMaster 类,用来管理所有的 mapreduce,需要1.5G的一个启动内存,所以内存至少要2G以上,而 maptask 和 reducetask 都需要至少1G内存。并不是说会全部吃满内存,但是需要这样的一个上限值才能启动。

会依次启动yarnchild(maptask)和yarnchild(reducetask),提交到job的客户端

第一代hadoop,只有一个hdfs文件系统和mr计算调度平台
第二代hadoop,将调度交给yarn统一管理资源
Hdfs namenode datanode 第一代 client提交job,jobtracker先从namenode里拿到所需文件的数据块信息,发送到datanode中运行,tasktracker监督datanode状态,反馈给jobtracker,并一直反馈给client。客户端包括ide运行或者shell里 hadoop jar x.jar

Yarn 分为resourcemanager nodemanager 主从结构

1.用户向YARN中提交应用程序,其中包括AM程序、启动AM的命令、用户程序等。
2.RM为该应用程序分配一个container,并向对应的NM通信,要求他在这个container中启动ApplicationMaster。
3.AM向RM注册,这样用户可以直接通过RM查看应用程序的运行状态,然后它将为任务申请资源,并监控其运行状态,直到运行结束(重复4-7)
4.AM采用轮询的方式通过RPC协议向RM申请和领取资源。
5.AM申请到资源后,便于NM通信,要求其启动任务。
6.NM为任务设置好运行环境(包括环境变量、JAR包、二进制程序等)后,将任务启动命令写到一个脚本中,并通过运行改脚本启动任务。
7.各个任务通过RPC协议向AM汇报自己的进度和状态,让AM随时掌握各个任务的运行状态,从而可以在任务失败是重启任务。在应用程序运行过程中,用户可随时通过RPC向AM查询应用程序的当前运行状态。
8.应用程序运行完成后,AM注销并关闭自己。

1.5 自定义类型

输入输出都是 k,v 的形式,k 是唯一的,但是 v 如果有很多个,那就使用一个类来封装,但是这个类需要实现 hadoop 的序列化接口:implements Writable,重写 write(DataOutput out) 和 readFields(DataInput in) 方法。

out.writeInt(字段); // 一定是4个字节
out.writeUTF("我"); // 一共是5个字节,按照utf-8编码,一个汉字3个字节,然后最前面有2个字节来声明后面有几个字节是属于这个字符串的,所以可以通过这个方式来截取字节长度,然后反序列化字符串
out,write("我".getBytes()); // 3个字节,单纯的utf-8编码,一个汉字3个字节,在传输时并不知道具体字符串会有多长,不方便截取,所以更推荐使用上面的方式

unicode 一个英文2个字节,一个汉字2个字节,中英文标点都是2个字节
utf-8 一个英文1个字节,一个汉字3个字节,中文标点3个字节,英文标点1个字节
gbk 英文1个字节,汉字2个字节

字段 = in.readInt();
字段 = in.readUTF();

1.6 以求最大值为例,在reduce之后还要进行最终结果的筛选

首先在 reduce() 方法里就不能直接用 context.write(),这是直接写结果的。可以创建一个类,来保存 reduce 后的所有结果,然后在 reduceTask 处理完所有数据之后,会调用 cleanUp(Context context) 方法,重写这个方法,获取到这个类,然后进行数据筛选,把最终结果写到 context.write() 里

但是如果是大数据量的话,通过构建最小堆来获取最大的几个值

Configuration conf = context.getConfiguration();
int topN = conf.getInt("topN", 5); // 会尝试从 conf 中获取这个 name 对应的值,如果没有配置的话,默认值就是5;可以在启动类那里用过传参来设置这个topN是多少;或者自己写一个xml配置文件,通过 conf.addResource("xx.xml"),就可以识别出来

在 map 和 reduce 里都存在 setup()、map()/reduce()、cleanup() 方法

eg: 
父类 Mapper(){
    run(){
        setup();
        try{
            while(){
                mapper();
            }
        }finally{
            cleanup();
        }
    }
}

1.7 Partitioner 修改分发规则

分发的动作由 maptask 来执行,由 maptask 来执行 getPartitioner() 方法,然后分发到对应的 reduce 中

源码:
默认是按照 map 输出的 key.hashcode() % reduceTask的数量
public class HashPartitioner<K2, V2> implements Partitioner<K2, V2>{
    public void configure(JobConf job){}
    public int getPartitioner(K2 key, V2 value, int numReduceTasks){
        return (key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;
    }
}

创建一个类,extends Partitioner 覆写 getPartitioner() 方法,在启动类中设置 job.setPartitionerClass(xxx.class);

public static class WordcountHashPartitioner extends Partitioner<Text, IntWritable> {

    @Override
    public int getPartition(Text key, IntWritable value, int numPartitions) {
        String location = key.toString().split(":")[0];
        return Math.abs(location.hashCode() * 127) % numPartitions;
    }
}

其他的几个分发方法:BinaryPartitioner, HashPartitioner, KeyFieldBasedPartitioner, TotalOrderPartitioner。这些分发方法只能保证大体有序

全局排序:确保Partition之间是有序
对数据进行抽样分层,为了保证partition分布均匀,在相同区间的数据返回到同一个reduce,map输出的key默认是按照字典顺序实现了升序排序,如果需要降序或者输出的key是一个对象的话,要覆写comparable,那么到一个reduce中的数据都是有序的。
取样:

  1. 对Math.min(10, splits.length)个split(输入分片)进行随机取样,对每个split取10000个样,总共10万个样
  2. 10万个样排序,根据reducer的数量(n),取出间隔平均的n-1个样
  3. 将这个n-1个样写入partitionFile(_partition.list,是一个SequenceFile),key是取的样,值是nullValue
  4. 将partitionFile写入DistributedCache

全局排序在key是相对稳定的时候,可以随机采样元数据,重写partitioner方法,将数据分到reduce上,来保证数据的连续性;还有一个方式是实现TotalOrderPartitioner类的方法,一共有三种采样方式,动态生成一个partition file,在查找具体key应该分到哪个reduce时底层是通过一个Tire tree来实现,使用二分法继续查找找到返回key在划分数组中的索引,找不到会返回一个和它最接近的划分的索引。

类名称 采样方式 构造方法 效率 特点
SplitSampler<K,V> 对前n个记录进行采样 采样总数,划分数 最高
RandomSampler<K,V> 遍历所有数据,随机采样 采样频率,采样总数,划分数 最低
IntervalSampler<K,V> 固定间隔采样 采样频率,划分数 对有序的数据十分适用
参考文档

1.8 map端输入的分区切片原理

在 map 方法中,有一个变量是 context,存储了作业运行时的上下文信息,可以通过 InputSplit inputSplit = context.getInputSplit(); 方法来获取切片信息

在 Hadoop 读取文件时,会通过 getPartitions() 方法来获取分区,在这个方法里会调用 InputFormat 这个抽象类的 getSplits() 方法,这个方法的具体实现是在 FileInputFormat 类中对 getSplits() 方法进行覆写。如何去切分文件数据的思路是:

  1. inputPath 如果是一个文件夹的话,遍历里面的所有文件,累加所有文件的size作为totalSize
  2. 求一个平均文件大小 goalSize = totalSize / numSplits; // numSplits 指的是 min(想要的分区个数,默认分区个数2个) 在spark里面是这样的,mr可能是读的配置
  3. while(fileSize / goalSize > 1.1L){fileSize -= goalSize} 如果当前文件的大小超过平均大小的1.1倍,那么就从平均大小对应的offset那里进行切分文件
  4. 综上,如果文件不可切分,那么一个分区就是那一整个不可切分文件;如果文件可切分,计算出一个理想文件大小,然后依次判断文件大小和这个理想文件大小的关系,幅度在1.1倍之内的都可以接受,否则就需要切割。

切片是在 data manager 拿到 mr 后,创建容器来运行 maptask,创建几个要根据切片后的结果来确定。通过 getSplits() 方法,获取所有输入文件(也可能是连接数据库,那就保存的是库名表名偏移量信息),然后按照128M开始切片,不到128M的也会是一个maptask,不允许被切片的文件比如二进制文件,会单独一个切片,空文件也会是一个切片,一个切片可以跨大文件的多个block
当一个分片包含的多个block的时候,总会从其他节点读取数据,也就是做不到所有的计算都是本地化。为了发挥计算本地化性能,应该尽量使InputSplit大小与块大小相当

由于 InputSplit 是一个抽象类,用的时候,需要用它的实现方法
FileSplit inputSplit = (FileSplit)context.getInputSplit(); // 强制转换,从大的类转到小的子类
String fileName = inputSplit.getPath().getName(); 

可以用于倒排索引,获取文件的信息

1.9 分组排序 WritableComparator

比如两个 key 是否一样,会调用 GroupComparator 类下的 compare(o1, o2) 方法

用bean来做key,包括orderid,amounfee两个属性,相同的orderid排在一起,id小的在前面
重写分发规则 partitioner,让orderid相同的分到同一个reduce
重写 groupComparator,由于传递的key是对象,所以要指出来只要orderid相同,就会被看成同一组进行一次reduce聚合(跟上一步不一样的地方在于,有可能存在不同的key取hashcode再取模后分发到同一个reduce上,这时候需要通过groupComparator类下的compare()方法来保证同一类orderid进行聚合)

class OrderIdGroupingComparator extends WritableComparator {
    public OrderIdGroupingComparator(){
        super(OrderBean.class, true); // 调用父类的构造函数,将 OrderBean.class 序列化,要不然下面的方法无法识别 OrderBean,true代表是执行序列化
    }
    @Overwrite
    public int compare(WritableComparable a, WritableComparable b){
        OrderBean o1 = (OrderBean)a;
        OrderBean o2 = (OrderBean)b;
        return o1.getOrderId().compareTo(o2.getOrderId());
    }
}

pic2

1.10 合合

配置类读取所有的库表以及字段关系

final HiveConf hiveconf = new HiveConf(conf, HHDriver.class);
HiveMetaStoreClient client = new HiveMetaStoreClient(hiveconf);

通过HiveMetaStoreClient连接Hive,先要拿到库表的分区信息,client.listPartitionNames,如果存在就添加,如果没有就新建

优化:
map 中 context(库.表,kafka中的data)
extends Partitioner 库.表+rand() 重写 int getPartition()
extends WritableComparator 重写 int compare()
reduce 中 保证是同一类库.表,输出 mos

MultipleOutputs mos 结果输出到多个文件或多个文件夹,用 mos.write(String nameOutput, Key key,Value value,String baseOutputPath)代替context.write(key, value) // nameOutput 用来指定是哪一种输出,通过别称,可以设置输出的不同地址、数据格式等

增量表是 text,最终表是 parquet。基于列式存储,由于每一列的数据类型都是一样的,因此可以针对每一列的数据类型使用更高效的压缩算法;在查询时只要扫描需要查询的列数据,不用进行全表扫描

String newStr = new String(str.getBytes("UTF-8"),"UTF-8"); 将字符串按照 UTF-8 放入到bytes[]数组里,再按照 UTF-8 的方式读取

posted @ 2020-10-22 10:49  猫熊小才天  阅读(147)  评论(0编辑  收藏  举报