Hadoop 中的大数据技术:MapReduce(2)

续 Hadoop 中的大数据技术:MapReduce(1)

第3章 MapReduce框架原理

3.1 InputFormat 数据输入
3.1.1 切片与MapTask并行度决定机制
  1. 问题引出

    • MapTask的并行度决定了Map阶段的任务处理并发程度,从而影响整个Job的处理速度。

    • 思考:对于1GB的数据,启动8个MapTask可以提高集群的并发处理能力。但对于1KB的数据,也启动8个MapTask,是否同样能提升集群性能?MapTask并行任务是否越多越好?哪些因素会影响MapTask并行度?

      分析如下

      1. 对于1KB的数据启动8个MapTask是否能提升集群性能?

        • 答案是不能。对于如此小的数据量,启动多个MapTask不仅不会提升性能,反而会因为额外的管理开销(如启动MapTask、调度、数据传输等)而降低整体效率。在这种情况下,一个MapTask就足以处理全部数据。
      2. MapTask并行任务是否越多越好?

        • 并非越多越好。MapTask的数量需要根据实际数据量、集群资源状况、任务特性等因素综合考虑。过多的MapTask会导致不必要的资源浪费,例如更多的启动时间、调度延迟和管理开销。
      3. 哪些因素会影响MapTask并行度?

        • 数据量:数据总量是决定MapTask数量的主要因素之一。通常情况下,数据量越大,可以分配的MapTask数量越多。

        • 集群资源:集群可用的CPU核心数、内存等硬件资源限制了可以同时运行的MapTask数量。

        • 数据分布:数据在HDFS上的分布方式也会影响MapTask的并行度。例如,如果数据分布在多个较小的文件中,可能会导致MapTask数量过多。

        • 任务特性:某些任务可能需要更多的计算资源或者有特殊的执行要求,这也会影响MapTask的并行度。

        • 配置参数 :Hadoop配置文件中的参数,如mapreduce.job.split.metainfo.maxsizemapreduce.input.fileinputformat.split.minsize等,可以用来控制输入切片的大小,间接影响MapTask的并行度。

        • InputFormat :不同的InputFormat实现可能有不同的切片策略,例如TextInputFormat默认按文件切片,而CombineTextInputFormat可以根据设置合并小文件。

  2. MapTask并行度决定机制

    • 数据块:Block是HDFS物理上把数据分割成的一系列块。数据块是HDFS存储数据的基本单位。
    • 数据切片:数据切片是在逻辑层面对输入数据进行分片,并不会在磁盘上将其物理切分。数据切片是MapReduce程序处理输入数据的基本单位,一个切片通常对应一个MapTask。
3.1.2 Job提交流程源码

Job提交流程源码详解

waitForCompletion()

submit();

// 1建立连接
	connect();	
		// 1)创建提交Job的代理
		new Cluster(getConfiguration());
			// (1)判断是本地运行环境还是yarn集群运行环境
			initialize(jobTrackAddr, conf); 

// 2 提交job
submitter.submitJobInternal(Job.this, cluster)

	// 1)创建给集群提交数据的Stag路径
	Path jobStagingArea = JobSubmissionFiles.getStagingDir(cluster, conf);

	// 2)获取jobid ,并创建Job路径
	JobID jobId = submitClient.getNewJobID();

	// 3)拷贝jar包到集群
copyAndConfigureFiles(job, submitJobDir);	
	rUploader.uploadFiles(job, jobSubmitDir);

	// 4)计算切片,生成切片规划文件
writeSplits(job, submitJobDir);
		maps = writeNewSplits(job, jobSubmitDir);
		input.getSplits(job);

	// 5)向Stag路径写XML配置文件
writeConf(conf, submitJobFile);
	conf.writeXml(out);

	// 6)提交Job,返回提交状态
status = submitClient.submitJob(jobId, submitJobDir.toString(), job.getCredentials());
3.1.3 FileInputFormat切片机制

FileInputFormat 的切片机制是 Hadoop MapReduce 中一个重要的组成部分,它决定了输入数据如何被分割为多个部分,以便可以并行处理。以下是 FileInputFormat 的切片机制的关键点:

1. 切片概念

  • InputSplit:这是数据的一个逻辑划分,它可以被一个 Map 任务处理。
  • FileSplit :这是 FileInputFormat 中的一种具体的 InputSplit 实现,代表了一个文件的一部分。

2. 切片大小

  • 默认情况下,切片的大小通常等于 HDFS 的块大小,但也可以通过配置进行调整。
  • 可以通过以下配置来调整切片大小:
    • mapreduce.input.fileinputformat.split.minsize:最小切片大小。
    • mapreduce.input.fileinputformat.split.maxsize:最大切片大小。
    • mapreduce.input.fileinputformat.split.ignorebadlines:是否忽略输入中的坏行。

3. 切片创建过程

  1. 获取输入文件 :首先,FileInputFormat 会获取所有输入文件的列表。
  2. 计算切片大小:基于配置计算出切片的最大和最小大小。
  3. 分割文件:对于每个文件,如果其大小小于最小切片大小,则整个文件作为一个切片;如果文件大小大于最大切片大小,则将其分割为多个切片。
  4. 创建 FileSplit 对象 :为每个切片创建一个 FileSplit 对象,该对象包含了文件路径、偏移量和长度等信息。

4. 切片示例

假设有一个 HDFS 文件,大小为 16MB,而 HDFS 的默认块大小为 128MB。如果我们想要将此文件分割为多个切片,可以按照以下步骤操作:

  1. 获取配置 :获取 mapreduce.input.fileinputformat.split.minsizemapreduce.input.fileinputformat.split.maxsize 的值。

  2. 计算切片:假设最小切片大小为 4MB,最大切片大小为 16MB,则文件将被分割为 4 个切片,每个切片大小为 4MB。

  3. 创建切片 :为每个切片创建一个 FileSplit 对象。

代码示例

以下是 FileInputFormatgetSplits 方法的大致实现:

public List<InputSplit> getSplits(JobContext job) throws IOException {
    // 获取所有输入文件
    List<FileStatus> files = listStatus(job);

    // 计算切片大小
    long minSize = job.getConfiguration().getLong("mapreduce.input.fileinputformat.split.minsize", 0);
    long maxSize = job.getConfiguration().getLong("mapreduce.input.fileinputformat.split.maxsize", Long.MAX_VALUE);

    List<InputSplit> splits = new ArrayList<>();
    for (FileStatus file : files) {
        if (file.getLen() <= minSize) {
            // 文件太小,直接作为一个切片
            splits.add(new FileSplit(file.getPath(), 0, file.getLen(), file.getBlockLocations()));
        } else {
            // 文件较大,需要根据切片大小分割
            long start = 0;
            while (start < file.getLen()) {
                long length = Math.min(maxSize, file.getLen() - start);
                if (length > minSize) {
                    splits.add(new FileSplit(file.getPath(), start, length, file.getBlockLocations()));
                }
                start += length;
            }
        }
    }

    return splits;
}
3.1.4 TextInputFormat
  1. FileInputFormat实现类
    • 在运行MapReduce程序时,需要处理各种格式的输入文件,如基于行的日志文件、二进制文件、数据库表等。针对不同的数据类型,MapReduce提供了多种InputFormat实现类,包括TextInputFormatKeyValueTextInputFormatNLineInputFormatCombineTextInputFormat等。
  2. TextInputFormat
    • TextInputFormat是默认的FileInputFormat实现类,按行读取每条记录。键是该行在整个文件中的起始字节偏移量(LongWritable类型),值是这一行的内容(Text类型),不包含行终止符。
    • 示例:假设一个切片包含以下四条文本记录:
      • Rich learning form
      • Intelligent learning engine
      • Learning more convenient
      • From the real demand for more close to the enterprise
    • 每条记录表示为以下键/值对:
      • (0,Rich learning form)
      • (20,Intelligent learning engine)
      • (49,Learning more convenient)
      • (74,From the real demand for more close to the enterprise)
3.1.5 CombineTextInputFormat切片机制
  1. 应用场景
    • CombineTextInputFormat适用于小文件过多的场景,它可以将多个小文件逻辑上合并到一个切片中,由一个MapTask处理。
  2. 虚拟存储切片最大值设置
    • 可以通过CombineTextInputFormat.setMaxInputSplitSize(job, size)来设置虚拟存储切片的最大值。设置值应根据实际情况确定。
  3. 切片机制
    • 生成切片过程分为虚拟存储过程和切片过程两部分。
      • 虚拟存储过程:根据设置的切片最大值,将输入文件逻辑上划分为多个块。
      • 切片过程:将虚拟存储的文件进一步组合成切片。
    • 示例 :假设setMaxInputSplitSize值为4MB,输入文件大小为8.02MB,则首先逻辑上划分一个4MB的块。剩余的4.02MB若按4MB逻辑划分,则会得到一个小于4MB的切片,因此将其均分为两个2.01MB的块。
3.1.6 CombineTextInputFormat案例实操

CombineTextInputFormat 是 Hadoop 中一种特殊的 InputFormat 类,它主要用于处理大量的小文件。它的主要特点是能够合并多个小文件到一个较大的切片中,以减少 Map 任务的数量,从而提高 MapReduce 作业的效率。下面是关于 CombineTextInputFormat 切片机制的解析:

CombineTextInputFormat 概述

CombineTextInputFormat 是为了应对"大量小文件"场景设计的,这类场景下的问题在于过多的小文件会导致 Map 任务的数量增加,进而导致资源管理器(如 YARN)调度负担加重。通过合并小文件到更大的切片中,可以显著减少 Map 任务的数量。

切片机制

CombineTextInputFormat 的切片机制主要体现在它的 getSplits 方法上,该方法决定了如何将输入文件合并成切片。

代码解析

下面是 CombineTextInputFormatgetSplits 方法的大致实现逻辑:

public List<InputSplit> getSplits(JobContext job) throws IOException {
    // 获取所有输入文件
    List<FileStatus> files = listStatus(job);

    // 计算切片大小
    long minSize = job.getConfiguration().getLong("mapreduce.input.fileinputformat.split.minsize", 0);
    long maxSplitSize = job.getConfiguration().getLong("mapreduce.input.fileinputformat.split.maxsize", Long.MAX_VALUE);

    List<InputSplit> splits = new ArrayList<>();
    long currentSplitSize = 0;
    List<FileStatus> currentFiles = new ArrayList<>();

    for (FileStatus file : files) {
        if (currentSplitSize + file.getLen() <= maxSplitSize) {
            // 文件可以添加到当前切片
            currentSplitSize += file.getLen();
            currentFiles.add(file);
        } else {
            // 当前切片已满,创建切片
            addSplit(splits, currentFiles, currentSplitSize, minSize);
            currentSplitSize = file.getLen();
            currentFiles.clear();
            currentFiles.add(file);
        }
    }

    // 添加最后一个切片
    addSplit(splits, currentFiles, currentSplitSize, minSize);

    return splits;
}

private void addSplit(List<InputSplit> splits, List<FileStatus> files, long size, long minSize) {
    if (size >= minSize) {
        // 创建并添加一个新的 CombineFileSplit
        Path[] paths = new Path[files.size()];
        long[] lengths = new long[files.size()];
        String[] racks = new String[files.size()];

        for (int i = 0; i < files.size(); i++) {
            FileStatus file = files.get(i);
            paths[i] = file.getPath();
            lengths[i] = file.getLen();
            racks[i] = file.getReplication() > 0 ? NetworkTopology.DEFAULT_RACK : "";
        }

        splits.add(new CombineFileSplit(paths, lengths, racks, null));
    }
}

关键点说明

  1. 合并文件
    • 遍历所有输入文件,将文件添加到当前切片中,直到达到最大切片大小。
    • 当达到最大切片大小时,创建一个 CombineFileSplit 并添加到切片列表中。
  2. 切片大小
    • 最大切片大小通过 mapreduce.input.fileinputformat.split.maxsize 配置。
    • 最小切片大小通过 mapreduce.input.fileinputformat.split.minsize 配置。
  3. CombineFileSplit
    • CombineFileSplitCombineTextInputFormat 中使用的切片类型,它包含了多个文件的信息。
    • 它包含了多个文件的路径、每个文件的长度、机架信息等。

结论

CombineTextInputFormat 通过合并多个小文件到一个较大的切片中,有效地减少了 Map 任务的数量,提高了 MapReduce 作业的执行效率。这种方法特别适用于大量小文件的场景。

3.2 MapReduce工作流程
  • 上述流程概述了整个MapReduce的工作流程,其中Shuffle过程主要关注从MapTask到ReduceTask的数据传输过程,具体步骤如下:
    • MapTask收集map()方法输出的键值对,并暂时存放在内存缓冲区中。
    • 内存缓冲区中的数据定期溢写到本地磁盘文件中,可能产生多个文件。
    • 多个溢出文件会被合并成较大的文件。
    • 溢出和合并过程中,都会调用Partitioner进行分区和排序。
    • ReduceTask根据自己的分区号,从各MapTask机器上拉取相应分区的数据。
    • ReduceTask将同一分区的不同MapTask的结果文件进行合并排序。
    • 完成合并后,Shuffle过程结束,随后进入ReduceTask的逻辑处理阶段(调用用户自定义的reduce()方法)。
  • 注意事项
    • Shuffle中的缓冲区大小会影响MapReduce程序的执行效率。原则上缓冲区越大,磁盘I/O次数越少,执行速度越快。
    • 缓冲区大小可通过参数mapreduce.task.io.sort.mb调整,默认为100MB。
3.3 Shuffle 机制详解
3.3.1 Shuffle 机制概述

Shuffle 是 MapReduce 处理流程中的一个关键阶段,位于 Map 函数之后和 Reduce 函数之前。在这个阶段,Map 任务的输出数据会被排序、分区,并传输给 Reduce 任务进行进一步处理。

3.3.2 Partition 分区

分区是 Shuffle 阶段的重要组成部分,用于决定 Map 任务的输出数据应该发送给哪个 Reduce 任务处理。分区策略决定了数据如何分布到不同的 Reduce 任务中。

3.3.3 Partition 分区案例实操

1. 需求描述

根据手机号码的归属地(前三位号码),将统计结果输出到不同的文件中。具体来说,手机号码以 136、137、138、139 开头的分别输出到四个独立的文件中,其余号码则输出到同一个文件中。

2. 需求分析

为了满足上述需求,我们需要编写一个自定义的分区类,根据手机号码的前三位来确定数据应发送给哪个 Reduce 任务。

3. 自定义分区类
package com.lzl.mapreduce.partitioner;

import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Partitioner;

public class ProvincePartitioner extends Partitioner<Text, FlowBean> {

    @Override
    public int getPartition(Text text, FlowBean flowBean, int numPartitions) {
        String phone = text.toString();
        String prePhone = phone.substring(0, 3);
        int partition;

        switch (prePhone) {
            case "136":
                partition = 0;
                break;
            case "137":
                partition = 1;
                break;
            case "138":
                partition = 2;
                break;
            case "139":
                partition = 3;
                break;
            default:
                partition = 4;
                break;
        }

        return partition;
    }
}
4. 驱动函数中的配置
package com.lzl.mapreduce.partitioner;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import java.io.IOException;

public class FlowDriver {

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

        job.setJarByClass(FlowDriver.class);
        job.setMapperClass(FlowMapper.class);
        job.setReducerClass(FlowReducer.class);

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

        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(FlowBean.class);

        job.setPartitionerClass(ProvincePartitioner.class);
        job.setNumReduceTasks(5);

        FileInputFormat.setInputPaths(job, new Path("D:\\inputflow"));
        FileOutputFormat.setOutputPath(job, new Path("D:\\partitionout"));

        boolean success = job.waitForCompletion(true);
        System.exit(success ? 0 : 1);
    }
}
3.3.4 WritableComparable 排序

为了实现自定义排序,需要让 FlowBean 类实现 WritableComparable<FlowBean> 接口,并重写 compareTo 方法。这样可以按照总流量大小对数据进行排序。

@Override
public int compareTo(FlowBean bean) {
    int result;

    if (this.sumFlow > bean.getSumFlow()) {
        result = -1;
    } else if (this.sumFlow < bean.getSumFlow()) {
        result = 1;
    } else {
        result = 0;
    }

    return result;
}
3.3.5 WritableComparable 全排序案例实操

需求说明

根据之前序列化案例的结果,对总流量进行倒序排序。

  1. 输入数据:

    原始数据

    1	13736230513	192.196.100.1	www.lzl.com	2481	24681	200
    2	13846544121	192.196.100.2			264	0	200
    3 	13956435636	192.196.100.3			132	1512	200
    4 	13966251146	192.168.100.1			240	0	404
    5 	18271575951	192.168.100.2	www.lzl.com	1527	2106	200
    6 	84188413	192.168.100.3	www.lzl.com	4116	1432	200
    7 	13590439668	192.168.100.4			1116	954	200
    8 	15910133277	192.168.100.5	www.hao123.com	3156	2936	200
    9 	13729199489	192.168.100.6			240	0	200
    10 	13630577991	192.168.100.7	www.shouhu.com	6960	690	200
    11 	15043685818	192.168.100.8	www.baidu.com	3659	3538	200
    12 	15959002129	192.168.100.9	www.lzl.com	1938	180	500
    13 	13560439638	192.168.100.10			918	4938	200
    14 	13470253144	192.168.100.11			180	180	200
    15 	13682846555	192.168.100.12	www.qq.com	1938	2910	200
    16 	13992314666	192.168.100.13	www.gaga.com	3008	3720	200
    17 	13509468723	192.168.100.14	www.qinghua.com	7335	110349	404
    18 	18390173782	192.168.100.15	www.sogou.com	9531	2412	200
    19 	13975057813	192.168.100.16	www.baidu.com	11058	48243	200
    20 	13768778790	192.168.100.17			120	120	200
    21 	13568436656	192.168.100.18	www.alibaba.com	2481	24681	200
    22 	13568436656	192.168.100.19			1116	954	200
    

    第一次处理后的数据

  2. 期望输出数据:

    手机号码 上行流量 下行流量 总流量
    13509468723 7335 110349 117684
    13736230513 2481 24681 27162
    13956435636 132 1512 1644
    13846544121 264 0 264

需求分析

对总流量进行倒序排序,输出结果按总流量从高到低排列。

代码实现

  1. FlowBean类:

    package com.lzl.mapreduce.writablecompable;
    
    import org.apache.hadoop.io.WritableComparable;
    import java.io.DataInput;
    import java.io.DataOutput;
    import java.io.IOException;
    
    public class FlowBean implements WritableComparable<FlowBean> {
    
        private long upFlow; // 上行流量
        private long downFlow; // 下行流量
        private long sumFlow; // 总流量
    
        public FlowBean() {}
    
        // Getters and Setters
        public long getUpFlow() { return upFlow; }
        public void setUpFlow(long upFlow) { this.upFlow = upFlow; }
        public long getDownFlow() { return downFlow; }
        public void setDownFlow(long downFlow) { this.downFlow = downFlow; }
        public long getSumFlow() { return sumFlow; }
        public void setSumFlow(long sumFlow) { this.sumFlow = sumFlow; }
        public void setSumFlow() { this.sumFlow = this.upFlow + this.downFlow; }
    
        // Serialization and Deserialization
        @Override
        public void write(DataOutput out) throws IOException {
            out.writeLong(this.upFlow);
            out.writeLong(this.downFlow);
            out.writeLong(this.sumFlow);
        }
    
        @Override
        public void readFields(DataInput in) throws IOException {
            this.upFlow = in.readLong();
            this.downFlow = in.readLong();
            this.sumFlow = in.readLong();
        }
    
        // To String method
        @Override
        public String toString() {
            return upFlow + "\t" + downFlow + "\t" + sumFlow;
        }
    
        // Comparison logic
        @Override
        public int compareTo(FlowBean other) {
            return Long.compare(other.sumFlow, this.sumFlow); // Inverted order for descending sort
        }
    }
    
  2. FlowMapper类:

    package com.lzl.mapreduce.writablecompable;
    
    import org.apache.hadoop.io.LongWritable;
    import org.apache.hadoop.io.Text;
    import org.apache.hadoop.mapreduce.Mapper;
    import java.io.IOException;
    
    public class FlowMapper extends Mapper<LongWritable, Text, FlowBean, Text> {
    
        private FlowBean outK = new FlowBean();
        private Text outV = new Text();
    
        @Override
        protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
            String[] fields = value.toString().split("\t");
            outK.setUpFlow(Long.parseLong(fields[1]));
            outK.setDownFlow(Long.parseLong(fields[2]));
            outK.setSumFlow();
            outV.set(fields[0]);
            context.write(outK, outV);
        }
    }
    
  3. FlowReducer类:

    package com.lzl.mapreduce.writablecompable;
    
    import org.apache.hadoop.io.Text;
    import org.apache.hadoop.mapreduce.Reducer;
    import java.io.IOException;
    
    public class FlowReducer extends Reducer<FlowBean, Text, Text, FlowBean> {
    
        @Override
        protected void reduce(FlowBean key, Iterable<Text> values, Context context) throws IOException, InterruptedException {
            for (Text value : values) {
                context.write(value, key);
            }
        }
    }
    
  4. FlowDriver类:

    package com.lzl.mapreduce.writablecompable;
    
    import org.apache.hadoop.conf.Configuration;
    import org.apache.hadoop.fs.Path;
    import org.apache.hadoop.io.Text;
    import org.apache.hadoop.mapreduce.Job;
    import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
    import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
    import java.io.IOException;
    
    public class FlowDriver {
    
        public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
            Configuration conf = new Configuration();
            Job job = Job.getInstance(conf);
            job.setJarByClass(FlowDriver.class);
            job.setMapperClass(FlowMapper.class);
            job.setReducerClass(FlowReducer.class);
            job.setMapOutputKeyClass(FlowBean.class);
            job.setMapOutputValueClass(Text.class);
            job.setOutputKeyClass(Text.class);
            job.setOutputValueClass(FlowBean.class);
            FileInputFormat.addInputPath(job, new Path("D:\\inputflow2"));
            FileOutputFormat.setOutputPath(job, new Path("D:\\comparout"));
            boolean success = job.waitForCompletion(true);
            System.exit(success ? 0 : 1);
        }
    }
    
3.3.6 WritableComparable 区内排序案例实操

需求说明

要求每个省份手机号输出的文件中按照总流量内部排序。

需求分析

基于之前的全排序需求,增加自定义分区类,分区依据为手机号的前三位。

案例实操

  1. ProvincePartitioner2类:

    package com.lzl.mapreduce.partitionercompable;
    
    import org.apache.hadoop.io.Text;
    import org.apache.hadoop.mapreduce.Partitioner;
    
    public class ProvincePartitioner2 extends Partitioner<FlowBean, Text> {
    
        @Override
        public int getPartition(FlowBean flowBean, Text text, int numPartitions) {
            String phone = text.toString();
            String prePhone = phone.substring(0, 3);
            int partition;
            switch (prePhone) {
                case "136":
                    partition = 0;
                    break;
                case "137":
                    partition = 1;
                    break;
                case "138":
                    partition = 2;
                    break;
                case "139":
                    partition = 3;
                    break;
                default:
                    partition = 4;
                    break;
            }
            return partition;
        }
    }
    
  2. FlowDriver类修改:

    // 设置自定义分区器
    job.setPartitionerClass(ProvincePartitioner2.class);
    
    // 设置ReduceTask的数量
    job.setNumReduceTasks(5);
    
3.3.7 Combiner 合并

实现步骤

  1. WordCountCombiner类:

    package com.lzl.mapreduce.combiner;
    
    import org.apache.hadoop.io.IntWritable;
    import org.apache.hadoop.io.Text;
    import org.apache.hadoop.mapreduce.Reducer;
    import java.io.IOException;
    
    public class WordCountCombiner extends Reducer<Text, IntWritable, Text, IntWritable> {
    
        private IntWritable outV = new IntWritable();
    
        @Override
        protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
            int sum = 0;
            for (IntWritable value : values) {
                sum += value.get();
            }
            outV.set(sum);
            context.write(key, outV);
        }
    }
    
  2. WordcountDriver类:

    // 指定Combiner类
    job.setCombinerClass(WordCountCombiner.class);
    
3.3.8 Combiner 合并案例实操

需求说明

统计过程中对每一个MapTask的输出进行局部汇总,以减少网络传输量。

案例实操 - 方案一

  1. WordCountCombiner类:

    package com.lzl.mapreduce.combiner;
    
    import org.apache.hadoop.io.IntWritable;
    import org.apache.hadoop.io.Text;
    import org.apache.hadoop.mapreduce.Reducer;
    import java.io.IOException;
    
    public class WordCountCombiner extends Reducer<Text, IntWritable, Text, IntWritable> {
    
        private IntWritable outV = new IntWritable();
    
        @Override
        protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
            int sum = 0;
            for (IntWritable value : values) {
                sum += value.get();
            }
            outV.set(sum);
            context.write(key, outV);
        }
    }
    
  2. WordcountDriver类:

    // 指定Combiner类
    job.setCombinerClass(WordCountCombiner.class);
    

案例实操 - 方案二

  1. 将WordcountReducer作为Combiner

    // 指定Combiner类
    job.setCombinerClass(WordCountReducer.class);
    

运行程序后,您可以看到输出数据的网络传输量显著减少。

3.4 OutputFormat 数据输出
3.4.1 OutputFormat接口实现类

Hadoop MapReduce 提供了多种内置的 OutputFormat 实现类,这些类可以用来控制 MapReduce 作业如何将结果写入文件系统。下面是一些常用的 OutputFormat 实现类及其简要说明:

  1. TextOutputFormat
    • 用途: 将结果以文本格式写入文件。
    • 特点: 最常见的输出格式,适用于大多数场景。
  2. SequenceFileOutputFormat
    • 用途: 将结果以 SequenceFile 格式写入文件。
    • 特点: SequenceFile 是一种二进制格式,支持压缩,通常用于 Hadoop 内部数据交换。
  3. DBOutputFormat
    • 用途: 将 MapReduce 结果直接写入关系型数据库。
    • 特点: 可以将结果直接存储到数据库中,适合大数据量的导入。
  4. MultipleOutputs
    • 用途: 允许将不同的键值对输出到不同的文件或目录。
    • 特点 : 通过 MultipleOutputs 可以灵活地将数据按照某些规则输出到不同的文件中。
  5. LazyOutputFormat
    • 用途: 延迟写入结果,直到作业完成。
    • 特点: 在某些情况下可以提高性能,因为它减少了中间写入操作。
  6. FileOutputFormat
    • 用途: 文件输出的基础类,其他特定的输出格式都是它的子类。
    • 特点: 提供基本的文件输出功能,如设置输出路径等。

示例代码

下面是一个简单的示例,展示了如何使用 TextOutputFormat 和自定义 OutputFormat 类:

TextOutputFormat 示例

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.output.TextOutputFormat;

public class TextOutputFormatExample {

    public static void main(String[] args) throws Exception {
        Job job = Job.getInstance();
        job.setJobName("TextOutputFormat Example");

        // 设置Mapper和Reducer类
        job.setMapperClass(TextOutputFormatExampleMapper.class);
        job.setReducerClass(TextOutputFormatExampleReducer.class);

        // 设置Map和Reduce输出类型
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(IntWritable.class);

        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(IntWritable.class);

        // 设置输出格式为TextOutputFormat
        job.setOutputFormatClass(TextOutputFormat.class);

        // 设置输入和输出路径
        FileInputFormat.addInputPath(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        // 等待作业完成
        System.exit(job.waitForCompletion(true) ? 0 : 1);
    }
}

自定义 OutputFormat 示例

import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.RecordWriter;
import org.apache.hadoop.mapreduce.TaskAttemptContext;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.IOException;

public class CustomLogOutputFormat extends FileOutputFormat<Text, NullWritable> {

    @Override
    public RecordWriter<Text, NullWritable> getRecordWriter(TaskAttemptContext taskAttemptContext)
            throws IOException, InterruptedException {
        // 创建自定义的RecordWriter实例
        return new CustomLogRecordWriter(taskAttemptContext);
    }
}

自定义RecordWriter 示例

import org.apache.hadoop.fs.FSDataOutputStream;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IOUtils;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.RecordWriter;
import org.apache.hadoop.mapreduce.TaskAttemptContext;

import java.io.IOException;

public class CustomLogRecordWriter extends RecordWriter<Text, NullWritable> {

    private FSDataOutputStream lzlOut;
    private FSDataOutputStream otherOut;

    public CustomLogRecordWriter(TaskAttemptContext job) {
        try {
            // 获取文件系统对象
            FileSystem fs = FileSystem.get(job.getConfiguration());
            // 创建两个输出流对应不同的文件
            lzlOut = fs.create(new Path("e:/lzl.log"));
            otherOut = fs.create(new Path("e:/other.log"));
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void write(Text key, NullWritable value) throws IOException, InterruptedException {
        String log = key.toString();
        // 根据日志内容决定输出到哪个文件
        if (log.contains("lzl")) {
            lzlOut.writeBytes(log + "\n");
        } else {
            otherOut.writeBytes(log + "\n");
        }
    }

    @Override
    public void close(TaskAttemptContext context) throws IOException, InterruptedException {
        // 关闭输出流
        IOUtils.closeStream(lzlOut);
        IOUtils.closeStream(otherOut);
    }
}

请注意,在实际应用中,你需要将上述代码中的类名和包名替换为你自己的命名空间。此外,还需要编写相应的 Mapper 和 Reducer 类来配合自定义的 OutputFormat 使用。

3.4.2 自定义OutputFormat案例实操

需求

过滤输入的日志文件,将包含"lzl"的记录输出到e:/lzl.log,不含"lzl"的记录输出到e:/other.log

  1. 输入数据:

    http://www.baidu.com
    http://www.google.com
    http://cn.bing.com
    http://www.lzl.com
    http://www.sohu.com
    http://www.sina.com
    http://www.sin2a.com
    http://www.sin2desa.com
    http://www.sindsafa.com
    
  2. 期望输出数据:

    • e:/lzl.log: 包含"lzl"的日志记录。
    • e:/other.log: 不含"lzl"的日志记录。

需求分析

设计一个自定义的OutputFormat来实现基于日志内容的条件性输出。

案例实操

  1. LogMapper类:

    package com.lzl.mapreduce.outputformat;
    
    import org.apache.hadoop.io.LongWritable;
    import org.apache.hadoop.io.NullWritable;
    import org.apache.hadoop.io.Text;
    import org.apache.hadoop.mapreduce.Mapper;
    
    import java.io.IOException;
    
    public class LogMapper extends Mapper<LongWritable, Text, Text, NullWritable> {
    
        @Override
        protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
            // 直接输出每行日志数据
            context.write(value, NullWritable.get());
        }
    }
    
  2. LogReducer类:

    package com.lzl.mapreduce.outputformat;
    
    import org.apache.hadoop.io.NullWritable;
    import org.apache.hadoop.io.Text;
    import org.apache.hadoop.mapreduce.Reducer;
    
    import java.io.IOException;
    
    public class LogReducer extends Reducer<Text, NullWritable, Text, NullWritable> {
    
        @Override
        protected void reduce(Text key, Iterable<NullWritable> values, Context context) throws IOException, InterruptedException {
            // 迭代输出所有相同的键值
            for (NullWritable value : values) {
                context.write(key, NullWritable.get());
            }
        }
    }
    
  3. LogOutputFormat类:

    package com.lzl.mapreduce.outputformat;
    
    import org.apache.hadoop.io.NullWritable;
    import org.apache.hadoop.io.Text;
    import org.apache.hadoop.mapreduce.RecordWriter;
    import org.apache.hadoop.mapreduce.TaskAttemptContext;
    import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
    
    import java.io.IOException;
    
    public class LogOutputFormat extends FileOutputFormat<Text, NullWritable> {
    
        @Override
        public RecordWriter<Text, NullWritable> getRecordWriter(TaskAttemptContext job) throws IOException, InterruptedException {
            // 创建一个自定义的RecordWriter并返回
            return new LogRecordWriter(job);
        }
    }
    
  4. LogRecordWriter类:

    package com.lzl.mapreduce.outputformat;
    
    import org.apache.hadoop.fs.FSDataOutputStream;
    import org.apache.hadoop.fs.FileSystem;
    import org.apache.hadoop.fs.Path;
    import org.apache.hadoop.io.IOUtils;
    import org.apache.hadoop.io.NullWritable;
    import org.apache.hadoop.io.Text;
    import org.apache.hadoop.mapreduce.RecordWriter;
    import org.apache.hadoop.mapreduce.TaskAttemptContext;
    
    import java.io.IOException;
    
    public class LogRecordWriter extends RecordWriter<Text, NullWritable> {
    
        private FSDataOutputStream lzlOut;
        private FSDataOutputStream otherOut;
    
        public LogRecordWriter(TaskAttemptContext job) {
            try {
                // 获取文件系统对象
                FileSystem fs = FileSystem.get(job.getConfiguration());
                // 创建两个输出流对应不同的文件
                lzlOut = fs.create(new Path("d:/hadoop/lzl.log"));
                otherOut = fs.create(new Path("d:/hadoop/other.log"));
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    
        @Override
        public void write(Text key, NullWritable value) throws IOException, InterruptedException {
            String log = key.toString();
            // 根据日志内容决定输出到哪个文件
            if (log.contains("lzl")) {
                lzlOut.writeBytes(log + "\n");
            } else {
                otherOut.writeBytes(log + "\n");
            }
        }
    
       @Override
        public void close(TaskAttemptContext context) throws IOException, InterruptedException {
            // 关闭输出流
            IOUtils.closeStream(lzlOut);
            IOUtils.closeStream(otherOut);
        }
    }
    
  5. LogDriver类:

    package com.lzl.mapreduce.outputformat;
    
    import org.apache.hadoop.conf.Configuration;
    import org.apache.hadoop.fs.Path;
    import org.apache.hadoop.io.NullWritable;
    import org.apache.hadoop.io.Text;
    import org.apache.hadoop.mapreduce.Job;
    import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
    import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
    
    import java.io.IOException;
    
    public class LogDriver {
    
        public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
    
            Configuration conf = new Configuration();
            Job job = Job.getInstance(conf);
    
            job.setJarByClass(LogDriver.class);
            job.setMapperClass(LogMapper.class);
            job.setReducerClass(LogReducer.class);
    
            job.setMapOutputKeyClass(Text.class);
            job.setMapOutputValueClass(NullWritable.class);
    
            job.setOutputKeyClass(Text.class);
            job.setOutputValueClass(NullWritable.class);
    
            // 设置自定义的OutputFormat
            job.setOutputFormatClass(LogOutputFormat.class);
    
            FileInputFormat.setInputPaths(job, new Path("D:\\input"));
            // 指定一个输出目录,即使它不会被直接使用
            FileOutputFormat.setOutputPath(job, new Path("D:\\logoutput"));
    
            boolean success = job.waitForCompletion(true);
            System.exit(success ? 0 : 1);
        }
    }
    
3.5 MapReduce 内核源码解析
3.5.1 MapTask 工作机制

MapTask 的工作流程可以分为以下几个主要阶段:

  1. Read 阶段 :
    • MapTask 通过 InputFormat 获得的 RecordReader 从输入 InputSplit 中解析出一个个 key/value 对。
  2. Map 阶段 :
    • 解析出的 key/value 对被传给用户编写的 map() 函数进行处理,并产生一系列新的 key/value 对。
  3. Collect 收集阶段 :
    • 在 map() 函数中,处理后的 key/value 对通过 OutputCollector.collect() 输出。在此函数内部,它会对生成的 key/value 进行分区(使用 Partitioner),并将它们写入一个环形内存缓冲区中。
  4. Spill 溢写阶段 :
    • 当环形缓冲区满后,MapReduce 会将数据写到本地磁盘上,生成一个临时文件。在写入磁盘之前,会对数据进行排序,并可能进行合并、压缩等操作。
    • 溢写阶段详情 :
      1. 利用快速排序算法对缓存区内的数据进行排序,首先按分区编号 Partition 排序,然后按 key 排序。
      2. 按照分区编号由小到大依次将每个分区中的数据写入本地临时文件 output/spillN.out(N 表示当前溢写次数)。如果配置了 Combiner,则写入文件之前,对每个分区中的数据进行一次聚集操作。
      3. 将分区数据的元信息写入内存索引数据结构 SpillRecord 中,每个分区的元信息包括在临时文件中的偏移量、压缩前数据大小和压缩后数据大小。如果当前内存索引大小超过 1MB,则将内存索引写入文件 output/spillN.out.index 中。
  5. Merge 合并阶段 :
    • 当所有数据处理完成后,MapTask 对所有临时文件进行一次合并,以确保最终只会生成一个数据文件。合并过程以分区为单位进行,采用多轮递归合并的方式,每轮合并 mapreduce.task.io.sort.factor(默认 10)个文件,直至最终得到一个大文件。
3.5.2 ReduceTask 工作机制

ReduceTask 的工作流程主要包括以下阶段:

  1. Copy 阶段 :
    • ReduceTask 从各个 MapTask 上远程拷贝数据,并根据数据大小决定是否将其写入磁盘或放入内存。
  2. Sort 排序阶段 :
    • 在远程拷贝数据的同时,ReduceTask 启动了两个后台线程对内存和磁盘上的文件进行合并,以防止内存使用过多或磁盘上文件过多。ReduceTask 通过归并排序将 key 相同的数据聚集在一起。
  3. Reduce 阶段 :
    • reduce() 函数处理聚集后的数据,并将结果写入 HDFS。
3.5.3 ReduceTask 并行度决定机制
  1. 设置 ReduceTask 并行度:

    • ReduceTask 的并行度可以通过手动设置来决定,例如:

      job.setNumReduceTasks(4);
      
  2. 实验测试:

    • 实验表明,在不同 ReduceTask 数量下,作业完成的时间有所不同。例如,在一个具有 16 个 Slave 节点的集群中,随着 ReduceTask 数量的增加,作业完成时间先减少后增加。
  3. 注意事项:

    • 注意选择合适的 ReduceTask 数量,以达到最佳性能。
3.5.4 MapTask & ReduceTask 源码解析

MapTask 源码解析流程

================== MapTask ===================
context.write(k, NullWritable.get());  // 自定义 map 方法的写出,进入
output.write(key, value);
collector.collect(key, value, partitioner.getPartition(key, value, partitions));  // HashPartitioner() 分区器
collect()  // map 端所有的 kv 全部写出后会走下面的 close 方法
close()
collector.flush()  // 溢出刷写方法
sortAndSpill()  // 溢写排序
sorter.sort()  // 快速排序
0mergeParts();  // 合并文件
collector.close();  // 收集器关闭

ReduceTask 源码解析流程

================== ReduceTask ===================
if (isMapOrReduce())
initialize()
init(shuffleContext);
totalMaps = job.getNumMapTasks();  // ShuffleSchedulerImpl
merger = createMergeManager(context);  // 合并方法
this.inMemoryMerger = createInMemoryMerger();  // 内存合并
this.onDiskMerger = new OnDiskMerger(this);  // 磁盘合并
rIter = shuffleConsumerPlugin.run();
eventFetcher.start();  // 开始抓取数据
eventFetcher.shutDown();  // 抓取结束
copyPhase.complete();  // copy 阶段完成
taskStatus.setPhase(TaskStatus.Phase.SORT);  // 开始排序阶段
sortPhase.complete();  // 排序阶段完成,即将进入 reduce 阶段
reduce()  // reduce 阶段调用的就是我们自定义的 reduce 方法,会被调用多次
cleanup(context);  // reduce 完成之前,会最后调用一次 Reducer 里面的 cleanup 方法
3.6 Join 应用
3.6.1 Reduce Join

Map 端的工作:

  • 为来自不同表或文件的 key/value 对打上标签以区分不同来源的记录。
  • 使用连接字段作为 key,其余部分和新增加的标志作为 value 输出。

Reduce 端的工作:

  • 在 Reduce 端,以连接字段作为 key 的分组已经完成。
  • 我们只需要在每个分组中将那些来源于不同文件的记录(已在 Map 阶段打上标志)分开,然后进行合并即可。
3.6.2 Reduce Join 案例实操

1. 需求

订单数据表 t_order:

id pid amount
1001 01 1
1002 02 2
1003 03 3
1004 01 4
1005 02 5
1006 03 6

商品信息表 t_product:

pid pname
01 小米
02 华为
03 格力

目标:

  • 将商品信息表中的数据根据商品 pid 合并到订单数据表中。

最终数据形式:

id pname amount
1001 小米 1
1004 小米 4
1002 华为 2
1005 华为 5
1003 格力 3
1006 格力 6

2. 需求分析

  • 通过将关联条件作为 Map 输出的 key,将两表中满足 Join 条件的数据以及数据所来源的文件信息发送至同一个 ReduceTask。
  • 在 Reduce 中进行数据的串联。

3. 代码实现

(1)创建商品和订单合并后的 TableBean 类

package com.lzl.mapreduce.reducejoin;

import org.apache.hadoop.io.Writable;

import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;

public class TableBean implements Writable {

    private String id; // 订单 ID
    private String pid; // 产品 ID
    private int amount; // 产品数量
    private String pname; // 产品名称
    private String flag; // 标志字段,用于区分数据来源

    public TableBean() {}

    // Getters and Setters...

    @Override
    public String toString() {
        return id + "\t" + pname + "\t" + amount;
    }

    @Override
    public void write(DataOutput out) throws IOException {
        out.writeUTF(id);
        out.writeUTF(pid);
        out.writeInt(amount);
        out.writeUTF(pname);
        out.writeUTF(flag);
    }

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

(2)编写 TableMapper 类

package com.lzl.mapreduce.reducejoin;

import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.InputSplit;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.lib.input.FileSplit;

import java.io.IOException;

public class TableMapper extends Mapper<LongWritable, Text, Text, TableBean> {

    private String filename;
    private Text outK = new Text();
    private TableBean outV = new TableBean();

    @Override
    protected void setup(Context context) throws IOException, InterruptedException {
        InputSplit split = context.getInputSplit();
        FileSplit fileSplit = (FileSplit) split;
        filename = fileSplit.getPath().getName();
    }

    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
        String line = value.toString();

        if (filename.contains("order")) {  // 处理订单表
            String[] split = line.split("\t");
            outK.set(split[1]);
            outV.setId(split[0]);
            outV.setPid(split[1]);
            outV.setAmount(Integer.parseInt(split[2]));
            outV.setPname("");
            outV.setFlag("order");
        } else {  // 处理商品表
            String[] split = line.split("\t");
            outK.set(split[0]);
            outV.setId("");
            outV.setPid(split[0]);
            outV.setAmount(0);
            outV.setPname(split[1]);
            outV.setFlag("pd");
        }

        context.write(outK, outV);
    }
}

(3)编写 TableReducer 类

package com.lzl.mapreduce.reducejoin;

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

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

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

    @Override
    protected void reduce(Text key, Iterable<TableBean> values, Context context) throws IOException, InterruptedException {
        ArrayList<TableBean> orderBeans = new ArrayList<>();
        TableBean pdBean = new TableBean();

        for (TableBean value : values) {
            if ("order".equals(value.getFlag())) {  // 处理订单表
                TableBean tmpOrderBean = new TableBean();
                try {
                    BeanUtils.copyProperties(tmpOrderBean, value);
                } catch (IllegalAccessException | InvocationTargetException e) {
                    e.printStackTrace();
                }
                orderBeans.add(tmpOrderBean);
            } else {  // 处理商品表
                try {
                    BeanUtils.copyProperties(pdBean, value);
                } catch (IllegalAccessException | InvocationTargetException e) {
                    e.printStackTrace();
                }
            }
        }

        for (TableBean orderBean : orderBeans) {
            orderBean.setPname(pdBean.getPname());
            context.write(orderBean, NullWritable.get());
        }
    }
}

(4)编写 TableDriver 类

package com.lzl.mapreduce.reducejoin;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.IOException;

public class TableDriver {
    public static void main(String[] args) throws IOException, ClassNotFoundException, InterruptedException {
        Job job = Job.getInstance(new Configuration());

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

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

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

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

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

4. 测试

  • 运行程序查看结果。

    1004	小米	4
    1001	小米	1
    1005	华为	5
    1002	华为	2
    1006	格力	6
    1003	格力	3
    

5. 总结

缺点:

  • 合并操作在 Reduce 阶段完成,导致 Reduce 端处理压力过大,Map 节点的运算负载较低,资源利用率不高。
  • 在 Reduce 阶段容易产生数据倾斜。

解决方案:

  • 在 Map 端实现数据合并。
3.6.3 Map Join

使用场景

Map Join 适用于一张表非常小而另一张表很大的场景。

优点

考虑到在 Reduce 端处理过多的表容易产生数据倾斜,可以在 Map 端缓存多张表,提前处理业务逻辑,从而增加 Map 端的业务处理能力,减轻 Reduce 端的数据处理压力,尽可能减少数据倾斜的发生。

具体办法:采用 DistributedCache

  1. 在 Mapper 的 setup 阶段,将文件读取到缓存集合中。

  2. 在 Driver 驱动类中 加载缓存。

    // 缓存普通文件到 Task 运行节点。
    job.addCacheFile(new URI("file:///e:/cache/pd.txt"));
    // 如果是集群运行,需要设置 HDFS 路径
    job.addCacheFile(new URI("hdfs://hadoop102:8020/cache/pd.txt"));

Map Join 案例实操

1. 需求

订单数据表 t_order:

id pid amount
1001 01 1
1002 02 2
1003 03 3
1004 01 4
1005 02 5
1006 03 6

商品信息表 t_product:

pid pname
01 小米
02 华为
03 格力

目标:

  • 将商品信息表中的数据根据商品 pid 合并到订单数据表中。

最终数据形式:

id pname amount
1001 小米 1
1004 小米 4
1002 华为 2
1005 华为 5
1003 格力 3
1006 格力 6

2. 需求分析

Map Join 适用于关联表中有小表的情形。

3. 实现代码

(1)在 MapJoinDriver 驱动类中添加缓存文件

package com.lzl.mapreduce.mapjoin;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;

public class MapJoinDriver {

    public static void main(String[] args) throws IOException, URISyntaxException, ClassNotFoundException, InterruptedException {

        // 1 获取 job 信息
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf);
        // 2 设置加载 jar 包路径
        job.setJarByClass(MapJoinDriver.class);
        // 3 关联 mapper
        job.setMapperClass(MapJoinMapper.class);
        // 4 设置 Map 输出 KV 类型
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(NullWritable.class);
        // 5 设置最终输出 KV 类型
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(NullWritable.class);

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

        // 6 设置输入输出路径
        FileInputFormat.setInputPaths(job, new Path("D:\\input"));
        FileOutputFormat.setOutputPath(job, new Path("D:\\output"));
        // 7 提交
        boolean b = job.waitForCompletion(true);
        System.exit(b ? 0 : 1);
    }
}

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

package com.lzl.mapreduce.mapjoin;

import org.apache.commons.lang.StringUtils;
import org.apache.hadoop.fs.FSDataInputStream;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.IOUtils;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;

public class MapJoinMapper extends Mapper<LongWritable, Text, Text, NullWritable> {

    private Map<String, String> pdMap = new HashMap<>();
    private Text text = new Text();

    // 任务开始前将 pd 数据缓存进 pdMap
    @Override
    protected void setup(Context context) throws IOException, InterruptedException {

        // 通过缓存文件得到小表数据 pd.txt
        URI[] cacheFiles = context.getCacheFiles();
        Path path = new Path(cacheFiles[0]);

        // 获取文件系统对象,并开流
        FileSystem fs = FileSystem.get(context.getConfiguration());
        FSDataInputStream fis = fs.open(path);

        // 通过包装流转换为 reader,方便按行读取
        BufferedReader reader = new BufferedReader(new InputStreamReader(fis, "UTF-8"));

        // 逐行读取,按行处理
        String line;
        while (StringUtils.isNotEmpty(line = reader.readLine())) {
            // 切割一行    
            // 01	小米
            String[] split = line.split("\t");
            pdMap.put(split[0], split[1]);
        }

        // 关流
        IOUtils.closeStream(reader);
    }

    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {

        // 读取大表数据    
        // 1001	01	1
        String[] fields = value.toString().split("\t");

        // 通过大表每行数据的 pid,去 pdMap 里面取出 pname
        String pname = pdMap.get(fields[1]);

        // 将大表每行数据的 pid 替换为 pname
        text.set(fields[0] + "\t" + pname + "\t" + fields[2]);

        // 写出
        context.write(text, NullWritable.get());
    }
}
3.7 数据清洗(ETL)
3.7.1 ETL 定义

ETL 是英文 Extract-Transform-Load 的缩写,用来描述将数据从来源端经过抽取(Extract)、转换(Transform)、加载(Load)至目的端的过程。ETL 通常与数据仓库相关联,但其对象并不限于数据仓库。

3.7.2 需求

去除日志中字段个数小于等于 11 的日志。

  1. 输入数据

    194.237.142.21 - - [18/Sep/2013:06:49:18 +0000] "GET /wp-content/uploads/2013/07/rstudio-git3.png HTTP/1.1" 304 0 "-" "Mozilla/4.0 (compatible;)"
    183.49.46.228 - - [18/Sep/2013:06:49:23 +0000] "-" 400 0 "-" "-"
    163.177.71.12 - - [18/Sep/2013:06:49:33 +0000] "HEAD / HTTP/1.1" 200 20 "-" "DNSPod-Monitor/1.0"
    163.177.71.12 - - [18/Sep/2013:06:49:36 +0000] "HEAD / HTTP/1.1" 200 20 "-" "DNSPod-Monitor/1.0"
    101.226.68.137 - - [18/Sep/2013:06:49:42 +0000] "HEAD / HTTP/1.1" 200 20 "-" "DNSPod-Monitor/1.0"
    101.226.68.137 - - [18/Sep/2013:06:49:45 +0000] "HEAD / HTTP/1.1" 200 20 "-" "DNSPod-Monitor/1.0"
    60.208.6.156 - - [18/Sep/2013:06:49:48 +0000] "GET /wp-content/uploads/2013/07/rcassandra.png HTTP/1.0" 200 185524 "http://cos.name/category/software/packages/" "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.66 Safari/537.36"
    222.68.172.190 - - [18/Sep/2013:06:49:57 +0000] "GET /images/my.jpg HTTP/1.1" 200 19939 "http://www.angularjs.cn/A00n" "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.66 Safari/537.36"
    222.68.172.190 - - [18/Sep/2013:06:50:08 +0000] "-" 400 0 "-" "-"
    183.195.232.138 - - [18/Sep/2013:06:50:16 +0000] "HEAD / HTTP/1.1" 200 20 "-" "DNSPod-Monitor/1.0"
    183.195.232.138 - - [18/Sep/2013:06:50:16 +0000] "HEAD / HTTP/1.1" 200 20 "-" "DNSPod-Monitor/1.0"
    66.249.66.84 - - [18/Sep/2013:06:50:28 +0000] "GET /page/6/ HTTP/1.1" 200 27777 "-" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
    221.130.41.168 - - [18/Sep/2013:06:50:37 +0000] "GET /feed/ HTTP/1.1" 304 0 "-" "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.66 Safari/537.36"
    sa=t&rct=j&q=nodejs%20%E5%BC%82%E6%AD%A5%E5%B9%BF%E6%92%AD&source=web&cd=1&cad=rja&ved=0CCgQFjAA&url=%68%74%74%70%3a%2f%2f%62%6c%6f%67%2e%66%65%6e%73%2e%6d%65%2f%6e%6f%64%65%6a%73%2d%73%6f%63%6b%65%74%69%6f%2d%63%68%61%74%2f&ei=rko5UrylAefOiAe7_IGQBw&usg=AFQjCNG6YWoZsJ_bSj8kTnMHcH51hYQkAA&bvm=bv.52288139,d.aGc" "Mozilla/5.0 (Windows NT 5.1; rv:23.0) Gecko/20100101 Firefox/23.0"
    58.215.204.118 - - [18/Sep/2013:06:51:41 +0000] "-" 400 0 "-" "-"
    58.215.204.118 - - [18/Sep/2013:06:51:41 +0000] "-" 400 0 "-" "-"
    58.215.204.118 - - [18/Sep/2013:06:51:41 +0000] "-" 400 0 "-" "-"
    
  2. 期望输出数据

每行字段长度都大于 11。

3.7.3 需求分析

需要在 Map 阶段对输入的数据根据规则进行过滤清洗。

3.7.4 实现代码
(1)编写 WebLogMapper 类
package com.lzl.mapreduce.weblog;

import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;

import java.io.IOException;

public class WebLogMapper extends Mapper<LongWritable, Text, Text, NullWritable> {

    @Override
    protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {

        // 1 获取 1 行数据
        String line = value.toString();

        // 2 解析日志
        boolean result = parseLog(line, context);

        // 3 日志不合法退出
        if (!result) {
            return;
        }

        // 4 日志合法就直接写出
        context.write(value, NullWritable.get());
    }

    // 2 封装解析日志的方法
    private boolean parseLog(String line, Context context) {

        // 1 截取
        String[] fields = line.split(" ");

        // 2 日志长度大于 11 的为合法
        if (fields.length > 11) {
            return true;
        } else {
            return false;
        }
    }
}
(2)编写 WebLogDriver 类
package com.lzl.mapreduce.weblog;

import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

public class WebLogDriver {

    public static void main(String[] args) throws Exception {

        // 输入输出路径需要根据自己电脑上实际的输入输出路径设置
        args = new String[]{"D:/input/inputlog", "D:/output1"};

        // 1 获取 job 信息
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf);

        // 2 加载 jar 包
        job.setJarByClass(WebLogDriver.class);

        // 3 关联 map
        job.setMapperClass(WebLogMapper.class);

        // 4 设置最终输出类型
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(NullWritable.class);

        // 设置 reducetask 个数为 0
        job.setNumReduceTasks(0);

        // 5 设置输入和输出路径
        FileInputFormat.setInputPaths(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        // 6 提交
        boolean b = job.waitForCompletion(true);
        System.exit(b ? 0 : 1);
    }
}
3.8 MapReduce 开发总结
3.8.1 输入数据接口:InputFormat
  • 默认使用的实现类TextInputFormat
  • 功能逻辑:一次读取一行文本,然后将该行的起始偏移量作为 key,行内容作为 value 返回。
  • CombineTextInputFormat 可以把多个小文件合并成一个切片处理,提高处理效率。
3.8.2 逻辑处理接口:Mapper
  • 用户根据业务需求实现其中三个方法:map()setup()cleanup()
3.8.3 Partitioner 分区
  • 默认实现HashPartitioner,逻辑是根据 key 的哈希值和 numReduces 来返回一个分区号:key.hashCode() & Integer.MAX_VALUE % numReduces
  • 自定义分区:如果业务上有特别的需求,可以自定义分区。
3.8.4 Comparable 排序
  • 自定义对象作为 key :当使用自定义的对象作为 key 来输出时,必须实现 WritableComparable 接口,重写其中的 compareTo() 方法。
  • 部分排序:对最终输出的每一个文件进行内部排序。
  • 全排序:对所有数据进行排序,通常只有一个 Reduce。
  • 二次排序:排序的条件有两个。
3.8.5 Combiner 合并
  • 提高程序执行效率:Combiner 合并可以提高程序执行效率,减少 IO 传输。
  • 不影响原有业务处理结果:但是使用时必须确保不会影响原有的业务处理结果。
3.8.6 逻辑处理接口:Reducer
  • 用户根据业务需求实现其中三个方法:reduce()setup()cleanup()
3.8.7 输出数据接口:OutputFormat
  • 默认实现类TextOutputFormat,功能逻辑是:将每一个 KV 对,向目标文本文件输出一行。
  • 自定义 OutputFormat :用户还可以自定义 OutputFormat

第4章 Hadoop数据压缩

4.1 概述
4.1.1 压缩的好处与坏处
  • 好处
    • 减少磁盘I/O操作。
    • 节省磁盘存储空间。
  • 坏处
    • 增加CPU负担。
4.1.2 压缩原则
  • 运算密集型作业:减少使用压缩。
  • I/O密集型作业:多使用压缩。
4.2 支持的压缩编码
4.2.1 压缩算法对比
压缩格式 Hadoop自带 算法 文件扩展名 是否可切片 是否需修改程序
DEFLATE DEFLATE .deflate 不需要
Gzip DEFLATE .gz 不需要
bzip2 bzip2 .bz2 不需要
LZO LZO .lzo 需要
Snappy Snappy .snappy 不需要
4.2.2 压缩性能对比
压缩算法 原始文件大小 压缩文件大小 压缩速度 解压速度
gzip 8.3GB 1.8GB 17.5MB/s 58MB/s
bzip2 8.3GB 1.1GB 2.4MB/s 9.5MB/s
LZO 8.3GB 2.9GB 49.3MB/s 74.6MB/s
Snappy 8.3GB 变化较大 250MB/s+ 500MB/s+
4.3 压缩方式的选择
4.3.1 Gzip压缩
  • 优点:较高的压缩比。
  • 缺点:不支持切片;压缩/解压速度一般。
4.3.2 Bzip2压缩
  • 优点:高压缩比;支持切片。
  • 缺点:压缩/解压速度较慢。
4.3.3 LZO压缩
  • 优点:压缩/解压速度快;支持切片。
  • 缺点:压缩比一般;需要额外创建索引才能支持切片。
4.3.4 Snappy压缩
  • 优点:压缩和解压缩速度快。
  • 缺点:不支持切片;压缩比一般。
4.3.5 压缩位置选择
  • 选择:根据作业特性,在不同阶段启用压缩。
4.4 压缩参数配置
4.4.1 编码/解码器
压缩格式 对应的编码/解码器
DEFLATE org.apache.hadoop.io.compress.DefaultCodec
Gzip org.apache.hadoop.io.compress.GzipCodec
bzip2 org.apache.hadoop.io.compress.BZip2Codec
LZO com.hadoop.compression.lzo.LzopCodec
Snappy org.apache.hadoop.io.compress.SnappyCodec
4.4.2 启用压缩的配置
参数 默认值 阶段 建议
io.compression.codecs 输入压缩 使用文件扩展名判断
mapreduce.map.output.compress false Mapper输出 启用压缩
mapreduce.map.output.compress.codec org.apache.hadoop.io.compress.DefaultCodec Mapper输出 使用特定编解码器
mapreduce.output.fileoutputformat.compress false Reducer输出 启用压缩
mapreduce.output.fileoutputformat.compress.codec org.apache.hadoop.io.compress.DefaultCodec Reducer输出 使用特定编解码器
4.5 压缩实操案例
4.5.1 Map 输出端采用压缩

驱动程序

package com.lzl.mapreduce.compress;

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.io.compress.BZip2Codec;
import org.apache.hadoop.io.compress.CompressionCodec;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

public class WordCountDriver {

    public static void main(String[] args) throws Exception {
        Configuration conf = new Configuration();

        // 开启map端输出压缩
        conf.setBoolean("mapreduce.map.output.compress", true);

        // 设置map端输出压缩方式
        conf.setClass("mapreduce.map.output.compress.codec", BZip2Codec.class, CompressionCodec.class);

        Job job = Job.getInstance(conf);
        job.setJarByClass(WordCountDriver.class);
        job.setMapperClass(WordCountMapper.class);
        job.setReducerClass(WordCountReducer.class);

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

        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(IntWritable.class);

        FileInputFormat.addInputPath(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

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

Mapper

package com.lzl.mapreduce.compress;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;

public class WordCountMapper extends Mapper<LongWritable, Text, Text, IntWritable> {

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

    @Override
    public void map(LongWritable key, Text value, Context context) throws Exception {
        String[] words = value.toString().split("\\s+");
        for (String w : words) {
            word.set(w);
            context.write(word, one);
       }
    }
}

Reducer

package com.lzl.mapreduce.compress;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;

public class WordCountReducer extends Reducer<Text, IntWritable, Text, IntWritable> {

    private IntWritable result = new IntWritable();

    @Override
    public void reduce(Text key, Iterable<IntWritable> values, Context context) throws Exception {
        int sum = 0;
        for (IntWritable val : values) {
            sum += val.get();
        }
        result.set(sum);
        context.write(key, result);
    }
}
4.5.2 Reduce 输出端采用压缩

修改驱动程序

package com.lzl.mapreduce.compress;

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.io.compress.BZip2Codec;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
0import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;

public class WordCountDriver {

    public static void main(String[] args) throws Exception {
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf);

        job.setJarByClass(WordCountDriver.class);
        job.setMapperClass(WordCountMapper.class);
        job.setReducerClass(WordCountReducer.class);

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

        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(IntWritable.class);

        FileInputFormat.addInputPath(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));

        // 设置reduce端输出压缩开启
        FileOutputFormat.setCompressOutput(job, true);

        // 设置压缩的方式
        FileOutputFormat.setOutputCompressorClass(job, BZip2Codec.class);

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

Mapper 和 Reducer 保持不变(详见4.5.1)

第5章 常见错误及解决方案

5.1 导入包错误
  • 常见问题 :导入包错误,尤其是TextCombineTextInputFormat
5.2 Mapper 输入参数错误
  • 错误 :Mapper的第一个输入参数不能是IntWritable
  • 解决 :使用LongWritableNullWritable
5.3 Partition 和 ReduceTask 数量不匹配
  • 错误java.lang.Exception: java.io.IOException: Illegal partition
  • 解决:调整ReduceTask的数量。
5.4 Partition 不执行
  • 条件:如果分区数不是1但ReduceTask数量为1,不会执行分区过程。
  • 原因:MapTask源码中判断ReduceTask数量是否大于1来决定是否执行分区。
5.5 Windows 环境下的JAR包移植问题
  • 错误

    Exception in thread "main" java.lang.UnsupportedClassVersionError: com/lzl/mapreduce/wordcount/WordCountDriver : Unsupported major.minor version 52.0
    
  • 原因:Windows环境与Linux环境的JDK版本不一致。

  • 解决:统一使用相同的JDK版本。

5.6 缓存文件找不到
  • 错误:找不到缓存的小文件。
  • 解决:检查路径是否正确;确保文件命名正确;尝试使用绝对路径。
5.7 类型转换异常
  • 常见原因:驱动函数中设置Map输出和最终输出时出现错误。
  • 解决:检查类型是否匹配;确认Map输出的key已排序。
5.8 HDFS 输入文件问题
  • 错误:无法获取输入文件。
  • 解决:不要将输入文件放置在HDFS集群的根目录下。
5.9 运行时异常
  • 错误

    Exception in thread "main" java.lang.UnsatisfiedLinkError: org.apache.hadoop.io.nativeio.NativeIO$Windows.access0(Ljava/lang/String;I)Z
    
    ​	at org.apache.hadoop.io.nativeio.NativeIO$Windows.access0(Native Method)
    
    ​	at org.apache.hadoop.io.nativeio.NativeIO$Windows.access(NativeIO.java:609)
    
    ​	at org.apache.hadoop.fs.FileUtil.canRead(FileUtil.java:977)
    
    java.io.IOException: Could not locate executable null\bin\winutils.exe in the Hadoop binaries.
    
    ​	at org.apache.hadoop.util.Shell.getQualifiedBinPath(Shell.java:356)
    
    ​	at org.apache.hadoop.util.Shell.getWinUtilsPath(Shell.java:371)
    
    ​	at org.apache.hadoop.util.Shell.<clinit>(Shell.java:364)
    
  • 解决 :复制hadoop.dll文件到C:\Windows\System32;修改Hadoop源码。

5.10 自定义OutputFormat时关闭流资源
  • 重要 :在RecordWriter中的close方法必须关闭流资源。否则输出的文件内容中数据为空。

  • 示例

    @Override
    public void close(TaskAttemptContext context) throws IOException, InterruptedException {
    if (fileOutputStream != null) {
    fileOutputStream.close();
    }
    }

相关推荐
昨天今天明天好多天4 小时前
【数据仓库】
大数据
油头少年_w4 小时前
大数据导论及分布式存储HadoopHDFS入门
大数据·hadoop·hdfs
Elastic 中国社区官方博客5 小时前
释放专利力量:Patently 如何利用向量搜索和 NLP 简化协作
大数据·数据库·人工智能·elasticsearch·搜索引擎·自然语言处理
力姆泰克5 小时前
看电动缸是如何提高农机的自动化水平
大数据·运维·服务器·数据库·人工智能·自动化·1024程序员节
力姆泰克5 小时前
力姆泰克电动缸助力农业机械装备,提高农机的自动化水平
大数据·服务器·数据库·人工智能·1024程序员节
QYR市场调研5 小时前
自动化研磨领域的革新者:半自动与自动自磨机的技术突破
大数据·人工智能
工业互联网专业6 小时前
Python毕业设计选题:基于Hadoop的租房数据分析系统的设计与实现
vue.js·hadoop·python·flask·毕业设计·源码·课程设计
半部论语7 小时前
第三章:TDengine 常用操作和高级功能
大数据·时序数据库·tdengine
EasyGBS7 小时前
国标GB28181公网直播EasyGBS国标GB28181软件管理解决方案
大数据·网络·音视频·媒体·视频监控·gb28181
2403_875736877 小时前
道品科技的水肥一体化智能灌溉:开启现代农业的创新征程
大数据·人工智能·1024程序员节