Hadoop的第二个核心组件:MapReduce框架第三节

九、MR程序运行的核心阶段的细节性知识

1、MR程序在运行过程中,涉及到的阶段和作用

InputFormat阶段:两个作用

  • 负责对输入的数据进行切片,切片的数据和Mapper阶段的MapTask的数量是相对应的。
  • 负责MapTask读取切片数据时,如何将切片的数据转换成为Key-value类型的数据,包括key-value的数据类型的定义。

Mapper阶段

  • 作用处理每一个切片数据的计算逻辑。
  • map方法的执行方式:一组kv执行一次,一组kv在常用实现类当中都是一行数据为一组kv。

Partitioner阶段

  • map阶段处理完成的数据输出到缓冲区溢写磁盘的时候必须进行分区。

WritableComparable

  • 负责对map输出的kv数据进行排序的。
  • mr阶段发生三次排序,每次排序的执行时机以及排序使用的算法。

Combiner阶段:可以存在可以不存在

  • 相当于是一个Reducer,只不过这个reducer是针对于当前的MapTask有效。
  • 对map阶段输出的数据进行局部汇总,Combiner不是所有的MR程序都能添加,Combiner的添加不能影响原先MR程序的执行逻辑。

WritableComparator组件(可选)

  • 负责分组排序的,reduce在把数据拉取回来以后,需要根据key值来进行分组,哪些key我们认为是一组相同的key,可以通过辅助排序(分组排序)来决定。

Reducer阶段

  • 作用就是用来聚合所有MapTask的数据,聚合起来之后计算逻辑的编写。
  • reduce主要对map输出的数据进行全局汇总,汇总把相同的key值的value数据聚合起来,然后一组相同的key值调用一次reduce方法。
  • reduceTask的数量我们可以手动设置的,设置的时候注意和分区的关系。

OutputFormat阶段

  • 作用就是MR程序输出的数据如何以key-value的形式输出到最终的目的地。

2、MR程序运行的的第一个组件:InputFormat

InputFormat是一个抽象类,提供了两个抽象方法

  • getSplits:这个方法是用来进行输入数据文件的切片计算的

  • createRecordReader:这个方法是MapTask读取切片数据时,是按照行读取还是按照其他规则读取,包括读取时key-value分别代表什么含义,什么类型;如何将读取的数据转换成为key-value格式的数据。

常用的InputFormat的实现类:FileInputFormat(是InputFormat的默认实现类)

  • FileInputFormat是专门用来读取文件数据时使用的输入格式化类,但是FileInputFormat也是一个抽象类

  • FileInputFormat抽象类有五个常用的非抽象子类

    • TextInputFormat(是FileInputFormat默认实现类)

      • 如何切片(切片机制):
      两个核心参数:MinSplitSize = 1L   MaxSplitSize = Long.MAX_VALUE
      configuration.set("mapreduce.input.fileinputformat.split.minsize",xxxL)
      configuration.set("mapreduce.input.fileinputformat.split.maxsize",xxxL)
      
      每一个输入文件单独进行切片,如果输入文件有N个,那么切片数量最少有N个
      每一个文件先获取它的blockSize,然后计算文件的切片大小splitSize = Math.max(minSize, Math.min(maxSize, blockSize))
      先判断文件是否能被切片,如果文件是一个压缩包(.gz、.zip),单独成为一个切片,如果文件能被切片,判断文件的长度是否大于splitSize的1.1倍,如果不大于 文件单独成为一个切片,如果大于1.1倍,按照splitsize切一片,然后将剩余的大小和splitsize继续比较
          
      示例:
      第一种情况:  a.tar.gz    300M   blocksize 128M    只有一个数据切片300M
      第二种情况:blocksize均为128M
      a.txt   200M      两个切片:一个切片128M  第二个切片72M
      b.txt   130M      一个切片:130M  
          
      【注意】
          TextInputFormat是按照SplitSize进行切片的,默认情况下SplitSize=文件的BlockSize
          如果你要让SplitSize大于blockSize,那么我们需要在MR程序调整minsize的大小即可
          如果你要让SplitSize小于blockSize 那么需要MR程序调整maxSize的大小即可
      
      • 如何读取数据成为key-value(k-v数据的读取机制):
      TextInputFormat读取切片数据是按行读取,一行一行读取的,每一行数据以行的偏移量为key,以每一行的数据为value进行读取。
      
      行的偏移量指的是每一行的首字符在文件中的位置,位置是一个正整数,因此key是用LongWritable表示的,value因为代表的是每一行的数据,是个字符串,因此使用Text类型来表示。
      

    image-20230725111340088

    image-20230725111416072

    image-20230725111358951

    • KeyValueTextInputFormat

      • 切片机制:和TextInputFormat的切片机制是一模一样的
      • k - v数据的读取机制:
      按照一行一行的读取数据,每一行的数据以指定的分隔符分割这一行的数据,以分割之后的第一个字符串当作key值,剩余的字符串当作value值
      因此在这种机制下  key和value都是Text类型的
      
      如果使用KeyValueTextInputFormat,我们需要指定一个行的分隔符,如果没有指定,那么默认的分隔符的\t
      conf.set("mapreduce.input.keyvaluelinerecordreader.key.value.separator","分隔符")
      

    image-20230725112144455

    image-20230725112214025

    image-20230725112020569

    image-20230725112045738

    • NLineInputFormat

      • 切片机制
      切片不是按照文件的大小和splitsize进行切片的,而是根据所有输入文件的行数进行切片的,每一个文件单独切片
      
      使用NLineInputFormat的时候,需要指定切片的行数
      NLineInputFormat.setNumLinesPerSplit(job,3)
      
      3个文件    指定3行一个切片
      a.txt  10行   4
      b.txt  12行   4
      c.txt  10行   4
      
      • kv读取数据的机制:
      和TextInputFormat一模一样
      以LongWirtable 每一行的偏移量为key
      以Text每一行的数据为value进行读取
      

    image-20230725113151032

    image-20230725113217728

    image-20230725113257208

    image-20230725113405443

    image-20230725113424921

    image-20230725113508459

    image-20230725113526768

    image-20230725113541441

    image-20230725113555566

    • CombineTextInputFormat:使用频率相对比较高

      • 切片机制
      适用于大量的小文件的场景
      
      #诞生背景
      不管是TextInputFormat还是KeyValueTextInputFormat、还是NLineInputFormat,在进行切片的时候都是每一个文件单独进行切片,也就意味着,如果输入文件有n个,切片数最小有n个。
      如果输入的文件都是一堆小文件,每一个文件只有几百kb,如果使用上述的切片机制,会产生很多的小切片,每一个切片就撑死几百KB,然后我们还得需要启动N个maptask运行。这就浪费资源了。大数据中,资源可是非常宝贵的东西,浪费可耻。
      MR程序一般情况MapTask处理的切片一般最好都在几百M左右,这样才不浪费资源。
      这个ConbineTextInputFormat使用大量小文件的切片规划,进行切片的时候,不是一个文件单独切片,而是根据容量进行切片,可能在一个切片中包含很多个小文件
      
      #切片规则
      ConbineTextInputFormat进行切片之前,需要指定一个容量--虚拟的切片容量(可以理解为切片容量)
      CombineTextInputFormat.setMaxInputSplitSize(job, 4194304);//b
      切片的时候,每一个文件先按照虚拟的切片容量进行一次虚拟切片,虚拟切片机制如下:每一个文件判断,如果小于虚拟的切片容量,那么成为一个虚拟切片,如果文件大于虚拟切片容量但是小于虚拟切片的2倍,那么文件平均划分为两个虚拟切片,如果文件大于虚拟切片的2倍,那么按照虚拟切片的大小切一片,剩余的容量继续上述的判断
      假如指定虚拟切片容量是4M
                                   虚拟切片
      a.txt    4.1m                 2.05M 2.05M    
      b.txt    3m                   3M
      c.txt    10M                  4M 3M 3M
      将文件虚拟切片完成以后,我们将虚拟切片按照顺序累加起来,如果累加起来的容量大于设置的虚拟切片容量,单独成为一个物理切片,如果不大于的话,那么继续累加下一个切片,直到累加的结果大于等于设置的虚拟切片容量。
      虚拟切片:2.05M 2.05M 3m 4M 3M 3M  
      物理切片:4.1M 7M 6M
      如果想把所有文件放到一个切片中,则将虚拟切片容量改为100M
                                   虚拟切片
      a.txt    4.1M                 4.1M
      b.txt    3M                   3M
      c.txt    10M                  10M
      虚拟切片:4.1M 3m 10M  
      物理切片:17.1M
      
      • KV数据的读取机制
      和TextInputFormat一模一样的
      key  LongWritable类型的  每一行的偏移量
      value  Text类型  每一行的数据
      

    image-20230725115135086

    image-20230725115151976

    image-20230725115248645

    image-20230725115327351

    image-20230725115434440

    image-20230725115506588

    • SequenceFileInputFormat —— 暂时用不到

如何自定义InputFormat实现类

自定义InputFormat(自己定义切片机制以及KV数据的读取规则)
	1、自定义一个类继承InputFormat
	2、重写getSplits方法
	3、重写createRecordReader方法

3、MR程序的Job提交流程的源码分析

MR程序在运行的时候,首先先计算MR程序的输入文件的切片(根据指定的InputFormat实现类),生成一个切片规划文件job.split,随后会在根据MR程序的相关Configuration的配置生成一个配置文件job.xml,然后将job.xml、job.split、job.jar(编写好的MR程序)提供给资源调度器。

我们MR程序运行的时候,我们目前是在windows上运行的,并不在大数据集群环境中,因此windows上的运行严格意义上不属于分布式运行,只是Windows模拟MR的运行环境,因此如果在windows上运行的话,MR程序运行的时候只需要提供job.split、job.xml文件给本地的一个目录。

同时MR程序一般在windows上去属于测试运行(看看代码有没有bug的),如果代码没有bug,我们一般在企业中都是这样运行MR程序的,将MR程序打成一个jar包,然后上传到Hadoop集群的节点上,然后通过hadoop jar xxx.jar xxx.xxDriver方式运行MR程序,这样运行的MR程序是在YARN之上运行的,此时MR程序才属于真正的分布式运行,同时如果是在YARN上运行的话,job提交job.split、job.xml、job.jar文件到HDFS上。

1、底层会先识别我们的运行环境
2、生成一个资源提交目录,如果是本地运行模式,那么资源提交到本地的某个路径下,如果是YARN运行模式,那么资源提交给HDFS的某个路径,生成一个JobID
3、基于InputFormat的切片机制生成切片规划文件job.split文件,并且把文件写入到资源提交目录
4、将MR程序中所有的配置项写入到一个job.xml文件,文件也写入到资源提交目录
5、程序开始申请运行资源,运行Map任务和reduce任务

4、MR程序运行中Mapper组件的作用

Mapper阶段是MR程序运行的一个核心阶段,提供一个map方法,这个map方法会借助Inputformat提供的createRecordReader方法进行对应切片的key-value数据的读取,map方法每读取一个k-v数据处理一次key-value,输出一个结果到MR的一个内存缓冲区当中。

Mapper阶段启动多个MapTask任务,多个MapTask是并行运行,互不干扰的;MapTask的任务个数和切片个数是对应的,默认情况切片机制下,一个切片就是一个block块。

多个MapTask可能会在多个节点上运行,那么到底这些MapTask要在哪些节点上启动运行,MR程序也是有规矩的,移动数据不如移动计算。
启动MapTask的时候,一般要求MapTask最好在该MapTask负责的切片节点启动。这样的话我们MapTask计算数据的时候就不需要移动数据了。如果切片节点没有资源能启动MapTask了,那我们也会在距离这个数据最近的节点启动MapTask(网络拓扑原则)

【注意】我们之所以可以在切片所在节点启动计算任务,是因为当初我们配置Hadoop集群的时候,DataNode和NodeManager是同时配置的。

5、MR程序运行中Shuffle阶段

shuffle是大数据分布式计算的一个核心,也是大数据分布式计算性能受影响的核心,shuffle又名重新洗牌(将数据重新打乱,然后不同节点上和网络中进行数据的传输)。

如果Shuffle过程中,需要传输的数据量过大,那么分布式计算的效率的偏低。在MapReduce中,Shuffle机制需要涉及到大量的磁盘IO(数据从内存写入到磁盘当中)——磁盘IO也是影响计算性能的核心因素
【MapReduce的优化操作】提升MR程序的计算效率,优化基本上都是对shuffle阶段进行优化

MapReduce程序中,MR的分布式计算程序的shuffle出现在map方法输出之后,reduce方法执行之前。

Shuffle阶段工作的详细流程

Shuffle阶段的执行逻辑

  • Map方法执行之后的逻辑
1、map方法输出kv数据时,先根据指定的Partitioner计算kv数据的分区,计算成功之后,将kv数据的分区编号、kv数据本身、key、value分别在内存的起始地址,key、value数据的长度等信息写入到一个内存的环形缓冲区中(100M)。
2、当环形缓冲区到达设定的阈值(80%),将环形缓冲区的数据溢写到磁盘文件,溢写数据之前,环形缓冲区的数据会根据不同分区进行一次分区排序(根据key值进行排序,默认使用快速排序算法),将排好序的分区数据溢写到磁盘文件中。
3、可能Map阶段进行多次溢写,每一次溢写都需要先在环形缓冲区进行分区排序,然后再溢写文件,每一次溢写都会产生一个新的溢写文件
4、如果溢写文件的数量的超过3个,那么就会触发自己设置的combiner操作,对已经溢写完成的数据先进行一次map端的聚合操作。Combiner操作可选的。
5、当map阶段执行完成,会将产生的多个溢写文件,以及环形缓冲区剩余的还没有溢写的数据进行一次合并操作,合并成为一个大文件,只不过再合并的时候也需要进行一次排序(排序也是基于每一个分区进行,基于key值大小,使用的排序算法是归并排序算法)。
6、归并排序生成大文件之后,还会进行一次自定义的Combiner操作,对map阶段输出的数据进行一次局部汇总。
【注意】Combiner操作可选的组件,如果加上的操作,第4和第6步就会执行,如果没有加,第4步和第6步一定不会执行
Combiner就算你指定了,可能一次也不执行,当map任务的计算负担很重,如果map任务的计算压力很大,那么combiner操作就算设置了,MR程序也不会执行的。
  • Reduce方法执行之前的逻辑
1、Copy阶段:Reduce任务根据负责的分区,从不同的MapTask上把对应的分区数据拉取到ReduceTask的内存中,如果ReduceTask内存放不下这些数据,把数据写到文件。
2、merge阶段:会把我们从不同maptask拉去回来的数据进行一次整体的合并。
3、sort阶段:合并拉取的不同mapTask分区的数据的时候,还需要对数据进行一次排序,排序可以单独指定规则,如果没有指定,默认还是使用key值的大小规则,排序算法也是归并排序。

Shuffle中map输出开始执行源码解读

1、collecotr收集器往环形缓冲区写出数据,只不过写出数据的时候先根据Partitioner计算数据的分区,partitioner分区计算默认情况下有两种计算方式
	1、如果reduceTask的数量等于1的时候,采用一个内部类的分区器进行分区,分区器是把所有的数据都分配到0号分区
	2、如果reduceTask的数量大于1的时候,采用一个HashPartitioner分区机制,按照key的hashcode值和Integer.MAX_VALUE进行一次&位运算,然后和reduceTask取%余数得到一个分区编号。
分区编号看ReduceTask数量,[0,reduceTask-1]
	如果你想自己控制分区的数据,那么就得需要自定义Partitioner来完成

2、collector将数据写入环形缓冲区,环形缓冲区代码的体现就是一个字节数组,字节数组默认100M,超过80M,需要把缓冲区的数据写入到一个文件中
缓冲区可以设置大小,阈值可以设置
mapreduce.task.io.sort.mb	100	    指定MR程序运行中环形缓冲区的默认大小  100M
mapreduce.map.sort.spill.percent	0.80   指定MR程序运行中缓冲区的阈值 默认是0.8
也可以再mapred-site.xml配置,如果在这个文件配置了,以后所有在Hadoop集群上运行的MR程序的缓冲区和阈值都是配置文件的值了。但是这样的配置我们不建议。

因为不同的计算程序环形缓冲区和阈值配置不同的参数,因此一般在MR的驱动程序使用Configuration配置,虽然这个配置只是对当前的MR生效。但是这是最常用的。

配置有个规则:缓冲区越大,溢写的次数越小,计算的速度越高。

MapTask的输出的分区问题

MapTask输出数据时,先计算kv数据的分区,计算出分区编号以后,然后将kv以及分区编号借助collector收集器将数据写出到环形缓冲区中。

计算分区的机制:

  • 分区数(底层就是NumReduceTask)如果等于1的时候:底层会借助一个Partitioner的匿名内部类的形式去计算分区编号,计算逻辑直接返回的就是0。

image-20230727104204223

  • 分区数如果大于1的时候:底层会使用我们在Driver驱动程序设置的分区类,如果分区类没有设置,那么默认使用HashPartitioner类进行分区。

image-20230727104210497

分区数和NumReduceTask的关系:

  • 一个ReduceTask只能处理一个分区的数据,因此原则上ReduceTask的数量和分区数必须是一致的。
  • 如果分区数和ReduceTask的数量如果不一致出现以下三种情况:
    • 自定义的分区数大于1,但是ReduceTask的数量等于1,此时程序正常运行,返回一个底层的匿名内部类的分区器进行分区,所有的数据都到0号分区了,自定义的分区类没有任何的用处。
    • 自定义的分区数大于1,ReduceTask的数量大于1 但是小于分区数,程序运行会报错。
    • 自定义的分区数据大于1,ReduceTask的数量大于1 而且大于自定义的分区数,程序会正常运行,按照我们自己定义的分区机制运行,只不过多余的ReduceTask会空运行。

Shuffle阶段中通过自定义分区实现数据的分区规则定义

1、定义Java类继承Partitioner类
2、重写Partitioner类中getPartition 方法自定义分区规则即可
分区的数量必须和ReduceTask的数量保持一致,如果两者不一致,出现以下三种情况
	1、reduceTask的数量大于分区数,那么会产生多个结果文件,只不过有些结果文件就是一个空白文件,多余的reduceTask没有分区数据处理才会产生空白文件
	2、reduceTask的数量小于分区数,而且大于1的,报错
	3、reduceTask的数量小于分区数,但是等于1  正常执行,只不过分区不执行了
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Partitioner;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.mapreduce.lib.output.TextOutputFormat;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Random;

/**
 * 现在想使用mapreduce去实现单词技术案例,案例需求:
 *      1、要求可以统计出输入文件中每一个单词出现的总次数
 *      2、要求输出文件有两个,其中如果单词的首字母是大写,那么单词的统计结果写出到part-r-00000文件
 *          如果单词的首字母是小写,那么单词的统计结果写出到part-r-00001文件中
 *
 * 逻辑实现:
 *      因为结果需要两个文件,因此我们需要两个ReduceTask(因为MR程序中一个reduceTask默认只输出一个文件)
 *      而且现在我们还指定了分区的数据规则,MR程序的默认分区机制无法满足我们的需求,因此我们还需要自定义分区机制
 *      剩余的操作就是基本的求单词计数案例的代码
 */
public class WCDriver02 {
    public static void main(String[] args) throws IOException, URISyntaxException, InterruptedException, ClassNotFoundException {
        Configuration configuration = new Configuration();
        //configuration.set("fs.defaultFS","hdfs://192.168.31.104:9000");

        Job job = Job.getInstance(configuration);
        //这一行代码用来指定程序打成JAR包之后在集群中运行时避免ClassNotFound异常问题
        job.setJarByClass(WCReducer02.class);

        job.setInputFormatClass(TextInputFormat.class);
        FileInputFormat.setInputPaths(job,new Path("/wordcount.txt"));

        job.setMapperClass(WCMapper02.class);
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(LongWritable.class);

        //封装分区机制  Partitioner
        job.setPartitionerClass(WCPartitioner.class);

        job.setReducerClass(WCReducer02.class);
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(LongWritable.class);
        /**
         * 设置reduceTask的数量,默认情况下 我们规定ReduceTask的数量必须和自定义的分区数保持一致
         * 但是规定是规定 是可以打破的,但是打破规则是要接受代价的
         * 代价:如果reduceTask的数量和分区返回的数量不一致,会出现以下三种情况:
         * 1、reduceTask的数量大于分区数,那么会产生多个结果文件,只不过有些结果文件就是一个空白文件,多余的reduceTask没有分区数据处理才会产生空白文件
         * 2、reduceTask的数量小于分区数,而且大于1的,报错
         * 3、reduceTask的数量小于分区数。但是等于1 正常执行,只不过分区不执行
         */
        job.setNumReduceTasks(2);

        //job.setOutputFormatClass();
        Path path = new Path("/output");
        FileSystem fs = FileSystem.get(new URI("hdfs://192.168.31.104:9000"), configuration, "root");
        if (fs.exists(path)){
            fs.delete(path,true);
        }
        FileOutputFormat.setOutputPath(job,path);

        boolean b = job.waitForCompletion(true);
        System.exit(b?0:1);
    }
}
class WCMapper02 extends Mapper<LongWritable, Text,Text,LongWritable>{
    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, Text, LongWritable>.Context context) throws IOException, InterruptedException {
        String line = value.toString();
        String[] words = line.split(" ");
        for (String word : words) {
            context.write(new Text(word),new LongWritable(1l));
        }
    }
}

class WCReducer02 extends Reducer<Text,LongWritable,Text,LongWritable>{
    @Override
    protected void reduce(Text key, Iterable<LongWritable> values, Reducer<Text, LongWritable, Text, LongWritable>.Context context) throws IOException, InterruptedException {
        long sum = 0l;
        for (LongWritable value : values) {
            sum += value.get();
        }
        context.write(key,new LongWritable(sum));
    }
}

/**
 * 自定义Partitioner实现数据分区机制
 * 1、自定义的Partitioner需要传递两个泛型,两个泛型就是map阶段输出的key-value的类型,
 *      因为partitioner分区是map阶段输出数据的时候触发的
 * 2、重写getPartitioner方法
 */

class WCPartitioner extends Partitioner<Text,LongWritable>{
    /**
     *
     * @param key      map阶段输出的key值
     * @param value    map阶段输出的value值
     * @param numPartitions 设置的reduceTask的数量
     * @return         返回值整数类型  整数代表的是数据的分区编号 分区编号从0开始 而且分区编号必须是连贯的
     */
    @Override
    public int getPartition(Text key, LongWritable value, int numPartitions) {
        /**
         * 分区逻辑是  如果单词的首字母是大写 那么把数据分配给0号分区处理
         * 如果单词的首字母是小写 那么把数据分配给1号分区处理
         * 分区的编号从0开始
         */
        String word = key.toString();
        char first = word.charAt(0);
        if (first >= 65 && first <=90){
            return 0;
        } else {
            return 1;
        }
    }
}

image-20230726154210195

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Partitioner;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;

/**
 * 要求:实现单词计数,只不过要求:
 * 如果单词的首字母是h或者H  那么结果输出到0号分区
 * 如果单词的首字母是s或者S  那么结果输出到1号分区
 * 如果单词的首字母不是上述的情况,那么结果输出到2号分区
 */
public class WCDriver03 {
    public static void main(String[] args) throws IOException, URISyntaxException, InterruptedException, ClassNotFoundException {
        Configuration configuration = new Configuration();

        Job job = Job.getInstance(configuration);

        job.setInputFormatClass(TextInputFormat.class);
        FileInputFormat.setInputPaths(job,new Path("/wordcount.txt"));

        job.setMapperClass(WCMapper03.class);
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(LongWritable.class);

        job.setPartitionerClass(WCPartitioner03.class);

        job.setReducerClass(WCReducer03.class);
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(LongWritable.class);
        job.setNumReduceTasks(3);


        Path path = new Path("/output");
        FileSystem fs = FileSystem.get(new URI("hdfs:/192.168.31.104:9000"), configuration, "root");
        if (fs.exists(path)){
            fs.delete(path,true);
        }
        FileOutputFormat.setOutputPath(job,path);

        boolean flag = job.waitForCompletion(true);
        System.exit(flag?0:1);
    }
}

class WCMapper03 extends Mapper<LongWritable, Text,Text,LongWritable>{
    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, Text, LongWritable>.Context context) throws IOException, InterruptedException {
        String line = value.toString();
        String[] words = line.split(" ");
        for (String word : words) {
            context.write(new Text(word),new LongWritable(1l));
        }
    }
}

class WCReducer03 extends Reducer<Text,LongWritable,Text,LongWritable>{
    @Override
    protected void reduce(Text key, Iterable<LongWritable> values, Reducer<Text, LongWritable, Text, LongWritable>.Context context) throws IOException, InterruptedException {
        long sum = 0l;
        for (LongWritable value : values) {
            sum += value.get();
        }
        context.write(key,new LongWritable(sum));
    }
}

class WCPartitioner03 extends Partitioner<Text,LongWritable>{
    @Override
    public int getPartition(Text key, LongWritable value, int numPartitions) {
//        String word = key.toString();
//        char first = word.charAt(0);
//        if (first == 72 || first == 104){
//            return 0;
//        } else if (first == 83 || first == 115) {
//            return 1;
//        }else {
//            return 2;
//        }

        String word = key.toString();
        //首字母是全部转成小写形式,这样的话我们就可以实现类似于忽略大小写判断的规则
        char first = word.toLowerCase().charAt(0);
        if (first == 'h'){
            return 0;
        } else if (first =='s') {
            return 1;
        }else {
            return 2;
        }
    }
}

image-20230726162548852

image-20230726161810847

MapTask的输出的环形缓冲区的问题

当我们计算完成kv数据的分区之后,MR程序会借助collector收集器的collect方法将kv数据以及分区编号向环形缓冲区写入,环形缓冲区是一个内存中概念,在底层源码当中就是一个字节数组byte[]  kvbuffer。

环形缓冲区默认只有100M,而且环形缓冲区还有一个阈值,阈值默认是80%,如果缓冲区写入的数据超过了阈值,缓冲区的已经写入的数据会溢写到磁盘文件中spliiN.out文件,同时溢写的过程中,会在环形缓冲区剩余的20%的空间反向继续写入后续的MapTask计算完成的数据。

环形缓冲区大小和阈值可以自己设置的:
mapreduce.task.io.sort.mb    100M    设置环形缓冲区的大小
mapreduce.map.sort.spill.percent     0.8  设置环形缓冲区的溢写因子

【优化机制】如果想让MR程序执行的更加快速,在缓冲区这块我们可以减少溢写磁盘的次数,因此一般情况下对于不同的计算程度可以设置缓冲区的大小和阈值,减少溢写次数。

Shuffle阶段中通过自定义排序规则保证输出结果有序

整体Shuffle阶段,一共对数据进行三次排序,而且最终输出结果文件里面的数据其实是有顺序的。三次排序分别发生在:
1、当环形缓冲区超过阈值之后溢写磁盘的时候,会先在环形缓冲区进行第一次排序操作,排序基于key值的比较器进行排序,底层采用的快速排序的算法。
2、当map阶段产生了多个溢写文件之后,合并多个溢写文件以及缓冲区中的数据的之后会进行第二次排序操作,排序基于key值得比较器进行排序的,底层采用是归并排序的算法
3、当ReduceTask把它所负责的分区数据拉去到ReduceTask节点之后,也需要对拉取的多个MapTask上的数据在进行一次归并排序,默认情况下我们排序也是基于key值的比较器进行排序,但是reduce比较特殊,也可以单独指定另外一种排序规则。
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.io.WritableComparable;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Objects;
import java.util.Random;

/**
 * 计算完成了每一个手机消耗的上行流量信息、下行流量信息、总流量信息,
 * 现在我们要求在上次计算的结果文件基础之上,实现将计算结果中的所有信息按照总流量信息从高到低进行排序
 *
 * 输入文件格式:
 * 13480253104	180	180	360
 * 13502468823	7335	110349	117684
 * 13560436666	2481	24681	27162
 * 13560436666	3597	25635	2070
 * 13560439658	918	4938	5856
 * 13560439658	2034	5892	2070
 * 13602846565	1938	2910	4848
 * 13660577991	6960	690	7650
 * 13719199419	240	0	240
 * 13726230503	2481	24681	27162
 * 13760778710	120	120	240
 * 13826544101	264	0	264
 * 13922314466	3008	3720	6728
 * 13925057413	11058	48243	59301
 * 13926251106	240	0	240
 * 13926435656	132	1512	1644
 * 15013685858	3659	3538	7197
 * 15920133257	3156	2936	6092
 * 15989002119	1938	180	2118
 * 18211575961	1527	2106	3633
 * 18320173382	9531	2412	11943
 * 84138413	4116	1432	5548
 * 案例逻辑实现:如果实现了排序,只需要把数据当作map阶段的输出key传递即可完成排序 key的排序规则定义好
 *      1、通过map阶段读取文件数据,数据封装成为JavaBean,然后以JavaBean为key,以null为value
 */
public class FlowDriver {
    public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException, URISyntaxException {
        Configuration configuration = new Configuration();

        Job job = Job.getInstance(configuration);
        job.setJarByClass(FlowDriver.class);
        job.setInputFormatClass(TextInputFormat.class);
        //输出路径是以前统计的流量信息的输出结果文件
        FileInputFormat.setInputPaths(job,new Path("/output/part-r-00000"));

        job.setMapperClass(FlowMapper.class);
        job.setMapOutputKeyClass(FlowBean.class);
        job.setMapOutputValueClass(NullWritable.class);
        job.setReducerClass(FlowReducer.class);
        job.setOutputKeyClass(FlowBean.class);
        job.setOutputValueClass(NullWritable.class);
        job.setNumReduceTasks(1);

        Path path = new Path("/output1");
        FileSystem fs = FileSystem.get(new URI("hdfs://192.168.31.104:9000"),configuration, "root");
        if (fs.exists(path)){
            fs.delete(path);
        }

        FileOutputFormat.setOutputPath(job,path);

        boolean flag = job.waitForCompletion(true);
        System.exit(flag?0:1);
    }
}

/**
 * map的计算逻辑:
 *      把统计好的流量信息文件读取进行,每一行数据以\t分割得到每一个字段,将字段以FlowBean进行封装
 *      以FlowBean为key 以null为value输出reduce
 *      这样的话 最后MR统计完成的数据必然是有顺序
 */
class FlowMapper extends Mapper<LongWritable, Text,FlowBean, NullWritable>{
    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, FlowBean, NullWritable>.Context context) throws IOException, InterruptedException {
        String line = value.toString();
        String[] array = line.split("\t");
        String phoneNumber = array[0];
        Long upFlow = Long.parseLong(array[1]);
        Long downFlow = Long.parseLong(array[2]);
        Long sumFlow = Long.parseLong(array[3]);
        FlowBean flowBean = new FlowBean(phoneNumber,upFlow,downFlow,sumFlow);
        context.write(flowBean,NullWritable.get());
    }
}
class FlowReducer extends Reducer<FlowBean,NullWritable,FlowBean,NullWritable>{
    @Override
    protected void reduce(FlowBean key, Iterable<NullWritable> values, Reducer<FlowBean, NullWritable, FlowBean, NullWritable>.Context context) throws IOException, InterruptedException {
        context.write(key,NullWritable.get());
    }
}


/**
 * JavaBean一会当作MapReduce的map阶段的key值进行传输,必须实现序列化  而且必须实现比较器
 * 而且还要求了排序规则,因此比较器不能随便写
 */
class FlowBean implements WritableComparable<FlowBean> {
    private String phoneNumber;
    private Long upFlow;
    private Long downFlow;
    private Long sumFlow;

    public FlowBean() {

    }

    public FlowBean(String phoneNumber, Long upFlow, Long downFlow, Long sumFlow) {
        this.phoneNumber = phoneNumber;
        this.upFlow = upFlow;
        this.downFlow = downFlow;
        this.sumFlow = sumFlow;
    }

    public String getPhoneNumber() {
        return phoneNumber;
    }

    public void setPhoneNumber(String phoneNumber) {
        this.phoneNumber = phoneNumber;
    }

    public Long getUpFlow() {
        return upFlow;
    }

    public void setUpFlow(Long upFlow) {
        this.upFlow = upFlow;
    }

    public Long getDownFlow() {
        return downFlow;
    }

    public void setDownFlow(Long downFlow) {
        this.downFlow = downFlow;
    }

    public Long getSumFlow() {
        return sumFlow;
    }

    public void setSumFlow(Long sumFlow) {
        this.sumFlow = sumFlow;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        FlowBean flowBean = (FlowBean) o;
        return Objects.equals(phoneNumber, flowBean.phoneNumber) && Objects.equals(upFlow, flowBean.upFlow) && Objects.equals(downFlow, flowBean.downFlow) && Objects.equals(sumFlow, flowBean.sumFlow);
    }

    @Override
    public int hashCode() {
        return Objects.hash(phoneNumber, upFlow, downFlow, sumFlow);
    }

    @Override
    public String toString() {
        return phoneNumber + "\t" + upFlow + "\t" + downFlow + "\t" + sumFlow;
    }

    /**
     * 要求按照总流量进行降序排序
     *      前者大于后者 返回-1 小于后者 返回1  降序
     * @param o the object to be compared.
     * @return
     */
    @Override
    public int compareTo(FlowBean o) {
        if (this.sumFlow > o.sumFlow){
            return -1;
        } else if (this.sumFlow < o.sumFlow) {
            return 1;
        }else {
            return 0;
        }
    }

    @Override
    public void write(DataOutput out) throws IOException {//序列化写
        out.writeUTF(phoneNumber);
        out.writeLong(upFlow);
        out.writeLong(downFlow);
        out.writeLong(sumFlow);
    }

    @Override
    public void readFields(DataInput in) throws IOException {//反序列化读
            phoneNumber = in.readUTF();
            upFlow = in.readLong();
            downFlow = in.readLong();
            sumFlow = in.readLong();
    }
}

image-20230726185111798

image-20230726185146248

shuffle阶段reduce聚合数据的时候,哪些数据为相同的key值?

除了需要借助自定义类型的hashCode和equals方法以外,还需要通过比较器判断。

将上述代码案例改为 —— 以后写比较器不要返回0;若返回0大数据会认为,通过比较器比较的两个参数相对应的对象为同一个对象

 @Override
    public int compareTo(FlowBean02 o) {
        if (this.sumFlow > o.sumFlow){
            return 1;
        } else {
            return -1;
        }
    }

MapTask的输出的溢写排序的问题

环形缓冲区在进行溢写的时候,会先对环形缓冲区的数据按照不同的分区,按照分区的key值的比较器进行排序,排序的目的保证溢写文件分区有序。因此在MR程序当中,要求MapTask输出的key值必须实现WritableComparable接口,并且重写序列化和反序列化机制以及比较器方法,同时在比较器方法中重写比较规则。MapReduce溢写文件的时候,是一次性全部写入的,全部溢写完成以后清空环形缓冲区的溢写数据。

溢写磁盘的时候,每一次溢写都会进行一次排序,溢写的排序底层默认使用的是快速排序算法实现的。

MapTask运行过程中,可能产生多个溢写文件,最后多个溢写文件合并成一个大的溢写文件,合并大的溢写文件的时候,还得需要进行一次排序操作,排序采用的归并排序算法。

【问题】重写比较器的比较方法时,一定要注意,比较器返回的值只能是正整数或者负整数,但千万不能是0,因为一旦是0,那么两个相等的数据只会保留一个。

Shuffle阶段中的Combiner操作(MR程序的可选组件)

Combiner其实也是一个Reducer,只不过和Reducer不一样的地方在于,Reducer是对所有的MapTask计算的结果进行聚合操作,Combiner只对当前的MapTask计算的结果进行一次局部汇总,目的是为了减少了Map阶段向Reduce阶段传输的数据量,从而提升MR程序的计算效率。

Combiner的使用规则:
	1、一般默认情况下,Combiner就是Reducer,Reducer可以当作Combiner来使用。
	2、如果你不想用Reducer充当Combiner,也可以自定义Combiner,如果自定义Combiner,那么必须满足以下要求:
		1、自定义的Combiner的类必须继承Reducer。
		2、Combiner的输入的KV是map阶段输出的kv类型
		  Combiner输出的kv类型必须是Reducer阶段输入的key  value类型。
	使用在Map阶段给Reduce阶段传输的数据量过大的情况下,可以使用Combine进行一次map的局部汇总,减少数据的传输量。
	
Combiner的执行时机:
	1、当Map阶段的的溢写文件超过三个,自动触发Combiner操作
	2、当Map阶段执行完成之后,把所有的溢写文件合并之后也会触发一次Combiner操作
	3、Combiner在有些极端的情况下,就算我们设置了,它也可能不会执行,如果map端的计算压力过大,那么Combiner就不会执行了,而是直接执行Reducer

自定义Combiner如何添加

class WCCombiner extends Reducer<Text,LongWritable,Text,LongWritable>{
    @Override
    protected void reduce(Text key, Iterable<LongWritable> values, Reducer<Text, LongWritable, Text, LongWritable>.Context context) throws IOException, InterruptedException {
        Long sum = 0L;
        for (LongWritable value : values) {
            sum += value.get();
        }
        context.write(key,new LongWritable(sum));
    }
}
public class WCDriver {
    public static void main(String[] args) throws IOException, URISyntaxException, InterruptedException, ClassNotFoundException {
        job.setCombinerClass(WCCombiner.class);
    }
}

image-20230727100310655

MapTask输出数据时的Combiner局部聚合问题

Combiner是MapReduce的可选组件,可以添加也可以不加,如果我们添加了Combiner,Combiner是在map输出之后,reduce输入之前执行的,Combiner在这个过程中,执行几次,执行时机都是不确定的。和MR程序的计算负载,资源是有很大关系的,有可能Combiner设置了,一次也不执行。

Combiner可以理解为Map端的局部聚合,Combiner的存在,可以减少Map端的溢写文件的数据量以及Map向Reduce传输的数据量。

并不是所有的MR程序都可以添加Combiner,Combiner使用的前提是不能影响MR程序的执行逻辑。如果使用MapReduce程序计算平均值等操作,Combiner一定不能存在。

如果我们要自定义Combiner,Combiner的输入和输出的kv类型必须和Map阶段的输出类型保持一致。Combiner其实就是一个Reducer。所以在有些情况下,如果Combiner和Reducer的逻辑是一样的,同时Reducer的输入和输出满足Combiner的要求,那么可以使用Reducer充当Combiner使用。

ReduceTask拉取数据的分组排序的问题

ReduceTask是和分区一一对应的,一个ReduceTask用来处理一个分区的数据,ReduceTask处理的分区数据可能是来自多个MapTask,因此ReduceTask在进行计算之前,需要先进行一个copy阶段,copy阶段主要是将每一个MapTask上该分区的数据拉去到ReduceTask所在的节点上。默认拉去到ReduceTask内存中,如果内存放不下Spill溢写操作。

因为ReduceTask拉取的数据量可能很大,拉取的过程中也会对数据进行merge合并操作。

ReduceTask把数据拉取合并完成之后,需要进行分组以及排序,排序merge合并完成之后,需要对整体的拉取的数据再进行一次归并排序,分组将该分区的数据按照key值划分不同的数据组,然后一组相同的key值调用一次Reduce方法进行处理。

排序默认使用的是key值的比较器进行排序的,分组默认基于key值的判断相等(hashCode、equals)策略进行key值相等判断以外,还会借助比较器进行key值的相等判断,如果hashCode和equals判断两个key值相等,但是比较器比较出来两个对象大小不一致,那么此时MR程序也会认为两个key值不等,划分不同的组中。
MR程序中判断相等比较器是主力。

默认情况下,reduce聚合key值的时候,需要对key值进行分组,但是key值分组的时候默认使用的是map阶段输出key值的比较器进行相等判断。但是在有些情况下,我们reduce聚合key值并不是按照map阶段的key值的比较器进行分组,因此我们就需要在Reduce阶段在单独定义分组排序,分组排序的目的是为了告诉reduce你应该如何进行分组。

reduce端的分组排序如果我们要自定义,只需要继承一个类即可WritableComparator
/**
 * Reduce判断两个key值是否相等的一个核心
 */
class FlowGroupComparator extends WritableComparator {
    /**
     * 创建一个分组排序的构造器
     */
    protected FlowGroupComparator(){
        //这一行代码代表分组排序的key值是flowbean类型的
        super(FlowBean02.class,true);
    }
    /**
     * 方法就是Reduce判断key值是否相等的核心逻辑
     * 判断两个FlowBean是否相等,基于手机号的判断
     * @param a     map阶段输出的key值
     * @param b     map阶段输出的另外一个key值
     * @return
     */
    @Override
    public int compare(WritableComparable a, WritableComparable b) {
        FlowBean02 f1 = (FlowBean02) a;
        FlowBean02 f2 = (FlowBean02) b;
        if (f1.getPhoneNumber().equals(f2.getPhoneNumber())){
            return 0;
        }else {
            return 1;
        }
    }
}
public class FlowDriver02 {
    public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException, URISyntaxException {
         job.setGroupingComparatorClass(FlowGroupComparator.class);
    }
}

ReduceTask拉取数据的分组辅助排序的案例实现

package com.kang.group;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.*;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.DataInput;
import java.io.DataOutput;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;


/**
 * MR程序辅助排序(分组排序)的案例:
 *      辅助程序是reduce拉取完数据之后执行,通过辅助排序,reduce可以判断哪些key值为相同的key,如果没有辅助排序,那么MR程序会使用map阶段输出的key排序规则当作key值判断相等的条件
 *
 *  现在有一个订单文件,格式如下:
 *  订单id	商品id	成交金额
 * 0000001	Pdt_01	222.8
 * 0000001	Pdt_05	25.8
 * 0000002	Pdt_03	522.8
 * 0000002	Pdt_04	122.4
 * 0000002	Pdt_05	722.4
 * 0000003	Pdt_01	222.8
 * 0000003	Pdt_02	33.8
 * 这个文件三列,每一列之间都是以\t分割的。现在我们需要基于上述的文件求每一个订单中成交金额最大的商品。结果如下:
 * 0000001	Pdt_01	222.8
 * 0000002	Pdt_05	722.4
 * 0000003	Pdt_01	222.8
 *
 * 案例分析:如果我们只是想把订单数据按照订单编号从低到高排序,同时如果订单编号一致,那么按照成交金额从高到低排序。
 * 到时候只需要按照订单ID分组,取第一条数据,第一条数据就是我们某一个订单中成交金额最大的商品信息
 *
 * 如果我们要获取每一个订单的成交金额最大的信息,逻辑只需要在刚刚代码基础上,reduce在进行汇总数据的时候,重新指定一下分组规则即可
 * 分组条件只要是订单id一致即可。如果订单id一致,多个订单数据只有第一条数据才会进入reduce
 */
public class OrderDriver {
    public static void main(String[] args) throws IOException, URISyntaxException, InterruptedException, ClassNotFoundException {
        Configuration configuration = new Configuration();
        configuration.set("fs.defaultFS","hdfs://192.168.31.104:9000");

        Job job = Job.getInstance(configuration);
        job.setJarByClass(OrderDriver.class);
        job.setInputFormatClass(TextInputFormat.class);
        FileInputFormat.setInputPaths(job,new Path("/group.txt"));

        job.setMapperClass(OrderMapper.class);
        job.setMapOutputKeyClass(OrderBean.class);
        job.setMapOutputValueClass(NullWritable.class);

        job.setGroupingComparatorClass(OrderGroupComparator.class);

        job.setReducerClass(OrderReducer.class);
        job.setOutputKeyClass(OrderBean.class);
        job.setOutputValueClass(NullWritable.class);
        job.setNumReduceTasks(1);

        Path path = new Path("/orderOutput");
        FileSystem fs = FileSystem.get(new URI("hdfs://192.168.31.104:9000"), configuration, "root");
        if (fs.exists(path)){
            fs.delete(path,true);
        }
        FileOutputFormat.setOutputPath(job,path);

        boolean flag = job.waitForCompletion(true);
        System.exit(flag?0:1);
    }
}

/**
 * map阶段的逻辑就是把每一行的订单数据读取进来 以后,按照\t分割 ,将每一行的数据字段以orderBean对象封装,
 * 封装好以后以orderBean为key 以null值为value输出即可
 * 那么MR程序在计算过程中会自动根据OrderBean定义的排序规则对数据进行排序
 */
class OrderMapper extends Mapper<LongWritable, Text,OrderBean, NullWritable>{
    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, OrderBean, NullWritable>.Context context) throws IOException, InterruptedException {
        String line = value.toString();
        String[] message = line.split("\t");
        String orderId = message[0];
        String pId = message[1];
        Double amount = Double.parseDouble(message[2]);
        OrderBean orderBean = new OrderBean(orderId,pId,amount);
        context.write(orderBean,NullWritable.get());
    }
}

/**
 * Reducer阶段:reduce阶段只需要将读取进来的key value数据输出即可,因为排序规则在map到reduce中间的shuffle阶段已经全自动化完成了
 * 因此如果只是排序规则,到了reduce阶段只需要将处理好的数据原模原样的输出即可
 */
class OrderReducer extends Reducer<OrderBean,NullWritable,OrderBean,NullWritable>{
    @Override
    protected void reduce(OrderBean key, Iterable<NullWritable> values, Reducer<OrderBean, NullWritable, OrderBean, NullWritable>.Context context) throws IOException, InterruptedException {
        context.write(key,NullWritable.get());
    }
}
/**
 * 定义辅助排序,重新定于reduce的key值分组逻辑:
 *  如果orderId一致 认为两条数据是同一个可以 reduce聚合的时候使用第一条数据当作key值进行计算
 */
class OrderGroupComparator extends WritableComparator{
    public OrderGroupComparator(){
        super(OrderBean.class,true);
    }

    @Override
    public int compare(WritableComparable a, WritableComparable b) {
        OrderBean o1 = (OrderBean) a;
        OrderBean o2 = (OrderBean) b;
        return o1.getOrderId().compareTo(o2.getOrderId());
    }
}
/**
 * 因为你要对数据进行排序,排序的规则还涉及到多个不同的字段,MR程序中只有map阶段输出的key才具备排序的能力
 * 因此也就意味着多个排序字段都要当作map输出key来传递,但是key值只能传递一个,多个字段封装为一个JavaBean
 * 1、定义一个封装原始数据的JavaBean类
 */
class OrderBean implements WritableComparable<OrderBean>{

    private String orderId;//订单编号
    private String pId;//商品编号
    private Double amount;//成交金额

    public OrderBean() {
    }

    public OrderBean(String orderId, String pId, Double amount) {
        this.orderId = orderId;
        this.pId = pId;
        this.amount = amount;
    }

    public String getOrderId() {
        return orderId;
    }

    public void setOrderId(String orderId) {
        this.orderId = orderId;
    }

    public String getpId() {
        return pId;
    }

    public void setpId(String pId) {
        this.pId = pId;
    }

    public Double getAmount() {
        return amount;
    }

    public void setAmount(Double amount) {
        this.amount = amount;
    }

    @Override
    public String toString() {
        return orderId + "\t" + pId + "\t" + amount;
    }

    /**
     * 比较器:想按照订单id升序排序,订单id一致按照成交金额降序排序
     * @param o the object to be compared.
     * @return
     */
    @Override
    public int compareTo(OrderBean o) {
        if (this.orderId.compareTo(o.orderId) == 0){
            //判断成交金额
            if (this.amount > o.amount){
                return -1;
            } else if (this.amount < o.amount) {
                return 1;
            } else {
                return 0;
            }
        }else {
            return this.orderId.compareTo(o.orderId);
        }
    }

    @Override
    public void write(DataOutput out) throws IOException {
        out.writeUTF(orderId);
        out.writeUTF(pId);
        out.writeDouble(amount);
    }

    @Override
    public void readFields(DataInput in) throws IOException {
        orderId = in.readUTF();
        pId = in.readUTF();
        amount = in.readDouble();
    }
}

结果展示中:第一组数据是没有添加分组辅助排序逻辑的输出结果

​ 第二组数据是文件原数据

​ 第三组数据是添加分组辅助排序逻辑的输出结果

image-20230727182743466

6、MR程序运行中的ReduceTask机制

ReduceTask把数据分组好以后,一组相同的key调用一次Reduce方法,Reduce方法就可以去聚合数据,进行逻辑计算。

第一知识点:ReduceTask任务数的设置
	MR程序当中,MapTask的数量我们是基于切片数量自动确定的,我们人为无法手动设置MapTask的任务数,如果想修改MapTask的个数,我们无法直接修改,只能通过修改切片机制间接的修改MapTask的任务个数。
	MR程序当中,ReduceTask的数量机制和MapTask机制不太一样的,ReduceTask的任务个数是可以手动指定的。因此ReduceTask的数量给多少合适?默认要求ReduceTask的数量必须和分区数保持一致,因为一个Reduce任务处理一个分区的数据。
	【注意】在MR程序中,有一个比较特殊的机制,Reduce的数量可以设置为0,那么一旦ReduceTask的数量设置为0,那么MR程序只有Mapper阶段,没有reduce阶段,map阶段的输出就是整个MR程序的最终输出了。
一般写MR程序的时候,要求如果操作不涉及到对数据集整体的聚合操作(计算的结果需要从数据集整体中获得),我们都不建议大家增加Reduce阶段,因为增加了Reduce,MR执行效率会非常的低。

第二个知识点(非常重要):数据倾斜问题:Map阶段和Reduce阶段都存在
	数据倾斜不是MR程序运行原理,是MR程序在运行过程中可能会出现一种影响MR程序运行效率的情况。所谓的数据倾斜指的是多个ReduceTask处理的分区数据量差距过大,这样的话就会导致一个问题,有些ReduceTask会快速的运行完成,而有些ReduceTask运行时间非常久。
	如果我们发现所有reduceTask,有大部分运行很块结束了,而少部分Task运行时间过长,这一般都是因为数据倾斜问题的导致。解决方案很简单
		1、自定义分区机制,尽可能让各个分区的数据分布均匀一点
		2、消除热点key的数据,分区之所以不均匀,还有很大的可能性是因为确实有部分的key值出现的次数太多了。消除机制在处理数据时候,在热点key值的后面增加一些随机数。
		3、抽样分析数据 -- 数学算法和思想

无reduce阶段运行案例

package com.kang.noreduce;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.InputFormat;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;

/**
 * 现在有一个文件。文件中有很多行数据,每一行数据都是以空格分割的多个单词组成的,
 * 现在要求通过MR程序实现将文件中所有以大写字母开头的英语单词过滤掉,最终输出结果文件,结果文件
 * 中只有以小写字母开头的英语单词
 */
public class DemoDriver {
    public static void main(String[] args) throws IOException, URISyntaxException, InterruptedException, ClassNotFoundException {
        Configuration configuration = new Configuration();
        configuration.set("fs.defaultFS","hdfs://192.168.31.104:9000");
        Job job = Job.getInstance(configuration);
        job.setJarByClass(DemoDriver.class);
        job.setInputFormatClass(TextInputFormat.class);
        FileInputFormat.setInputPaths(job,"/wordcount.txt");
        /**
         * 当前MR程序只要map阶段 没有reduce阶段
         * 也就意味着map阶段的输出就是最终的结果数据
         */
        job.setMapperClass(DemoMapper.class);
        job.setOutputKeyClass(NullWritable.class);
        job.setOutputValueClass(Text.class);
        /**
         * 如果MR程序当中没有reduce阶段,一定一定要把reduce的任务数设置为0,
         * 如果没有设置为0,同时MR程序中没有指定reducer类,那么MR程序会默认自动给你添加reducer类,并且启动一个reduceTask
         * 自动生成的reduce类很简单,reduce类就是map输出的是什么结果 reduce原模原样输出
         */
        job.setNumReduceTasks(0);
        Path path = new Path("/output2");
        FileSystem fs = FileSystem.get(new URI("hdfs:192.168.31.104:9000"), configuration, "root");
        if (fs.exists(path)){
            fs.delete(path);
        }
        FileOutputFormat.setOutputPath(job,path);
        boolean flag = job.waitForCompletion(true);
        System.exit(flag?0:1);
    }
}

class DemoMapper extends Mapper<LongWritable, Text,NullWritable, Text>{
    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, NullWritable, Text>.Context context) throws IOException, InterruptedException {
        String line = value.toString();
        String[] words = line.split(" ");
        for (String word : words) {
            if (word.charAt(0) >=97 && word.charAt(0) <= 122){
                context.write(NullWritable.get(),new Text(word));
            }else {
                continue;//过滤操作  只要map方法执行完成,没有调用context.write方法将数据写出 那么就代表当前数据需要舍弃
            }
        }
    }
}

image-20230727205344621

7、MR程序运行中的OutputFormat组件

OutputFormat是MR程序中输出格式化类,抽象类中定义了一个方法getRecordWriter,方法是OutputFormat的核心作用,方法作用就是定义了我们的Reduce或者Map阶段输出最终的结果数据时,kv数据如何写入到文件中,写入的规则是一个什么规则。

OutputFormat常见的实现类

  • TextOutputFormat:是OutputFormat的默认实现类
    • 输出的是纯文本文档数据,输出数据的规则是将reduce输出的每一个key-value数据以\t分割,然后一组kv数据单独占据一行。
    • 通过这个类输出数据时,会数据放到指定的文件,文件默认的命名规则part-m/r-xxxxx。
  • SequenceFileOutputFormat
    • 是Hadoop中一种特殊的输出文件格式,输出文件格式SequenceFile文件。
    • SequenceFile文件
      • SequenceFile文件是Hadoop提供的一种特殊的二进制文件,二进制文件支持对数据进行压缩以后再写出到文件内部,这种文件在某种程度上可以极大的提高分布式计算的效率。SequenceFile文件存储数据的格式都是kv格式,只不过kv数据都是二进制压缩过的数据。
      • Sequencefile文件支持对数据压缩,也可以不压缩,整体上文件的压缩方式一共有三种:
        • none:对数据不压缩
        • record:只对kv数据中value数据进行压缩
        • block:将多个kv数据都进行压缩

自定义OutputFormat

默认情况下,mapreduce是掉用TextOutputFormat类将MR程序的输出数据写出到了文件当中,文件的格式默认是将key-value以\t分割,并且输出的文件名是part-m/r-xxxxx。

除了TextOutputFormat之外,还有一个实现类SequenceFileOutputFormat,这个类是将文件以key-value的二进制形式进行输出,并且输出的二进制数据支持压缩,同时输出的文件名也是part-m/r-xxxxx。
以上这两个实现类默认一个reduceTask只输出一个文件。

在有些情况下,这两个实现类满足不了我们的输出需求,因此我们就得自定义InputFormat实现输出效果
	1、自定义一个类继承FileOutputFormat类
	2、重写getRecordWriter方法,方法需要返回一个RecordWriter的子类对象

案例分析

package com.kang.customoutput;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FSDataOutputStream;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.*;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.mapreduce.lib.output.PathOutputCommitterFactory;
import org.apache.hadoop.mapreduce.lib.output.TextOutputFormat;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;

/**
 * 实现单词计数案例,并且要求整个MR程序中只能有一个分区,一个reduceTask
 * 但是我们要求你将统计的单词计数结果,首字母大写的单词输出到upper.txt文件
 * 首字母小写的单词输出到lower.txt文件中
 *
 * 案例:一个reduceTask任务输出两个文件,两个文件名不是part-r/m-xxxxx
 */
public class WCDriver {
    public static void main(String[] args) throws IOException, URISyntaxException, InterruptedException, ClassNotFoundException {
        Configuration configuration = new Configuration();
        configuration.set("fs.defaultFS","hdfs://192.168.31.104:9000");

        Job job = Job.getInstance(configuration);
        job.setJarByClass(WCDriver.class);

        job.setInputFormatClass(TextInputFormat.class);
        FileInputFormat.setInputPaths(job,"/wordcount.txt");

        job.setMapperClass(WCMapper.class);
        job.setMapOutputKeyClass(Text.class);
        job.setOutputValueClass(LongWritable.class);

        job.setReducerClass(WCReducer.class);
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(LongWritable.class);
        job.setNumReduceTasks(1);

        Path path = new Path("/wordcountOutput");
        FileSystem fs = FileSystem.get(new URI("hdfs://192.168.31.104:9000"), configuration, "root");
        if (fs.exists(path)){
            fs.delete(path);
        }
        job.setOutputFormatClass(WCOutputFormat.class);
        FileOutputFormat.setOutputPath(job,path);

        boolean flag = job.waitForCompletion(true);
        System.exit(flag?0:1);
    }
}

class WCMapper extends Mapper<LongWritable, Text,Text,LongWritable>{
    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, Text, LongWritable>.Context context) throws IOException, InterruptedException {
        String line = value.toString();
        String[] words = line.split(" ");
        for (String word : words) {
            context.write(new Text(word),new LongWritable(1l));
        }
    }
}

class WCReducer extends Reducer<Text,LongWritable,Text,LongWritable>{
    @Override
    protected void reduce(Text key, Iterable<LongWritable> values, Reducer<Text, LongWritable, Text, LongWritable>.Context context) throws IOException, InterruptedException {
        long sum = 0l;
        for (LongWritable value : values) {
            sum += value.get();
        }
        context.write(key,new LongWritable(sum));
    }
}
/**
 * 自定义一个OutputFormat类实现数据的输出规则 :
 *      首字母大写的单词 输出到upper.txt文件中
 *      首字母小写的单词 输出到lower.txt文件中
 */
class WCOutputFormat extends FileOutputFormat<Text,LongWritable>{
    /**
     * 如何输出数据的核心代码逻辑
     * @param context context是一个MR程序运行的上下问对象  可以通过上下文对象获取job的所有Configuration配置
     * @return
     * @throws IOException
     * @throws InterruptedException
     */
    @Override
    public RecordWriter<Text, LongWritable> getRecordWriter(TaskAttemptContext context) throws IOException, InterruptedException {
        /**
         * 采用内部类的方式创建一个RecordWriter的实现类 当作返回值返回
         */
        class WCRecordWriter extends RecordWriter<Text,LongWritable>{
            private FSDataOutputStream stream1;//这个输出IO流用来连接HDFS上的upper.txt文件
            private FSDataOutputStream stream2;//这个输出IO流用来连接HDFS上的lower.txt文件
            public WCRecordWriter() throws URISyntaxException, IOException, InterruptedException {
                this(context);
            }
            public WCRecordWriter(TaskAttemptContext context) throws URISyntaxException, IOException, InterruptedException {
                //这一行代码代表获取MR程序的所有配置对象
                Configuration configuration = context.getConfiguration();//通过上下文对象拿到配置文件
                String hdfsAddress = configuration.get("fs.defaultFS");//通过配置文件拿到hdfs的地址
                FileSystem fs = FileSystem.get(new URI(hdfsAddress), configuration, "root");//通过hdfs的地址连上hdfs
                /**
                 * 创建和HDFS的Io流,两个文件输出到Driver驱动程序中指定的输出目录下。输出目录按道理来说不能自己手动写
                 * 应该获取Driver设置的输出目录
                 */
                String outputDir = configuration.get(FileOutputFormat.OUTDIR);//设置Driver里面的输出目录的
                stream1 = fs.create(new Path(outputDir + "/upper.txt"));
                stream2 = fs.create(new Path(outputDir + "/lower.txt"));
            }
            /**
             * 如何写出数据,写出数据必须是两个文件
             * @param key the key to write.
             * @param value the value to write.
             * @throws IOException
             * @throws InterruptedException
             */
            @Override
            public void write(Text key, LongWritable value) throws IOException, InterruptedException {
                String word = key.toString();
                char c = word.charAt(0);
                if (c >=65 && c <=90){
                    String line = key.toString() + "=" + value.get() + "\n";
                    stream1.write(line.getBytes());
                    stream1.flush();
                }else {
                    String line = key.toString() + "=" + value.get() + "\n";
                    stream2.write(line.getBytes());
                    stream2.flush();
                }
            }

            @Override
            public void close(TaskAttemptContext context) throws IOException, InterruptedException {
                stream1.close();
                stream2.close();
            }
        }
        try {
            return new WCRecordWriter(context);
        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }
    }
}

image-20230729103056008

案例分析:将结果写到数据库中

package com.kang.wordcount;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.*;
import org.apache.hadoop.mapreduce.lib.input.*;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class WCDriver {
    public static void main(String[] args) throws IOException, URISyntaxException, InterruptedException, ClassNotFoundException {
        //1、准备一个配置文件对象
        Configuration configuration = new Configuration();
        configuration.set("fs.defaultFS","hdfs://192.168.31.104:9000");

        //2、创建一个封装MR程序使用Job对象
        Job job = Job.getInstance(configuration);
        job.setJarByClass(WCDriver.class);

        //指定输入文件路径  输入路径默认是本地的,如果你想要是HDFS上的 那么必须配置fs.defaultFS 指定HDFS的路径
        FileInputFormat.setInputPaths(job,new Path("/wordcount.txt"));

        /**
         * 4、封装Mapper阶段
         */
        job.setMapperClass(WCMapper.class);
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(LongWritable.class);

        /**
         * 6、封装Reducer阶段
         */
        job.setReducerClass(WCReducer.class);
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(LongWritable.class);
        job.setNumReduceTasks(1);

        //封装输出路径 输出路径不能提前存在,因此在代码中先判断是否存在,如果存在删除了
        Path path = new Path("/output");
        FileSystem fileSystem = FileSystem.get(new URI("hdfs://192.168.31.104:9000"), configuration, "root");
        if (fileSystem.exists(path)){
            fileSystem.delete(path,true);
        }
        job.setOutputFormatClass(WCOutputFormat.class);
        FileOutputFormat.setOutputPath(job,path);

        /**
         * 8、提交程序运行
         *      提交的时候先进行切片规划,然后将配置和代码提交给资源调度器
         */
        boolean b = job.waitForCompletion(true);
        System.exit(b?0:1);
    }
}
class WCMapper extends Mapper<LongWritable, Text, Text, LongWritable>{
    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, Text, LongWritable>.Context context) throws IOException, InterruptedException {
        //long l = key.get();
        String line = value.toString();
        //System.out.println("map通过InputFormat机制读取的key值为" +l + "读取的value值为" + line);
        //System.out.println("map通过InputFormat机制读取的key值为" + key.toString() + "读取的value值为" + line);
        System.out.println("map通过InputFormat机制读取的key值为" + key.get() + "读取的value值为" + line);
        String[] words = line.split(" ");
        for (String word : words) {
            context.write(new Text(word),new LongWritable(1L));
        }
    }
}

class WCReducer extends Reducer<Text, LongWritable, Text, LongWritable>{
    @Override
    protected void reduce(Text key, Iterable<LongWritable> values, Reducer<Text, LongWritable, Text, LongWritable>.Context context) throws IOException, InterruptedException {
        Long sum = 0L;
        for (LongWritable value : values) {
            sum += value.get();
        }
        context.write(key,new LongWritable(sum));
    }
}
class WCOutputFormat extends FileOutputFormat<Text,LongWritable>{

    @Override
    public RecordWriter<Text, LongWritable> getRecordWriter(TaskAttemptContext job) throws IOException, InterruptedException {
        return new WCRecordWriter();
    }
}
class WCRecordWriter extends RecordWriter<Text,LongWritable>{
    private Connection connection;
    private PreparedStatement preparedStatement;
    public WCRecordWriter(){
        /**
         * 在无参构造器中先连接MySQL
         */
        try {
            Class.forName("com.mysql.cj.jdbc.Driver");
            connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mr?serverTimezone=UTC&useUnicode=true&characterEncoding=UTF-8","root","root");
            String sql = "insert into wordcount(word,count) values(?,?)";
            preparedStatement = connection.prepareStatement(sql);
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void write(Text key, LongWritable value) throws IOException, InterruptedException {
        String word = key.toString();
        Long count = value.get();
        try {
            preparedStatement.setString(1,word);
            preparedStatement.setInt(2,count.intValue());
            preparedStatement.executeUpdate();
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void close(TaskAttemptContext context) throws IOException, InterruptedException {
        if (preparedStatement != null){
            try {
                preparedStatement.close();
            } catch (SQLException e) {
                throw new RuntimeException(e);
            }
        }
        if (connection != null){
            try {
                connection.close();
            } catch (SQLException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

image-20230731102044351

8、Hadoop中对于SequenceFile文件的支持和处理

SequenceFile文件是Hadoop提供的一种比较的特殊的文件,文件中存储的是key-value的二进制数据,而且SequenceFile文件支持对存储的二进制key-value数据进行压缩,是大数据中比较常用的一种数据文件,在Spark和Flink、Hive中有很多的情况下都是使用SequenceFile文件格式进行数据的保存等操作。

SequenceFile文件因为存储的是key-value数据的二进制类型数据,因此文件支持value或者key为图片、视频、音频数据。

SequenceFile文件中内容是由三部分组成:

  • Header
    • Header区域存放了文件中key-value的类型、以及key、value采用的压缩方式、压缩使用的算法规则,以及同步标识。
  • Record区域|block区域
    • 存放的就是key和value的二进制数据,如果指定了压缩,存储的就是key-value的二进制压缩数据。
  • 同步标记sync-mark

SequenceFile文件的三种压缩方式:

  • none:对key-value数据不进行压缩。
  • record:只对每一个key-value数据中的value数据进行压缩,key值不进行压缩。
  • block:对多个key-value数据进行压缩,key和value都会压缩。

MapReduce中,可以处理SequenceFile文件,也可以将结果输出为SequenceFile文件,之所以MR可以处理这个文件,是因为MR提供了两个类:

  • SequenceFileInputFormat

    • 是InputFormat的一个实现类,专门用来读取SequenceFile文件格式的一个InputFormat类
    • 读取数据的时候,文件中一个一个keyvalue依次读取,而且这个类可以根据sequenceFile文件中的Header头部信息,自动识别数据是否被压缩以及采集的压缩方法和压缩算法,如果数据是被压缩过的,使用header中提供的压缩算法进行解压缩操作
    • 同时在SequneceFile文件的Header中,还指定了key和value的类型(类型是序列化类型的),那InputFormat还会根据序列化类型自动把key value解压缩之后的二进制数据自动转换成为对应的key-value数据类型
    • 【注意】如果使用SequenceFileInputFormat,那么map阶段输入的key-value类型就是不确定的。
  • SequenceFileOutputFormat

    • 是OutputFormat的一个实现类,支持可以把Reduce最终数据的结果以我们指定的压缩方法把数据输出成为SequenceFile文件格式。

    • 如果我们想要这个类帮助我们输出SequenceFile文件格式的数据,我们必须满足,MR输出的key-value必须实现了Hadoop的序列化机制。

    • job.setOutputFormatClass(SequenceFileOutputFormat.class);
      SequenceFileOutputFormat.setCompressOutput(job,true);
      SequenceFileOutputFormat.setOutputCompressionType(job, SequenceFile.CompressionType.RECORD);
      SequenceFileOutputFormat.setOutputCompressorClass(job, GzipCodec.class);
      
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Augenstern K

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值