大数据Hadoop学习(三)MapReduce

MapReduce概述

定义

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

优缺点

  • 优点:
    • 易于编程:简单的实现一些接口,就可以完成一个分布式程序。
    • 良好的扩展性:当你的计算机资源不能得到满足的时候,可以通过简单的增加机器来扩展它的计算能力。
    • 高容错性:假设一台机器挂了,它可以把上面的计算任务转移到到另一个正常的节点上运行。
    • 适合PB级以上海量数据的离线处理
  • 缺点:
    • 不擅长实时计算:MapReduce无法像MySQL一样,在毫秒或者描记内返回结果
    • 不擅长流式计算:流式计算的输入数据是动态的,而MapReduce的输入数据集是静态的,不能动态变化的。
    • 不擅长DAG计算:多个应用程序存在依赖关系,后一个应用程序的输入为前一个的输出。在这种情况下,MapReduce 并不是不能做,而是使用后,每个MapReduce 作业的输出结果都会写入到磁盘,会造成大量的磁盘 IO,导致性能非常的低下。

核心思想

  1. MapReduce运行程序一般需要分成2个阶段:Map阶段和Reduce阶段
  2. Map阶段的并发MapTask,完全并行运行,互不相干。并行运行主要是将数据源分块进行计算,就比如任务一就计算数据源前100M的数据,任务二就计算数据源后100M的数据
  3. Reduce阶段的并发ReduceTask,完全互不相干,但是他们的数据依赖于上一个阶段的输出
  4. MapReduce编程模型只能包含一个Map阶段和一个Reduce阶段,如果用户业务逻辑复杂,那就只能多个MapReduce程序,串行运行
    在这里插入图片描述

进程

  • 一个完整的 MapReduce 程序在分布式运行时有三类实例进程:
    • MrAppMaster:负责整个程序的过程调度及状态协调。
    • MapTask:负责 Map 阶段的整个数据处理流程。
    • ReduceTask:负责 Reduce 阶段的整个数据处理流程。

常用数据序列化类型

在这里插入图片描述

MapReduce编程规范

  1. Mapper阶段
    • 用户自定义的Mapper要继承自己的父类
    • Mapper的输入数据是KV对的形式(KV的类型可自定义)
    • Mapper中的业务逻辑写在map()方法中
    • Mapper的输出数据是KV对的形式(KV的类型可自定义)
    • map()方法(MapTask进程)对每一个<K,V>调用一次
  2. Reducer阶段
    • 用户自定义的Reducer要继承自己的父类
    • Reducer的输入数据类型对应Mapper的输出数据类型,也是KV
    • Reducer的业务逻辑写在reduce()方法中
    • ReduceTask进程对每一组相同k的<k,v>组调用一次reduce()方法
  3. Driver阶段
    • 相当于YARN集群的客户端,用于提交我们整个程序到YARN集群,提交的是
      封装了MapReduce程序相关运行参数的job对象

实操

  1. 首先通过图来简单演示一下本地案例
    在这里插入图片描述

搭建环境

  1. 创建maven工程,MapReduceDemo
  2. 在pom.xml文件中添加依赖:
<dependencies>
 <dependency>
 <groupId>org.apache.hadoop</groupId>
 <artifactId>hadoop-client</artifactId>
 <version>3.1.3</version>
 </dependency>
 <dependency>
 <groupId>junit</groupId>
 <artifactId>junit</artifactId>
 <version>4.12</version>
 </dependency>
 <dependency>
 <groupId>org.slf4j</groupId>
 <artifactId>slf4j-log4j12</artifactId>
 <version>1.7.30</version>
 </dependency>
</dependencies>
  1. 在项目的 src/main/resources 目录下,新建一个文件,命名为“log4j.properties”,在文件中填入。
    在这里插入图片描述
log4j.rootLogger=INFO, stdout 
log4j.appender.stdout=org.apache.log4j.ConsoleAppender 
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout 
log4j.appender.stdout.layout.ConversionPattern=%d %p [%c] - %m%n 
log4j.appender.logfile=org.apache.log4j.FileAppender 
log4j.appender.logfile.File=target/spring.log 
log4j.appender.logfile.layout=org.apache.log4j.PatternLayout 
log4j.appender.logfile.layout.ConversionPattern=%d %p [%c] - %m%n

编写程序

  1. 编写WordCountMapper类,下图是mapper类的相关介绍,代码是我们需要重写的部分
    在这里插入图片描述
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,map阶段输入的KEY的类型:LongWritable 是偏移量
//VALUEIN,map阶段输入的VALUE的类型,Text
//KEYOUT, map阶段输出的KEY类型:TEXT 标识单词
//VALUEOUT,map阶段输出的VALUE类型,IntWritable 单词次数
public class WordCountMapper extends Mapper<LongWritable, Text, Text, IntWritable> {

    //创建封装类
    private Text outKey = new Text();
    private IntWritable intWritable = new IntWritable(1);

    //重写方法
    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
        //1. 获取一行数据
        String line = value.toString();

        //2. 切割数据
        String[] words = line.split(" ");

        //3. 循环写出
        for (String word: words) {
            //4. 封装数据
            outKey.set(word);
            System.out.println("map阶段的Key:" + outKey);
            context.write(outKey, intWritable);
        }
    }
}
  1. 编写WordCountReducer类,下图是源码解析,代码是自己重写的业务逻辑
    在这里插入图片描述
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;

import java.io.IOException;

//KEYIN,reduce阶段输入的KEY的类型:Text
//VALUEIN,reduce阶段输入的VALUE的类型,IntWritable
//KEYOUT, reduce阶段输出的KEY类型:TEXT 标识单词
//VALUEOUT,reduce阶段输出的VALUE类型,IntWritable 单词次数
public class WordCountReducer extends Reducer<Text, IntWritable, Text, IntWritable> {

    //创建封装类型
    private IntWritable intWritable = new IntWritable();
    
    @Override
    protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
        //创建累加的值
        int sum = 0;

        //循环累加
        for (IntWritable value:values) {
            sum += value.get();
        }
        
        //封装数据
        intWritable.set(sum);
        context.write(key,intWritable);
    }
}
  1. 编写WordCountDriver类
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 WordCountDriver {

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

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

        //3.关联mapper和reducer
        job.setMapperClass(WordCountMapper.class);
        job.setReducerClass(WordCountReducer.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:\\input"));
        FileOutputFormat.setOutputPath(job,new Path("D:\\output"));

        //7.提交job
        boolean result = job.waitForCompletion(true);
        System.exit(result ? 0: 1);
    }
}
  1. 测试一下
    在这里插入图片描述
    在这里插入图片描述

集群运行程序

  1. 在pom.xml文件上配置打包插件
<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>
  1. 将程序打包成JAR包
    在这里插入图片描述

  2. 修改路径,因为windows有路径,但是linux没有,所以需要修改
    在这里插入图片描述

  3. 再重新打包,通过如下操作找到jar包
    在这里插入图片描述
    在这里插入图片描述

  4. 将jar上传到服务器
    在这里插入图片描述

  5. 使用命令hadoop jar wc.jar com.lwb.mapreduceDemo.wordcount.WordCountDriver /wcinput /output

Hadoop序列化

序列化概述

  1. 什么是序列化?
    • 序列化就是把内存中的对象转换成字节序列以便于存储到磁盘和网络传输。
    • 反序列化就是将收到字节序列或者是磁盘的持久化数据转换成内存中的对象
  2. 为什么要序列化?
    • 一般来说,“活的”对象只生存在内存里,关机断电就没有了。而且“活的”对象只能由本地的进程使用,不能被发送到网络上的另外一台计算机。 然而序列化可以存储“活的”对象,可以将“活的”对象发送到远程计算机。
  3. 为什么不使用JAVA的序列化
    • Java 的序列化是一个重量级序列化框架(Serializable),一个对象被序列化后,会附带很多额外的信息(各种校验信息,Header,继承体系等),不便于在网络中高效传输。所以,Hadoop 自己开发了一套序列化机制(Writable)。
  4. Hadoop序列化特点:
    • 紧凑:高效使用存储空间
    • 快速:读写数据的额外开销小
    • 互操作:支持多语言的交互

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

  • 具体实现bean对象序列化步骤有7步:
  1. 必须实现Writable接口
  2. 反序列化,需要反射调用空参构造函数,所以必须有空参构造
  3. 重写序列化方法
  4. 重写反序列化方法
  5. 如果需要把想要的结果显示到文件中,需要重写tostring()方法,可用"\t"分开,方便后续用。
  6. 如果需要将自定义的bean放在key钟传输,则需要实现Comparable接口,因为MapReduce框中的shuffle过程要求对key必须能排序。

序列化实操

  1. 首先我们需要假设一个需求,统计每一个手机号耗费的总上行流量,总下行流量,总流量,数据如下
1	13736230513	192.196.100.1	www.atguigu.com	2481	24681	200
2	13846544121	192.196.100.2			264	0	200
3 	13956435636	192.196.100.3			132	1512	200
4 	13966251146	192.168.100.1			240	0	404
5 	18271575951	192.168.100.2	www.atguigu.com	1527	2106	200
6 	84188413	192.168.100.3	www.atguigu.com	4116	1432	200
7 	13590439668	192.168.100.4			1116	954	200
8 	15910133277	192.168.100.5	www.hao123.com	3156	2936	200
9 	13729199489	192.168.100.6			240	0	200
10 	13630577991	192.168.100.7	www.shouhu.com	6960	690	200
11 	15043685818	192.168.100.8	www.baidu.com	3659	3538	200
12 	15959002129	192.168.100.9	www.atguigu.com	1938	180	500
13 	13560439638	192.168.100.10			918	4938	200
14 	13470253144	192.168.100.11			180	180	200
15 	13682846555	192.168.100.12	www.qq.com	1938	2910	200
16 	13992314666	192.168.100.13	www.gaga.com	3008	3720	200
17 	13509468723	192.168.100.14	www.qinghua.com	7335	110349	404
18 	18390173782	192.168.100.15	www.sogou.com	9531	2412	200
19 	13975057813	192.168.100.16	www.baidu.com	11058	48243	200
20 	13768778790	192.168.100.17			120	120	200
21 	13568436656	192.168.100.18	www.alibaba.com	2481	24681	200
22 	13568436656	192.168.100.19			1116	954	200
  1. 开始写流量统计的bean对象
    • 定义类实现writable接口
    • 重写序列化和反序列化方法
    • 重写空参构造
    • tostring方法
/*
    1. 定义类实现writable接口
    2. 重写序列化和反序列化方法
    3. 重写空参构造
    4. tostring方法
 */
public class FlowBean implements Writable {

    //上行流量
    private long upFlow;
    //下行流量
    private long downFlow;
    //总流量
    private long sumFlow;

    //重写空参构造
    public FlowBean() {
    }

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

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

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

    public long getUpFlow() {
        return upFlow;
    }

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

    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 = this.upFlow + this.downFlow;
    }
}
  1. 编写Mapper类进行计算
public class FlowMapper extends Mapper<LongWritable, Text, Text, FlowBean> {

    Text outKey = new Text();
    FlowBean outVal = new FlowBean();
    
    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
        //1.获取一行
        //1	13736230513	192.196.100.1	www.atguigu.com	2481	24681	200
        //2	13846544121	192.196.100.2			264	0	200
        String line = value.toString();

        //2.切割
        //1	13736230513	192.196.100.1	www.atguigu.com	2481	24681	200
        //2	13846544121	192.196.100.2			264	0	200
        String[] split = line.split("\t");

        //3.抓取想要的数据
        //1	13736230513	192.196.100.1	www.atguigu.com	2481	24681	200
        //2	13846544121	192.196.100.2			264	0	200
        String phone = split[1];
        String upFlow = split[split.length - 3];
        String downFlow = split[split.length - 2];
        
        //4.封装数据
        outKey.set(phone);
        outVal.setUpFlow(Long.parseLong(upFlow));
        outVal.setDownFlow(Long.parseLong(downFlow));
        outVal.setSumFlow();
        
        //5.写入
        context.write(outKey,outVal);
    }
}
  1. 然后编写reducer类
public class FlowReducer extends Reducer<Text, FlowBean, Text, FlowBean> {

    private FlowBean outVal = new FlowBean();

    @Override
    protected void reduce(Text key, Iterable<FlowBean> values, Context context) throws IOException, InterruptedException {

        //1. 遍历集合进行累加
        long totalUp = 0;
        long totaldown = 0;
        for(FlowBean value:values){
            totalUp += value.getUpFlow();
            totaldown += value.getDownFlow();
        }

        //2. 封装数据
        outVal.setUpFlow(totalUp);
        outVal.setDownFlow(totaldown);
        outVal.setSumFlow();

        //3. 写入数据
        context.write(key,outVal);
    }
}
  1. 编写driver类
public class FlowDriver {
    public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
        //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(FlowReducer.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("D:\\input"));
        FileOutputFormat.setOutputPath(job,new Path("D:\\output3"));
//        FileInputFormat.setInputPaths(job,new Path(args[0]));
//        FileOutputFormat.setOutputPath(job,new Path(args[1]));

        //7.提交JOB
        boolean result = job.waitForCompletion(true);
        System.exit(result ? 0: 1);
    }
}
  1. 测试一下
    在这里插入图片描述

MapReduce框架原理

在这里插入图片描述

切片与MapTask并行度决定机制

  1. 问题引出:
    • MapTask的并行度决定Map阶段的任务处理并发度,进而影响到整个Job的处理速度。
    • 那假设1G的数据,启动8个MapTask任务,可以提高集群的并发处理能力,那1k的数据,如果也启动8个MapTask,会提高集群性能吗?如果不能,那MapTask启动多少个比较合适呢,究竟是什么因素影响了MapTask并行度呢?
  2. MapTask并行度决定机制
    • 数据块:Block 是 HDFS 物理上把数据分成一块一块。数据块是 HDFS 存储数据单位。
    • 数据切片:数据切片只是在逻辑上对输入进行分片,并不会在磁盘上将其切分成片进行存储。数据切片是 MapReduce 程序计算输入数据的单位,一个切片会对应启动一个 MapTask。
  3. 我们通过两个假设来对比来解释数据切片跟MapTask并行度的决定机制
    • 首先,我们假设切片大小为100M,但是每个服务器节点的数据块为128M
      在这里插入图片描述
  • 可以看出,如果每个切片固定是100M,则每个MapTask任务获取数据不仅要从本地获取,还要从别的服务器里面获取,大大加大了成本,影响了性能
  • 那要如何做呢,假设我们MapTask获取任务就只获取我们本地的数据块,也就是获取本地128的数据进行任务,那效率会不会好多了呢,所以我们得出一下四个结论
    • 一个Job的Map阶段并行度由客户端在提交Job时的切片数决定
    • 每一个Split切片分配一个MapTask并行实例处理
    • 默认情况下,切片大小=BlockSize
    • 切片时不考虑数据集整体,而是逐个针对每一个文件单独切片(意思是如果再加一个文件100M的数据,不需要100+300M再进行计算,只需要计算300M的数据后,再继续计算100M的数据,不需要合并)

JOB提交流程源码

  1. 首先第一步我们需要打断点,再用debug进入
    在这里插入图片描述
  2. 点击强制进入,进到方法里面

在这里插入图片描述

  1. 进入submit方法
    在这里插入图片描述

  2. 首先解释ensureState方法
    在这里插入图片描述

  3. 接着看setNewApi方法
    在这里插入图片描述

  4. connect方法
    在这里插入图片描述在这里插入图片描述

    • 初始化这个方法主要是判断是本地运行环境还是 yarn 集群运行环境
      在这里插入图片描述在这里插入图片描述
  5. 进入submitJobInternal方法,这才是真正的提交方法,前面的只是为了初始化

    • checkSpecs方法主要是为了验证输出路径的方法
      在这里插入图片描述
      在这里插入图片描述
      在这里插入图片描述
    • 随着方法接着执行,一直执行到getStagingDir方法,会发现创建给集群提交数据的 Stag 路径
      在这里插入图片描述在这里插入图片描述
    • 执行到getNewJobID方法,发现创建了一个JOB路径,但是还没真正执行
      在这里插入图片描述
    • 一直执行到 copyAndConfigureFiles 方法,进去看看,这个方法主要是提取jar包,如果是本地模式就不会提取jar包,如果是集群模式就会
      在这里插入图片描述
      在这里插入图片描述
      在这里插入图片描述
      * 创建一个集群文件夹
      在这里插入图片描述
      在这里插入图片描述
      • 执行到 writeSplits 方法,这个方法主要是数据切片
        在这里插入图片描述
        在这里插入图片描述
        在这里插入图片描述
      • 运行到下面那个方法
        在这里插入图片描述
      • 执行到writeConf方法,执行完这个方法多了两个文件,文件打开展示的是job参数设置,
        在这里插入图片描述
        在这里插入图片描述
      • 执行到 submitJob 方法,提交 Job,返回提交状态
        在这里插入图片描述
  6. 最后执行到返回方法结束
    在这里插入图片描述

  7. 其实主要大体的步骤主要是:

waitForCompletion()
submit();
// 1 建立连接
connect();
// 1)创建提交 Job 的代理
new Cluster(getConfiguration());
// (1)判断是本地运行环境还是 yarn 集群运行环境
initialize(jobTrackAddr, conf); 
// 2 提交 job
submitter.submitJobInternal(Job.this, cluster)
// 1)创建给集群提交数据的 Stag 路径
Path jobStagingArea = JobSubmissionFiles.getStagingDir(cluster, conf);
// 2)获取 jobid ,并创建 Job 路径
JobID jobId = submitClient.getNewJobID();
// 3)拷贝 jar 包到集群
copyAndConfigureFiles(job, submitJobDir);
rUploader.uploadFiles(job, jobSubmitDir);
// 4)计算切片,生成切片规划文件
writeSplits(job, submitJobDir);
maps = writeNewSplits(job, jobSubmitDir);
input.getSplits(job);
// 5)向 Stag 路径写 XML 配置文件
writeConf(conf, submitJobFile);
conf.writeXml(out);
// 6)提交 Job,返回提交状态
status = submitClient.submitJob(jobId, submitJobDir.toString(),
job.getCredentials());
  1. Job任务提交流程
    在这里插入图片描述

FileInputFormat切片源码

  • 主要研究的是 input.getSplits(job) 方法
  1. 首先跟上面一样,从waitForCompletion() --》submit() --》submitJobInternal() --》writeSplits() --》writeNewSplits() --》getSplits(),到达这个方法了
    在这里插入图片描述
  2. 开始执行方法,执行到long maxSize = getMaxSplitSize(job);我们发现上面的minSize大小是1,先来探索一下这个值是怎么来的
    • getFormatMinSplitSize() 方法返回的就是1L
      在这里插入图片描述
    • getMinSplitSize()方法
      在这里插入图片描述
      在这里插入图片描述
  3. 向下执行,执行完long maxSize = getMaxSplitSize(job);方法,但是maxsize没有获取到,所以就查看一下这个方法
    在这里插入图片描述
    在这里插入图片描述
  4. 随后一直执行进入循环
    在这里插入图片描述
  5. 执行到if (this.isSplitable(job, path)) {判断中,这个方法主要是看这个文件是否支持切割,进入代码块中,其中long blockSize = file.getBlockSize();获取的是数据块大小,long splitSize = this.computeSplitSize(blockSize, minSize, maxSize);获取的是切片大小,因为我们是在本地运行,所以统一都是32M
    在这里插入图片描述
  6. 进入到computeSplitSize()方法,看一下如何确定切割块大小
    在这里插入图片描述
  7. 随后开始循环切割
    在这里插入图片描述
  8. 一直执行进行收尾工作,回到writeNewSplits()方法,一直运行到createSplitFiles()方法,这个方法是为了形成切片文件
    在这里插入图片描述
  9. 然后运行结束
  10. 下面理一下切面原理整个流程做了啥
(1)程序先找到你数据存储的目录。
(2)开始遍历处理(规划切片)目录下的每一个文件
(3) 遍历第一个文件ss.txt
		a)获取文件大小fs.sizeOf(ss.txt)
		b)计算切片大小 computeSplitSize(Math.max(minSize,Math.min(maxSize,blocksize)))=blocksize=128M
		c)默认情况下,切片大小=blocksize
		d)开始切,形成第1个切片:ss.txt—0:128M 第2个切片ss.txt—128:256M 第3个切片ss.txt—256M:300M(每次切片时,都要判断切完剩下的部分是否大于块的1.1倍,不大于1.1倍就划分一块切片)
		e)将切片信息写到一个切片规划文件中
		f)整个切片的核心过程在getSplit()方法中完成
		g)InputSplit只记录了切片的元数据信息,比如起始位置、长度以及所在的节点列表等。
(4)提交切片规划文件到YARN上,YARN上的MrAppMaster就可以根据切片规划文件计算开启MapTask个数。
  1. FileInputFormat切片机制
    • 简单地按照文件的内容长度进行切片
    • 切片大小,默认等于Block大小
    • 切片时不考虑数据集整体,二十逐个针对每一个文件单独切片
  2. 在运行MapReduce程序时,输入的文件格式包括:基于行的日志文件,二进制格式文件,数据库表等。针对不同的数据类型,MapReduce如何读取这些数据?

TextInputFormat

  • TextInputFormat是默认的FileInputFormat实现类。按行读取每条记录。键(Key)是存储该行在整个文件中的起始字节偏移量,LongWritable类型。值是这行的内容,不包括任何行终止符,Text类型。
    在这里插入图片描述

CombineTextInputFormat

  • 框架默认的 TextInputFormat 切片机制是对任务按文件规划切片,不管文件多小,都会是一个单独的切片,都会交给一个 MapTask,这样如果有大量小文件,就会产生大量的MapTask,处理效率极其低下,所以需要CombineTextInputFormat切片机制来改变
  • CombineTextInputFormat切片机制:生成切片过程包括:虚拟存储过程和切片过程二部分
    • 判断虚拟内存文件大小是否大于setMaxInputSplitSize的值,大于等于则单独形成一个切片
    • 如果不大于则跟下一个虚拟存储文件进行合并共同形成一个切片。
      在这里插入图片描述

MapReduce工作流程

  • 流程一 Map阶段
    在这里插入图片描述
  • 流程二 Reducer阶段
    在这里插入图片描述

Shuffle机制

  • 从上面的流程可以了解这大概是MapReduce的工作流程,其中第7步到第16步结束,有一个专属的名字,叫Shufflc过程:
    • MapTask 收集我们的 map()方法输出的 kv 对,放到内存缓冲区中
    • 从内存缓冲区不断溢出本地磁盘文件,可能会溢出多个文件
    • 多个溢出文件会被合并成大的溢出文件
    • 在溢出过程及合并的过程中,都要调用 Partitioner 进行分区和针对 key 进行排序
    • ReduceTask 根据自己的分区号,去各个 MapTask 机器上取相应的结果分区数据
    • ReduceTask 会抓取到同一个分区的来自不同 MapTask 的结果文件,ReduceTask 会将这些文件再进行合并(归并排序)
    • 合并成大文件后,Shuffle 的过程也就结束了,后面进入 ReduceTask 的逻辑运算过程(从文件中取出一个一个的键值对 Group,调用用户自定义的 reduce()方法)
  • 也就是说Map方法之后,Reduce方法之前的数据处理过程就称之为Shuffle。
    在这里插入图片描述

Partiton分区

  • 思考一下,有个问题,如何将已经计算好的数据按照条件输出到不同文件中,也就是不同的分区中,比如:将统计结果按照果按照手机归属地不同省份输出到不同文件中(分区)

  • 解决方法肯定就是使用Partiton分区进行重新分组

  • 默认Partiton分区:用来指定map输出的key交给哪个reuducer处理了,默认是通过对map输出的key取hashcode对指定的reduce个数取余
    在这里插入图片描述

  • 自定义Partiton分区:

    • 自定义类继承Partitioner,重写getPartition()方法
      在这里插入图片描述

    • 在Job驱动中,设置自定义Partitioner
      在这里插入图片描述

    • 自定义Partition后,要根据自定义Partitioner的逻辑设置相应数量的ReduceTask
      在这里插入图片描述

  • Partiton分区总结:

    • 如果ReduceTask的数量> getPartition的结果数,则会多产生几个空的输出文件part-r-000xx;
    • 如果1<ReduceTask的数量<getPartition的结果数,则有一部分分区数据无处安放,会Exception;
    • 如果ReduceTask的数量=1,则不管MapTask端输出多少个分区文件,最终结果都交给这一个ReduceTask,最终也就只会产生一个结果文件 part-r-00000;

WritableComparable排序

  • 排序概述:
    • MapTask和ReduceTask均会对数据按照Key进行排序。该操作属于Hadoop的默认行为。任何应用程序中的数据均会被排序,而不管逻辑上是否需要。
    • 默认排序是按照字典顺序排序,且实现该排序的方法是快速排序
    • MapTask来说,它会将处理的结果暂时存放到环形缓冲区中,当环形缓冲区使用率到达一定阈值后,再对缓冲区中的数据进行一次快速排序,并将这些有序数据溢写到磁盘上,而当数据处理完毕后,它会对磁盘上所有文件进行归并排序。
    • ReduceTask来说,它从每个MapTask上远程拷贝相应的数据文件
      • 如果文件大小超过一定阈值,则溢写磁盘上,否则存储在内存中
      • 如果磁盘上文件数目达到一定阈值,则进行一次归并排序以生成一个更大文件
      • 如果内存中文件大小或者数目超过一定阈值,则进行一定合并后将数据溢写到磁盘上。
      • 当所有数据拷贝完毕后,ReduceTask统一对内存和磁盘上的所有数据进行一次归并排序
  • 排序分类:
    • 部分排序:MapReduce根据输入记录的键对数据集排序。保证输出的每个文件内部有序。
    • 全排序:最终输出结果只有一个文件,且文件内部有序。实现方式是只设置一个ReduceTask。但该方法在处理大型文件时效率极低,因为一台机器处理所有文件,完全丧失了MapReduce所提供的并行架构。
    • 辅助排序:(GroupingComparator分组):在Reduce端对key进行分组。应用于:在接收的key为bean对象时,想让一个或几个字段相同(全部字段比较不相同)的key进入到同一个reduce方法时,可以采用分组排序。
    • 二次排序:在自定义排序过程中,如果compareTo中的判断条件为两个即为二次排序。
  • 自定义排序WritableComparable步骤:
    • bean对象做为Key传输,需要实现WritableComparable接口重写CompareTo方法
      在这里插入图片描述
      在这里插入图片描述

Combiner合并

  • 概览:
    • Combiner是MR程序中Mapper和Reducer之外的一种组件
    • Combiner组件的父类就是Reducer
    • Combiner和Reducer的区别在于运行的位置
      • Combiner是在每一个MapTask所在的节点运行
      • Reducer是接收全局所有Mapper的输出结果
    • Combiner的意义就是对每一个MapTask的输出进行局部汇总,以减少网络传输量
    • Combiner能够应用的前提是不能影响最终的业务逻辑,而且,Combiner的输出KV应该跟Reducer的输入KV类型要对应起来
  • 自定义Combiner实现类的步骤:
    • 自定义一个Combiner集成Reducer,重写Reduce方法:
      在这里插入图片描述

    • 在JOB驱动类中设置:
      在这里插入图片描述

OutputFormat数据输出

  • OutputFormat接口实现类
    • 是MapReduce输出的基类,所有实现MapReduce输出都实现了OutputFormat接口
      在这里插入图片描述

    • 默认输出格式TextOutputFormat

    • 自定义OutputFormat

      • 应用场景:输出到不同的存储框架中
      • 步骤:
        • 自定义一个类集成FileOutputFormat
          在这里插入图片描述
        • 改写RecordWriter,具体改写输出数据的方法write();
          在这里插入图片描述

MapReduce内核源码解析

MapTask工作机制

在这里插入图片描述

  • Read阶段:MapTask通过InputFormat获得RecordReader,从输入InputSplit中解析出一个个Key/Value
  • Map阶段:该节点主要是解析出的Key/Value交给用户编写map()函数处理,并产生一系列新的Key/Value
  • Collect收集阶段:在用户编写Map()函数中,当数据处理完成后,一般会调用OutputCollector.collect()输出结果。在该函数内部,它会生成的Key/Value分区,并写入一个环形内存缓冲区中。
  • Spill阶段:即”溢写“,当环形缓冲区满后,MapReduce会将数据写到本地磁盘上,生成一个临时文件,需要注意的是,将数据写入本地磁盘之前,先要对数据进行一次本地排序,并在必要时对数据进行合并,压缩等操作。下面是溢写操作
    • 利用快速排序算法对缓冲区内的数据进行排序,排序方式是,先按照分区编号Partition进行排序,然后按照Key进行排序。这样,经过排序后,数据以分区为单位聚集在一起,且同一分区内所有数据按照Key有序。
    • 按照分区编号由小到大依次将每个分区中的数据写入任务工作目录下的临时文件output/spillN.out中。如果用户设置了Combiner,则写入文件之前,对每个分区中的数据进行一次聚集操作。
    • 将分区数据的元信息写到内存索引数据结构SpillRecord中,其中每个分区的元信息包括在临时文件中的偏移量,压缩前数据大小和压缩后数据大小。如果当前内存索引大小超过1MB,则将索引写到文件output/spillN.out.index中
  • Merge阶段:当所有数据处理完成后,MapTask对所有临时文件进行一次合并,以确保最终只会生成一个数据文件。

ReduceTask工作机制

在这里插入图片描述

  • Copy阶段:ReduceTask从各个MapTask上远程拷贝一片数据,并针对某一片数据,如果其大小超过一定阈值,则写到磁盘上,否则直接放到内存中。
  • Sort阶段:在远程拷贝数据的同时,ReduceTask启动了两个后台线程对内存和磁盘上的文件进行合并,以防止内存使用过多或磁盘上文件过多。由于MapTask已经实现了对自己的处理结果进行了局部排序,因此,ReduceTask只需对所有数据进行一次归并排序即可。
  • Reduce阶段:reduce()函数将计算结果写到HDFS上。
  • ReduceTask并行度决定机制:ReduceTask的并行度由谁决定?
    • 设置ReduceTask并行度:ReduceTask的并行度同样影响整个JOB的执行并发度和执行效率,但与MapTask的并发数由切片数决定不同,ReduceTask数量的决定是可以直接手动设置:
  • ReduceTask阶段的注意事项:
    • ReduceTask = 0,表示没有Reduce阶段,输出文件个数和Map个数一致
    • ReduceTask = 1,是默认值,所以输出文件个数为一个
    • 如果数据分布不均匀,就有可能在Reduce阶段产生数据倾斜
    • 如果分区数不为1,但是ReduceTask为1,不执行分区过程,因为在源码中,执行分区之前是先判断ReduceNum个数是否大于1

MapTask & ReduceTask源码解析

MapTask源码分析
  1. 因为我们分析的是MapTask所以我们需要在,Context.write()方法打断点,debug的时候从这个方法进去
    在这里插入图片描述
    • 进入到write方法,一直进入在这里插入图片描述在这里插入图片描述
    • 一直进入到一个write方法,其中有分区partition有关,接着进入在这里插入图片描述
      • 这时候就进入我们自定义的分区方法,如果没有就是默认的在这里插入图片描述
    • 执行完自定义的分区之后,就回到了write方法,然后再进入collect方法在这里插入图片描述
      • collect方法就是环形缓冲区的方法在这里插入图片描述
      • 后面一直运行,运行到下面这一段,这就是环形缓冲区的执行方法
        在这里插入图片描述
    • 之后一直运行,就回到了之前写入的方法
      在这里插入图片描述
  2. 写完之后就进入原本的mapper方法中
    在这里插入图片描述
  3. 再运行就又回到了context.write的方法中,但是想要看到溢出我们的数据量不够,但是我们可以看一下,就一直运行,一直运行到13568436656这个数据的时候,选择进入,然后就一直进入,直到运行到环形缓冲区
    在这里插入图片描述
    在这里插入图片描述
    • 在这个方法一直运行,一直运行到下面这段代码在这里插入图片描述
    • 运行完之后,就跳到下面那个方法,在close方法那里打下断点,运行到这个方法进入在这里插入图片描述
      • 进入之后就到了下面的方法,
        • 进入flush 刷写方法,进入之后发现sortAndSpill方法,这个方法看名字就知道是排序加溢写的方法
          在这里插入图片描述
        • 进入sortAndSpill方法,进入sort方法先进行排序在这里插入图片描述
          • 进到sort方法,再进入就是sortInternal方法,也就是快速排序算法在这里插入图片描述在这里插入图片描述
        • 出来之后,也就是排完序之后,就准备溢写在这里插入图片描述
      • 一直循环遍历,将这个方法运行完之后就回到flush方法,mergeParts是使用归并排序处理溢写文件,
        在这里插入图片描述
  4. 之後一直运行,到下面的代码MapTask阶段就结束了
    在这里插入图片描述
  • 总结一下,过程主要就是下面的流程图
    在这里插入图片描述
ReduceTask 源码解析流程
  1. 进入到ReduceTask的run方法,因为有分区,所以我们需要遍历五遍这个方法,但是这里就显示第一次
    在这里插入图片描述
  2. 一直运行,直到initialize方法,也就是初始化方法
    在这里插入图片描述
    • 进入到initialize方法中,都是初始化方法
      在这里插入图片描述
  3. 一直运行,运行到shuffleConsumerPlugin.init(shuffleContext)这个方法,这个方法是shuffle机制的初始化
    在这里插入图片描述
    • 进入到这个方法中前面都是创建的资源,this.scheduler = new ShuffleSchedulerImpl()这个方法有探究的必要
      在这里插入图片描述
      • 进入到这个方法
        • this.totalMaps = job.getNumMapTasks();这个方法主要是为了获取多少个MapTask
          在这里插入图片描述
    • 接着从这个方法退出,看到this.merger = this.createMergeManager(context)这个方法,这个就是创建合并的项目merge阶段
      在这里插入图片描述
      • 进入这个方法,前面都是初始化数据
        在这里插入图片描述
      • 一直到这个位置才开始运行
  4. 初始化完成之后,运行到rIter = shuffleConsumerPlugin.run();这个方法开始进行copy阶段,抓取资源,之后运行到this.sortPhase.complete();方法,sort阶段结束
    在这里插入图片描述
  5. 之后准备开始reduce阶段,运行到 方法
    • 进入这个方法,一直运行到reducer.run(reducerContext)这个方法
      • 进入这个方法,运行到reduce方法
        在这里插入图片描述
        * 进入之后,就来到我们自定义的reduce方法
        在这里插入图片描述
  6. 之后一直运行就回到了之前的run方法,第一个分区的reduceTask执行完毕
  7. 总结一下整个过程
    在这里插入图片描述

Join多种应用

Reduce Join

  • Map端的主要工作:为来自不同表或文件的Key/Value对,打标签以区别不同来源的记录。然后用连接字段作为Key,其余部分和新加的标志作为value,最后进行输出。
  • Reduce端的主要工作:在Reduce端以连接字段作为key的分组已经完成,我们只需要在每一个分组当中将那些来源于不同文件的记录分开,最后进行合并就OK了。
  • 所以需要将两个不同的文件的两个不同的数据联系到一起,就需要用到join,具体怎么做,看下面的案例
  • 需求:通过将关联条件作为 Map 输出的 key,将两表满足 Join 条件的数据并携带数据所来源的文件信息,发往同一个 ReduceTask,在 Reduce 中进行数据的串联。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
  • 代码实现:
//bean对象
public class TableBean implements Writable {

    //订单ID
    private String id;
    //商品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 out) throws IOException {
        out.writeUTF(id);
        out.writeUTF(pid);
        out.writeInt(amount);
        out.writeUTF(pname);
        out.writeUTF(flag);
    }

    @Override
    public void readFields(DataInput input) throws IOException {
        this.id = input.readUTF();
        this.pid = input.readUTF();
        this.amount = input.readInt();
        this.pname = input.readUTF();
        this.flag = input.readUTF();
    }

    @Override
    public String toString() {
        return  id + '\t' +
                pname + '\t' +
                amount;
    }
}
public class TableMapper extends Mapper<LongWritable, Text, Text, TableBean> {

    private String fileName;
    private Text outK = new Text();
    private TableBean outV = new TableBean();
    //初始化
    @Override
    protected void setup(Context context) throws IOException, InterruptedException {
        FileSplit inputSplit = (FileSplit) context.getInputSplit();
        fileName = inputSplit.getPath().getName();
    }

    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
        // 1. 获取一行
        String line = value.toString();
        // 2. 判断哪个文件
        if(fileName.contains("order")){//处理订单表
            String[] s = line.split("\t");
            outK.set(s[1]);
            outV.setId(s[0]);
            outV.setPid(s[1]);
            outV.setAmount(Integer.parseInt(s[2]));
            outV.setPname("");
            outV.setFlag("order");
        }else {//处理商品表
            String[] s = line.split("\t");
            outK.set(s[0]);
            outV.setId("");
            outV.setPid(s[0]);
            outV.setAmount(0);
            outV.setPname(s[1]);
            outV.setFlag("pd");
        }
        // 3.写出
        context.write(outK, outV);
    }
}
public class TableReducer extends Reducer<Text, TableBean, TableBean, NullWritable> {

    @Override
    protected void reduce(Text key, Iterable<TableBean> values, Context context) throws IOException, InterruptedException {
        // 1. 创建集合
        ArrayList<TableBean> orderBeans = new ArrayList<>();
        TableBean pdBean = new TableBean();

        // 2. 循环遍历传值
        for(TableBean value:values){
            if("order".equals(value.getFlag())){//订单表
                TableBean n = new TableBean();
                try {
                    BeanUtils.copyProperties(n,value);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                } catch (InvocationTargetException e) {
                    e.printStackTrace();
                }
                orderBeans.add(n);
            }else{//商品表
                try {
                    BeanUtils.copyProperties(pdBean,value);
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                } catch (InvocationTargetException e) {
                    e.printStackTrace();
                }
            }
        }

        // 3. 循环遍历orderBeans,赋值pdname
        for (TableBean orderBean : orderBeans) {
            orderBean.setPname(pdBean.getPname());
            context.write(orderBean,NullWritable.get());
        }
    }
}
public class TableDriver {
    public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
        Job job = Job.getInstance(new Configuration());

        job.setJarByClass(TableDriver.class);
        job.setMapperClass(TableMapper.class);
        job.setReducerClass(TableReducer.class);

        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(TableBean.class);

        job.setOutputKeyClass(TableBean.class);
        job.setOutputValueClass(NullWritable.class);

        FileInputFormat.setInputPaths(job, new Path("D://input"));
        FileOutputFormat.setOutputPath(job, new Path("D://output"));

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

}
  • 结果:
    在这里插入图片描述
  • 总结:
    • 这种方式中,合并的操作是在 Reduce 阶段完成,Reduce 端的处理压力太大,Map节点的运算负载则很低,资源利用率不高,且在 Reduce 阶段极易产生数据倾斜。
    • 所以为了解决这种问题,我们需要在Map端就实现数据合并

Map Join

  • 使用场景:Map Join适用于一张表十分小,一张表很大的场景。
  • 优点:在 Map 端缓存多张表,提前处理业务逻辑,这样增加 Map 端业务,减少 Reduce 端数据的压力,尽可能的减少数据倾斜。
  • 具体方法:采用DistributedCache
    • 在Mapper的setup阶段,将文件读取到缓存集合中
    • 在Driver驱动类中加载缓存
  • 代码:
public class MapJoinMapper extends Mapper<LongWritable, Text, Text, 
NullWritable> {
 private Map<String, String> pdMap = new HashMap<>();
 private Text text = new Text();
 //任务开始前将 pd 数据缓存进 pdMap
 @Override
 protected void setup(Context context) throws IOException, 
InterruptedException {
 //通过缓存文件得到小表数据 pd.txt
 URI[] cacheFiles = context.getCacheFiles();
 Path path = new Path(cacheFiles[0]);
 //获取文件系统对象,并开流
 FileSystem fs = FileSystem.get(context.getConfiguration());
 FSDataInputStream fis = fs.open(path);
 //通过包装流转换为 reader,方便按行读取
 BufferedReader reader = new BufferedReader(new 
InputStreamReader(fis, "UTF-8"));
//逐行读取,按行处理
 String line;
 while (StringUtils.isNotEmpty(line = reader.readLine())) {
 //切割一行 
//01 小米
 String[] split = line.split("\t");
 pdMap.put(split[0], split[1]);
 }
 //关流
 IOUtils.closeStream(reader);
 }
 @Override
 protected void map(LongWritable key, Text value, Context context) 
throws IOException, InterruptedException {
 //读取大表数据 
//1001 01 1
 String[] fields = value.toString().split("\t");
 //通过大表每行数据的 pid,去 pdMap 里面取出 pname
 String pname = pdMap.get(fields[1]);
 //将大表每行数据的 pid 替换为 pname
 text.set(fields[0] + "\t" + pname + "\t" + fields[2]);
 //写出
 context.write(text,NullWritable.get());
 }
}

public class MapJoinDriver {
 public static void main(String[] args) throws IOException, 
URISyntaxException, ClassNotFoundException, InterruptedException {
 // 1 获取 job 信息
 Configuration conf = new Configuration();
 Job job = Job.getInstance(conf);
 // 2 设置加载 jar 包路径
 job.setJarByClass(MapJoinDriver.class);
 // 3 关联 mapper
 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:///D:/input/tablecache/pd.txt"));
 // Map 端 Join 的逻辑不需要 Reduce 阶段,设置 reduceTask 数量为 0
 job.setNumReduceTasks(0);
 // 6 设置输入输出路径
 FileInputFormat.setInputPaths(job, new Path("D:\\input"));
 FileOutputFormat.setOutputPath(job, new Path("D:\\output"));
 // 7 提交
 boolean b = job.waitForCompletion(true);
 System.exit(b ? 0 : 1);
 }
}

MapReduce开发总结

在这里插入图片描述

  1. 输入数据接口:InputFormat
    • 默认使用的实现类是:TextInputFormat
    • TextInputFormat的功能逻辑是:一次读一行文本,然后将该行的起始偏移量作为Key,行内容作为value返回
    • CombineTextInputFormat 可以把多个小文件合并成一个切片处理,提高处理效率
  2. 逻辑处理接口:Mapper
    • 用户根据业务需求实现其中三个方法:map() setup() cleanup()
  3. Partitioner分区
    • 默认分区是HashPartitioner,逻辑是根据Key的哈希值和numReduces来返回一个分区号;key.hashCode()&Integer.MAXVALUE%numReduces
    • 如果业务上有特别的需求,可以自定义分区
  4. Comparable排序
    • 当我们用自定义的对象作为key来输出时,就必须要实现WritableComparable接口,重写其中的compareTo()方法。
    • 部分排序:对最终输出的每一个文件进行内部排序
    • 全排序:对所有数据进行排序,通常只有一个Reduce
    • 二次排序:排序的条件有两个
  5. Combiner合并:可以提高程序执行效率,减少IO传输。但是使用时必须不能影响原有的业务处理结果
  6. 逻辑处理接口:Reducer
    • 用户根据业务需求实现其中三个方法:reduce() setup() cleanup()
  7. 输出数据接口:OutputFormat
    • 默认实现类是TextOutputFormat,功能逻辑是:将每一个KV对,向目标文本文件输出一样。
    • 用户还可以自定义OutputFormat

Hadoop数据压缩

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值