Hadoop:MapReduce总结

MapReduce

1、架构

  • MR AppMaster:负责整个成都的过程调度及状态协调;
  • MapTask:负责map阶段的整个数据处理流程;
  • ReduceTask:负责reduce阶段的整个数据处理流程;

2、数据类型

除了String对应Hadoop Writable类型为Text以外,其他基本都是类似boolean -> BooleanWritable

3、Hadoop序列化

4、InputFormat 数据输入

Job提交流程

流程总结
  1. 建立连接,创建Job提交的代理类,并判断该Job的运行环境是本地还是yarn集群。
  2. 提交Job,
  • 2.1:创建集群提交数据的Stag路径;
  • 2.2:获取JobID,Stag路径+JobID该构建Job的路径;
  • 2.3:拷贝jar包到集群(如果不是本地运行的话);
  • 2.4:计算切片,生成切片规划文件;
  • 2.5:往Stag路径中写入XML配置文件(Job运行的相关参数);
  • 2.6:Job提交,返回提交状态;
源码
 
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());

FileInputFormat切片流程&源码分析

  1. 程序寻找数据存储目录;

  2. 开始遍历处理(规划切片)数据目录下的每一个文件;

    遍历第一个文件file.txt(300MB)

​ 2.1:获取文件大小fs.sizeOf(file.txt);

​ 2.2:计算切片大小:computeSplitSize(Math.max(minSize,Math.min(maxSize,blocksize)))=blocksize=128M

​ minSize:默认值是0,因此决定切片大小默认情况是受限与blocksize;

mapreduce.input.fileinputformat.split.maxsize= Long.MAXValue 默认值Long.MAXValue

maxsize并未在官网的mapred-default.xml文件中找到参考。【上面的参数我是没找到,尚硅谷是这么说的,大家自己瞅着看看奥】

​ maxsize(切片最大值):参数如果调得比 blocksize 小,则会让切片变小,就等于配置的这个参数的值。
​ minsize(切片最小值):参数调的比 blockSize 大,则可以让切片变得比 blocksize 还大

image-20220223215615068

​ 2.3:默认情况下,切片大小=blocksize;

​ 2.4:开始对示例的第一个文件file.txt进行切片

​ 【0,128)区间左闭右开

​ 第一个切片:file.txt-----0:128M

​ 第二个切片:file.txt-----128:256M

​ 第三个切片:file.txt-----256:300M

每次开始切片前,要判断进行这一次切片后,如果剩下的部分大于blocksize的SPLIT_SLOP倍(默认值1.1倍)则可以切片,否则就和 前面的一起作为一个切片

​ 切片大小=blocksize(本地运行默认32M,集群上为128M), 若为32.1M此时比值不大于SPLIT_SLOP(默认值1.1),则不进入while 循环,while循环中的split.add(***)用的参数是blocksize(32M),不进入while循环也就是比值不大于1.1时,使用的split.add(***) 用的 参数 是byteRemaining,此时就开一个大小为32.1的切片。

​ 2.5:将切片信息写道一个切片规划文件中

​ 2.6:整个切片的核心过程在getSplit()方法中完成

​ 2.7: InputSplit只记录了切片的元数据信息,比如起始位置长度,以及所在的节点列表等。

  1. 提交切片规划文件到YARN上,YARN上的MrAppMaster就可以根据切片规划文件计算开启MapTask个数。

FileInputFormat切片机制

1)切片机制

(1)简单地按照文件的内容长度进行切片

(2)切片大小,默认等于blocksize块大小

(3)切片时,不考虑数据集整体,而是针对每一个文件单独进行切片

2)切片举例

(1)输入数据:file1.txt:300M;file2.txt:10M;

(2)经过FIleInputFormat切片机制处理后,得到的切片规划信息如下:

切片文件切片范围【集群上bolcksize默认128M,本地32M】
file1.txt.split10 ~ 128 M
file1.txt.split2128 ~ 256 M
file1.txt.split3256 ~ 300 M
file2.txt.split10 ~ 10 M
3)切片大小的参数配置

(1) 源码中计算切片大小的公式

Math.max(minSize, Math.min(maxSize, blockSize));

mapreduce.input.fileinputformat.split.minsize=1 默 认 值 为 1 mapreduce.input.fileinputformat.split.maxsize= Long.MAXValue 默认值Long.MAXValue 因此,默认情况下,切片大小=blocksize。

(2) 切片大小设置

maxsize(切片最大值):参数如果调得比blockSize小,则会让切片变小,而且就等于配置的这个参数的值。

minsize(切片最小值):参数调的比blockSize大,则可以让切片变得比blockSize还大。

(3) 获取切片信息****API

// 获取切片的文件名称

String name = inputSplit.getPath().getName();

// 根据文件类型获取切片信息

FileSplit inputSplit = (FileSplit) context.getInputSplit();

TextInputFormat:FileInputFormat的子类

TextInputFormat这个类继承自FileInputFormat,FileInputFormat抽象类实现了InputFormat接口

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

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

CombineTextInputFormat切片机制

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

1)应用场景:

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

2)虚拟存储切片最大值设置

CombineTextInputFormat.setMaxInputSplitSize(job, 4194304);// 4m=41024kb=41024*1024b

3)切片机制

生成切片过程包括:虚拟存储过程和切片过程二部分。

image-20220223225330569

(1)虚拟存储过程:

将输入目录下所有文件大小,依次和设置的 setMaxInputSplitSize 值比较,

规则1: 文件大小 < setMaxInputSplitSize :逻辑上单独划分一块(按照setMaxInputSplitSize的大小)

规则2:setMaxInputSplitSize < 文件大小 < 2*setMaxInputSplitSize :对半切片分成两块。

规则3:文件大小 > 2*setMaxInputSplitSize :可以完整切割下一块setMaxInputSplitSize,如果setMaxInputSplitSize<剩余的大小<2*setMaxInputSplitSize则按文件大小对半切片分成两块。

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

(2) 切片过程:

(a) 判断虚拟存储的文件大小是否大于 setMaxInputSplitSize 值,大于等于则单独形成一个切片。

(b) 如果不大于则跟下一个虚拟存储文件进行合并,共同形成一个切片。

(c) 测试举例:有 4 个小文件大小分别为 1.7M、5.1M、3.4M 以及 6.8M 这四个 小文件,则虚拟存储之后形成 6 个文件块,大小分别为:

1.7M,(2.55M、2.55M),3.4M 以及(3.4M、3.4M)

最终会形成 3 个切片,大小分别为:

(1.7+2.55)M,(2.55+3.4)M,(3.4+3.4)M

5、MapReduce工作流程【重点】

RecorderReader读取完文件后,把读到的信息给Mapper

6、Shuffle 机制

7、MapTask阶段工作机制【重点】

Job的提交

image-20220227140153258

1.Read阶段

image-20220227141306494

红框就是Read阶段,读完之后回将读取到的数据传给Mapper,然后进入Map阶段。

Read阶段,通过InputFormat【默认是用TextInputFormat】去读取数据,通过调用RecordReader里的reader()方法。

读取到的Key是偏移量,Value是每一行的内容。【如果是CombineTextInputFormat,那就是一次读取一个文件】

2.Map阶段

image-20220227141401417

3.Collect阶段

image-20220227141557904

4.spill溢写阶段

image-20220227141620755

5.Merge阶段

Merge阶段,对溢写阶段产生的大量溢写文件进行合并。

image-20220227141644512

MapTask并行度决定机制

MapTask 并行度由切片个数决定,切片个数由输入文件和切片规则决定。

computeSliteSize(Math.max(minSize,Math.min(maxSize,blocksize)))=blocksize=128M 公式,

调整 maxSize 最大值。让 maxSize 低于 blocksize 就可以增加 map 的个数。

工作流程概述

Map端:

1.每个输入分片会让一个map任务来处理,默认情况下,以HDFS的一个块的大小(默认为128M)为一个分片,当然我们也可以设置块的大小。map输出的结果会暂且放在一个环形内存缓冲区中(该缓冲区的大小默认为100M,由io.sort.mb属性控制),当该缓冲区快要溢出时(默认为缓冲区大小的80%,由io.sort.spill.percent属性控制),会在本地文件系统中创建一个溢出文件,将该缓冲区中的数据写入这个文件。

2.在写入磁盘之前,线程首先根据reduce任务的数目将数据划分为相同数目的分区,也就是一个reduce任务对应一个分区的数据。这样做是为了避免有些reduce任务分配到大量数据,而有些reduce任务却分到很少数据,甚至没有分到数据的尴尬局面。

其实分区就是对数据进行hash的过程。然后对每个分区中的数据进行排序**【对索引进行排序】**,如果此时设置了Combiner,将排序后的结果进行Combiner操作,这样做的目的是让尽可能少的数据写入到磁盘。

3.当map任务输出最后一个记录时,可能会有很多的溢出文件,这时需要将这些文件合并。合并的过程中会不断地进行排序和Combiner操作,目的有两个:1.尽量减少每次写入磁盘的数据量;2.尽量减少下一复制阶段网络传输的数据量。最后合并成了一个已分区且已排序的文件。为了减少网络传输的数据量,这里可以将数据压缩,只要将mapred.compress.map.out设置为true就可以了。

4.将分区中的数据拷贝给相对应的reduce任务。有人可能会问:分区中的数据怎么知道它对应的reduce是哪个呢?其实map任务一直和其父TaskTracker保持联系,而TaskTracker又一直和JobTracker保持心跳。所以JobTracker中保存了整个集群中的宏观信息。只要reduce任务向JobTracker获取对应的map输出位置就可以了。

源码分析

1.write()阶段

每读取一行就执行context.write(K,V),将数据写到环形缓冲区

2.collect()阶段【环形缓冲区】

write()方法进入后,来到collection()方法,这里将执行Partition的分区策略,写好环形缓冲区中的索引和数据两个部分。

反复执行1和2,until达到环形缓冲区的溢写条件。 达到80%或者所有内容写完【文件内容总大小不够默认100MB缓冲区大小】
3.sortAndSpill()阶段【溢写】
4.mergerParts()

将所有溢写文件进行合并

*.out 是溢写文件

*.out.index 是记录了所有溢写文件的索引信息,这样reduce来磁盘拉取数据的时候,才知道自己拉的是哪个分区的数据。因为我们要求同一个分区的数据最终进入同一个reduce中。

image-20220227152423438

8、ReduceTask阶段工作机制【重点】

数据已经持久化到了本地磁盘上,等待被reducer拉取

image-20220227142342485

1.Copy阶段

不同的reducer拉取自己自己指定的分区

2.Sort阶段

Merge看需求有没有

image-20220227144220057

3.Reduce阶段

image-20220227144302743

ReduceTask并行度决定机制

(1) ReduceTask=0,表示没有Reduce阶段,输出文件个数和Map个数一致。

(2) ReduceTask默认值就是1,所以输出文件个数为一个。

(3) 如果数据分布不均匀,就有可能在Reduce阶段产生数据倾斜

(4) ReduceTask数量并不是任意设置,还要考虑业务逻辑需求,有些情况下,需要计算全局汇总结果,就只能有1个ReduceTask。

(5) 具体多少个ReduceTask,需要根据集群性能而定。

(6) 如果分区数不是1,但是ReduceTask为1,是否执行分区过程。答案是:不执行分区过程。因为在MapTask的源码中,执行分区的前提是先判断ReduceNum个数是否大于1。不大于1 肯定不执行。

工作流程概述

Reduce端:

1.Reduce会接收到不同map任务传来的数据,并且每个map传来的数据都是有序的。

如果reduce端接受的数据量相当小,则直接存储在内存中(缓冲区大小由mapred.job.shuffle.input.buffer.percent属性控制,表示用作此用途的堆空间的百分比),如果数据量超过了该缓冲区大小的一定比例(由mapred.job.shuffle.merge.percent决定),则对数据合并后溢写到磁盘中

2.随着溢写文件的增多,后台线程会将它们合并成一个更大的有序的文件,这样做是为了给后面的合并节省时间。其实不管在map端还是reduce端,MapReduce都是反复地执行排序,合并操作,现在终于明白了有些人为什么会说:排序是hadoop的灵魂。

3.合并的过程中会产生许多的中间文件(写入磁盘了),但MapReduce会让写入磁盘的数据尽可能地少,并且最后一次合并的结果并没有写入磁盘,而是直接输入到reduce函数。

源码分析

image-20220227163408627

0.init
  1. getNumMapTasks();在init()的内部中,初始化maptask的数量,从而好让reduce知道去哪儿的分区拉取数据。

  2. 分配内存和磁盘空间的初始化

  3. 初始化init()结束之后,就开始执行run()方法,进入真正的数据抓取过程。

1.Copy阶段

抓取数据,

image-20220227174641512

image-20220227174710148

2.Sort阶段

对抓取过来的数据进行merge排序,copy阶段结束后,马上进入SORT阶段

image-20220227174733948

3.reduce阶段

后续写出处理

image-20220227174806198

image-20220227175014397

上图中reduce会进入我自己写的reduce代码。

9、Join应用

1.Reduce join

Map 端主要工作:为来自不同表或文件的 key/value 对,打标签以区别不同来源的记录。然后用连接字段作为key,其余部分和新加的标志作为 value,最后进行输出。

Reduce 端主要工作:在Reduce 端以连接字段作为key 的分组已经完成,我们只需要在每一个分组当中将那些来源于不同文件的记录(在 Map 阶段已经打标志)分开,最后进行合并就 ok 了。

2.Reduce join案例

image-20220227182606767
TableBean类

对存储的内容进行封装

package com.atguigu.mapreduce.reducejoin;
import org.apache.hadoop.io.Writable; import java.io.DataInput;
import java.io.DataOutput; import java.io.IOException;
public class TableBean implements Writable { private String id; //订单 id
    private String pid; //产品 id
    private int amount; //产品数量
   private String pname; //产品名称
    private String flag; //判断是 order 表还是 pd 表的标志字段

    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 String toString() {
    return id + "\t" + pname + "\t" + amount;
    }

    @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 in) throws IOException {  
        this.id = in.readUTF(); 
        this.pid = in.readUTF(); 
        this.amount = in.readInt(); 
        this.pname = in.readUTF(); 
        this.flag = in.readUTF();
	}
}
        
TableMapper类
package com.atguigu.mapreduce.reducejoin;

import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.InputSplit; import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.lib.input.FileSplit; import java.io.IOException;
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 {
        //获取对应文件名称
        InputSplit split = context.getInputSplit(); FileSplit fileSplit = (FileSplit) split; filename = fileSplit.getPath().getName();
    }
    
    @Override
	protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
        //获取一行
        String line = value.toString();

        //判断是哪个文件,然后针对文件进行不同的操作if(filename.contains("order")){ //订单表的处理
        String[] split = line.split("\t");
        // 封 装 outK 
        outK.set(split[1]);
        // 封 装 outV
        outV.setId(split[0]); 
        outV.setPid(split[1]);
        outV.setAmount(Integer.parseInt(split[2])); 
        outV.setPname("");
        outV.setFlag("order");
        }else {	//商品表的处理
        String[] split = line.split("\t");
        // 封 装 outK 
        outK.set(split[0]);
        // 封 装 outV 
        outV.setId(""); 
        outV.setPid(split[0]); 
        outV.setAmount(0); 
        outV.setPname(split[1]);
        outV.setFlag("pd");
    }
    //K:订单ID,
    //V:TableBean存储其他信息
    context.write(outK,outV);
    }
}
TableReducer类
package com.atguigu.mapreduce.reducejoin;

import org.apache.commons.beanutils.BeanUtils; import org.apache.hadoop.io.NullWritable; import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException; import java.util.ArrayList;

public	class	TableReducer	extends	Reducer<Text,TableBean,TableBean, NullWritable> {

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

        ArrayList<TableBean> orderBeans = new ArrayList<>(); 
        // 一个reduce接受到的pid都是相同的,因此他们的pname也都是相同的,只要一个pdBean类接收就ok
        TableBean pdBean = new TableBean();

        for (TableBean value : values) {

            //判断数据来自哪个表
            if("order".equals(value.getFlag())){	//订单表

            //创建一个临时 TableBean 对象接收 
                value TableBean tmpOrderBean = new TableBean();

            try { 
                BeanUtils.copyProperties(tmpOrderBean,value);
            } catch (IllegalAccessException e) { 
                e.printStackTrace();
            } catch (InvocationTargetException e) { 
                e.printStackTrace();
            }
			/**
				Hadoop不允许直接orderBeans.add(value),要新new一个然后copy过去再放进集合中
			*/
            	//将临时 TableBean 对象添加到集合 
            	orderBeans orderBeans.add(tmpOrderBean);
            }else {	//商品表
                try { 
                    BeanUtils.copyProperties(pdBean,value);
                } catch (IllegalAccessException e) { 
                    e.printStackTrace();
                } catch (InvocationTargetException e) { 
                    e.printStackTrace();
                }
            }
        }

    	//遍历集合 orderBeans,替换掉每个 orderBean 的 pid 为 pname,然后写出
         for (TableBean orderBean : orderBeans) {
             
         	orderBean.setPname(pdBean.getPname());
         //写出修改后的 orderBean 对象
         context.write(orderBean,NullWritable.get());
 		}
 	} 
}
        

3.Map join

1)使用场景

Map Join 适用于一张表十分小、一张表很大的场景。

小表预先读取到缓存中。

2)优点

​ 思考:在 Reduce 端处理过多的表,非常容易产生数据倾斜。怎么办?

在 Map 端缓存多张表,提前处理业务逻辑,这样增加 Map 端业务,减少 Reduce 端数

据的压力,尽可能的减少数据倾斜。

3)操作方法:采用 DistributedCache

image-20220227184751994

4.Map join案例

1)需求分析

image-20220227184919963 image-20220227185005674 image-20220227185037213

3)代码

1.驱动类中添加缓存文件

// 在driver类中添加下面内容

// 加载缓存数据
 job.addCacheFile(new URI("file:///D:/input/tablecache/pd.txt"));
 // Map 端 Join 的逻辑不需要 Reduce 阶段,设置 reduceTask 数量为 0
 job.setNumReduceTasks(0);

2.在 MapJoinMapper 类中的 setup 方法中读取缓存文件

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());
	 } 
}

10、ETL清洗

根据自己的清洗规则,在map()方法中,排除或保留符合规则的数据

image-20220227175705222

11、压缩

1) 压缩的好处和坏处

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

压缩的缺点:增加CPU 开销。

2) 压缩原则

(1) 运算密集型的 Job,少用压缩

(2) IO 密集型的 Job,多用压缩

3)压缩方式选择

Gzip 压缩

优点:压缩率比较高;

缺点:不支持 Split;压缩/解压速度一般;

Bzip2 压缩

优点:压缩率高;支持 Split

缺点:压缩/解压速度慢。

Lzo 压缩

优点:压缩/解压速度比较快;支持 Split

缺点:压缩率一般;想支持切片需要额外创建索引。

Snappy 压缩

优点:压缩和解压缩速度快;

缺点:不支持 Split;压缩率一般;

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值