大数据技术之Hadoop-MapReduce教程

目的

本文档全面描述了Hadoop MapReduce框架中面向用户的方方面面,并将其作为教程。


前提要求

确保已安装Hadoop,配置好并且是正在运行。想要了解更多细节:


概览

Hadoop MapReduce是一个可以轻松地编写应用程序,以可靠,容错的方式并行处理大型硬件集群(数千个节点)上的大量数据(多TB数据集)的软件框架。

MapReduce作业通常将输入数据集拆分为独立的块,这些任务由map tasks以完全并行的方式进行处理。该框架对maps的输出进行排序,然后将其输入到reduce tasks中。通常情况下,作业的输入和输出都存储在文件系统中。该框架负责安排任务,监控任务的执行并重新执行失败的任务。

通常,计算节点和存储节点是相同的,也就是说,MapReduce框架和Hadoop分布式文件系统(请参阅HDFS架构指南)在同一组节点上运行。这个配置使框架可以在已经存在数据的节点上有效地调度任务,从而在整个群集中产生很高的聚合带宽。

MapReduce框架由一个主资源管理器(ResourceManager),每个集群节点一个工作器NodeManager和每个应用程序一个MRAppMaster组成(请参阅YARN体系结构指南)。

应用程序指定了输入和输出位置,通过适当的接口或抽象类的实现来提供map和reduce功能。这些以及其他job的参数一起构成了job的配置。

然后,Hadoop作业客户端提交作业(jar包或者是可执行文件等)和配置给ResourceManager,然后由ResourceManager负责将软件/配置分发给工作节点,安排任务并对其进行监控,为job客户端提供状态和诊断信息。

尽管Hadoop框架是用Java™实现的,但MapReduce应用程序可以不用Java编写。

  • Hadoop Streaming是一个程序,它可以允许用户使用任何可执行文件(例如shell程序)作为mapper或reducer来创建和运行作业。
  • Hadoop PipesSWIG兼容的C ++ API,用于实现MapReduce应用程序(非基于JNI™)。

输入和输出

MapReduce框架仅在<key,value>键值对上进行操作,也就是说,该框架将作业的输入视为一组<key,value>键值对,并生成一组<key,value>键值对作为其输出。输入和输出的键值对的类型可能是不同类型。

作为键和值的类必须由框架实现可序列化,因此需要实现Writable接口。此外,作为键的类必须实现WritableComparable接口,以利于框架进行排序。

MapReduce作业的输入和输出类型:

(input) <k1, v1> -> map -> <k2, v2> -> combine -> <k2, v2> -> reduce -> <k3, v3> (output)

示例:WordCount v1.0

在进入细节之前,让我们来看一个MapReduce应用程序的示例,以了解它们的工作方式。

WordCount是一个简单的应用程序,可以计算给定输入集中每个单词的出现次数。

这适用于将Hadoop安装成本地运行模式,伪分布式运行模式或完全分布式模式(单节点的安装)。

源码

import java.io.IOException;
import java.util.StringTokenizer;

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.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

public class WordCount {

  public static class TokenizerMapper
       extends Mapper<Object, Text, Text, IntWritable>{

    private final static IntWritable one = new IntWritable(1);
    private Text word = new Text();

    public void map(Object key, Text value, Context context
                    ) throws IOException, InterruptedException {
      StringTokenizer itr = new StringTokenizer(value.toString());
      while (itr.hasMoreTokens()) {
        word.set(itr.nextToken());
        context.write(word, one);
      }
    }
  }

  public static class IntSumReducer
       extends Reducer<Text,IntWritable,Text,IntWritable> {
    private IntWritable result = new IntWritable();

    public void reduce(Text key, Iterable<IntWritable> values,
                       Context context
                       ) throws IOException, InterruptedException {
      int sum = 0;
      for (IntWritable val : values) {
        sum += val.get();
      }
      result.set(sum);
      context.write(key, result);
    }
  }

  public static void main(String[] args) throws Exception {
    Configuration conf = new Configuration();
    Job job = Job.getInstance(conf, "word count");
    job.setJarByClass(WordCount.class);
    job.setMapperClass(TokenizerMapper.class);
    job.setCombinerClass(IntSumReducer.class);
    job.setReducerClass(IntSumReducer.class);
    job.setOutputKeyClass(Text.class);
    job.setOutputValueClass(IntWritable.class);
    FileInputFormat.addInputPath(job, new Path(args[0]));
    FileOutputFormat.setOutputPath(job, new Path(args[1]));
    System.exit(job.waitForCompletion(true) ? 0 : 1);
  }
}

用法

  • 假设环境变量设置如下:
export JAVA_HOME=/usr/java/default
export PATH=${JAVA_HOME}/bin:${PATH}
export HADOOP_CLASSPATH=${JAVA_HOME}/lib/tools.jar
  • 编译WordCount.java并生成一个jar包:
$ bin/hadoop com.sun.tools.javac.Main WordCount.java
$ jar cf wc.jar WordCount*.class
  • 假如说在HDFS文件系统中有这两个文件夹:
    • /user/joe/wordcount/input - input directory in HDFS
    • /user/joe/wordcount/output - output directory in HDFS

  • 将样本文本文件作为输入:
$ bin/hadoop fs -ls /user/joe/wordcount/input/
/user/joe/wordcount/input/file01
/user/joe/wordcount/input/file02

$ bin/hadoop fs -cat /user/joe/wordcount/input/file01
Hello World Bye World

$ bin/hadoop fs -cat /user/joe/wordcount/input/file02
Hello Hadoop Goodbye Hadoop
  • 运行应用程序:
$ bin/hadoop jar wc.jar WordCount /user/joe/wordcount/input /user/joe/wordcount/output
  • 输出:
$ bin/hadoop fs -cat /user/joe/wordcount/output/part-r-00000
Bye 1
Goodbye 1
Hadoop 2
Hello 2
World 2

应用程序可以使用-files选项指定以逗号分隔的路径列表,这些路径将出现在任务的当前工作目录中。-libjars选项允许应用程序将jar添加到map和reduces的类路径。-archives选项允许他们将逗号分隔的存档列表作为参数传递。这些归档文件是未归档的,并且在当前任务工作目录中创建了带有归档文件名称的链接。有关命令行选项的更多详细信息,请参见命令指南

使用-libjars-files-archives运行wordcount示例:

bin/hadoop jar hadoop-mapreduce-examples-.jar wordcount -files cachefile.txt -libjars mylib.jar -archives myarchive.zip input output

在这里,myarchive.zip将被放置并解压缩到名为“myarchive.zip”的目录中。

用户可以使用#为通过-files-archives选项传递的文件和归档指定不同的符号名。
示例:

bin/hadoop jar hadoop-mapreduce-examples-.jar wordcount -files dir1/dict.txt#dict1,dir2/dict.txt#dict2 -archives mytar.tgz#tgzdir input output

这里,任务可以分别使用符号名称dict1和dict2访问文件dir1/dict.txt和dir2/dict.txt。归档文件mytar.tgz将被放置tgzdir这个目录,并取消归档。

应用程序可以通过在命令行上分别使用-Dmapreduce.map.env,-Dmapreduce.reduce.env和-Dyarn.app.mapreduce.am.env选项在命令行上指定mapper,reducer和application master tasks的环境变量。

例如,以下为mappers和reducers设置环境变量FOO_VAR = barLIST_VAR = a,b,c

bin/hadoop jar hadoop-mapreduce-examples-<ver>.jar wordcount -Dmapreduce.map.env.FOO_VAR=bar -Dmapreduce.map.env.LIST_VAR=a,b,c -Dmapreduce.reduce.env.FOO_VAR=bar -Dmapreduce.reduce.env.LIST_VAR=a,b,c input output

实战演练

下面的WordCount应用程序非常简单:

public void map(Object key, Text value, Context context) throws IOException, InterruptedException {
  StringTokenizer itr = new StringTokenizer(value.toString());
  while (itr.hasMoreTokens()) {
    word.set(itr.nextToken());
    context.write(word, one);
  }
}

Mapper实现通过map方法一次处理一行,这由指定的TextInputFormat提供。然后,它通过StringTokenizer根据空格将行进行分隔,并生成键值对<<word>,1>

对于给定的样本输入,第一个map生成的键值对:

< Hello, 1>
< World, 1>
< Bye, 1>
< World, 1>

第二个map生成的键值对:

< Hello, 1>
< Hadoop, 1>
< Goodbye, 1>
< Hadoop, 1>

在本教程的后面部分,我们将详细了解为给定任务生成的Map数量,以及如何以精细的方式控制它们。


job.setCombinerClass(IntSumReducer.class);

WordCount还指定一个聚合器。因此,在对键进行排序之后,每个Map的输出都将通过本地聚合器(参数输入的类型与每一个作业配置的Reducer相同)进行本地聚合。

第一个Map的输出:

< Bye, 1>
< Hello, 1>
< World, 2>

第二个Map的输出:

< Goodbye, 1>
< Hadoop, 2>
< Hello, 1>

public void reduce(Text key, Iterable<IntWritable> values,
                   Context context
                   ) throws IOException, InterruptedException {
  int sum = 0;
  for (IntWritable val : values) {
    sum += val.get();
  }
  result.set(sum);
  context.write(key, result);
}

Reducer接口的实现中的reduce方法只是对值进行求和,这些值是每个键的出现次数(即本示例中的单词)。

因此,Job的输出为:

< Bye, 1>
< Goodbye, 1>
< Hadoop, 2>
< Hello, 2>
< World, 2>

main方法指定作业的各个方面,例如作业中的输入输出路径(通过命令行传递),键值类型,输入输出格式等。 然后,它调用job.waitForCompletion提交作业并监控其进度。

我们将在本教程稍后的部分中详细了解Job,InputFormat,OutputFormat和其他接口和类。


MapReduce-用户接口

本节提供有关MapReduce框架每个面向用户方面的合理数量的详细信息。这应该可以帮助用户以细粒度的方式实现,配置和调整作业。但是,请注意每个类或接口的javadoc仍然是最全面的文档,这仅仅是一个教程。

首先让我们使用Mapper接口和Reducer接口。应用程序通常实现它们以提供map方法和reduce方法。

然后,我们将讨论其他核心接口,包括Job,Partitioner,InputFormat,OutputFormat等。

最后,我们将讨论框架的一些有用功能,例如DistributedCache,IsolationRunner等,作为总结。


核心

应用程序通常实现Mapper和Reducer接口以提供map方法和reduce方法,这些构成了工作的核心。

Mapper

Mapper接口将输入键值对映射到一组中间键值对。

Maps是将输入数据转换为中间数据的单个任务。转换后的中间数据的类型可以不用和输入数据的类型相同。给定的输入键值对可以映射为零或多个输出键值对。

Hadoop MapReduce框架为作业的InputFormat生成的每个InputSplit生成一个map任务。

总体而言,Mapper的实现是通过Job.setMapperClass(Class)方法传递给Job作业的。然后,框架针对该任务的InputSplit中的每个键值对调用map(WritableComparable,Writable,Context)。然后,应用程序可以重写cleanup(Context)方法以执行任何必需的清理。

输出键值对的数据类型可以和输入键值对不同。给定的输入键值对可以映射为零或多个输出键值对。通过对context.write(WritableComparable,Writable)的调用来收集输出键值对。

应用程序可以使用计数器报告其统计信息。

随后,与给定输出键关联的所有中间值都由框架进行分组,并传递给Reducer,以确定最终输出。用户可以通过Job.setGroupingComparatorClass(Class)指定一个Comparator来控制分区。

Mapper的输出会进行排序,然后按每个Reducer进行分区。分区总数与作业的reduce任务总数相同。用户可以通过实现自定义分区程序来控制将哪些键(从而记录)转到哪个Reducer。

用户可以选择通过Job.setCombinerClass(Class)指定一个聚合器,以执行中间输出的本地聚合,这有助于减少从Mapper传递给Reducer的数据量。

排序的中间输出始终以简单的格式(key-len,key,value-len,value)存储。应用程序可以通过配置控制是否以及如何压缩中间输出,以及使用CompressionCodec


有多少个Map?

maps数通常由输入的总大小也即输入文件的块总数决定。

maps的正确并行度级别似乎是每个节点10-100个maps,尽管已经为非常cpu-light的map任务设置了300个maps 。任务设置需要一段时间,因此最好执行map至少一分钟。

因此,如果您期望输入的数据为10TB,块大小为128MB,则最终将获得82,000个maps,除非使用Configuration.set
(MRJobConfig.NUM_MAPS,int)(仅向框架提供提示)进行设置它甚至更高。


Reducer

Reducer对一组中间值进行归并操作,这些中间值共享一个较小值集的key。

用户通过Job.setNumReduceTasks(int)设置作业的reduces数量。

总体而言,Reducer实现是通过Job.setReducerClass(Class)方法传递作业的Job的,并且可以重写它来初始化自己。然后,框架为分组输入中的每个<key,(list of values)>键值对调用reduce(WritableComparable,Iterable ,Context)方法。然后,应用程序可以重写cleanup(Context)方法以执行任何必需的清理。

Reducer 分为三个主要阶段:shuffle,sort和reduce。


Shuffle

Reducer的输入是mappers的排序输出。在此阶段,框架通过HTTP获取所有mappers的输出的相关分区。


排序

在此阶段,框架根据键将Reducer的输入数据进行排序(因为不同的mappers可能输出相同的键)。
Shuffle阶段和排序阶段会同时进行; 在提取map的输出时会将它们进行合并。


二次排序

如果在Reducer之前要求用于分组中间键的等效规则与用于分组键的等效规则不同,则可以通过Job.setSortComparatorClass(Class)指定一个Comparator。由于Job.setGroupingComparatorClass(Class)可用于控制中间键的分组方式,因此可以结合使用这些键来模拟对值的二次排序。


Reduce

在此阶段,将对分组输入中的每个<key,(list of values)>键值对调用reduce(WritableComparable,Iterable ,Context)方法。

reduce任务的输出通常通过Context.write(WritableComparable,Writable)方法写入文件系统

应用程序可以使用计数器报告其统计信息。

Reducer的输出未排序


有多少Reduces?

reduces的正确数量似乎是0.95或1.75乘以(<节点数> * <每个节点的最大容器数>)。

使用0.95时,所有reduce都可以立即启动,并在maps完成时开始传输map的输出。当使用1.75时,更快的节点将完成其第一轮reduces,并发起第二次reduces,从而更好地完成负载平衡。

增加reduces的数量会增加框架开销,但会增加负载平衡并降低故障成本。

上面的缩放因子略小于整数,以在框架中为推测性任务和失败任务保留一些reduce的时间。


零个Reduces

如果不需要Reduces,则将Reduces任务的数量设置为零是合法的。

在这种情况下,map任务的输出将直接转到文件系统,进入FileOutputFormat.setOutputPath(Job,Path)设置的输出路径。该框架不会在将map输出写入文件系统之前对其进行排序。


分区器

分区程序对key空间进行分区。

分区器控制中间map的输出的键的分区。Key(或Key的子集)通常用于通过hash函数计算得出分区。分区总数与作业的reduce任务总数相同。因此,这控制了将中间键(以及记录)发送到m个reduce任务中的哪个reduce任务以进行reduction。

HashPartitioner是默认的分区器。


计数器

计数器是MapReduce应用程序报告其统计信息的工具。

Mapper和Reducer接口的实现可以使用Counter报告统计信息。

Hadoop MapReduce绑定了一个包含通常有用的mappers,reducers和partitioners的库。


Job的配置

Job代表MapReduce作业配置。

Job是用户向Hadoop框架描述MapReduce作业以执行的主要接口。该框架尝试按照Job的配置忠实地执行作业,然而:

Job通常用于指定Mapper,combiner(如果有),Partitioner,Reducer,InputFormat,OutputFormat的实现。 FileInputFormat指示输入文件集(FileInputFormat.setInputPaths(Job,Path…)/ FileInputFormat.addInputPath(Job,Path…))和(FileInputFormat.setInputPaths(Job,String…)/ FileInputFormat.addInputPaths(Job,String…))和输出文件应被写入的位置(FileOutputFormat.setOutputPath(Path))。

这是可选地,作业用于指定作业的其他高级方面,例如要使用的比较器,要放置在DistributedCache中的文件,是否要压缩中间和/或作业输出(以及如何压缩),是否可以执行作业任务 以推测方式执行(setMapSpeculativeExecution(boolean)/ setReduceSpeculativeExecution(boolean)),每个任务的最大尝试次数(setMaxMapAttempts(int)/ setMaxReduceAttempts(int))等。

当然,用户可以使用Configuration.set(String,String)/ Configuration.get(String)设置/获取应用程序所需的任意参数。但是,对大量(只读)数据使用DistributedCache。

任务的执行与环境

MRAppMaster在单独的jvm中将Mapper / Reducer任务作为子进程执行。

子任务继承了父MRAppMaster的环境。用户可以通过mapreduce.{map | reduce} .java.opts向child-jvm指定额外的选项和在Job配置参数,例如运行时链接程序通过非标准路径-Djava.library.path=<>搜索共享依赖库等。如果mapreduce.{map | reduce} .java.opts参数包含符号@ taskid @,则将其插入MapReduce任务的taskid值。

这是一个包含多个参数和替换项的示例,显示了jvm GC日志记录以及启动了无密码JVM JMX代理,以便它可以与jconsole等连接以监视子内存、线程并获取线程转储。它还将map和reduce的子jvm最大堆大小分别设置为512MB和1024MB。它还向child-jvm的java.library.path添加了一条额外路径。

<property>
  <name>mapreduce.map.java.opts</name>
  <value>
  -Xmx512M -Djava.library.path=/home/mycompany/lib -verbose:gc -Xloggc:/tmp/@taskid@.gc
  -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false
  </value>
</property>

<property>
  <name>mapreduce.reduce.java.opts</name>
  <value>
  -Xmx1024M -Djava.library.path=/home/mycompany/lib -verbose:gc -Xloggc:/tmp/@taskid@.gc
  -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false
  </value>
</property>

内存管理

用户或管理员还可以使用mapreduce.{map | reduce} .memory.mb指定启动的子任务以及该子任务以递归方式启动的任何子进程的最大虚拟内存。需要注意的是,此处设置的值是针对每个进程进行限制的。mapreduce.{map | reduce} .memory.mb的值应以兆字节(MB)为单位来进行指定。并且该值必须大于或等于传递给JavaVM的-Xmx,否则VM可能无法启动。

注意:mapreduce.{map | reduce} .java.opts仅用于配置从MRAppMaster启动的子任务。Hadoop守护程序的环境配置中介绍了配置守护程序的内存选项。

框架某些部分可用的内存也是可配置的。在map和reduce任务中,并发性的操作和数据撞击磁盘频率的参数的调整可能会使性能受到影响。监视文件系统中Job的计数特别是相对于从map到reduce的字节数的记数相关参数的调整而言是无价的。


Map参数

从map发出的数据将被序列化到缓冲区中,而元数据将被存储到accounting缓冲区中。如以下选项中所述,当序列化缓冲区或元数据超过阈值时,当map继续输出数据时,缓冲区的内容将被排序并在后台写入磁盘。如果在溢出过程中任一个缓冲区已完全填满,则map线程将阻塞。map过程完成后,所有剩余的数据都将写入到磁盘,并且所有磁盘上的数据片段都将合并到一个文件中。

NameTypeDescription
mapreduce.task.io.sort.mbintThe cumulative size of the serialization and accounting buffers storing records emitted from the map, in megabytes.
mapreduce.map.sort.spill.percentfloatThe soft limit in the serialization buffer. Once reached, a thread will begin to spill the contents to disk in the background.

其他注意事项:

  • 如果在泄漏的过程中超过了任一泄漏阈值,收集将继续进行直到泄漏完成为止。例如,如果将mapreduce.map.sort.spill.percent设置为0.33,并且在溢出运行时填充了缓冲区的其余部分,则下一个溢出将包括所有收集的数据或缓冲区的0.66,并且不会产生额外的泄漏。换句话说,阈值是定义触发器,而不是阻塞。
  • 大于序列化缓冲区的数据将首先触发溢出,然后溢出到一个单独的文件中。尚不确定此记录是否首先通过聚合器。

Shuffle/Reduce 参数

如前所述,每个reduce都会将分区程序通过HTTP分配给它的输出提取到内存中,并定期将这些输出合并到磁盘上。如果打开了map输出的中间压缩,则将每个输出解压缩到内存中。以下选项会影响在还原之前这些合并到磁盘的频率以及在reduce期间分配给map输出的内存。

NameTypeDescription
mapreduce.task.io.soft.factorintSpecifies the number of segments on disk to be merged at the same time. It limits the number of open files and compression codecs during merge. If the number of files exceeds this limit, the merge will proceed in several passes. Though this limit also applies to the map, most jobs should be configured so that hitting this limit is unlikely there.
mapreduce.reduce.merge.inmem.thresholdsintThe number of sorted map outputs fetched into memory before being merged to disk. Like the spill thresholds in the preceding note, this is not defining a unit of partition, but a trigger. In practice, this is usually set very high (1000) or disabled (0), since merging in-memory segments is often less expensive than merging from disk (see notes following this table). This threshold influences only the frequency of in-memory merges during the shuffle.
mapreduce.reduce.shuffle.merge.percentfloatThe memory threshold for fetched map outputs before an in-memory merge is started, expressed as a percentage of memory allocated to storing map outputs in memory. Since map outputs that can’t fit in memory can be stalled, setting this high may decrease parallelism between the fetch and merge. Conversely, values as high as 1.0 have been effective for reduces whose input can fit entirely in memory. This parameter influences only the frequency of in-memory merges during the shuffle.
mapreduce.reduce.shuffle.input.buffer.percentfloatThe percentage of memory- relative to the maximum heapsize as typically specified in mapreduce.reduce.java.opts- that can be allocated to storing map outputs during the shuffle. Though some memory should be set aside for the framework, in general it is advantageous to set this high enough to store large and numerous map outputs.
mapreduce.reduce.input.buffer.percentfloatThe percentage of memory relative to the maximum heapsize in which map outputs may be retained during the reduce. When the reduce begins, map outputs will be merged to disk until those that remain are under the resource limit this defines. By default, all map outputs are merged to disk before the reduce begins to maximize the memory available to the reduce. For less memory-intensive reduces, this should be increased to avoid trips to disk.

其他注意事项:

  • 如果map输出大于分配给复制到内存中的map输出的的25%,则将其直接写入磁盘,而无需先经过内存。
  • 当使用聚合器运行时,关于高合并阈值和大缓冲区的推理可能不成立。对于在获取所有map输出之前开始的合并,合并器将在溢出到磁盘的同时运行。在某些情况下,可以通过花费大量资源来组合map输出(使磁盘溢出量较小,并使溢出和获取并行化),而不是大幅度增加缓冲区大小,从而缩短时间。
  • 当将内存中的映射输出合并到磁盘以开始reduce时,如果因为有要溢出的片段并且至少已经在磁盘上存在mapreduce.task.io.sort.factor片段而需要进行中间合并,则内存中的map输出将成为中间合并的一部分。

配置参数

以下属性已在作业配置中本地化,以执行每个任务:

NameTypeDescription
mapreduce.job.idStringThe job id
mapreduce.job.jarStringjob.jar location in job directory
mapreduce.job.local.dirStringThe job specific shared scratch space
mapreduce.task.idStringThe task id
mapreduce.task.attempt.idStringThe task attempt id
mapreduce.task.is.mapbooleanIs this a map task
mapreduce.task.partitionintThe id of the task within the job
mapreduce.map.input.fileStringThe filename that the map is reading from
mapreduce.map.input.startlongThe offset of the start of the map input split
mapreduce.map.input.lengthlongThe number of bytes in the map input split
mapreduce.task.output.dirStringThe task’s temporary output directory

注意:在执行流作业期间,将转换“ mapreduce”参数的名称。点(.)变成下划线(_)。例如,mapreduce.job.id变为mapreduce_job_id,而mapreduce.job.jar变为mapreduce_job_jar。要在流作业的mapper和reducer中获取值,请在参数名称下加上下划线。


任务日志

NodeManager读取标准输出(stdout)和错误(stderr)流以及任务的syslog,并将其记录到$ {HADOOP_LOG_DIR}/userlogs中。


分布式依赖库

DistributedCache也可以用于分发jar和本地依赖库,以供在map任务或reduce任务中使用。child-jvm始终将其当前工作目录添加到java.library.path和LD_LIBRARY_PATH中。因此,可以通过System.loadLibrarySystem.load加载缓存中的依赖库。本地库中记录了有关如何通过分布式缓存加载共享库的更多详细信息。


作业提交和监控

Job是用户作业与ResourceManager交互的主要接口。

Job提供了提交作业,跟踪其进度,访问组件任务的报告和日志,获取MapReduce集群的状态信息等功能。

作业提交过程涉及:

  1. 检查作业的输入和输出规格。
  2. 计算作业的InputSplit值。
  3. 如有必要,为作业的DistributedCache设置必要的accounting信息。
  4. 将作业的jar和配置复制到FileSystem上的MapReduce系统目录。
  5. 将作业提交到ResourceManager并可以选择监视其状态。

作业历史记录文件也记录到用户指定的目录mapreduce.jobhistory.intermediate-done-dirmapreduce.jobhistory.done-dir,该目录默认为作业输出目录。

用户可以使用以下命令查看指定目录中的历史日志摘要:$ mapred job -history output.jhist,这个命令将打印作业详细信息,失败和终止的提示详细信息。可以使用以下命令查看有关作业的更多详细信息,例如成功的任务和为每个任务进行的任务尝试,如下所示:$ mapred job -history all output.jhist


工作控制

用户可能需要MapReduce作业链以完成无法通过单个MapReduce作业完成的复杂任务。这是相当容易的,因为作业的输出通常转到分布式文件系统,并且该输出又可以用作下一个作业的输入。

但是,这也意味着确保工作完成(成功/失败)的责任完全落在客户端身上。在这种情况下,各种作业控制选项是:


作业输入

InputFormat描述了MapReduce作业的输入规范。

MapReduce框架依靠作业的InputFormat来:

  1. 验证作业的输入规范。
  2. 将输入文件拆分为逻辑InputSplit实例,然后将每个实例分配给一个单独的Mapper。
  3. 提供RecordReader实现,该实现用于从逻辑InputSplit中收集输入数据,以供Mapper处理。

基于文件的InputFormat实现(通常是FileInputFormat的子类)的默认行为是根据输入文件的总大小(以字节为单位)将输入拆分为逻辑InputSplit实例。但是,输入文件的FileSystem块大小被视为输入拆分的上限。可以通过mapreduce.input.fileinputformat.split.minsize设置拆分大小的下限。

显然,对于许多应用程序而言,基于输入大小的逻辑拆分是不够的,因为必须遵守数据上下限边界。在这种情况下,应用程序应实现RecordReader这个接口,用来负责遵守数据上下限边界,并为单个任务提供逻辑InputSplit的面向数据的视图。

TextInputFormat是默认的InputFormat的实现。

如果TextInputFormat是给定作业的InputFormat的实现,则框架将检测带有.gz扩展名的输入文件,并使用适当的CompressionCodec自动将其解压缩。但是,必须注意,具有上述扩展名的压缩文件无法拆分,并且每个压缩文件均由单个mapper完整处理。


输入拆分

InputSplit表示要由单个Mapper处理的数据。

通常,InputSplit呈现输入的面向字节的视图,RecordReader负责处理和呈现面向数据的视图。

FileSplit是默认的InputSplit的实现。它将mapreduce.map.input.file设置为逻辑拆分的输入文件的路径。


RecordReader接口

RecordReader从InputSplit读取<key,value>键值对。

通常,RecordReader会转换InputSplit提供的输入的面向字节的视图,并将面向数据的形式呈现给Mapper实现以进行处理。因此,RecordReader承担处理数据边界的责任,并为任务提供键和值。


作业输出

OutputFormat描述MapReduce作业的输出规范。

MapReduce框架依靠作业的OutputFormat来:

  1. 验证作业的输出规格;例如,检查输出目录是否不存在。
  2. 提供用于写入作业输出文件的RecordWriter实现。输出文件存储在FileSystem中。

TextOutputFormat是OutputFormat接口的默认实现。

OutputCommitter

OutputCommitter描述了MapReduce作业的任务输出的提交。

MapReduce框架依赖于作业的OutputCommitter来:

  1. 在初始化期间设置作业。例如,在作业初始化期间为该作业创建临时输出目录。当作业处于PREP状态且初始化任务后,作业设置由单独的任务完成。设置任务完成后,作业将移至RUNNING状态。
  2. 作业完成后清理作业。例如,在作业完成后删除临时输出目录。作业清理由作业结束时的单独任务完成。清理任务完成后,作业被声明为SUCCEDED/FAILED/KILLED。
  3. 设置任务临时输出。在任务初始化期间,任务设置是同一任务的一部分。
  4. 检查任务是否需要提交。如果任务不需要提交,这将避免提交过程。
  5. 提交任务输出。任务完成后,任务将根据需要提交其输出。
  6. 放弃任务提交。如果任务失败/被杀死,输出将被清除。如果任务无法清除(在异常块中),将使用相同的try-id启动单独的任务以进行清除。

FileOutputCommitter是OutputCommitter接口的默认实现。 作业设置或清除任务会占用map或reduce容器,无论哪个NodeManager可用。 并且JobCleanup任务,TaskCleanup任务和JobSetup任务具有最高优先级,并按照这个顺序进行执行。


任务副作用文件

在某些应用程序中,组件任务需要创建或写入附带文件,这些文件与实际的作业输出文件是不同的。

在这种情况下,试图同时打开或写入文件系统上同一文件(路径)的同一Mapper或Reducer的两个实例同时运行(例如,推测性任务)可能会出现问题。因此,应用程序编写者将不得不为每个尝试执行任务的用户选择唯一的名称(使用attemptid,例如try_200709221812_0001_m_000000_0),而不仅仅是每个任务。

为避免这些问题,当OutputCommitterFileOutputCommitter时,MapReduce框架维护一个特殊的${mapreduce.output.fileoutputformat.outputdir}/_temporary/_${taskid}子目录,可通过${mapreduce.task.output.dir}访问对于存储任务尝试输出的文件系统上的每个任务尝试。成功完成任务尝试后,$ {mapreduce.output.fileoutputformat.outputdir}/_ temporary/_ $ {taskid}(仅仅是)中的文件将升级为$ {mapreduce.output.fileoutputformat.outputdir}。当然,该框架会丢弃尝试失败的子目录。此过程对应用程序完全透明。

注意:在执行特定任务尝试期间,${mapreduce.task.output.dir}的值实际上是${mapreduce.output.fileoutputformat.outputdir}/_ temporary/_ {$ taskid}
该值由MapReduce框架设置。 因此,只需在MapReduce任务的FileOutputFormat.getWorkOutputPath(Conext)返回的路径中创建任何辅助文件,即可利用此功能。

整个讨论对于具有reducer = NONE(即0reduces)的作业的map都是正确的,因为在这种情况下,map的输出直接进入HDFS。


RecordWriter

RecordWriter将输出<key, value>对写入到输出文件中。
RecordWriter接口的实现将作业输出写入FileSystem。


其他有用的功能

将作业提交到队列

用户将作业提交到队列。队列作为作业的集合,允许系统提供特定的功能。例如,队列使用ACL来控制哪些用户可以向其提交作业。队列预计主要由Hadoop Scheduler使用。

Hadoop配备了一个强制性队列,称为“default”。队列名称在Hadoop site configuration的mapreduce.job.queuename属性中定义。某些作业调度程序(例如Capacity Scheduler)支持多个队列。

作业定义了需要通过mapreduce.job.queuename属性或通过Configuration.set(MRJobConfig.QUEUE_NAME,String)API提交到的队列。设置队列名称是可选的。如果提交的作业没有关联的队列名称,则将其提交到“default”队列。


计数器

计数器代表由MapReduce框架或应用程序定义的全局计数器。每个记数器可以是任何Enum类型。特定Enum的计数器被分成Counters.Group类型的组。

应用程序可以定义任意计数器(类型为Enum),并通过map或reduce方法中的Counters.incrCounter(Enum,long)或Counters.incrCounter(String,String,long)更新它们。然后,这些计数器由框架全局汇总。


DistributedCache

DistributedCache有效地分发特定于应用程序的大型只读文件。

DistributedCache是​​MapReduce框架提供的一种工具,用于缓存应用程序所需的文件(文本,档案,jars等)。

应用程序通过作业中的URL(hdfs://)指定要缓存的文件。DistributedCache假定通过hdfs:// url指定的文件已存在于文件系统上。

在作业的任何任务在该节点上执行之前,该框架会将必需的文件复制到该工作节点。其效率源于以下事实:每个作业仅复制一次文件,以及缓存未存档在工作节点上的档案的能力。

DistributedCache跟踪缓存文件的修改时间戳。显然,在执行作业时,不应由应用程序或在外部修改缓存文件。

DistributedCache可用于分发简单的只读数据或文本文件以及更复杂的类型,例如存档和jar包。归档文件(zip,tar,tgz和tar.gz文件)在工作程序节点上未归档。文件具有执行权限设置。

可以通过设置属性mapreduce.job.cache.{files | archives}来分发文件/归档。如果必须分发多个文件或归档,则可以将它们添加为逗号分隔的路径。也可以通过API Job.addCacheFile(URI)/Job.addCacheArchive(URI)Job.setCacheFiles(URI[])/Job.setCacheArchives(URI[])设置属性,其中URI的格式为hdfs://host:port/absolute-path#link-name。在流式传输中,可以通过命令行选项-cacheFile/-cacheArchive分发文件。

私有和公共DistributedCache文件

DistributedCache文件可以是私有的也可以是公共的,这决定了如何在工作节点上共享它们。

  • “私有” DistributedCache文件被缓存在作业需要这些文件的用户专用的本地目录中。这些文件仅由特定用户的所有任务和作业共享,并且其他用户在工作节点上的作业无法访问这些文件。由于DistributedCache文件具有在其上传文件的文件系统(通常是HDFS)上的权限,因此它变得私有。如果文件没有world可读的访问权限,或者导致文件的目录路径没有world上的可执行文件访问权限,则文件将变为私有。
  • “公共” DistributedCache文件被缓存在全局目录中,并且文件访问权限已设置为对所有用户公开可见。这些文件可以由工作节点上所有用户的任务和作业共享。由于DistributedCache文件在上传文件的文件系统(通常是HDFS)上的权限而成为公共文件。如果文件具有全局可读访问权限,并且如果导致文件的目录路径具有全局可执行访问权限以进行查找,则文件将变为公用。换句话说,如果用户打算使文件对所有用户公开可用,则必须将文件权限设置为全局可读,并且指向该文件的路径上的目录权限必须是全局可执行的。

Profiling

Profiling是一种实用程序,用于获取代表性的(2或3个)内置Java分析器示例,以获取map和reduce的示例。

用户可以通过设置配置属性mapreduce.task.profile来指定系统是否应收集作业中某些任务的分析器信息。可以使用api Configuration.set(MRJobConfig.TASK_PROFILE,boolean)来设置该值。如果将该值设置为true,则启用任务分析。分析器信息存储在用户日志目录中。默认情况下,作业不会启用分析。

一旦用户配置了需要分析的用户,就可以使用配置属性mapreduce.task.profile.{maps|reduces}设置要分析的MapReduce任务的范围。可以使用api Configuration.set(MRJobConfig.NUM_ {MAP | REDUCE} _PROFILES,String)设置该值。默认情况下,指定范围是0-2。

用户还可以通过设置配置属性mapreduce.task.profile.params来指定分析器配置参数。可以使用api Configuration.set(MRJobConfig.TASK_PROFILE_PARAMS,String)指定该值。如果字符串包含%s,则在任务运行时,它将被配置文件输出文件的名称替换。这些参数通过命令行传递到任务子JVM。分析参数的默认值为-agentlib:hprof=cpu=samples,heap=sites,force=n,thread=y,verbose=n,file=%s.


调试

MapReduce框架提供了一种运行用户提供的脚本进行调试的功能。当MapReduce任务失败时,用户可以运行调试脚本来处理,例如任务日志。该脚本可以访问任务的stdout和stderr输出,syslog和jobconf。调试脚本的stdout和stderr的输出显示在诊断控制台中,也作为作业UI的一部分显示。

在以下各节中,我们讨论如何通过作业提交调试脚本。脚本文件需要分发并提交给框架。

如何分发脚本文件:

用户需要使用DistributedCache来分发和链接到脚本文件。

如何提交脚本:

提交调试脚本的一种快速方法是为mapreduce.map.debug.scriptmapreduce.reduce.debug.script属性设置值,分别用于调试map和reduce任务。也可以使用API​​ Configuration.set(MRJobConfig.MAP_DEBUG_SCRIPT,String)Configuration.set(MRJobConfig.REDUCE_DEBUG_SCRIPT,String)来设置这些属性。在流模式下,可以使用命令行选项-mapdebug-reducedebug提交调试脚本,分别用于调试map和reduce任务。

脚本的参数是任务的stdout,stderr,syslog和jobconf文件。在MapReduce任务失败的节点上运行的debug命令是: $script $stdout $stderr $syslog $jobconf

管道程序将c ++程序名称作为命令的第五个参数。因此,对于管道程序,命令为$script $stdout $stderr $syslog $jobconf $program


默认行为:

对于管道,将运行默认脚本来处理gdb下的核心转储,打印堆栈跟踪并提供有关正在运行的线程的信息。


数据压缩

Hadoop MapReduce为应用程序编写器提供了便利,以便为中间map输出和作业输出(即reduces的输出)指定压缩。它还与zlib压缩算法的CompressionCodec实现捆绑在一起。还支持gzipbzip2snappylz4文件格式。

出于性能(zlib)和Java库不可用的原因,Hadoop还提供了上述压缩编解码器的本地实现。有关其用法和可用性的更多详细信息,请参见此处

中间输出

应用程序可以通过Configuration.set(MRJobConfig.MAP_OUTPUT_COMPRESS,boolean) api和通过Configuration.set(MRJobConfig.MAP_OUTPUT_COMPRESS_CODEC,Class) api使用的CompressionCodec来控制中间map输出的压缩。

作业输出

应用程序可以通过FileOutputFormat.setCompressOutput(Job,boolean) api控制作业输出的压缩,可以通过FileOutputFormat.setOutputCompressorClass(Job,Class)api指定要使用的CompressionCodec。

如果作业输出要存储在SequenceFileOutputFormat中,则可以通过SequenceFileOutputFormat.setOutputCompressionType(Job,SequenceFile.CompressionType)api指定所需的SequenceFile.CompressionType (i.e. RECORD / BLOCK - defaults to RECORD)。


跳过Bad Records

Hadoop提供了一个选项,可以在处理map输入时跳过某些不良输入数据集。应用程序可以通过SkipBadRecords类控制此功能。

当map任务在某些输入上确定性崩溃时,可以使用此功能。这通常是由于map函数中的错误引起的。通常,用户必须修复这些错误。但是,有时这是不可能的。该错误可能在第三方库中,例如,该第三方库的源代码不可用。在这种情况下,即使多次尝试,任务也永远无法成功完成,并且作业也会失败。使用此功能,仅丢失了坏数据周围的一小部分数据,这对于某些应用程序(例如那些对非常大的数据执行统计分析的应用程序)可以接受。

默认情况下,此功能处于禁用状态。要启用它,请参阅SkipBadRecords.setMapperMaxSkipRecords(Configuration,long)SkipBadRecords.setReducerMaxSkipGroups(Configuration,long)

启用此功能后,框架会在map发生一定次数的故障后进入“skipping mode”。有关更多详细信息,请参见SkipBadRecords.setAttemptsToStartSkipping(Configuration,int)。在“skipping mode”下,map任务会维护要处理的数据范围。为此,框架依赖于已处理的数据计数器。请参阅SkipBadRecords.COUNTER_MAP_PROCESSED_RECORDSSkipBadRecords.COUNTER_REDUCE_PROCESSED_GROUPS。该计数器使框架能够知道已成功处理了多少条记录,因此知道什么记录范围导致任务崩溃。在进一步尝试时,将跳过此记录范围。

跳过的记录数取决于应用程序增加处理的记录计数器的频率。建议在处理每条记录后将该计数器递增。在某些通常分批处理的应用程序中,这可能是不可能的。在这种情况下,框架可能会跳过不良记录周围的其他记录。 用户可以通过SkipBadRecords.setMapperMaxSkipRecords(Configuration,long)SkipBadRecords.setReducerMaxSkipGroups(Configuration,long)控制跳过的记录数。该框架尝试使用类似于二分搜索的方法来缩小跳过记录的范围。跳过的范围分为两半,只有一半被执行。在后续失败时,框架会找出其中一半包含不良数据。将重新执行任务,直到达到可接受的跳过值或用尽所有任务尝试为止。要增加任务尝试次数,请使用Job.setMaxMapAttempts(int)Job.setMaxReduceAttempts(int)

跳过的数据以序列文件格式写入HDFS,以供后续的分析。可以通过SkipBadRecords.setSkipOutputPath(JobConf,Path)更改位置。


Example: WordCount v2.0

这是一个更完整的WordCount,它使用了到目前为止我们讨论的MapReduce框架提供的许多功能。

这需要HDFS能够启动并运行,尤其是对于与DistributedCache相关的功能。因此,它仅适用于伪分布式完全分布式Hadoop安装。

源代码

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.StringTokenizer;

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.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.mapreduce.Counter;
import org.apache.hadoop.util.GenericOptionsParser;
import org.apache.hadoop.util.StringUtils;

public class WordCount2 {

  public static class TokenizerMapper
       extends Mapper<Object, Text, Text, IntWritable>{

    static enum CountersEnum { INPUT_WORDS }

    private final static IntWritable one = new IntWritable(1);
    private Text word = new Text();

    private boolean caseSensitive;
    private Set<String> patternsToSkip = new HashSet<String>();

    private Configuration conf;
    private BufferedReader fis;

    @Override
    public void setup(Context context) throws IOException,
        InterruptedException {
      conf = context.getConfiguration();
      caseSensitive = conf.getBoolean("wordcount.case.sensitive", true);
      if (conf.getBoolean("wordcount.skip.patterns", false)) {
        URI[] patternsURIs = Job.getInstance(conf).getCacheFiles();
        for (URI patternsURI : patternsURIs) {
          Path patternsPath = new Path(patternsURI.getPath());
          String patternsFileName = patternsPath.getName().toString();
          parseSkipFile(patternsFileName);
        }
      }
    }

    private void parseSkipFile(String fileName) {
      try {
        fis = new BufferedReader(new FileReader(fileName));
        String pattern = null;
        while ((pattern = fis.readLine()) != null) {
          patternsToSkip.add(pattern);
        }
      } catch (IOException ioe) {
        System.err.println("Caught exception while parsing the cached file '"
            + StringUtils.stringifyException(ioe));
      }
    }

    @Override
    public void map(Object key, Text value, Context context
                    ) throws IOException, InterruptedException {
      String line = (caseSensitive) ?
          value.toString() : value.toString().toLowerCase();
      for (String pattern : patternsToSkip) {
        line = line.replaceAll(pattern, "");
      }
      StringTokenizer itr = new StringTokenizer(line);
      while (itr.hasMoreTokens()) {
        word.set(itr.nextToken());
        context.write(word, one);
        Counter counter = context.getCounter(CountersEnum.class.getName(),
            CountersEnum.INPUT_WORDS.toString());
        counter.increment(1);
      }
    }
  }

  public static class IntSumReducer
       extends Reducer<Text,IntWritable,Text,IntWritable> {
    private IntWritable result = new IntWritable();

    public void reduce(Text key, Iterable<IntWritable> values,
                       Context context
                       ) throws IOException, InterruptedException {
      int sum = 0;
      for (IntWritable val : values) {
        sum += val.get();
      }
      result.set(sum);
      context.write(key, result);
    }
  }

  public static void main(String[] args) throws Exception {
    Configuration conf = new Configuration();
    GenericOptionsParser optionParser = new GenericOptionsParser(conf, args);
    String[] remainingArgs = optionParser.getRemainingArgs();
    if ((remainingArgs.length != 2) && (remainingArgs.length != 4)) {
      System.err.println("Usage: wordcount <in> <out> [-skip skipPatternFile]");
      System.exit(2);
    }
    Job job = Job.getInstance(conf, "word count");
    job.setJarByClass(WordCount2.class);
    job.setMapperClass(TokenizerMapper.class);
    job.setCombinerClass(IntSumReducer.class);
    job.setReducerClass(IntSumReducer.class);
    job.setOutputKeyClass(Text.class);
    job.setOutputValueClass(IntWritable.class);

    List<String> otherArgs = new ArrayList<String>();
    for (int i=0; i < remainingArgs.length; ++i) {
      if ("-skip".equals(remainingArgs[i])) {
        job.addCacheFile(new Path(remainingArgs[++i]).toUri());
        job.getConfiguration().setBoolean("wordcount.skip.patterns", true);
      } else {
        otherArgs.add(remainingArgs[i]);
      }
    }
    FileInputFormat.addInputPath(job, new Path(otherArgs.get(0)));
    FileOutputFormat.setOutputPath(job, new Path(otherArgs.get(1)));

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

运行案例

  • 样本文本文件作为输入:
$ bin/hadoop fs -ls /user/joe/wordcount/input/
/user/joe/wordcount/input/file01
/user/joe/wordcount/input/file02

$ bin/hadoop fs -cat /user/joe/wordcount/input/file01
Hello World, Bye World!

$ bin/hadoop fs -cat /user/joe/wordcount/input/file02
Hello Hadoop, Goodbye to hadoop.
  • 运行应用程序:
$ bin/hadoop jar wc.jar WordCount2 /user/joe/wordcount/input /user/joe/wordcount/output
  • 输出:
$ bin/hadoop fs -cat /user/joe/wordcount/output/part-r-00000
Bye 1
Goodbye 1
Hadoop, 1
Hello 2
World! 1
World, 1
hadoop. 1
to 1
  • 请注意,输入内容与我们查看的第一个版本不同,并且它们如何影响输出。
    现在,让我们插入一个模式文件,该文件通过DistributedCache列出要忽略的单词模式。
$ bin/hadoop fs -cat /user/joe/wordcount/patterns.txt
\.
\,
\!
to
  • 再次运行它,这次有更多选择:
$ bin/hadoop jar wc.jar WordCount2 -Dwordcount.case.sensitive=true /user/joe/wordcount/input /user/joe/wordcount/output -skip /user/joe/wordcount/patterns.txt
  • 如预期的那样,输出:
$ bin/hadoop fs -cat /user/joe/wordcount/output/part-r-00000
Bye 1
Goodbye 1
Hadoop 1
Hello 2
World 2
hadoop 1
  • 再次运行它,这一次将区分大小写:
$ bin/hadoop jar wc.jar WordCount2 -Dwordcount.case.sensitive=false /user/joe/wordcount/input /user/joe/wordcount/output -skip /user/joe/wordcount/patterns.txt
  • 果然,输出:
$ bin/hadoop fs -cat /user/joe/wordcount/output/part-r-00000
bye 1
goodbye 1
hadoop 2
hello 2
horld 2

强调

WordCount的第二个版本通过使用MapReduce框架提供的一些功能对前一个版本进行了改进:

  • 演示应用程序如何在Mapper(和Reducer)实现的设置方法中访问配置参数。
  • 演示如何使用DistributedCache分发作业所需的只读数据。在这里,它允许用户指定在计数时要跳过的单词模式。
  • 演示GenericOptionsParser的实用程序来处理通用的Hadoop命令行选项。
  • 演示应用程序如何使用计数器以及如何设置传递到map(和reduce)方法的特定于应用程序的状态信息。

Java和JNI是Oracle America,Inc.在美国和其他国家/地区的商标或注册商标。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值