Hadoop(四)MapReduce

1.概述

MapReduce是一个分布式运算程序的编程框架,是用户开发"基于Hadoop的数据分析应用"的核心框架
MapReduce核心功能是将用户编写的业务逻辑代码和自带默认组件整合成一个完整的分布式运算程序,并发运行在一个Hadoop集群上

优点
1)MapReduce易于编程
它简单的实现一些接口,就可以完成一个分布式程序,这个分布式程序可以分布到大量廉价的PC机器上运行

2)良好的扩展性
当计算资源不能得到满足时,通过简单的增加机器来扩展它的计算能力

3)高容错性
其中一台机器挂了,它可以把上面的计算任务转移到另外一个节点上运行,不至于这个任务运行失败,完全是由Hadoop内部完成的

4)适合PB级以上海量数据的离线处理
可以实现上千台服务器集群并发工作,提供数据处理能力

缺点
1)不擅长实时计算
MapReduce无法像MySQL一样,在毫秒或者秒级内返回结果

2)不擅长流式计算
流式计算的输入数据是动态的,而MapReduce的输入数据集是静态的,不能动态变化这是因为MapReduce自身的设计特点决定了数据源必须是静态的

3)不擅长DAG(有向无环图)计算
多个应用程序存在依赖关系,后一个应用程序的输入为前一个的输出。在这种情况下,MapReduce并不是不能做,而是使用后,每个MapReduce作业的输出结果都会写入到磁盘,会造成大量的磁盘IO,导致性能非常的低下

2.核心思想

(1)分布式的运算程序往往需要分成至少2个阶段
(2)第一个阶段的MapTask并发实例,完全并行运行,互不相干
(3)第二个阶段的ReduceTask并发实例互不相干,但是他们的数据依赖于上一个阶段的所有MapTask并发实例的输出
(4)MapReduce编程模型只能包含一个Map阶段和一个Reduce阶段,如果用户的业务逻辑非常复杂,那就只能多个MapReduce程序,串行运行

在这里插入图片描述

3.MapReduce编程

编程规范

用户编写的程序分成三个部分:Mapper、Reducer和Driver。

Mapper阶段

1.用户自定义的Mapper要继承自己的父类
2.Mapper的输入数据是K-V对的形式
3.Mapper中的业务逻辑要写在map()方法中
4.Mapper的输出数据是K-V对的形式
5.map()方法对每一行数据都会调用一次

package com.gzhu.mapreduce.worldcount;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;

import java.io.IOException;

/*
泛型一:KEYIN:LongWritable,对应的Mapper的输入key。输入key是每行的行首偏移量
泛型二: VALUEIN:Text,对应的Mapper的输入Value。输入value是每行的内容
泛型三:KEYOUT:对应的Mapper的输出key,根据业务来定义
泛型四:VALUEOUT:对应的Mapper的输出value,根据业务来定义

KEYIN和VALUEIN写死(LongWritable,Text)。KEYOUT和VALUEOUT不固定,根据业务来定

Writable机制是Hadoop自身的序列化机制,常用的类型:
	a. LongWritable
	b. Text(String)  Text对应java中String类型
	c. IntWritable
	d. NullWritable
*/

/*
* 这里的输入数据为       输出数据 kun 3 song 2 zhang liu 1 wang 1 li 2
*  kun kun kun
*  song song
*  zhang
*  liu
*  wang
*  li li
*/
public class WorldCountMapper extends Mapper<LongWritable, Text,Text, IntWritable> {
    private Text text = new Text();
    private IntWritable intWritable = new IntWritable(1);
    @Override                  // 这里的value是每一行数据
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, Text, IntWritable>.Context context) throws IOException, InterruptedException {
        // 1.根据原始数据获取一行,转换成字符串,例如 kun kun kun
        String string = value.toString();

        // 2.切割每一行单词  [kun,kun,kun]
        String[] words = string.split(" ");

        // 3.循环写出,每一个kun都为1 K-V kun-1,Mapper阶段不汇总,所以每一个都是1
        for (String word : words) {
            // 将String类型转换成Text类型
            text.set(word);
            // write里面的参数为输出的两个参数类型 Text, IntWritable
            // 这里输出三个kun-1
            context.write(text,intWritable);
        }
    }
}

Reducer阶段
1.用户自定义的reduce要继承自己的父类
2.Reduce的输入数据类型对应Mapper的输出数据类型,也是K-V
3.Reduce的业务逻辑写在reduce()方法中
4.ReduceTask进程对每一组相同K的K-V组调用一次reduce()方法

public class WorldCountReduce extends Reducer<Text, IntWritable,Text,IntWritable> {
    private IntWritable intWritable = new IntWritable();
    @Override
    // key代表每一个key,例如Kun,values是一个集合,里面存的是key对应的V值
    protected void reduce(Text key, Iterable<IntWritable> values, Reducer<Text, IntWritable, Text, IntWritable>.Context context) throws IOException, InterruptedException {
        int sum = 0;
        
        // 将每一个Key对应的value汇总
        for (IntWritable value : values) {
            sum += value.get();
        }
        intWritable.set(sum);
        
        context.write(key,intWritable);
    }
}

Driver驱动

package com.gzhu.mapreduce.worldcount;

import org.apache.hadoop.conf.Configuration;
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.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.IOException;

// 固定代码
public class WorldCountDriver {
    public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException {
        // 1.获取job
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf);

        // 2.设置jar路径
        job.setJarByClass(WorldCountDriver.class);

        // 3.关联mapper和reducer
        job.setMapperClass(WorldCountMapper.class);
        job.setReducerClass(WorldCountReduce.class);

        // 4.设置map输出的kv类型
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(IntWritable.class);

        // 5.设置最终输出的kv类型
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(IntWritable.class);

        // 6.设置输入路径和输出路径
        FileInputFormat.setInputPaths(job, new Path("D:\\song"));
        FileOutputFormat.setOutputPath(job, new Path("D:\\output"));

        // 7.提交job
        boolean b = job.waitForCompletion(true);
        System.exit(b ? 0 : 1);
    }
}

在这里插入图片描述

Yarn阶段
相当于Yarn的客户端,用于提交我们整个程序到YARN集群

4.Hadoop集群测试WordCount案例

打包

<build>
    <plugins>
        // 只打包代码仅需如下插件
        <plugin>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.6.1</version>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
            </configuration>
        </plugin>
        // 将依赖也打包需要如下插件
        <plugin>
            <artifactId>maven-assembly-plugin</artifactId>
            <configuration>
                <descriptorRefs>
                    <descriptorRef>jar-with-dependencies</descriptorRef>
                </descriptorRefs>
            </configuration>
            <executions>
                <execution>
                    <id>make-assembly</id>
                    <phase>package</phase>
                    <goals>
                        <goal>single</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
        
    </plugins>
</build>

修改路径
在这里插入图片描述
打包上传到hadoop文件目录下

在这里插入图片描述

文件准备
在这里插入图片描述

测试

[gzhu@hadoop102 hadoop-3.1.3]$ hadoop jar wc.jar com.gzhu.mapreduce.worldcount2.WorldCountDriver /input /output

在这里插入图片描述

5.序列化

概念
序列化:序列化就是把内存中的对象,转换成字节序列(或其他数据传输协议)以便于存储到磁盘(持久化)和网络传输

反序列化:将收到字节序列(或其他数据传输协议)或者是磁盘的持久化数据,转换成内存中的对象

为什么要序列化
一般来说,"活跃"对象只生存在内存里,关机断电就没有了。而且"活跃"对象只能由本地的进程使用,不能被发送到网络上的另外一台计算机。 然而序列化可以存储"活跃"对象,可以将"活跃"对象发送到远程计算

关于Hadoop序列化
Java的序列化是一个重量级序列化框架(Serializable),一个对象被序列化后,会附带很多额外的信息(各种校验信息,Header,继承体系等),不便于在网络中高效传输。所以,Hadoop自己开发了一套序列化机制(Writable),附带的信息仅需一部分的校验信息

Hadoop序列化特点
(1)紧凑 :高效使用存储空间
(2)快速:读写数据的额外开销小
(3)互操作:支持多语言的交互

自定义bean对象实现序列化接口(Writable)

Java类型 -Hadoop Writable类型

Boolean -BooleanWritable
Byte -ByteWritable
Int -IntWritable
Float -FloatWritable
Long -LongWritable
Double -DoubleWritable
String -Text
Map -MapWritable
Array -ArrayWritable
Null -NullWritable

企业开发中往往常用的基本序列化类型不能满足所有需求,比如在Hadoop框架内部传递一个bean对象,那么该对象就需要实现序列化接口,我们需要自定义序列化

自定义序列化案例分析

对于如下的数据,我们希望可以根据用户的id,统计其上流量和下流量以及总和
在这里插入图片描述
在这里插入图片描述

自定义bean序列化的类

// 1.实现Writable接口
public class FlowBean implements Writable {
    private long upFlow; //上行流量
    private long downFlow; //下行流量
    private long sumFlow; //总流量

    // 2.反序列化,需要反射调用空参构造函数,所以必须有空参函数
    public FlowBean(){
    }
    // 3.重写序列化方法,注意!!!反序列化顺序要和序列化顺序完全一致!!!
    @Override
    public void write(DataOutput dataOutput) throws IOException {
        dataOutput.writeLong(upFlow);
        dataOutput.writeLong(downFlow);
        dataOutput.writeLong(sumFlow);
    }
    // 4.反序列化
    @Override
    public void readFields(DataInput dataInput) throws IOException {
        this.upFlow = dataInput.readLong();
        this.downFlow = dataInput.readLong();
        this.sumFlow = dataInput.readLong();
    }
    // 5.get set方法
    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;
    }

    public void setSumFlow() {
        this.sumFlow = upFlow + downFlow;
    }
    // 6.toString()方法
    @Override
    public String toString() {
        return
        "upFlow=" + upFlow +
        ", downFlow=" + downFlow +
        ", sumFlow=" + sumFlow ;
    }
    /*
    * 7.Map<K1,V,K2,V>
    如果将自定义的bean放在K2传输,必须要实现Comparable接口,K2必须能排序
    */}

Mapper

public class FlowMapper extends Mapper<LongWritable, Text,Text,FlowBean> {
    Text text = new Text();
    FlowBean flowBean = new FlowBean();
    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, Text, FlowBean>.Context context) throws IOException, InterruptedException {
        String string = value.toString();

        String []words = string.split("\t");

        String keyNumber = words[1];
        text.set(keyNumber);

        flowBean.setDownFlow(Long.parseLong(words[words.length - 3]));
        flowBean.setUpFlow(Long.parseLong(words[words.length - 2]));
        flowBean.setSumFlow();

        context.write(text,flowBean);
    }
}

Reducer

public class FlowReduce extends Reducer<Text,FlowBean,Text,FlowBean> {
    FlowBean flowBean = new FlowBean();
    @Override
    protected void reduce(Text key, Iterable<FlowBean> values, Reducer<Text, FlowBean, Text, FlowBean>.Context context) throws IOException, InterruptedException {
        long down = 0;
        long up = 0;

        for (FlowBean value : values) {
            down += value.getDownFlow();
            up += value.getUpFlow();
        }

        flowBean.setDownFlow(down);
        flowBean.setUpFlow(up);
        flowBean.setSumFlow();

        context.write(key,flowBean);
    }
}

Driver

public class FlowDriver {
    public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException {
        // 1.获取job
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf);

        // 2.设置jar路径
        job.setJarByClass(FlowDriver.class);

        // 3.关联mapper和reducer
        job.setMapperClass(FlowMapper.class);
        job.setReducerClass(FlowReduce.class);

        // 4.设置map输出的kv类型
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(FlowBean.class);

        // 5.设置最终输出的kv类型
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(FlowBean.class);

        // 6.设置输入路径和输出路径
        FileInputFormat.setInputPaths(job, new Path("F:\\phone_data.txt"));
        FileOutputFormat.setOutputPath(job, new Path("F:\\output"));

        // 7.提交job
        boolean b = job.waitForCompletion(true);
        System.exit(b ? 0 : 1);
    }
}
6.MapReduce框架原理

在这里插入图片描述

6.1 InputFormat数据输入

切片与MapTask并行度决定机制

数据块:Block是HDFS物理上把数据分成一块一块。数据块是HDFS存储数据单位

数据切片:数据切片只是在逻辑上对输入进行分片,并不会在磁盘上将其切分成片进行存储。数据切片是MapReduce程序计算输入数据的单位,一个切片会对应启动一个MapTask

默认情况下切片大小 = BlockSize,一个切片分配一个MapTask进程,数据切片针对的对象是单个文件

在这里插入图片描述假如切片大小为100M,则对第一个128M的block文件处理时,要跨服务器,必然会降低性能

FileInputFormat切片过程

1.程序先找到数据存储的目录
2.开始遍历目录下的每一个文件(切片的单位是一个文件)
3.对于每一个文件(getSplit()方法):

  • 获取文件的大小 fs.sizeOf(a.txt)
  • 计算切片大小,默认是128M = blocksize
  • 形成切片,每次切片时,都要判断剩下的部分是否大于块的1.1倍,不大于就划分为一个切片
  • 将切片信息写到一个切片规划文件中
  • 提交切片文件到Yarn上,MrAppMaster根据计算的切片开启MapTask个数

FileInputFormat实现类

FileInputFormat常见的接口实现类包括:TextInputFormat、KeyValueTextInputFormat、NLineInputFormat、CombineTextInputFormat和自定义InputFormat等

①TextInputFormat
TextInputFormat是默认的FileInputFormat实现类。按行读取每条记录。键是存储该行在整个文件中的起始字节偏移量, LongWritable类型。值是这行的内容,不包括任何行终止符(换行符和回车符),为Text类型

②CombineTextInputFormat

框架默认的TextInputFormat切片机制是对任务按文件规划切片,不管文件多小,都会是一个单独的切片,都会交给一个MapTask,这样如果有大量小文件,就会产生大量的MapTask,处理效率极其低下

CombineTextInputFormat用于小文件过多的场景,它可以将多个小文件从逻辑上规划到一个切片中,这样,多个小文件就可以交给一个MapTask处理

CombineTextInputFormat切片机制 - 分为虚拟存储过程和切片过程

虚拟存储过程:将输入目录下所有文件大小,依次和设置的setMaxInputSplitSize值比较,如果不大于设置的最大值,逻辑上划分一个块。如果输入文件大于设置的最大值两倍,那么以最大值切割一块;当剩余数据大小超过设置的最大值且不大于最大值2倍,此时将文件均分成2个虚拟存储块(防止出现太小切片)

例如setMaxInputSplitSize值为4M,输入文件大小为8.02M,则先逻辑上分成一个4M。剩余的大小为4.02M,如果按照4M逻辑划分,就会出现0.02M的小的虚拟存储文件,所以将剩余的4.02M文件切分成(2.01M和2.01M)两个文件

CombineTextInputFormat案例

有4个小文件,如果使用默认的TextInputFormat,则会启动4个MapTask,浪费资源
在这里插入图片描述
使用上面的wordcount,修改文件路径,可以看到有4个切片

在这里插入图片描述
在Driver里面设置CombineTextInputFormat并且设置虚拟存储的大小

// 如果不设置InputFormat,它默认用的是TextInputFormat.class
job.setInputFormatClass(CombineTextInputFormat.class);

//虚拟存储切片最大值设置4m
CombineTextInputFormat.setMaxInputSplitSize(job, 4194304);

可以看到使用了3个切片
在这里插入图片描述

6.2 MapReduce工作流程

Map机制 :Read阶段 - Map阶段 - Collect阶段 - 溢写阶段 - Merge阶段

map方法之后,reduce方法之前的数据处理过程称之为Shuffle

Reduce机制:Copy阶段 - Sort阶段 - Reduce阶段

1.文件在HDFS,客户端提交前,首先会获取处理数据的信息,然后根据切片机制将数据切片,形成切片文件

2.客户端将信息job.split、jar包、job.xml、切片文件提交到Yarn

3.Yarn读取相关的信息,根据切片信息开启MapTask

4.根据InputFormat读取数据信息,默认TextInputFormat,按行为单位进行读取数据,形成K-V

5.将K-V给Mapper程序处理,按照用户的业务逻辑处理后,context.write(K,V)输出数据

6.map方法之后,数据首先进入到分区方法,把数据标记好分区,然后把数据发送到环形缓冲区(收集器);环形缓冲区默认大小100M,环形缓冲区达到 80%时,开始反向写入,并进行溢写。溢写前对数据进行快速排序,排序按照对key的索引进行字典顺序排序。溢写产生大量溢写文件(临时文件的性质),需要对溢写文件进行归并排序。对溢写的文件也可以进行Combiner操作,前提是汇总操作,求平均值不行,因为Combiner会对每一个map task的输出进行局部聚合。最后将文件按照分区存储到磁盘,等待Reduce端拉取

①为什么用了80%,反向写?

反向写入的同时前面可以进行溢写,提高了效率,对于缓冲区,写入写出两不误,提高了效率

②溢写前的排序算法为什么使用快排?

  • 快排的空间复杂度较低 nlogn
  • 原地排序,不需要额外的空间

7.每个Reduce拉取Map端对应分区的数据(ReduceTask个数取决于分区数)。拉取数据后先存储到内存中,内存不够了,再存储到磁盘。拉取完所有数据后,采用归并排序将内存和磁盘中的数据都进行排序,按照Key分组进入reduce方法,根据OutputFormat输出

在这里插入图片描述
在这里插入图片描述

6.3 分区

为什么分区

因为在进行MapReduce计算时,有时候需要把最终的输出数据分到不同的文件中,我们知道最终的输出数据是来自于ReducerTask。那么,如果要得到多个文件,意味着有同样数量的Reducer任务在运行。Reducer任务的数据来自于Mapper任务,也就说Mapper任务要划分数据,对于不同的数据分配给不同的Reducer任务运行。Mapper任务划分数据的过程就称作Partition。负责实现划分数据的类称作Partitioner

默认分区

默认分区是根据key的hashcode对ReduceTask个数(默认个数为1)取模得到的,用户无法控制某一个key存储到哪个分区,但是我们可以自定义分区方法
在这里插入图片描述

案例:自定义分区规则

1.增加一个分区类

由于分区是处理map阶段后的数据,所以泛型应该是map的输出类型

// 1.继承Partitioner
public class ProvincePartitioner extends Partitioner<Text, FlowBean> {
    @Override
    public int getPartition(Text text, FlowBean flowBean, int i) {

        //获取手机号前三位prePhone
        String phone = text.toString();
        String prePhone = phone.substring(0, 3);

        //定义一个分区号变量partition,根据prePhone设置分区号
        int partition;
        // 2.编写分区逻辑
        if("136".equals(prePhone)){
            partition = 0;
        }else if("137".equals(prePhone)){
            partition = 1;
        }else if("138".equals(prePhone)){
            partition = 2;
        }else if("139".equals(prePhone)){
            partition = 3;
        }else {
            partition = 4;
        }

        //最后返回分区号partition
        return partition;
    }
}

2.Driver类配置

// 指定自定义分区器
job.setPartitionerClass(ProvincePartitioner.class);

// 同时指定相应数量的ReduceTask,几个分区就指定几个
job.setNumReduceTasks(5);

可以看到生成了5个文件
在这里插入图片描述

  • ReduceTask个数为0,表示没有reduce阶段,输出文件个数和Map个数一致
  • ReduceTask = 1,则不管几个分区,根据源码,不会进行分区,所以只会生成一个文件(ReduceTask默认为1,所以输出文件个数为1)
  • ReduceTask < 分区数,报错
  • ReduceTask > 分区数,则多余的文件会是空文件

所以,ReduceTask的个数一定要与分区数保持一致,否则分区将不具有任何意义

6.4 排序

哪里发生了排序?

在MapReduce整个流程中,Shuffle阶段会进行两次排序

①从缓冲区快速排序后溢写到文件
②将多个溢写的文件归并排序后写入磁盘

Reduce阶段会将磁盘和内存属于同一个分区的数据也会进行归并排序

注意,我们是针对key进行排序

为什么费劲周折排序?

假如有以下不排序的数据

<a,1> <b,2><b,4><c,2><a,2><b,2>

我们最终进行ReduceTask时,数据将会按照相同的key进行reduce()方法,也就是一组相同的key的数据执行一次reduce()方法

不排序,我需要检测每一个key,来判断是否满足相同的key

第一个数据是<a,1>,我需要判断剩余的key是否为a,而且每一个都需要判断!

排序后

经过对key的排序后,假如得到了如下排序

例如<a,1> <a,2> <b,2><b,4><b,2><c,2>

我只检测当前的key和前面的key是否一样,到了<b,2>,发现和前面的key不一样,那么说明前面的(<a,1> <a,2>)是相同的key,直接统一进入reduce()方法,提高了效率

全排序案例

全排序定义:只输出一个文件,且根据某个属性有序

假如我根据总流量(上+下)进行排序
在这里插入图片描述

自定义序列化的类实现WritableComparable<E> 接口 ,E为要比较的对象,重写compareTo方法,由于Map输出的K为对象,Reduce阶段会对每一个相同的key进行处理,所以在Reduce阶段,根据谁排序,谁就是Key

比如我根据总流量进行排序,手机号123的总流量为200,手机号为234的总流量也为200,这时Reduce会对所有总流量为200的对象进行一次reduce方法

key为所有总流量为200的对象,values为所有总流量为200的手机号的集合

FlowBean key, Iterable<Text> values

自定义排序类

// 1.实现Writable接口
public class FlowBean implements Writable, WritableComparable<FlowBean> {
    private long upFlow; //上行流量
    private long downFlow; //下行流量
    private long sumFlow; //总流量

    // 2.反序列化,需要反射调用空参构造函数,所以必须有空参函数
    public FlowBean(){
    }
    // 3.重写序列化方法,注意!!!反序列化顺序要和序列化顺序完全一致!!!
    @Override
    public void write(DataOutput dataOutput) throws IOException {
        dataOutput.writeLong(upFlow);
        dataOutput.writeLong(downFlow);
        dataOutput.writeLong(sumFlow);
    }
    // 4.反序列化
    @Override
    public void readFields(DataInput dataInput) throws IOException {
        this.upFlow = dataInput.readLong();
        this.downFlow = dataInput.readLong();
        this.sumFlow = dataInput.readLong();
    }
    // 5.get set方法
    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;
    }

    public void setSumFlow() {
        this.sumFlow = upFlow + downFlow;
    }
    // 6.toString()方法
    @Override
    public String toString() {
        return
        "upFlow=" + upFlow +
        ", downFlow=" + downFlow +
        ", sumFlow=" + sumFlow ;
    }

    @Override
    public int compareTo(FlowBean o) {

        if(this.sumFlow > o.sumFlow){
            return -1;
        }else if(this.sumFlow < o.sumFlow){
            return 1;
        }else {
            return 0;
        }
    }
    /*
    * 7.Map<K1,V,K2,V>
    如果将自定义的bean放在K2传输,必须要实现Comparable接口,K2必须能排序
    */}

Mapper阶段

这里我根据序列化的类的总流量排序,那么key就是我这个序列化类

public class FlowMapper extends Mapper<LongWritable, Text,FlowBean, Text> {
    Text text = new Text(); // V
    FlowBean flowBean = new FlowBean(); // K
    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, FlowBean, Text>.Context context) throws IOException, InterruptedException {
        String string = value.toString();

        String []words = string.split("\t");

        String keyNumber = words[1];
        text.set(keyNumber);

        flowBean.setDownFlow(Long.parseLong(words[words.length - 3]));
        flowBean.setUpFlow(Long.parseLong(words[words.length - 2]));
        flowBean.setSumFlow();

        context.write(flowBean,text);
    }
}

Reducer阶段

public class FlowReduce extends Reducer<FlowBean, Text,Text, FlowBean> {
    @Override
    protected void reduce(FlowBean key, Iterable<Text> values, Reducer<FlowBean, Text, Text, FlowBean>.Context context) throws IOException, InterruptedException {

        for (Text value : values) {
            context.write(value,key);
        }
    }
}

Driver阶段

public class FlowDriver {
    public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException {
        // 1.获取job
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf);

        // 2.设置jar路径
        job.setJarByClass(FlowDriver.class);

        // 3.关联mapper和reducer
        job.setMapperClass(FlowMapper.class);
        job.setReducerClass(FlowReduce.class);

        // 4.设置map输出的kv类型
        job.setMapOutputKeyClass(FlowBean.class);
        job.setMapOutputValueClass(Text.class);

        // 5.设置最终输出的kv类型
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(FlowBean.class);

        // 6.设置输入路径和输出路径
        FileInputFormat.setInputPaths(job, new Path("F:\\input\\phone"));
        FileOutputFormat.setOutputPath(job, new Path("F:\\output3"));

        // 7.提交job
        boolean b = job.waitForCompletion(true);
        System.exit(b ? 0 : 1);
    }
}

二次排序就是在compareTo方法里再定义一个排序

区内排序案例

区内排序就是输出的每一个文件,文件内部有序

在全排序的基础上加一个分区即可

public class PrPartitioner extends Partitioner<FlowBean, Text> {


    @Override
    public int getPartition(FlowBean flowBean, Text text, int i) {
        //获取手机号前三位prePhone
        String phone = text.toString();
        String prePhone = phone.substring(0, 3);

        //定义一个分区号变量partition,根据prePhone设置分区号
        int partition;
        // 2.编写分区逻辑
        if("136".equals(prePhone)){
            partition = 0;
        }else if("137".equals(prePhone)){
            partition = 1;
        }else if("138".equals(prePhone)){
            partition = 2;
        }else if("139".equals(prePhone)){
            partition = 3;
        }else {
            partition = 4;
        }

        //最后返回分区号partition
        return partition;
    }
}

Driver指定分区器

// 指定自定义分区器
job.setPartitionerClass(PrPartitioner.class);

// 同时指定相应数量的ReduceTask,几个分区就指定几个
job.setNumReduceTasks(5);
6.5 Combiner合并

Combiner是处理经过MapTask处理后的数据,进行求和聚集,比如有100个<a,1>,进行Combiner合并后变成<a,100>,减少网络传输量

Combiner只应该用于那种Reduce的输入key/value与输出key/value类型完全一致,且不影响最终结果的场景。比如累加,最大值等,而求平均值就不能使用,结果错误

使用

1.创建一个类,实现Reduce,重写reduce方法
2.Driver指明驱动类
job.setCombinerClass(Combiner.class);

或者

将WordcountReducer作为Combiner在WordcountDriver驱动类中指定

6.6 OutputFormat数据输出

Reduce处理完成后,以何种方式写,怎么写,写到哪里,都是由OutputFormat决定的

默认是TextOutputFormat,按行去写,写到一个文件里面

自定义OutputFormat案例

需求:有如下网址,我们希望域名长度<= 4的输出到文件web1,长度大于4的输出到web2
在这里插入图片描述
Mapper

package com.gzhu.mapreduce.outputformat;

import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;

import java.io.IOException;

public class WebMapper extends Mapper<LongWritable, Text,Text, NullWritable> {
    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, Text, NullWritable>.Context context) throws IOException, InterruptedException {
        context.write(value,NullWritable.get());
    }
}

Reducer

public class WebReduce extends Reducer<Text, NullWritable,Text,NullWritable> {
    @Override
    protected void reduce(Text key, Iterable<NullWritable> values, Reducer<Text, NullWritable, Text, NullWritable>.Context context) throws IOException, InterruptedException {
        for (NullWritable value : values) {
            context.write(key,NullWritable.get());
        }
    }
}

自定义OutPutFormat

// Reduce阶段输出的K-V,实现FileOutputFormat
public class WebOutPutFormat extends FileOutputFormat<Text, NullWritable> {
    @Override
    public RecordWriter<Text, NullWritable> getRecordWriter(TaskAttemptContext job) throws IOException, InterruptedException {
        // 需要返回一个RecordWriter
        WebRecordWriter webRecordWriter = new WebRecordWriter(job);

        return webRecordWriter;
    }
}

RecordWriter

// Reduce阶段输出的K-V
public class WebRecordWriter extends RecordWriter<Text, NullWritable> {

    private FSDataOutputStream web1;
    private FSDataOutputStream web2;

    public WebRecordWriter(TaskAttemptContext job) {
        // 创建两条流
        try {
            FileSystem fs = FileSystem.get(job.getConfiguration());

            web1 = fs.create(new Path("F:\\output\\outputformat\\web1"));
            web2 = fs.create(new Path("F:\\output\\outputformat\\web2"));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    // 具体写
    @Override
    public void write(Text text, NullWritable nullWritable) throws IOException, InterruptedException {
        String str = text.toString();
        String []arr = str.split("\\.");
        if(arr[1].length() <= 4){
            web1.writeBytes(str + "\n");
        }else{
            web2.writeBytes(str + "\n");
        }

    }

    @Override
    public void close(TaskAttemptContext taskAttemptContext) throws IOException, InterruptedException {
        IOUtils.closeStream(web1);
        IOUtils.closeStream(web2);
    }
}

Driver

public class WebDriver {
    public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException {
        // 1.获取job
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf);

        // 2.设置jar路径
        job.setJarByClass(WebDriver.class);

        // 3.关联mapper和reducer
        job.setMapperClass(WebMapper.class);
        job.setReducerClass(WebReduce.class);

        // 4.设置map输出的kv类型
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(NullWritable.class);

        // 5.设置最终输出的kv类型
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(NullWritable.class);

        // 自定义OutputFormat
        job.setOutputFormatClass(WebOutPutFormat.class);

        // 6.设置输入路径和输出路径
        FileInputFormat.setInputPaths(job, new Path("F:\\input\\web"));
        // 这里注意一下,因为WebRecordWriter中指定了输出路径,这里指定的路径为SUCCESS文件输出路径,必须有
        FileOutputFormat.setOutputPath(job, new Path("F:\\output\\outputformat"));

        // 7.提交job
        boolean b = job.waitForCompletion(true);
        System.exit(b ? 0 : 1);
    }
}

链接: Hadoop — 从MySQL数据库读取数据,经MapReduce处理后,写入MySQL数据库

6.7 Join应用

ReduceJoin案例

基本思想

我们知道,Join两张表需要有一个公共字段,我们可以把这个公共字段设置为Map的输出key,输出value设置为序列化的类(这个类包含两张表的所有字段),但我们还需要一个额外的标记信息,以此来标明每一条数据来源与哪个表。Reduce端以连接字段作为key的分组已经完成,我们只需要在每一个分组当中将那些来源于不同文件的记录(在Map阶段已经打标志)分开,最后进行合并就ok了

  • Map输出key:公共字段
  • Map输出value:序列化类(包括两张表所有字段和一个额外的标记信息)
  • Redcue输出key:序列化类(按照要求输出所需要的字段)
  • Reduce输出value:NullWritable

需求:如图有如下的两张表

在这里插入图片描述

在这里插入图片描述
我们最终想得到id,pname,amount三列
在这里插入图片描述
TableBean
包括两张表的全部属性并且有一个额外的标记信息

public class TableBean implements Writable {
    private String id;
    private String pid;
    private int amount;
    private String pname;
    private String flag; // 标记来源哪个表
    // 空参构造
    public TableBean() {
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getPid() {
        return pid;
    }

    public void setPid(String pid) {
        this.pid = pid;
    }

    public int getAmount() {
        return amount;
    }

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

    public String getPname() {
        return pname;
    }

    public void setPname(String pname) {
        this.pname = pname;
    }

    public String getFlag() {
        return flag;
    }

    public void setFlag(String flag) {
        this.flag = flag;
    }

    @Override
    public void write(DataOutput dataOutput) throws IOException {
        dataOutput.writeUTF(id);
        dataOutput.writeUTF(pid);
        dataOutput.writeInt(amount);
        dataOutput.writeUTF(pname);
        dataOutput.writeUTF(flag);
    }

    @Override
    public void readFields(DataInput dataInput) throws IOException {
        this.id = dataInput.readUTF();
        this.pid = dataInput.readUTF();
        this.amount = dataInput.readInt();
        this.pname =  dataInput.readUTF();
        this.flag = dataInput.readUTF();
    }
	 @Override
	    public String toString() {
	        return id + "\t" + pname + "\t" + amount;
	    }
	}

Mapper

public class TableMapper extends Mapper<LongWritable, Text,Text,TableBean> {
    String fileName;
    Text text = new Text();
    TableBean tableBean = new TableBean();

    @Override
    protected void setup(Mapper<LongWritable, Text, Text, TableBean>.Context context) throws IOException, InterruptedException {
        // 提前获取分片的文件名字
        FileSplit split = (FileSplit) context.getInputSplit();

        fileName = split.getPath().getName();
    }

    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, Text, TableBean>.Context context) throws IOException, InterruptedException {
        String string = value.toString(); // 获取一行数据

        String[] split = string.split("\t");

        // 判断属于哪个文件
        if(fileName.equals("order")){
            text.set(split[1]); // Map输出key

            tableBean.setId(split[0]);
            tableBean.setPid(split[1]);
            tableBean.setAmount(Integer.parseInt(split[2]));
            tableBean.setPname("");
            tableBean.setFlag("order");
        }else{
            text.set(split[0]); // Map输出key

            tableBean.setId("");
            tableBean.setPid(split[0]);
            tableBean.setAmount(0);
            tableBean.setPname(split[1]);
            tableBean.setFlag("pd");
        }

        context.write(text,tableBean);
    }
}

Map阶段输出的数据应为

在这里插入图片描述

Reducer

在Hadoop中,Iterable values中所有的对象都是用的同一块内存地址!!!如果我们往集合中直接添加value,由于都是同一块地址,所以集合中只会添加一个元素!!!所以我们每次都创建一个新的对象,将目前这块地址的对象赋值给这个对象,就可以将所有的对象拿到添加到集合!!!

public class TableReduce extends Reducer<Text,TableBean,TableBean, NullWritable> {
    @Override
    protected void reduce(Text key, Iterable<TableBean> values, Reducer<Text, TableBean, TableBean, NullWritable>.Context context) throws IOException, InterruptedException {
        ArrayList<TableBean> tableBeans = new ArrayList<>();

        TableBean pdBean = new TableBean();

        for (TableBean value : values) {
            if("order".equals(value.getFlag())){
                // 说明是 01 1001 1 order这样的行数据

                TableBean tableBean = new TableBean();

                try {
                    BeanUtils.copyProperties(tableBean,value);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                } catch (InvocationTargetException e) {
                    e.printStackTrace();
                }

                tableBeans.add(tableBean);
            }else{
                try {
                    BeanUtils.copyProperties(pdBean,value);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                } catch (InvocationTargetException e) {
                    e.printStackTrace();
                }
            }
        }

        for (TableBean bean : tableBeans) {
            bean.setPname(pdBean.getPname());

            context.write(bean,NullWritable.get());
        }
    }
}

Driver

public class TableDriver {
    public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException {
        // 1.获取job
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf);

        // 2.设置jar路径
        job.setJarByClass(TableDriver.class);

        // 3.关联mapper和reducer
        job.setMapperClass(TableMapper.class);
        job.setReducerClass(TableReduce.class);

        // 4.设置map输出的kv类型
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(TableBean.class);

        // 5.设置最终输出的kv类型
        job.setOutputKeyClass(TableBean.class);
        job.setOutputValueClass(NullWritable.class);


        // 6.设置输入路径和输出路径
        FileInputFormat.setInputPaths(job, new Path("F:\\input\\inputtable"));
        FileOutputFormat.setOutputPath(job, new Path("F:\\output\\outputtablen"));

        // 7.提交job
        boolean b = job.waitForCompletion(true);
        System.exit(b ? 0 : 1);
    }
}

通过控制toString()方法来控制输出的属性

在这里插入图片描述

MapJoin案例

合并的操作是在Reduce阶段完成,Reduce端的处理压力太大,Map节点的运算负载则很低,资源利用率不高,且在Reduce阶段极易产生数据倾斜,比如某个key的数据比较多

使用场景

Map Join适用于一张表十分小、一张表很大的场景,小表缓存到内存中

Mapper

package com.gzhu.mapreduce.mapjoin;

import org.apache.commons.lang.StringUtils;
import org.apache.hadoop.fs.FSDataInputStream;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IOUtils;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;


import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URI;
import java.util.HashMap;

public class MapJoinMapper extends Mapper<LongWritable, Text,Text, NullWritable> {
    private HashMap<String, String> map = new HashMap<>();
    private Text text = new Text();
    @Override
    protected void setup(Mapper<LongWritable, Text, Text, NullWritable>.Context context) throws IOException, InterruptedException {
        // 获取缓存文件,并把文件内容封装到集合
        URI[] files = context.getCacheFiles();

        FileSystem fs = FileSystem.get(context.getConfiguration());
        FSDataInputStream fis = fs.open(new Path(files[0]));

        // 从流中读数据
        BufferedReader reader = new BufferedReader(new InputStreamReader(fis, "UTF-8"));

        // 读取的是一行
        String line;
        while(StringUtils.isNotEmpty(line = reader.readLine())){
            // 切割
            String[] split = line.split("\t");

            map.put(split[0],split[1]);
        }

        // 关流
        IOUtils.closeStream(reader);
    }

    @Override
    protected void map(LongWritable key, Text value, Mapper<LongWritable, Text, Text, NullWritable>.Context context) throws IOException, InterruptedException {
        String[] fields = value.toString().split("\t");

        //通过大表每行数据的pid,去map里面取出pname
        String pname = map.get(fields[1]);

        //将大表每行数据的pid替换为pname
        text.set(fields[0] + "\t" + pname + "\t" + fields[2]);

        //写出
        context.write(text,NullWritable.get());
    }
}

Driver

package com.gzhu.mapreduce.mapjoin;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

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

public class MapJoinDriver {
    public static void main(String[] args) throws IOException, InterruptedException, ClassNotFoundException, URISyntaxException {
        // 1.获取job
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf);

        // 2.设置jar路径
        job.setJarByClass(MapJoinDriver.class);

        // 3.关联mapper和reducer
        job.setMapperClass(MapJoinMapper.class);

        // 4.设置map输出的kv类型
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(NullWritable.class);

        // 5.设置最终输出的kv类型
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(NullWritable.class);

        // 加载缓存数据
        job.addCacheFile(new URI("file:///F:/input/pdcache/pd.txt"));
        // 不需要reduce
        job.setNumReduceTasks(0);

        // 6.设置输入路径和输出路径
        FileInputFormat.setInputPaths(job, new Path("F:\\input\\inputtable"));
        FileOutputFormat.setOutputPath(job, new Path("F:\\output\\mapjoin"));

        // 7.提交job
        boolean b = job.waitForCompletion(true);
        System.exit(b ? 0 : 1);
    }
}

说一说Map端和Reduce端的JOIN

Map端适用于小表join小表或者小表join大表,通常是把一张小表加载到缓存,通过setup方法提前把小表的属性放入map中,key是要join的字段

这样再读取大表,通过大表的join字段去查询只有小表中有的属性,进行合并就可以了

Reduce端适合大表join大表,我们可以把这个公共字段设置为Map的输出key,输出value设置为序列化的类(这个类包含两张表的所有字段),但我们还需要一个额外的标记信息,以此来标明每一条数据来源与哪个表。Reduce端以连接字段作为key的分组已经完成,根据flag来区分两张表,一张表tab1放入list集合,另一张表只需要一个对象A即可,然后遍历集合,用单独的对象A去填充tab1缺失的属性

6.8 总结

1.输入数据接口:InputFormat
(1)默认使用的实现类是:TextInputFormat

(2)TextInputFormat的功能逻辑是:一次读一行文本,然后将该行的起始偏移量作为key,行内容作为value返回

(3)CombineTextInputFormat可以把多个小文件合并成一个切片处理,提高处理效率

2.逻辑处理接口:Mapper

用户根据业务需求实现其中三个方法:

  • setup() 初始化
  • map() 业务逻辑
  • cleanup () 关闭资源

3.Partitioner分区

(1)有默认实现 HashPartitioner,逻辑是根据key的哈希值和numReduces来返回一个分区号;key.hashCode()&Integer.MAXVALUE % numReduces

(2)如果业务上有特别的需求,可以自定义分区

4.Comparable排序
(1)当我们用自定义的对象作为key来输出时,就必须要实现WritableComparable接口,重写其中的compareTo()方法

(2)部分排序:对最终输出的每一个文件进行内部排序

(3)全排序:对所有数据进行排序,通常只有一个Reduce

(4)二次排序:排序的条件有两个

5.Combiner合并

Combiner合并可以提高程序执行效率,减少IO传输。但是使用时必须不能影响原有的业务处理结果,提前聚合map,在Map阶段实现聚合,减轻Reduce端压力

6.逻辑处理接口:Reducer
用户根据业务需求实现其中三个方法:

  • setup()
  • reduce()
  • cleanup ()

7.输出数据接口:OutputFormat

(1)默认实现类是TextOutputFormat,功能逻辑是:将每一个KV对,向目标文本文件输出一行
(2)可以自定义OutputFormat

7.小文件问题

小文件如何产生的

  • 动态分区插入数据,可能产生大量的小文件

  • reduce 数量越多,小文件也越多,reduce 的个数和输出文件个数一致;

  • 数据源本身就是大量的小文件;

危害

  • 元数据存储在NameNode的内存中,大量的小文件会耗尽NameNode中的大部分内存
  • 数据切片是以文件为单位的,小文件多,数据切片多,会导致开启过多的MapTask

解决办法

1.Hadoop Archive(HAR) 是一个高效地将小文件放入HDFS块中的文件存档工具,它能够将多个小文件打包成一个HAR文件,这样在减少namenode内存使用的同时,仍然允许对文件进行透明的访问。

2.Sequence file由一系列的二进制key/value组成,如果为key小文件名,value为文件内容,则可以将大批小文件合并成一个大文件。

记录压缩只压缩value。块压缩key和value都压缩。

3.CombineFileInputFormat是一种新的inputformat,用于将多个文件合并成一个单独的split,在map和reduce处理之前组合小文件。

4.压缩

压缩的好处和坏处

压缩的优点:以减少磁盘IO、减少磁盘存储空间

压缩的缺点:增加CPU开销

压缩原则

  • 运算密集型的Job,少用压缩
  • IO密集型的Job,多用压缩

压缩位置选择
在这里插入图片描述
Map输出端压缩

即使你的MapReduce的输入输出文件都是未压缩的文件,你仍然可以对Map任务的中间结果输出做压缩,因为它要写在硬盘并且通过网络传输到Reduce节点,对其压缩可以提高很多性能,这些工作只要设置两个属性即可

// 开启map端输出压缩
conf.setBoolean("mapreduce.map.output.compress", true);

// 设置map端输出压缩方式
conf.setClass("mapreduce.map.output.compress.codec", BZip2Codec.class, CompressionCodec.class);

Reduce输出端压缩

// 设置reduce端输出压缩开启
FileOutputFormat.setCompressOutput(job, true);

// 设置压缩的方式
FileOutputFormat.setOutputCompressorClass(job, BZip2Codec.class); 
//	FileOutputFormat.setOutputCompressorClass(job, GzipCodec.class); 
//	FileOutputFormat.setOutputCompressorClass(job, DefaultCodec.class); 

5.JVM重用

JVM正常指代一个Java进程,Hadoop默认使用派生的JVM来执行map-reducer,如果一个MapReduce程序中有100个Map,10个Reduce,Hadoop默认会为每个Task启动一个JVM来运行,那么就会启动100个JVM来运行MapTask,在JVM启动时内存开销大,尤其是Job大数据量情况,如果单个Task数据量比较小,也会申请JVM资源,这就导致了资源紧张及浪费的情况
为了解决上述问题,MapReduce中提供了JVM重用机制来解决,JVM重用可以使得JVM实例在同一个job中重新使用N次,当一个Task运行结束以后,JVM不会进行释放,而是继续供下一个Task运行,直到运行了N个Task以后,就会释放,N的值可以在Hadoop的mapred-site.xml文件中进行配置,通常在10-20之间。

  • 2
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Jumanji_

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

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

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

打赏作者

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

抵扣说明:

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

余额充值