【spark】Spark Streaming写HDFS解决小文件问题思考

在实时的需求越来越高的当下,流式处理越来越重要。特别是有些需求,需要流式数据join静态数据来制造一些大宽表,提供不同维度的分析。

然后往往这些数据我们会写到hdfs,但是写到hdfs就会遇到小文件的问题,其实我之前分享过批处理如何解决小文件的问题

大家有兴趣可以去看看。

【spark】存储数据到hdfs,自动判断合理分块数量(repartition和coalesce)(一):https://blog.csdn.net/lsr40/article/details/84968923

【spark】存储数据到hdfs,自动判断合理分块数量(repartition和coalesce)(二):

https://blog.csdn.net/lsr40/article/details/85078499

 

但是在批处理中,往往一个批次的数据没有那么多,而且数据还有分区,会导致存的小文件更多!

因此对于如何解决这样的问题,我会提出一些我的思考!

 

1、增加处理数据的批次时间,并且通过coalesce或repartition减少落盘的文件块(最简单的方法)

优点:不用额外的代码量,也几乎不需要额外的资源开销,不依赖其他框架就能解决问题。

缺点:流处理可能就变成了微批处理,导致产品上展示的数据会有所延迟(在实时性不是特别高的情况下,使用这个方案是最简单的)

 

2、流式框架处理完的数据,不马上录入hdfs!(最推荐的方法)

解释:这应该算比较通用的方案,流数据实时处理,虽然最终数据要落盘到HDFS,但是并不会在这个阶段直接选择落HDFS,而是写入其他的框架,如果是聚合型的任务,可以尝试写入redis或者各种类型的数据库,做实施展示;如果是清洗过滤型的任务,那数据可以落盘到kafka。后续再通过其他组件,将redis或者数据库或kafka的数据拉回HDFS(举例:kafak->sparkstreaming->kafka->flume->HDFS,通过flume就可以解决数据存hdfs文件块大小的问题)

优点:这是通用的解决方案,也就是说流式数据其实并不适合直接写入HDFS,通过写入其他框架来完成数据实时的需求,后续再来解决小文件问题

缺点:需要引用到其他的组件,多了一个组件,增加了数据的链路,增加了维护的成本,需要在不同组件中监控,校验数据,增加了工作量

 

3、强行就想写HDFS,并且还要满足实时需求

我想到了一个方法,写了代码的雏形(后面会分享出来),但是并没有在实际的场景中使用。

方法是这样子的:

我想到了hdfs是支持append文件的功能的,那是不是可以将数据写到对应的文件块中,当文件块大于某个阈值时,创建一个新的文件块,后续的数据写到新的文件块中以此类推

如下:

将文件按照
/20200229/年月日小时分钟(10分钟为间隔)-分区号-序号.txt,格式存放

例如:20200229号14点39分的数据,就存在
/20200229/202002291430-分区号-0.txt文件中
当202002291430-分区号-0.txt这个文件大于某个阈值,该时间段的数据就写入202002291430-分区号-1.txt文件块上

代码如下:

我使用了spark2.3的Structured Streaming的foreach接口来存的数据

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

        //构建SparkSession
        spark = buildSparkSession(true,"savehdfstest");

        /**
         * 因为只是测试,所以就从端口中读数据
         *
         * 在本地的虚拟机上执行 nc -lt 9999 来往这个端口上发数据
         * 数据格式:
         * 用户主键     时间戳     访问的url
         * user01,1584433934161,www.qq.com
         * user01,1584433934261,www.qq.com
         * 
         */

        Dataset<Row> lines  = spark.readStream()
                .format("socket")
                .option("host", "192.168.61.101")
                .option("port", 9999)
                .load();
        
        //将数据加载成对象
        Dataset<RequestDomain> requestDomains = lines
                .as(Encoders.STRING())
                .flatMap((FlatMapFunction<String, RequestDomain>) x -> {
                    String[] arr = x.split(",");
                    return Arrays.asList(new RequestDomain(arr[0],arr[1],arr[2])).iterator();
                }, Encoders.bean(RequestDomain.class));

        //将数据repartition成5个区,写入数据到hdfs
        StreamingQuery query = requestDomains.repartition(5).writeStream()
                .outputMode("append")
                .format("csv")
                .option("checkpointLocation", "hdfs://192.168.61.101:8020/HDFSForeach/check")
                .foreach(new HDFSForeachWriter<RequestDomain>())
                //.option("path", "D://path/data")
                .start();

        query.awaitTermination();

    }

  static class HDFSForeachWriter<RequestDomain> extends ForeachWriter<spark.streaming.hdfs.domain.RequestDomain>{

        FileSystem fs = null;
        Long partitionId = null;
        String schema = "hdfs://192.168.61.101:8020";
        @Override
        public boolean open(long partitionId, long version) {
            Configuration conf = new Configuration();
            this.partitionId = partitionId;

            /**
             * 注意,为了让hdfs支持append功能,需要有以下设置(hdfs集群上也得添加这些设置,并且重启服务)!否则会报错
             * 具体配置的详情,大家可以百度,或者给我留言
             */
            conf.setBoolean("dfs.support.append", true);
            conf.set("fs.defaultFS",schema);
            conf.set("fs.hdfs.impl", "org.apache.hadoop.hdfs.DistributedFileSystem");
            conf.setBoolean("fs.hdfs.impl.disable.cache", true);
                try {
                    fs = FileSystem.get(conf);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            return true;
        }


        @Override
        public void process(spark.streaming.hdfs.domain.RequestDomain value) {

            String writeFileName = getWriteFileName(value);

            ByteArrayInputStream in = new ByteArrayInputStream(value.toString().getBytes());
            OutputStream out = null;
            try {
                Path path = new Path(writeFileName);
                //文件存在就append,不存在就create
                if(!fs.exists(path)){
                    out = fs.create(path);
                }else {
                    out = fs.append(path);
                }
                //该方法会帮你写入数据,并且关闭in和out流
                IOUtils.copyBytes(in, out, 4096, true);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        @Override
        public void close(Throwable errorOrNull) {
            try {
                fs.close();
                fs = null;
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        /**
         * 获得该条数据需要写到哪个文件块中
         * @param value
         * @return
         */
        private String getWriteFileName(spark.streaming.hdfs.domain.RequestDomain value) {
            try {
                String minute = SimpleDateFormatUtil.getMinue(value.getTs());
                String date = minute.substring(0, 8);
                //将10分钟内的数据都归入到统一目录下
                minute = minute.substring(0, minute.length() - 1) + "0";
                String FileNameMatch = schema + "/" + date + "/" + minute + "-" + partitionId + "-";
                Path path = new Path(FileNameMatch + "*");

                //通过globStatus来匹配该时间段该分区的所有文件
                FileStatus[] fileStatuses =  fs.globStatus(path);
                //要判断文件大小来写入数据
                if (fileStatuses.length != 0) {
                    FileStatus finalFileName = fileStatuses[fileStatuses.length - 1];
                    /**
                     * 单位是byte,1M = 1048576 byte
                     * 这里为了测试就写了1M,一般我们需要hdfs文件块大小在200M左右
                     */
                    if (finalFileName.getLen() < 1048576) {
                        return FileNameMatch + String.valueOf(fileStatuses.length - 1) + ".txt";
                    } else {
                        return FileNameMatch + String.valueOf(fileStatuses.length) + ".txt";
                    }
                } else {
                    return FileNameMatch + "0" + ".txt";
                }

            } catch (IOException e) {
                e.printStackTrace();
                //如果出了异常,可以返回一个延迟的数据目录,后续集中处理这些异常数据
                return schema + "/delay/data.txt";
            }
        }
    }

 

代码其实一点都不复杂,大家看看我的注释,只要理解了数据我想怎么存就可以了!

结果图:

代码跑通了,数据存下来大概就是这样拉!

 

 

有几点想说明一下:

1、在测试的过程中,其实遇到了一些hdfs的bug:

如下参数我在代码中配置了,并且也在hdfs服务的配置文件中修改后,重启hdfs服务

报错1:开启dfs.support.append参数

 conf.setBoolean("dfs.support.append", true);

报错2:java.io.IOException: Not supported

 conf.set("fs.hdfs.impl", "org.apache.hadoop.hdfs.DistributedFileSystem")

报错3:java.io.IOException: Filesystem closed

https://blog.csdn.net/bitcarmanlee/article/details/68488616

 

2、代码有许多不完善的地方

没有良好的异常处理:

-1.大数据的环境下,数据百分之百是会有异常的会出现各种各样的问题,因此,需要在解析字符串到对象的时候有try-catch来对异常数据进行记录(以便后续分析,减少异常)

-2.当append数据的时候,如果失败,是否能够记录失败的数据,方便后续的解决(是否有一些事物回滚机制)

可以复用的对象是否一直在反复创建:例如那一票流对象,是否可以长期持有,等某个块写好了,再释放掉,又或者每次在插入数据的时候,都要查询hdfs的接口,来获取该条数据要往哪里写,是否可以通过redis来记录这个信息,减少hdfs的连接等等都是可以优化和提升的地方,只能说我写的太粗糙了。

代码不够优美:代码没有很遵守规范,我只是写出了一个可以跑通的版本,还需要清理下(但是清理可能就会封住更多的方法和类,为了让各位看起来方便,我就都写在一起了)

 

暂时想说的就这些了,推荐还是不要把流式数据直接写到HDFS中!

菜鸡一只,欢迎有不同想法的各位留言交流!!

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章