MapReduce Tutorial文档试译

MapReduce Tutorial




目的
作为教程,本文全面描述了Hadoop MapReduce框架面向用户的各个方面。

前提条件
确保你正确安装、配置并能运行,更多细节请查看:
  • 针对初级用户的单节点安装
  • 针对大型分布式集群的集群安装

概述
Hadoop MapReduce是一种软件框架,  通过它可以很容易写出高 可靠高容错的程序,实现 由商品级硬件组成的大型集群(上千个节点)上 并行处理 海量数据。

一个MapReduce job通常将输入的数据集切分成几个独立的数据块,这些数据块交由map任务通过完全并行的方式进行处理。map任务的输出结果经过MapReduce框架排序后,作为输入交给reduce任务处理。job的输入和输出通常都存储在文件系统中。 MapReduce框架负责对任务进行调度和监控,并重新执行那些失败的任务。

计算节点和存储节点一般是同一台机器,也就是说, MapReduce计算框架和Hadoop分布式文件系统运行在相同的节点集群上。这样的配置使得 MapReduce计算框架可以在数据所在的节点上高效地调度任务,有利于节省带宽。

MapReduce框架由一个主资源管理器(ResourceManager)、每个节点上的一个从节点管理器(NodeManager)和每application一个MRAppMaster组成(参见  YARN Architecture Guide )。

一个job能够正常运行,至少, 你写的应用应该 指明输入和输出结果的位置、提供一个map和reduce函数(这俩函数需要实现适当的接口或抽象类),另外还需要某些job参数。

Hadoop job客户端把job(jar/可执行文件等等)和相关配置提交给ResourceManager,然后ResourceManager负责把程序和配置分发到各 NodeManager,调度任务并对其监控,向job客户端提供状态和诊断信息。

尽管Hadoop框架是用Java语言实现的,但MR程序不一定非要用Java来写。
  • Hadoop Streaming 允许用户把任何可执行文件(比如shell脚本)作为mapper和reducer来创建、运行job。
  • Hadoop Pipes 是一种SWIG工具,可兼容C++ API来实现MapReduce。

输入和输出

MapReduce计算框架只对<key, value>对进行操作,也就是说,MR框架将job的输入看做是一个键值对的集合,最终产生一个键值对的集合作为job的输出,当然,他们的类型可能不同。

MR框架会对键和值进行序列化,因此键和值的类应该实现Writable接口。另外,键类还要实现WritableComparable接口以利于MR框架对其进行排序。

一个MapReduce job的输入和输出类型如下:

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


例子: WordCount v1.0
在深入细节之前,我们先来通过一个例子来大致了解MapReduce应用是怎样工作的。
WordCount 很简单,它可以统计给定输入集中每个单词的出现次数。
这个例子在本地单节点、伪分布或完全分布式集群上都可以运行。

源 码
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>{                      //继承Mapper类

    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




假设:
  • /user/joe/wordcount/input - 输入在HDFS上的目录
  • /user/joe/wordcount/output - 输出在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包添加到maps和recudes的类路径中。选项-archives可以传递用逗号分隔的归档文件列表作为参数。在各个任务的输入目录中,归档文件被解包,并创建归档文件的软链接。更多命令行选项的细节请参考 Commands Guide

使用  -libjars -files  和 -archives 选项来 运行wordcount 例子 :

bin/hadoop jar /home/work/hadoop/share/hadoop/mapreduce/hadoop-mapreduce-examples-2.7.2.jar wordcount -files a.txt,b.txt  -libjars mylib.jar -archives myarchive.zip  /tmp/test/t7/ /tmp/test/t8/


此处, /tmp/test/t7/和 /tmp/test/t8/分别是HDFS上的输入目录和输出目录,a.txt、b.txt分别是输入目录下的两个文本文件。 myarchive.zip 将被放到目录 ’myarchive.zip’下,然后解包并创建软链接。

使用’#’,通过-files和-archives,用户可以为文件和归档文件指定一个不同的符号链接名称。

比如,

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


这里,文件dir1/dict.txt 和 dir2/dict.txt 可以分别通过符号链接名dict1、dict2来访问。 归档文件 mytar.tgz 将在目录’tgzdir’中建立符号链接并解包。

走马观花

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




通过map方法,Mapper  一次处理一行:使用StringTokenizer 把一行文本用空白切分成多个tokens,然后生成键值对< <word>, 1>

对给定的样本文件1,map产生:

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




第2个样本文件产生:

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




在之后的教程中,我们可以了解到一个job产生的map数量,以及如何细粒度地控制它们。


    job.setCombinerClass(IntSumReducer.class);




WordCount  还指定了一个combiner(合成器)  因此,在按照键值进行排序后,每个map的输出会传递至本地的combiner来进行本地聚合(这和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方法,该方法只是简单地把每个key出现的次数进行累加 (在本例中就是每个word出现的次数)。

最后,整个job的输出就是下面这样:

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




main方法列出了job的各种参数,比如输入、输出路径(通过命令行传递),键/值类型,输入/输出的格式等等。然后调用 job.waitForCompletion  方法来提交job并监控其运行过程。

在之后的教程中,我们将学习更多有关Job、InputFormat、OutputFormat  和其他接口与类的知识。

MapReduce - 用户接口

本节将适当提供一些用户需要了解的有关MapReduce框架的细节。希望能帮助用户以恰当的粒度实现、配置和调整他们的job。但是请注意,对每个类/接口,javadoc仍然是可获得的最全面详细的文档资料。本文仅仅作为引导教程。

我们首先来看 Mapper 和 Reducer 接口。通常,程序通过提供map和reduce方法来实现它们。

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

最后,我们将讨论框架的一些有用的特性,诸如 DistributedCache, IsolationRunner 等。

Payload


程序一般通过提供map和reduce方法来实现 Mapper 和 Reducer 。这是job的核心。
Mapper

Mapper将输入的键值对集映射成中间键值对集。

Maps(说明不止一个map) 是一个个将输入记录转化成中间记录的任务。转化而来的中间记录不一定和输入记录类型一致。一个给定的输入键值对可能映射为0个或多个输出对。

Hadoop MapReduce 计算框架针对每个InputSplit(输入切片)生成一个map任务,输入切片是根据job的 InputFormat(输入格式) 产生的。

总的来说, Mapper 先通过 Job.setMapperClass(Class)方法提交给Job去执行,然后框架在一个map任务中对InputSplit中的每个键值对调用 map(WritableComparable, Writable, Context)。程序还可以重写 cleanup(Context) 方法来执行所需的清理工作。

结果键值对不需要和输入键值对的类型保持一致。给定的输入对可能映射为0个或多个输出对。结果中的键值对集合是通过调用 context.write(WritableComparable, Writable) 来得到的。

程序可以使用 Counter 来报告其统计结果。

产生中间结果键值对后,框架会对所有键相同的值进行分组,然后传递给 Reducer(s) 来计算最终结果。用户可以通过 Job.setGroupingComparatorClass(Class)方法指定一个比较器来控制分组。

Mapper 的结果经过排序后被partitioned(分区)到各个 Reducer。partitions 的数量和job的reduce任务数相同。通过实现一个自定义的 Partitioner(分区器),用户可以控制哪些键(从而能控制哪些记录)被分到哪个 Reducer。

用户还可以通过 Job.setCombinerClass(Class)方法指定一个 combiner(合成器),当然这是可选的。 combiner 可以对中间结果执行本地聚合,有助于减少从Mapper传输到Reducer 的数据量。

被排序后的中间结果默认以简单格式(key-len, key, value-len, value) 来存储。通过配置,程序可以控制中间结果是否需要被压缩、怎样压缩以及是否要使用  CompressionCodec 接口、怎么使用。

有多少 Maps?

map的数量一般与输入的大小有关, 换句话说,跟输入文件的block(文件块)的数量有关。

尽管对于消耗CPU资源较少的map任务来说,map数量甚至可以设置到300,但每个节点上并行的map数量大约为10到100是比较合适的。任务的建立是需要消耗一些时间的,因此maps至少能执行1分钟是最好的。

因此,如果你的输入数据有10TB,而块大小为128MB,那么map个数会将近82000个。除非使用 Configuration.set(MRJobConfig.NUM_MAPS, int) 来将其设置(which only provides a hint to the framework) is used to set it even higher.

Reducer

Reducer 将属于相同键值的中间结果集reduces(归纳)为一个更小的值的集合。

reduce的数量可通过 Job.setNumReduceTasks(int)来设置。

大体流程是:首先通过  Job.setReducerClass(Class) 方法将reducer提交给job,我们还可以通过重写  Job.setReducerClass(Class) 方法来初始化各个reducer。然后,MR框架对每个<key, (list of values)>对调用  reduce(WritableComparable, Iterable<Writable>, Context) 方法。程序还可以重写 cleanup(Context) 方法来执行必要的清理工作。

Reducer 有3个主要阶段: shuffle、 sort 和 reduce。

Shuffle

Reducer 的输入是来自各个mapper的经排序后的输出。在shuffle阶段,MapReduce框架通过HTTP为每一个reducer抓取所有mapper输出中与之相关的partition。

Sort

在该阶段,MapReduce框架对Reducer 的输入按照key进行分组(由于不同的mapper可能会输出相同的key)。

shuffle 和 sort 阶段同时进行; map的输出一边被抓取,一边被合并。

Secondary Sort

如果对中间结果的keys进行分组的规则和在reduce前对keys的分组规则不同,那么用户可以通过 Job.setSortComparatorClass(Class)指定一个比较器。由于  Job.setGroupingComparatorClass(Class)可以用来控制如何对中间结果的keys进行分组,所以可以结合二者来实现对值的二次排序。

Reduce

本阶段将对已分组的输入数据中的每一个<key, (list of values)>对调用reduce(WritableComparable, Iterable<Writable>, Context) 方法。

reduce 任务的输出结果一般通过Context.write(WritableComparable, Writable)被写到文件系统中。

程序可以使用Counter报告其各项状态。

Reducer的输出结果是未排序的。

有多少 Reduces?

合适的reduce数量一般是0.95或1.75 乘以 <节点数量> 乘以 <节点最大的容器数量>。

使用0.95,所有reduce可以在maps一完成就立即启动,并开始传输map的输出结果。使用1.75,速度快的节点将会在完成其第一轮reduce任务后,启动第二波reduce任务,有利于更好的负载均衡。

reduce数量增加会增加MR框架的开销,但可以提高负载均衡、降低出错成本。

上述比例系数比整体数目略小,是因为框架要给推测行任务和失败的任务预留一些reduce资源。
Reducer NONE
如果不需要reduce,将reduce任务数量设置为0是合法的。

在这种情况下,map任务的输出结果直接存储到文件系统中由 FileOutputFormat.setOutputPath(Job, Path)指定的路径下。在将map的输出结果写入到文件系统之前,框架不会对其排序。

Partitioner

Partitioner 将key space(键空间)进行分区。

Partitioner 对map输出的中间结果按照key进行划分。key(键)(或键的子集)用于产生分区,常见的是使用哈希函数。分区数量和reduce任务数量一致。因此,partitioner控制着中间结果的key(进而也就是整条记录)会被分到m个reduce任务中中的哪一个进行reduction。

HashPartitioner是默认的Partitioner.

Counter

Counter 是MapReduce应用中的一个组件,用于报告MR的统计状态。

Mapper 和 Reducer 可以使用 Counter 报告他们的状态。

Hadoop MapReduce 附带有一个很有用的 mappers, reducers, and partitioners 的库( library)。

Job 配置

Job 代表一个MapReduce 作业的配置。

Job 是用户向MapReduce框架描述其MapReduce任务如何工作的主要接口。框架尽可能按照Job的描述取执行作业,不过:

  • 一些配置参数可能原先已被管理员标记为final(参考 Final Parameters),因此是不可更改的。

  • 有些作业参数设置很简单(比如Job.setNumReduceTasks(int)),但还有一些参数或许和MapReduce框架的其他部分和/或其他作业参数有微妙的关系,设置起来就很复杂。

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)指定。

还有些可选项:Job 可用于指定作业的一些高级参数,比如 Comparator(比较器),放在DistributedCache(分布式缓存)的文件,中间结果和/或作业的结果是否要压缩(以及怎样压缩),作业的任务是否需要以推测性方式来执行( setMapSpeculativeExecution(boolean))/  setReduceSpeculativeExecution(boolean)),每个任务的最大尝试数目( setMaxMapAttempts(int)/ setMaxReduceAttempts(int)) 等等。

当然,用户可以使用 Configuration.set(String, String)Configuration.get(String) 来设置/获得程序所需的任意参数。但是,请在面对海量(只读)数据时才使用DistributedCache。

任务执行 & 环境

在隔离的jvm环境下,Mapper /Reducer 任务作为子进程由 MRAppMaster 执行。

子进程会继承MRAppMaster的环境。用户可以通过mapreduce.{map|reduce}.java.opts 向子jvm指定额外的选项,还可以指定Job的配置参数,如通过-Djava.library.path=<>来指定一个非标准的路径作为用于搜索共享库的运行时链接等等。如果mapreduce.{map|reduce}.java.opts  参数包含符号 @taskid@,它会被替换成 MapReduce 任务的taskid。

下面是一个例子,包含多处参数和替换,展示了jvm GC 日志, JVM JMX代理的免密码 启动,以便连接到console监控子进程的内存、线程并得到线程dumps。此外,还分别将map和reduce子jvm的最大堆内存设置未512MB和1024MB。并向子jam的 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 指定子任务以及子任务递归产生的子进程的最大虚拟内存。注意此处设置的值只限于每个进程。map reduce.{map|reduce}.memory.mb 的值的单位是MB,并且该值必须大于或等于传递给JavaVM的-Xmx ,否则虚拟机无法启动。

注意: mapreduce.{map|reduce}.java.opts 只用于配置从MRAppMaster启动的子任务。守护进程的内存配置选项可参考 Configuring the Environment of the Hadoop Daemons.

MapReduce框架的一些部分的可用内存也是可配置的。在map 和 reduce 任务中,调整影响操作次数的参数和磁盘读写数据的频率会影响性能。针对一个作业,监控其文件系统——特别是涉及从map出去、进入reduce的字节数—— 对调整这些参数是非常有用的。

Map 参数

从map产生的一条记录将被序列化至一个缓冲区,其元数据将存储在 accounting 缓冲区。如以下选项所述,当序列化缓冲区或元数据缓冲区超过阈值,map在继续输出结果的同时,缓冲区的内容将在后台经排序后写入(spill)磁盘。如果在写磁盘的过程中,又有缓冲区被填满,map线程将阻塞。当map完成,所有剩下的记录都被写入磁盘,最终所有磁盘上的数据段被合并到一个文件中。减少写入到磁盘的次数可以减少map时间,但反过来说,太大的缓冲区也会减少mapper的可用内存。

Name Type Description
mapreduce.task.io.sort.mb int 序列化缓冲区和accounting缓冲区能够存储的map输出结果的累积大小,单位为MB。
mapreduce.map.sort.spill.percent float 序列化缓冲区的软限制。一旦达到这个阈值,一个线程将在后台开始把缓冲区内容刷到磁盘。

其它注意点:

  • 当一个spill正在运行,而其阈值已被超出时,则剩余所有数据都会被spill到磁盘。例如,如果mapreduce.map.sort.spill.percent 被设为0.33, 缓冲区数据达到0.33时一个spill开始工作;如果在此期间,剩余的0.66已被填满,下一个spill不是只spill0.33的数据,而是把这0.66的数据全都spill到磁盘上。又如:mapreduce.map.sort.spill.percent 被设为0.66 ,这时并不会有第二个spill。换句话说,阈值定义的是一个spill的启动时机,而不是一个阈值块。
  • 如果一条记录大于序列化缓冲区,将会首先启动一个spill,然后被spill到一个单独的文件中。这条记录是不是会先被传递给combiner(合成器)并没有定义。


Shuffle/Reduce 参数

如前所述,Partitioner(分区器)通过HTTP把map中间结果分配到各自对应的reduce上,reduce把获得的数据放入内存,然后定期将其merge(合并)到磁盘。如果map的中间结果经过压缩,则压缩后的结果会在内存中解压。下面的选项影响在reduce之前merge到磁盘的频率和reduce过程中分配给map中间结果的内存。
Name Type Description
mapreduce.task.io.soft.factor int指定同一时刻merge到磁盘上的段数量。它限定了merge期间打开的文件数和压缩编码。如果文件数量超出限制,merge会在多个阶段进行处理。虽然这个限定同样适用于map,但是大多数作业的配置应该确保map小于这个限制。
mapreduce.reduce.merge.inmem.thresholds int 在merge到磁盘之前,载入内存的map的输出结果数量——如之前所讲的spill阈值,并不是定义了partition的单元,而是一个启动值。实际上,由于在段内存中merge的开销比在磁盘中merge的开销小得多(参见该表下面的注意点),这个值往往设置的很高(1000)或被设置为禁用(0)。这个阈值仅仅影响shuffle阶段在内存中merge的频率。
mapreduce.reduce.shuffle.merge.percent float 在内存merge之前,抓取map结果的阈值是用分配给map结果的内存所占百分比来表示的。由于没有被装入内存的map结果会被阻塞,所以将该参数设置的高的话会降低抓取map结果和merge之间的并发。相反,如果一个reduce的输入可以全部装入内存,那么该参数设置为1.0可有效提高reduce的效率。只影响shuffle阶段在内存中merge的频率。
mapreduce.reduce.shuffle.input.buffer.percent float 可分配给用于存储map结果的内存所占百分比,一般是由mapreduce.reduce.java.opts指定的,这个数值和堆内存的最大值有关。虽然应该预留一些内存给MapReduce框架,但总体上将其设置的足够大是有利于存储大而多的map结果的。
mapreduce.reduce.input.buffer.percent float 与堆内存最大值相关的内存空间会保留到reduce期间。当reduce开始后,map结果会被merge到磁盘,直到剩下的量在这个参数定义的限定阈值以下。默认情况下,在reduce开始之前,所有的map结果会merge到磁盘,以给reduce腾出内存空间。对内存开销不大的reduce而言,这个参数应该调大来避免写磁盘过程中的卡顿。

其它注意点:

  • 如果map结果的数量超过分配给copymap结果的内存的25%,就会被直接写入磁盘而不会在内存中处理。

  • 如果配置了combiner,对merge高阈值和大缓冲的推测将不起作用。在所有map结果被抓取以供merge时,combiner在spill到磁盘的同时开始运行。有些情况下,用户可以通过消耗一定资源来对map输出进行combine,从而使spill的数据量更小并且提高spill和抓取(fetch)的并发,最终获得更优的reduce效果————而不是一味粗鲁地增加缓冲区大小。

  • 当把内存中的map结果merge到磁盘并开始reduce时,如果因为有些段在spill从而需要二级merge,并且至少有mapreduce.task.io.soft.factor个段已经在磁盘上,那么内存中map结果将成为二级merge的一部分。

配置参数

下面的属性是每个任务执行时,在作业配置中的本地化参数:
Name Type Description
mapreduce.job.id String The job id
mapreduce.job.jar String job.jar location in job directory
mapreduce.job.local.dir String The job specific shared scratch space
mapreduce.task.id String The task id
mapreduce.task.attempt.id String The task attempt id
mapreduce.task.is.map boolean Is this a map task
mapreduce.task.partition int The id of the task within the job
mapreduce.map.input.file String The filename that the map is reading from
mapreduce.map.input.start long The offset of the start of the map input split
mapreduce.map.input.length long The number of bytes in the map input split
mapreduce.task.output.dir String The task’s temporary output directory

Note: 在作业流执行期间,会传递参数 “mapreduce” 。点( . ) 成为下划线 ( _ )。比如, mapreduce.job.id 变成 mapreduce_job_id ;mapreduce.job.jar 变成 mapreduce_job_jar。若想得到一个作业流中mapper/reducer的属性值,请使用带有下划线的参数。

任务日志

标准输出、标准错误流和任务的系统日志会被写入到${HADOOP_LOG_DIR}/userlogs,NodeManager会读取它们。

分布是库

分布式缓存( DistributedCache )也可以被用于分布放置map和reduce任务中使用的jar包和本地库。子jvm 将其工作目录加入到java.library.path 和 LD_LIBRARY_PATH 。因此缓存起来的库可以通过  System.loadLibrarySystem.load进行加载。 g关于如何通过分布式缓存加载共享库的更多细节请参考文档  Native Libraries

作业提交和监控

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

Job 提供了一些组件,包括:提交作业、跟踪进程、访问子任务报告和日志、获取MapReduce集群状态信息等。

作业的提交过程包括:

  1. 检查作业的输入和输出的格式规范。

  2. 计算作业的InputSplit(输入切片)的值。

  3. 必要的话,为作业的分布式缓存建立所需的统计信息。

  4. 拷贝作业的jar包和配置文佳到文件系统的MapReduce系统目录

  5. 将作业提交给ResourceManager,监控其状态(可选的)。


Job历史文件默认在作业的输出目录下,也可以记录在用户定义的目录mapreduce.jobhistory.intermediate-done-dir 和 mapreduce.jobhistory.done-dir下。

用户可以使用命令$ mapred job -history output.jhist 来查看指定目录下的历史日志摘要。这个命令将打印出作业细节、失败细节和被杀死的细节。有关作业的更多细节,如成功的任务和对每个任务进行的尝试,可以使用命令 $ mapred job -history all output.jhist来查看。

通常,用户使用Job来创建应用,描述作业的各个方面,提交作业并监控其进程。


作业的控制

有些复杂的任务难以通过单独的一个MapReduce作业来完成,那么用户就需要使用一系列MapReduce作业来搞定了。由于作业一般输出到分布式分析系统上,反过来,这些输出可以被用作下个作业的输入,所以,这个任务相当easy。

不过,这也意味着用户要确保每个作业都能完成(成功或失败)。在这种情况下,多个作业控制选项如下:


作业的输入

InputFormat 描述了一个MapReduce作业的输入格式。

MapReduce框架需要依赖作业的InputFormat来:

  1. 验证作业的输入格式是否规范。

  2. 将输入文件切割成逻辑上的InputSplit 实例,每个实例随后被分给一个独立的Mapper。

  3. 提供 RecordReader的实现(因为RecordReader是抽象类) ,用于从逻辑InputSplit收集输入记录以供Mapper处理。


基于文件的InputFormat实现(一般是 FileInputFormat的子类)的默认行为是基于输入文件总字节数,将输入分割成的逻辑上的InputSplit实例。但是,文件系统上输入文件的块大小被当做输入分片(split)的上限。而分片(split)的下限可以通过mapreduce.input.fileinputformat.split.minsize来设置。

显然,对许多程序来说,由于必须考虑到输入记录的边界,因此按照输入大小来进行逻辑分片(split)是不够的。在这种情况下,程序应该实现一个 RecordReader(记录读取器) , 用于处理记录边界并向每个任务提供一个面向记录的逻辑分片视图。

TextInputFormat 是默认的InputFormat

如果一个作业的InputFormat是TextInputFormat,框架会搜索.gz后缀的输入文件并使用合适的CompressionCodec自动解压。但是,必须注意.gz后缀的压缩文件不能被切割,每个压缩文件都有一个单独的mapper进行整体处理。
InputSplit
InputSplit 代表由一个单独的Mapper进行处理的数据。

InputSplit通常显示为一个输入字节样式的视图,然后由RecordReader处理成记录样式的视图。

FileSplit  是默认的InputSplit。用来进行逻辑分片的输入文件的路径,可以通过mapreduce.map.input.file来设置。

RecordReader

RecordReader 从InputSplit 读取键值对。

RecordReader一般转换由InputSplit提供的字节样式的输入,并且以记录样式输出给程序的Mapper实现。因此RecordReader需要负责处理记录的边界并以键值对的形式提供给任务。


作业的输出

OutputFormat 描述了一个MapReduce作业的输出格式。

MapReduce框架依赖作业的OutputFormat来:

  1. 确保作业的输出格式符合规范;比如,检查输出目录不是已经存在的目录。

  2. 提供RecordWriter的实现用来输出作业的输出文件。输出文件存在文件系统上。

TextOutputFormat是默认的OutputFormat。

OutputCommitter

OutputCommitter 描述了一个MapReduce作业中各个任务结果如何提交。

MapReudce框架依赖OutputCommitter 来:

  1. 初始化阶段创建作业。 比如,在初始化作业期间,创建一个作业结果的临时目录。当各任务初始化完毕并且作业处于预备状态,一个独立的任务将作业创建出来。一旦这个创建作业的任务完成了,作业就转变成运行状态。
  2. 当作业完成后负责清理作业。比如,删除临时结果的目录。作业完成后,由一个单独的任务负责将其清理。当改任务完成后,会报告作业的状态:SUCCEDED/FAILED/KILLED。

  3. 输出任务的临时结果。 在初始化任务期间,Task setup is done as part of the same task。(??暂时没理解)

  4. 检查一个任务是否需要提交。r如果一个任务无需提交,这样可以避免无谓的提交。

  5. 任务输出的提交。一旦任务完成,如果有需要的话,任务会提交其输出结果。

  6. 中断任务提交。如果任务失败或被杀死,其输出结果会被清理。如果任务无法清理(发生异常造成了堵塞),另一个具有相同attempt-id的任务会被启动用于清理工作。


FileOutputCommitter 是默认的OutputCommitter。作业的创建/清理任务占用NodeManager上可获得的map或reduce容器。另外,作业清理、任务清理和作业创建相关的任务具有最高的优先级,此三者优先级逐次降低。

Task Side-Effect Files

在一些程序中,子任务需要创建并/或写入一些side-files,side-files和真正的作业结果文件是不同的。

在这种情况下,同一个Mapper或Reducer的两个实例同时(例如,推测性任务)尝试打开并/或写入文件系统上的同一个文件(路径)就会产生冲突。因此程序在写文件时,将不得不对每个任务尝试(一个任务可进行多次尝试)选择一个唯一的名字(使用attemptid,比如attempt_200709221812_0001_m_000000_0),而不是仅仅针对每个任务有唯一名字。

为了避免此类冲突,如果OutputCommitter是FileOutputCommitter,在存放任务尝试输出结果的文件系统上,MapReduce框架为每次任务尝试维护了一个特别的${mapreduce.output.fileoutputformat.outputdir}/_temporary/_${taskid} 子目录,这个目录可通过${mapreduce.task.output.dir}访问。如果任务尝试成功了,只有${mapreduce.output.fileoutputformat.outputdir}/_temporary/_${taskid}目录下的文件会被转移到${mapreduce.output.fileoutputformat.outputdir}目录下。当然,框架会丢弃不成功的任务尝试的子目录。这个过程对程序是完全透明的。

在任务执行期间,程序在写文件时可以利用这个特性,使用 FileOutputFormat.getWorkOutputPath(Conext)在${mapreduce.task.output.dir}目录下创建必要的side-files,另外,框架同样会将成功的任务尝试结果文件进行移动,因此无需对每个任务尝试选择一个唯一的路径。

注意:在特殊的任务尝试中,${mapreduce.task.output.dir}的值实际上是${mapreduce.output.fileoutputformat.outputdir}/_temporary/_{$taskid},并且这个值MapReduce框架设置的。因此在MapReduce任务中,只需要在 FileOutputFormat.getWorkOutputPath(Conext) 返回的路径下创建side-file就可以利用这个特性。

所有的讨论对reducer=NONE的作业(例如reduces=0)的map同样适用,因为在这种情况下,map的结果将直接输出到HDFS。

RecordWriter
RecordWriter 将产生的键值对输出到结果文件中。

RecordWriter的实现将作业结果输出到FileSystem。

其他有用的特性

将作业提交到队列

用户将作业提交至队列。作为作业的容器,队列允许系统提供特定的功能。例如,队列使用ACLs控制哪些用户可以向队列提交作业。队列主要用于Hadoop调度。

Hadoop配置了一个强制性的队列,称为'default'。队列名称在Hadoop site配置文件的mapreduce.job.queuename属性中定义。一些作业调度器,比如 Capacity Scheduler,支持多重队列。

通过mapreduce.job.queuename属性,一个作业可以定义其需要提交给哪个队列,或者也可以使用 Configuration.set(MRJobConfig.QUEUE_NAME, String) API来配置。给队列命名是可选的。如果一个作业提交到一个未命名的队列,实际上是提交到了'default'队列中。


Counters(计数器)

Counters表示的是多个由MapReduce框架或程序定义的计数器。每个Counter可以是任何一种Enum类型。一个特定Enum类型的Counters被汇合成一个类型为Counters.Group的组。

程序可以定义任意类型Enum的Counters,并且可以通过  Counters.incrCounter(Enum, long)  或map/reduce中的方法 Counters.incrCounter(String, String, long)来更新。这些counters随后会被框架汇总。

DistributedCache(分布式缓存)

DistributedCache 可以有效分发和具体应用程序相关的、大的、只读的文件。 

DistributedCache 是MapReduce框架提供的组件,用以缓存程序所需的文件(文本, 档案, jars 等等)。

程序通过作业中的urls(hdfs://)指定需要缓存的文件。 DistributedCache  假定通过hdfs://指定的文件已经存放在文件系统上。

在从节点上的所有任务执行之前,框架将把必要的文件拷贝到该节点。过程的高效源于每个作业中文件仅拷贝一次以及在从节点上解压的档案文件会被缓存下来。

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

DistributedCache能够用于分发简单的、只读的数据/文本文件以及类型更加复杂的文件如档案文件和jar文件。档案文件(zip, tar, tgz and tar.gz 文件)在从节点上解压。这些文件能够被设置执行权限。

通过设置属性mapreduce.job.cache.{files |archives},文件/档案文件可以被分发出去。如果有一个以上的文件/档案文件需要分发,这些问津啊可以加入到一个逗号分隔的路径列表。这个属性也可以通过  Job.addCacheFile(URI)Job.addCacheArchive(URI) 或 [Job.setCacheFiles(URI[])](../../api/org/apache/hadoop/mapreduce/Job.html)/ [Job.setCacheArchives(URI[])](../../api/org/apache/hadoop/mapreduce/Job.html)API来设置,其中URI是hdfs://host:port/absolute-path\#link-name的形式。在流模式下,可以通过命令行选项-cacheFile/-cacheArchive来分发文件。

DistributedCache还可以在map、reduce任务中作为一个基本的软件分发机制来使用。它可以用于分发jar文件和本地库文件。  Job.addArchiveToClassPath(Path) Job.addFileToClassPath(Path)API可以用来缓存文件/jar,并且同样把它们加入到子jvm的路径中。通过设置配置文件中的属性mapreduce.job.classpath.{files |archives},也可达到同样的效果。同样地,被软链接到任务工作目录的缓存文件可以用来分发本地库并装载它们。


私有和公共的分布式缓存文件

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

  • “Private” DistributedCache文件被缓存在本地目录,对用户来说是私有的。这些文件仅被特定的用户的所有任务和作业所共享,其他用户的作业无法访问这些文件。一个DistributedCache文件上传到文件系统上,通常是HDFS,通过其在文件系统上的权限成为私有的。如果文件没有全局读权限或其目录路径对全局不可见,那么文件就会变成私有的。
  • “Public” DistributedCache files are cached in a global directory and the file access is setup such that they are publicly visible to all users. These files can be shared by tasks and jobs of all users on the slaves. A DistributedCache file becomes public by virtue of its permissions on the file system where the files are uploaded, typically HDFS. If the file has world readable access, AND if the directory path leading to the file has world executable access for lookup, then the file becomes public. In other words, if the user intends to make a file publicly available to all users, the file permissions must be set to be world readable, and the directory permissions on the path leading to the file must be world executable.

Profiling

针对一个map或reduce样例,可使用Profiling组件来获得一个典型的(2或3)内置的java profier分析样例。

用户可以通过设置配置文件中的属性mapreduce.task.profile,来指定系统是否应该收集profiler信息。 属性值可以通过API Configuration.set(MRJobConfig.TASK_PROFILE, boolean)来修改。如果值被设为true, 则任务的profiling功能将被开启。profiler信息存储在用户日志目录下。作业的profiling功能默认是关闭的。

一旦用户认为其需要profiling功能,那么她/他可以使用配置文件中的属性mapreduce.task.profile.{maps|reduces}设置MapReduce任务需要profile的范围。这个范围可以使用 Configuration.set(MRJobConfig.NUM_{MAP|REDUCE}_PROFILES, String)来设置。默认情况下,指定的范围是0-2。

用户还可以通过设定配置文档里的属性mapreduce.task.profile.params 来指定profiler配置参数。参数值可以使用AIP Configuration.set(MRJobConfig.TASK_PROFILE_PARAMS, String)来修改。当运行任务时,如果字符串包含%s,它将会被替换成profiling的输出结果文件名。这些参数会在命令行里传递给任务的子JVM。profiling参数默认的值为agentlib:hprof=cpu=samples,heap=sites,force=n,thread=y,verbose=n,file=%s。
Debugging
MapReduce框架提供了一个机制,可以运行用户提供的调试脚本。当一个MapReduce任务失败了,用户可以进行调试,比如处理任务日志。脚本用于访问任务的标准输出和标准错误、系统日志和作业配置。调试脚本的标准输出和标准错误被打印在控制台的诊断记录中,也可以作为作业UI的一部分。

在下面几节,我们探讨如何向作业提交一个调试脚本。脚本文件需要被分发出去并提交给MapReduce框架。


如何分发脚本文件:

用户需要使用 DistributedCache 来分发脚本文件、对脚本文件做符号链接。

如何提交脚本:

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

脚本的参数是任务的标准输出、标准错误、日志和作业配置文件。在MapReduce任务失败的节点上运行的debug命令是:$script $stdout $stderr $syslog $jobconf

Pipes程序将C++程序名作为第五个命令参数。因此pipes程序的命令是$script $stdout $stderr $syslog $jobconf $program


默认行为:

对于pipes,默认的脚本使用gdb处理core dumps,打印stack trace、报告正在运行的线程的信息。

数据压缩

Hadoop MapReduce 针对需要写文件的程序提供了一套组件,指定了中间map输出和作业输出的压缩方式。框架还绑定了 CompressionCodec  的实现——  lib  压缩算法。此外还支持   gzip bzip2 snappy , 和  lz4 文件格式。

由于性能(lib)问题和Java库的缺少,Hadoop 还提供了上述压缩算法的本地实现。光宇它们的用法和使用途径的更过细节请参考  here

中间结果

通过 Configuration.set(MRJobConfig.MAP_OUTPUT_COMPRESS, boolean)程序可以控制中间map输出的压缩方式,并且可以通过 Configuration.set(MRJobConfig.MAP_OUTPUT_COMPRESS_CODEC, Class)来指定CompressionCodec。


作业输出

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

如果作业的输出要保存成  SequenceFileOutputFormat的格式 ,则可以通过SequenceFileOutputFormat.setOutputCompressionType(Job, SequenceFile.CompressionType) api 来指定需要的SequenceFile.CompressionType (即 RECORD/BLOCK——默认是RECORD)

Example: WordCount v2.0

这里有个更完整的WordCount,使用了许多到目前位置我们讨论的MapReduce框架的特性。

这个例子需要用到HDFS,特别是对于分布式缓存相关的特性。因此,本例只能工作在伪分布或完全分布的Hadoop集群上。

Source Code

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", true)) {
        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)的实现中,程序如何通过setup方法获取配置参数。

  • 示范了如何使用DistributedCache来分发作业所需的只读数据。这里它允许用户指定在统计时要忽略的单词模式。

  • 展示了GenericOptionsParser处理Hadoop命令行选项的功能。

  • 展示了程序如何使用Counters以及程序如何设置传递给map(和reduce)方法的程序状态信息。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值