hadoop2.8.2 MapReduce官方教程

11 篇文章 0 订阅
5 篇文章 0 订阅

目标

这个文档从用户的角度出发,全面介绍MapRecue框架的各个方面。

先决条件

确保你的Hadoop已经安装好,并且正在运行,详细信息请查看:

单点集群:http://blog.csdn.net/maosijunzi/article/details/78395805
分布式集群:http://blog.csdn.net/maosijunzi/article/details/78396299
分布式集群实战:http://blog.csdn.net/maosijunzi/article/details/78417985

概述

Hadoop Map/Reduce是一个使用简易的软件框架,基于它写出来的应用程序能够运行在由上千个商用机器组成的大型集群上,并以一种可靠容错的方式并行处理上T级别的数据集。
一个Map/Reduce 作业(job) 通常会把输入的数据集切分为若干独立的数据块,由 map任务(task)以完全并行的方式处理它们。框架会对map的输出先进行排序, 然后把结果输入给reduce任务。通常作业的输入和输出都会被存储在文件系统中。 整个框架负责任务的调度和监控,以及重新执行已经失败的任务。
通常,Map/Reduce框架和分布式文件系统是运行在一组相同的节点上的,也就是说,计算节点和存储节点通常在一起。这种配置允许框架在那些已经存好数据的节点上高效地调度任务,这可以使整个集群的网络带宽被非常高效地利用。
MapReduce框架包含一个单独的master ResourceManager节点,以及每个
slave节点上的的NodeManager,以及每个应用的MRAppMaster。
应用程序至少应该指明输入/输出的位置(路径),并通过实现合适的接口或抽象类提供map和reduce函数。再加上其他作业的参数,就构成了作业配置(job configuration)
然后Hadoop job client提交任务(可执行的jar),并配置ResourceManager用来分配slave的责任,调度以及监控。为job-client提供状态和信息。
尽管Hadoop框架是Java实现的,但Map/Reduce应用程序则不一定要用 Java来写。

  • Hadoop Streaming是一种运行作业的实用工具,它允许用户创建和运行任何可执行程序 (例如:Shell工具)来做为mapper和reducer。
  • Hadoop Pipes是一个与SWIG兼容的C++ API (没有基于JNITM技术),它也可用于实现Map/Reduce应用程序

输入与输出

Map/Reduce框架运转在《key, value》 键值对上,也就是说, 框架把作业的输入看为是一组《key, value》 键值对,同样也产出一组 《key, value》 键值对做为作业的输出,这两组键值对的类型可能不同。

框架需要对key和value的类(classes)进行序列化操作, 因此,这些类需要实现 Writable接口。 另外,为了方便框架执行排序操作,key类必须实现 WritableComparable接口。

一个Map/Reduce 作业的输入和输出类型如下所示:

(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/jdk1.8
export PATH=${JAVA_HOME}/bin:${PATH}
export HADOOP_CLASSPATH=${JAVA_HOME}/lib/tools.jar

我在操作的过程中遇到一些防火墙导致的错误,没有找到更好的解决办法,所以就关闭了防火墙,关闭防火墙命令:

service iptables stop

编译WordCount.java

$ bin/hadoop com.sun.tools.javac.Main WordCount.java
$ jar cf wc.jar WordCount*.class

在HDFS创建一个input目录,其中有两个文件file0,file1。
output目录作为mapreduce的输出目录,会自动创建,不需要手动创建
如果手动创建会报出输出目录已存在的错误。
查看文件

$ bin/hadoop fs -ls /user/root/input/
/user/root/input/file01
/user/root/input/file02

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

$ bin/hadoop fs -cat /user/root/input/file02
Hello Hadoop Goodbye Hadoop

启动应用:

$ bin/hadoop jar wc.jar WordCount /user/root/input /user/root/output

输出日志如下:

17/11/06 19:25:23 INFO client.RMProxy: Connecting to ResourceManager at hadoop1/192.168.1.235:8032
17/11/06 19:25:26 WARN mapreduce.JobResourceUploader: Hadoop command-line option parsing not performed. Implement the Tool interface and execute your application with ToolRunner to remedy this.
17/11/06 19:25:29 INFO input.FileInputFormat: Total input paths to process : 2
17/11/06 19:25:30 INFO mapreduce.JobSubmitter: number of splits:2
17/11/06 19:25:30 INFO mapreduce.JobSubmitter: Submitting tokens for job: job_1509967496455_0001
17/11/06 19:25:33 INFO impl.YarnClientImpl: Submitted application application_1509967496455_0001
17/11/06 19:25:33 INFO mapreduce.Job: The url to track the job: http://hadoop1:8088/proxy/application_1509967496455_0001/
17/11/06 19:25:33 INFO mapreduce.Job: Running job: job_1509967496455_0001
17/11/06 19:26:17 INFO mapreduce.Job: Job job_1509967496455_0001 running in uber mode : false
17/11/06 19:26:17 INFO mapreduce.Job:  map 0% reduce 0%
17/11/06 19:26:53 INFO mapreduce.Job:  map 50% reduce 0%
17/11/06 19:27:13 INFO mapreduce.Job:  map 100% reduce 0%
17/11/06 19:27:33 INFO mapreduce.Job:  map 100% reduce 100%
17/11/06 19:27:35 INFO mapreduce.Job: Job job_1509967496455_0001 completed successfully
17/11/06 19:27:36 INFO mapreduce.Job: Counters: 50
    File System Counters
        FILE: Number of bytes read=79
        FILE: Number of bytes written=352798
        FILE: Number of read operations=0
        FILE: Number of large read operations=0
        FILE: Number of write operations=0
        HDFS: Number of bytes read=262
        HDFS: Number of bytes written=41
        HDFS: Number of read operations=9
        HDFS: Number of large read operations=0
        HDFS: Number of write operations=2
    Job Counters 
        Killed map tasks=1
        Launched map tasks=2
        Launched reduce tasks=1
        Data-local map tasks=2
        Total time spent by all maps in occupied slots (ms)=100456
        Total time spent by all reduces in occupied slots (ms)=38516
        Total time spent by all map tasks (ms)=50228
        Total time spent by all reduce tasks (ms)=19258
        Total vcore-milliseconds taken by all map tasks=50228
        Total vcore-milliseconds taken by all reduce tasks=19258
        Total megabyte-milliseconds taken by all map tasks=77150208
        Total megabyte-milliseconds taken by all reduce tasks=38516000
    Map-Reduce Framework
        Map input records=2
        Map output records=8
        Map output bytes=82
        Map output materialized bytes=85
        Input split bytes=212
        Combine input records=8
        Combine output records=6
        Reduce input groups=5
        Reduce shuffle bytes=85
        Reduce input records=6
        Reduce output records=5
        Spilled Records=12
        Shuffled Maps =2
        Failed Shuffles=0
        Merged Map outputs=2
        GC time elapsed (ms)=423
        CPU time spent (ms)=17680
        Physical memory (bytes) snapshot=1400213504
        Virtual memory (bytes) snapshot=9864355840
        Total committed heap usage (bytes)=1256398848
    Shuffle Errors
        BAD_ID=0
        CONNECTION=0
        IO_ERROR=0
        WRONG_LENGTH=0
        WRONG_MAP=0
        WRONG_REDUCE=0
    File Input Format Counters 
        Bytes Read=50
    File Output Format Counters 
        Bytes Written=41

查看结果:

[root@hadoop1 hadoop]# bin/hadoop fs -cat output/part-r-00000
Bye 1
Goodbye 1
Hadoop  2
Hello   2
World   2

应用程序能够使用-files选项来指定一个由逗号分隔的路径列表,这些路径是task的当前工作目录。
使用选项-libjars可以向map和reduce的classpath中添加jar包。使用-archives选项程序可以传递档案文件做为参数,
这些档案文件会被解压并且在task的当前工作目录下会创建一个指向解压生成的目录的符号链接(以压缩包的名字命名)。
使用-libjars和-files运行wordcount例子:
bin/hadoop jar hadoop-mapreduce-examples-《ver》.jar wordcount -files cachefile.txt -libjars mylib.jar -archives myarchive.zip input output

解释

WordCount0应用程序的Mapper非常直截了当。

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.这个方法通过TextInputFormat每次处理一行,
然后使用StringTokenizer用空格分割,然后输出一个键值对《 《word》, 1》.
第一个map输出如下

《 Hello, 1》
《 World, 1》
《 Bye, 1》
《 World, 1》

第二个map输出如下:

《 Hello, 1》
《 Hadoop, 1》
《 Goodbye, 1》
《 Hadoop, 1》

关于组成一个指定作业的map数目的确定,以及如何以更精细的方式去控制这些map,我们将在教程的后续部分学习到更多的内容。
WordCount还指定了一个combiner。因此,每次map运行之后,会对输出按照key进行排序,然后把输出传递给本地的combiner(按照作业的配置与Reducer一样),进行本地聚合。
job.setCombinerClass(IntSumReducer.class);
那么第一个map的输出的结果就会变为:

《 Bye, 1》
《 Hello, 1》
《 World, 2》

第二个map的输出就会变为:

《 Goodbye, 1》
《 Hadoop, 2》
《 Hello, 1》

下面我们看Reducer

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求和,输出结果如下:

《 Bye, 1》
《 Goodbye, 1》
《 Hadoop, 2》
《 Hello, 2》
《 World, 2》

main方法中指定了任务的参数,比如输入/输出路径(通过命令行指定)
key/value类型,入/输出的格式等等。job.waitForCompletion 方法提交任务并监控任务过程。
下面的教程,我们会介绍到Job, InputFormat, OutputFormat以及其他的类和接口。

MapReduce-用户接口

这部分文档为用户将会面临的Map/Reduce框架中的各个环节提供了适当的细节。这应该会帮助用户更细粒度地去实现、配置和调优作业。
然而,请注意每个类/接口的javadoc文档提供最全面的文档;本文只是想起到指南的作用。

我们首先讨论Mapper和Reducer接口。应用通常实现它们来提供map和reduce方法。
接下来讨论其他接口,包括Job, Partitioner, InputFormat, OutputFormat, deng
最后会介绍一些有用的特性,比如:DistributedCache, IsolationRunner等。

核心功能

Mapper和Reducer接口提供map和reduce方法,是任务的核心。

Mapper

Mapper将输入键值对(key/value pair)映射到一组中间格式的键值对集合。
Map是一类将输入记录集转换为中间格式记录集的独立任务。 这种转换的中间格式记录集不需要与输入记录集的类型一致。一个给定的输入键值对可以映射成0个或多个输出键值对。
Hadoop Map/Reduce框架为每一个InputSplit产生一个map任务,而每个InputSplit是由该作业的InputFormat产生的。
通过Job.setMapperClass(Class) 方法来指定mapper类,然后任务
中InputSplit 中的每个键值对都会调用map(WritableComparable, Writable, Context) 方法。
应用也可以覆盖cleanup(Context) 方法,来执行任何需要的清理操作。

输出键值对不需要和输入键值对有相同的类型,一个给定的输入对
会被映射为0个或多个输出对。输出通过context.write(WritableComparable, Writable)方法来收集键值对。
应用可以使用Counter 来报告它的状态。
框架随后会把与一个特定key关联的所有中间过程的值(value)分成组,然后把它们传给Reducer以产出最终的结果。用户可以通过 Job.setGroupingComparatorClass(Class)来指定具体负责分组的 Comparator。
Mapper的输出被排序后,就被划分给每个Reducer。分块的总数目和一个作业的reduce任务的数目是一样的。用户可以通过实现自定义的 Partitioner来控制哪个key被分配给哪个 Reducer。
用户可选择通过 Job.setCombinerClass(Class),指定一个combiner,它负责对中间过程的输出进行本地的聚集,这会有助于降低从Mapper到 Reducer数据传输量。
这些被排好序的中间过程的输出结果保存的格式是(key-len, key, value-len, value),应用程序可以控制对这些中间结果是否进行压缩以及怎么压缩,使用哪种 CompressionCodec。

需要多少个map?

Map的数目通常是由输入数据的大小决定的,一般就是所有输入文件的总块(block)数。

Map正常的并行规模大致是每个节点(node)大约10到100个map,对于CPU 消耗较小的map任务可以设到300个左右。由于每个任务初始化需要一定的时间,因此,比较合理的情况是map执行的时间至少超过1分钟。

这样,如果你输入10TB的数据,每个块(block)的大小是128MB,你将需要大约82,000个map来完成任务,除非使用 Configuration.set(MRJobConfig.NUM_MAPS, int) (注意:这里仅仅是对框架进行了一个提示(hint),实际决定因素见这里)将这个数值设置得更高。

Reducer

Reducer将与一个key关联的一组中间数值集归约(reduce)为一个更小的数值集。

用户可以通过Job.setNumReduceTasks(int)方法来指定reduce的数量。
通过Job.setReducerClass(Class)来指定Reducer。

框架为每个组合好的《key, (list of values)》调用reduce(WritableComparable, Iterable《Writable》, Context) 方法。
应用可以实现cleanup(Context)方法执行一些需要的清理操作。
Reducer有三个主要的阶段:shuffle,sort和reduce。

  • Shuffle:Reducer的输入就是Mapper已经排好序的输出。在这个阶段,框架通过HTTP为每个Reducer获得所有Mapper输出中与之相关的分块。
  • Sort:这个阶段,框架将按照key的值对Reducer的输入进行分组 (因为不同mapper的输出中可能会有相同的key)。
    Shuffle和Sort两个阶段是同时进行的;map的输出也是一边被取回一边被合并的。
  • Secondary Sort:如果需要中间过程对key的分组规则和reduce前对key的分组规则不同可以通过Job.setSortComparatorClass(Class)指定一个Comparator 再加Job.setGroupingComparatorClass(Class)可用于控制中间过程的key如何被分组,所以结合两者可以实现按值的二次排序。
  • Reduce:这个阶段reduce(WritableComparable, Iterable《Writable》, Context)方法会被调用。通过Context.write(WritableComparable, Writable)方法写入到输出目录。应用可以使用Counter 来报告统计状态。Reducer的输出没有排序。
多少个Reducer?

reduce的数量建议是0.95或1.75乘以(《节点数量》 * 《每个节点最大容量》).用0.95,所有reduce可以在maps一完成时就立刻启动,开始传输map的输出结果。用1.75,速度快的节点可以在完成第一轮reduce任务后,可以开始第二轮,这样可以得到比较好的负载均衡的效果。增加reduce的数目会增加整个框架的开销,但可以改善负载均衡,降低由于执行失败带来的负面影响。
上述比例因子比整体数目稍小一些是为了给框架中的推测性任务(speculative-tasks) 或失败的任务预留一些reduce的资源。

无Reducer

如果没有归约要进行,那么设置reduce任务的数目为零是合法的。这样map任务会直接写入到输出目录,通过设置FileOutputFormat.setOutputPath(Job, Path)框架在把它们写入FileSystem之前没有对它们进行排序。

Partitioner

Partitioner用于划分键值空间(key space)。Partitioner负责控制map输出结果key的分割。Key(或者一个key子集)被用于产生分区,通常使用的是Hash函数。分区的数目与一个作业的reduce任务的数目是一样的。因此,它控制将中间过程的key(也就是这条记录)应该发送给m个reduce任务中的哪一个来进行reduce操作。
HashPartitioner是默认的 Partitioner。

Counter

使用Counter来报告统计。

Hadoop Map/Reduce框架附带了一个包含许多实用型的mapper、reducer和partitioner 的类库。

任务配置

Job代表一个Map/Reduce作业的配置。

Job作为主要的接口,作为MapReduce的任务交给Hadoop框架来执行。
框架会按照Job提供的信息忠实的执行这个任务,然而:

  • 一些参数可能会被管理者标记为 final,这意味它们不能被更改。
  • 一些作业的参数可以被直截了当地进行设置(例如: Job.setNumReduceTasks(int)),而另一些参数则与框架或者作业的其他参数之间微妙地相互影响,并且设置起来比较复杂(例如:Configuration.set(JobContext.NUM_MAPS, int))。

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的使用是面向大规模只读数据的。

任务的执行和环境

TaskTracker是在一个单独的jvm上以子进程的形式执行 Mapper/Reducer任务(Task)的。
子进程继承MRAppMaster的环境,用户也可以通过mapreduce.{map|reduce}.java.opts
来指定附加选项。也可以使用Job指定一些非标准参数,通过-Djava.library.path=《》。如果mapreduce.{map|reduce}.java.opts
包含标识@taskid@,标识插入MapReduce任务的taskId。
下面是一个包含多个参数和替换的例子,其中包括:记录jvm GC日志; JVM JMX代理程序以无密码的方式启动,这样它就能连接到jconsole上,从而可以查看子进程的内存和线程,得到线程的dump;还把子jvm的最大堆尺寸设置为512MB, 并为子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来指定,单位是(MB),另外这个值必须大于等于通过-Xmx
指定的值,否则VM可能不能启动。
需要指出的是 mapreduce.{map|reduce}.java.opts仅仅用来配置子进程,daemons进程的内存配置,可以查看集群的文档。
框架中的其他部分的内存也是可以配置的。

map参数

map发出的数据会被序列化到一个序列化缓冲区中,并且元数据排序到排序缓冲区。如果两个缓冲区任何一个超出了阈值,缓冲区中的
内容会被排序并写入磁盘,但这个时候map仍然在继续输出,如果任意缓冲区被填满发生泄漏,map线程就会阻塞。当map结束,写入磁盘的记录以及
磁盘上所有的部分都会整合到一个单独的文件中。越少的数量泄露到磁盘,就会有越少的map时间,但是大的缓冲区会减少mapper可用内存。

名称类型描述
mapreduce.task.io.sort.mbint序列化和排序缓冲区大小,MB
mapreduce.map.sort.spill.percentfloat序列化缓冲区软限制。一旦达到,会启动一个线程泄露数据到硬盘

注意:
-如果任意泄露阈值超出,那么就会触发泄露。泄露完成之后才会继续收集。举个例子。mapreduce.map.sort.spill.percent设置为
0.33,剩下的缓冲区填满了,那么就发生泄露,下次泄露会包括所有收集到的数据,或者是缓冲区的0.66,并不会生成额外的泄露。
换句话说,阈值定义的是触发条件。
-一条记录大于序列化缓冲区的大小,会首先触发一个泄露,泄露会分割到文件中。并没有定义,是否这条记录会通过combiner。

Shuffle/Reduce参数

就像之前描述的那样,每个reduce通过Partitioner的HTTP来获取分配给它的输出,并且定时合并他们到硬盘。
如果map的压缩开启,每个输出也会解压到内存。下面的参数,影响合并到硬盘的频率和内存分配。

名称类型描述
mapreduce.task.io.soft.factorint指定一次合并到硬盘上的数量。如果文件数量超过此限制,会分几次处理。
mapreduce.reduce.merge.inmem.thresholdsint排序的map输出在被合并到硬盘之前发到内存中的个数。像泄露阈值一样,不是定义一部分而是一个触发,实战中,这个值通常设置的很高(1000)或者是0.因为在内存中合并要比硬盘中快的多。这个阈值仅影响内存操作。
mapreduce.reduce.shuffle.merge.percentfloat在内存中开始合并map outputs之前,取出map outputs的内存阈值,表示为分配给map outputs分类的内存在内存中的百分比。因为,map outputs不适合在内存中滞留,因此,设置较高的阈值,可能会降低获取和合并的并行操作。相反,高达1的阈值,对reduces的内存输入更加有效。这个设置的阈值,仅对于shuffle过程中的合并频率产生影响。
mapreduce.reduce.shuffle.input.buffer.percentfloat内存百分比,相对于在 mapreduce.reduce.java.opts中指定的最大的块大小,在shuffle过程中,能分配给map outputs的分组内存。虽然应该为框架预留一些内存,一般来说,该参数设置得足够高,去存储大量数据和大量的map outputs是有利的。
mapreduce.reduce.input.buffer.percentfloat内存百分比,相对于map outputs到reduce之间最大的块大小。当reduce开始运行时, map outputs将被合并到磁盘,直到这些记录在这个定义的资源限制以下为止。默认情况下,在reduce开始运行之前,所有的map outputs都将合并到磁盘,这样可以使reduce有最大的可用内存。内存密集型的reduces,应该调大这个值,避免读写磁盘。

注意:
-如果一个map output大于分配给复制所有map outputs的内存25%以上,那么,它将直接被写入磁盘,而不需要经过内存。
-Combiner运行时,关于高合并阈值和大缓存区的推理可能不成立。所有map outputs被取出之前,合并就已经开始了,Combiner当溢出到磁盘的时候,才开始运行。在某些情况下,通过占用资源的合并map outputs 而制造出小的溢出,好过去增大缓冲区的大小,更能达到最优reduce时间。
-当合并开始,内存中的map outputs写到磁盘同时开始reduce运行,如果中间的合并是必须的,因为存在溢出段和mapreduce.task.io.sort.factor定义的段至少会被写入磁盘,在内存中的map outputs将被合并到中间数据集中。

配置参数

为了任务执行,下列属性已本地化到job配置中。

名称类型描述
mapreduce.job.idStringjob id
mapreduce.job.jarStringjob.jar在job目录中
mapreduce.job.local.dirStringjob本地目录
mapreduce.task.idString任务id
mapreduce.task.attempt.idString尝试任务id
mapreduce.task.is.mapboolean该任务是否为map任务
mapreduce.task.partitionint任务在job中的id
mapreduce.map.input.fileStringmap读取的文件名
mapreduce.map.input.startlongmap读取文件的开始位置
mapreduce.map.input.lengthlongmap读取文件长度
mapreduce.task.output.dirString任务临时输出目录

说明:
一个流作业(streaming job)执行过程中,名称中有“mapreduce”的参数会被改变。点(.)会被变成下划线(_)。比如:mapreduce.job.id 会变成 mapreduce_job_id ,还有 mapreduce.job.jar 会变成 mapreduce_job_jar 。在流作业的mapper或reducer中,要通过带有下划线的参数名才能获取到值。

Task日志

sk的标准输出、错误输出和系统日志,可以通过NodeManager 进行读取和直接写入到 ${HADOOP_LOG_DIR}/userlogs 路径下。

分布式库

DistributedCache 也能被分布在jars和本地库之间,用到map或reduce任务中。child-jvm总是有自己的当前工作目录,这目录被添加到 java.library.path and LD_LIBRARY_PATH 。因此,缓存库可以通过 system.loadlibrary 或 system.load 进行加载。如何通过分布式缓存加载共享库的详细信息,被记录在:Native Libraries 。

作业的提交和监控

Job是用户和ResourceManager交互的主要接口。
Job负责提交作业,跟踪他们的进度,访问报告和日志,获得MapReduce集群的状态等等。
作业的提交过程包括:
1:检查输入和输出的格式
2:运算Job的InputSplit的值;
3:必要时,为Job的 DistributedCache 设置必须的统计信息;
4:复制Job的jar包到文件系统中MapReduce系统目录中;
5:提交作业给 ResourceManager 和随机监测它的状态。

Job的历史文件也会被记录在用户指定的目录:mapreduce.jobhistory.intermediate-done-dir 和 mapreduce.jobhistory.done-dir ,作为Job的默认输出目录。
用户可以用下面的命令行查看指定目录中的历史日志的概要信息:
mapredjobhistoryoutput.jhist mapred job -history all output.jhist
一般情况下,用户利用Job来创建应用程序,描述Job的各个方面,提交Job,并监测其执行情况。

Job控制

用户可能需要连接多个MapReduce Job 一起来完成一系列复杂的任务。这是相当容易的,因为Job的输出一般都存储在分布式文件系统中,反过来说,Job的output可以作下个任务的输入。
那么,这也意见着,每个客户机器,都需要确保Job已经执行完毕(不管是成功或者失败)。作业的控制方式有如下两种:
Job.submit() : 将作业提交给集群,并立即返回。
Job.waitForCompletion(boolean) : 将作业提交给集群,并等待作业完成。

Job输入

InputFormat 描述了一个MapReduce作业的输入规范。MapReduce框架依赖于InputFormat进行工作:
1:校验输入是否规范;
2:将输入文件进行逐一的拆分成,拆分成符合框架逻辑的InputSplit实例,然后每一个InputSplit实例都被分配到一个独立的Mapper。
3:RecordReader 实现了从符合框架逻辑的InputSplit实例收集输入的记录,提供给Mapper进行处理。

InputFormat的默认实现,通常是FileInputFormat的子类,对输入的文件(以byte为单位)进行分割,分割为符合框架逻辑的InputSplit实例。以文件系统的block大小,作为输入文件的分割界线。如果分隔线以下的文件,要进行分割,则可以通过mapreduce.input.fileinputformat.split.minsize 进行设置。
显然,遵守记录边界,基于输入文件的大小进行逻辑分割,是不以支持许多应用程序的。在这种情况下,应用程序应该实现RecordReader ,负责关联记录边界和提出一个面向记录逻辑分割的视图给每个任务。
TextInputFormat是默认的InputFormat。
如果Job的InputFormat是由TextInputFormat提供的,框架检查文件是.gz的扩展名文件,则会自动使用合适的CompressionCodec,对文件进行自动解压。然而,需要特别指明的是,.gz的压缩文件,是不能进行分割的,每个压缩文件只能由一个Mapper进行处理。

InputSplit

InputSplit代表一个Mapper对数据进行处理
通常情况下,InputSplit提供了一个面向字节的输入,负责对RecordReader 进行处理,并提出一个面向记录的视图。
ileSplit是默认的InputSplit。它把输入的文件路径设置到mapreduce.map.input.file ,用于进行逻辑分割。

RecordReader

RecordReader从一个InputSplit中读取《key,value》。
通常RecordReader把input转换成面向字节的输入,然后提供给InputSplit,再提供一个面向记录的输入给Mapper实例进行处理。从而RecordReader承担起从记录中提取键值对的任务。

Job输出

OutputFormat 描述了MapReduce作业的输出规范。
框架依赖OutputFormat进行工作的格式为:
1:输出规范性校验;例如:检查输出目录是否存在。(输出目录不能存在)
2:提供RecordWriter实例,将Job的结果写到outputs文件中,并存放到分布式文件系统中;
TextOutputFormat是默认的 OutputFormat.

OutputCommitter

OutputCommitter描述一个已提交的MapReduce作业的任务输出。
MapReduce框架job依赖的OutputCommitter,可以用来:
1:在初始化过程中,设置Job。例如,在job初始化过程中,为Job创建临时输出目录。当Job处于预备(PREP)状态和所有的task都初始化完毕后,通过一个单独的task来配置Job。一旦配置job的task执行完成,则Job的状态会变为运行中(RUNNING)。
2:ob执行完毕,清理Job。例如,Job执行完毕后,删除掉为Job创建的临时输出目录。Job清理是由一个单独的task来执行的,在job结束后被执行。Job宣告执行完毕的状态有(SUCCEDED,FAILED,KILLED),然后完成Job的清理工作。
3:设置task的临时输出。task初始化期间,task配置将作为相同任务的一部分,在初始化过程中完成task设置。
4:检查task是否需要进行提交。这样是为了避免无用的提交。如果一个task不需要提交,那 就不提交。
5:提交task的输出。task一旦完成,如果有输出的,那么task会提交输出。
6:放弃提交任务。如果task被标志为:failed,killed,那么task的输出将被清理掉。如果task清除失败(发生异常块),那么将会启动一个单独的task(具有相同的尝试ID),继续完成task的清理工作。
FileOutputCommitter 是默认的 OutputCommitter。在NodeManager中,Job的设置和清除都会占用map或reduce的容器,不管是哪一个都是有用的。JobCleanup task TaskCleanup task JobSetup task 拥有最高的优先级,他们三个的优化级,按照该顺序。

任务副作用文件

一些应用程序,任务组件需要创建或写入数据到站点中的目录,而这个目录与最终job的输出目录又一样。两个相同实例的Mapper,Reducer同时运行(例如:推测作业),它们可能打开或写入分布式文件系统中的相同目录,在这种情况下可能会出现问题。因此,应用程序不得不为每一个尝试作业定义一个唯一的名称(使用attemptid,则表示为: attempt_200709221812_0001_m_ 000000 _0),而不是为每个作业。
框架为了避免这种问题,当OutputCommitter是FileOutputCommitter的时候,保持一个特殊的输出{mapreduce.output.fileoutputformat.outputdir}/_temporary/_{taskid},子目录可以通过 mapreduce.task.output.dir {mapreduce.output.fileoutputformat.outputdir}/temporary/ taskid {mapreduce.output.fileoutputformat.outputdir}目录中。当然,框架会丢弃掉执行失败的尝试作业其对应目录中的文件,这个过程对于应用程序是完全透明的。
在task执行过程中,通过FileOutputFormat.getWorkOutputPath(Conext),应用程序的application-writer,能够利用这个特性,在 mapreduce.task.output.dirsidefiles使 {mapreduce.task.output.dir}的值,事实上是${mapreduce.output.fileoutputformat.outputdir}/temporary/{$taskid}值,这个值是由框架来设置的。所以,刚刚通过FileOutputFormat.getWorkOutputPath(Conext)返回目录来创建的任何一个side-files,都是从MapReduce 作业利用这一特性而得来的。
整个讨论过程,适用于没有Reducer的Maps的输出,在这情况下,将直接写入到HDFS中。

RecordWriter:

RecordWriter写入《key,value》键值对到输出文件中。
RecordWriter实现将Job的导出写到文件系统中。

其他有用的特性

提交Job到队列中

用户可以把Job提交到队列(Queues)中。Queues,是一个Job集合,允许系统提供特别的功能。例如,Queues用ACLs来控制,哪些用户可以向它提交作业。Queues期待被作为Hadoop的主要调度器。Hadoop配置了一个叫“default”的强制queue。Queue的名称定义在mapreduce.job.queuename 直接下层property 节点中(Hadoop site configuration文件中)。有些作业调度器支持多个Queues,如Capacity Scheduler(容量调度器)。
一个Job要定义queue,它通过mapreduce.job.queuename属性来提交,或者通过Configuration.set(MRJobConfig.QUEUE_NAME, String)应用程序接口进行提交。设置Queue的名称是可选的。如果一个作业被提交时没有指定queue名称,那么该Job将被提交到“default”queue。

Counters

Counters 代表全局的计数器,由MapReduce框架或应用程序自行定义。每个Counter可以是任何的枚举类型。某个枚举类型的都在Counters.Group这个类型组中。在map、reduce方法中,应用程序可以通过Counters.incrCounter(Enum, long) 或者Counters.incrCounter(String, String, long) 来定义任意计数器(任意枚举型的计数器)和更新计数器。这些Counter作为全局计数器被框架进行汇总。

DistributedCache

DistributedCache 使分发应用程序指定的,大的,只读的文件更有效。DistributedCache 是一个由MapReduce框架提供的设备,用于缓存应用程序需要的文件(text,archives,jars等等)。
在Job中,应用程序可以通过Url(hdfs://)指定需要被缓存的文件。DistributedCache 会假定该文件已存在文件系统中。在Job任务在节点上运行之前,框架将会把task所需的文件复制到该slave节点中。它的功效源于一个事实,在slave节点中,每个job只能复制一次文件,它能缓存未归档的文件。DistributedCache会跟踪文件修改的时间戳。显示,在Job运行过程中,缓存文件不应该被应用程序及外部修改。
DistributedCache能够被用于分发简单的,只读的数据或者文本文件和其他复杂类型的文件,如:archives,jars等等。在slave节点中,archives(zip,tar,tgz,tar.gz文件)都是未归档文件。文件具有执行权限集。
通过设置mapreduce.job.cache.{files|archives}属性,files或者archives能进行分布缓存。如果有多个文件或者归档文件想进行分布缓存,可能通过“,”逗号分隔字符串路径来指定。也可以通过应用程序接口: Job.addCacheFile(URI)/ Job.addCacheArchive(URI) and Job.setCacheFiles(URI[])/ Job.setCacheArchives(URI[]) 来设置,这里面的URI为 hdfs://host:port/absolute-path#link-name。在流作业中,还可以通过命令行:-cacheFile/-cacheArchive来设置。
DistributedCache也可以作为一种基本的软件分发机制,用于map、reduce作业中。它能被用于jar包和本地库。Job.addArchiveToClassPath(Path) 、Job.addFileToClassPath(Path) 应用程序接口,能用于缓存文件或者Jar包,也可以向child-jvm的classpath 添加。同样也可以通过配置文件 mapreduce.job.classpath.{files|archives}实现。同样的缓存文件,被系统连接到task的工作目录,能被用于分布本地库和加载。

私有和公共的DistributedCache文件

DistributedCache文件可以是公有的,也可以是私有的,这决定了这些文件如何在slave节点间共享。
私有的:DistributedCache文件被缓存在本地目录中,供私人的Job使用。这些文件共享仅给指定用户的所有的task和jobs,并不能供slaves上的其他用户使用。借用文件系统的权限来把文件变成私有的文件,这个文件系统一般为HDFS。
公有的:DistributedCache文件被缓存在全局的目录中,文件对于所有用户都是公开的,可见的。这些文件能供slaves上所有用户的task,job使用。借用文件系统的权限来把文件变成公有的文件,这个文件系统一般为HDFS。如果用户打算使一个文件变为公有的,那么文件权限设置为word readable,而且目录必须设置为executable。

分析器

分析器一个通用的程序,以获取一个有代表性的(2或3个)样本,为maps和reduces的样本而内置的java工具。用户可以指定系统是否需要为一些任务收集探测信息,可以通过配置文件属性来指定:mapreduce.task.profile。这个值也可以通过应用程序API来设置:Configuration.set(MRJobConfig.TASK_PROFILE, boolean)。如果设置为ture,作业启用分析器,探查信息存储在用户日志目录中。默认情况下,作业不启用分析器。
一旦用户启用分析器,就可以通过mapreduce.task.profile.{maps|reduces}属性,来设置分析器的分析范围。这个值也可以通过应用程序API来设置:Configuration.set(MRJobConfig.NUM_{MAP|REDUCE}_PROFILES, String)。
默认情况下,指定的范围是0-2。
用户可以指定分析器的参数,通过配置属性:mapreduce.task.profile.params。这个值也可以通过应用程序API来设置:Configuration.set(MRJobConfig.TASK_PROFILE_PARAMS, String)。如果该字符串包含”%s”,则将在任务运行时替换分析输出文件的名称。这些参数将被传递给task child JV 命令行上。分析器参数的默认值是:-agentlib:hprof=cpu=samples,heap=sites,force=n,thread=y,verbose=n,file=%s。

调试

MapReduce框架提供一个设备用于运行用户提供调试的脚本。以处理任务日志为例,当一个task运行失败时,用户可以运行一下调试脚本。脚本是获得任务的stdout和stderr输出,系统日志和jobconf。从调试脚本的stdout和stderr输出显示在控制台诊断器上,也被视为Job的UI的一部分。下面的章节,接讨论用户如何提交一个调试脚本。脚本文件需要分发并提交给框架。

如何分发脚本文件:

用户需要使用distributedcache分发和使用脚本文件。

如何提交脚本:

提交调试脚本的快速方法是为属性设置值:mapreduce.map.debug.script 和 mapreduce.reduce.debug.script ,分别用于调试map和reduce。这些属性也可以通过应用程序接口来设置:Configuration.set(MRJobConfig.MAP_DEBUG_SCRIPT, String) 和Configuration.set(MRJobConfig.REDUCE_DEBUG_SCRIPT, String)。如果在流工作中,也可以通过命令行来设置:-mapdebug 和 -reducedebug 。
脚本的参数是任务的stdout,stderr,syslog和jobconf文件。
调试命令,运行在MapReduce任务失败的节点,是:
script stdout stderr syslog $jobconf

默认行为

管道,一个默认的脚本在gdb下运行,打印堆栈跟踪,给出运行线程的信息。

数据压缩

Hadoop MapReduce为application-writer提供了一个为map-outputs和job-outputs指定压缩算法的设备。框架还附带了CompressionCodec实现的一个zlib压缩算法。gzip, bzip2, snappy, and lz4 格式的文件都支持。

中间输出

通过应用程序API:Configuration.set(MRJobConfig.MAP_OUTPUT_COMPRESS, boolean),应用程序可以控制对map-outputs进行压缩,通过应用程序API:Configuration.set(MRJobConfig.MAP_OUTPUT_COMPRESS_CODEC, Class) ,启用压缩算法CompressionCodec。

Job输出

通过应用程序API:FileOutputFormat.setCompressOutput(Job, boolean),应用程序可以控制对 job-outputs进行压缩。通过应用程序API:FileOutputFormat.setOutputCompressorClass(Job, Class),可以启用压缩算法:CompressionCodec。
如果Job-outputs存储在 SequenceFileOutputFormat中,则需要通过应用程序API:SequenceFileOutputFormat.setOutputCompressionType(Job, SequenceFile.CompressionType),指定SequenceFile.CompressionType (i.e. RECORD / BLOCK - defaults to RECORD)。

跳过不良计录

Hadoop 提供一个设置项,明确的设置了不良的输入记录,当map处理输入记录时,这些不良的输入记录将被跳过。应用程序可以通过SkipBadRecords类,来控制这个特性。此特性可以用于在明确map任务遇到损坏的输入记录的时候。这通常是由于map方法中有bug所导致的。通常情况下,用户必须修复这些bug。然而,这些bug有时候是无法修复的。例如,有些bug存在于第三方代码库,第三方代码库的代码是无法修改的。在这种情况下,task失败是必须的,即使是多次的尝试,job最终还是失败。用了这个特性,仅仅是丢弃一小部分的不良数据,对一个应用程序来讲是可以忽略的(对非常大的数据进行统计的情况下)。
默认情况下该特性是开启的。要开启,需要指定: SkipBadRecords.setMapperMaxSkipRecords(Configuration, long) 和 SkipBadRecords.setReducerMaxSkipGroups(Configuration, long)。启用这个特性,框架会在一定数量的map task失败后,进入“跳跃模式”。更多的详情见:SkipBadRecords.setAttemptsToStartSkipping(Configuration, int)。在“跳跃模式”下,map任务,将保持对其范围内的数据的处理。要做到这一点,该框架要依靠Counter(计数器)的处理结果。可以查看:SkipBadRecords.COUNTER_MAP_PROCESSED_RECORDS 和SkipBadRecords.COUNTER_REDUCE_PROCESSED_GROUPS 了解更多详情。这个Counter可以令框架知道有多少记录处理成功,多少范围的记录处理失败。再进一步尝试后,这些范围的记录就会被丢弃。
被丢弃的记录的数量依据应用程序的Counter频繁的处理记录。推荐,Counter在每个记录被处理完成后,再进行增加计数。在一些批处理应用程序中,有可能不会出现这种情况。在这种情况下,框架可以跳过不良记录周围的记录。用户可控制跳过记录的数量:SkipBadRecords.setMapperMaxSkipRecords(Configuration, long) 和SkipBadRecords.setReducerMaxSkipGroups(Configuration, long) 设置。框架将通过折半搜索,尝试缩小跳过的记录的数量。跳过过的记录被分为两半,先执行一半。在失败后,框架就能知道哪一半包含不良记录。这个任务将被重复执行,直到不良记录被找到或所有尝试任务都耗尽。想要增加尝试任务的数量,可以设置: Job.setMaxMapAttempts(int) and Job.setMaxReduceAttempts(int)。
跳过的文件会被写到HDFS中的序列格式文件中,以便后来进行分析。存储位置可以设置:SkipBadRecords.setSkipOutputPath(JobConf, Path)。

WordCount v2.0

使用上面特性实现WordCount2.0版本。首先,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);
  }
}

参考地址:http://blog.csdn.net/veechange/article/details/51114342

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值