【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中!

菜雞一隻,歡迎有不同想法的各位留言交流!!

 

 

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