在實時的需求越來越高的當下,流式處理越來越重要。特別是有些需求,需要流式數據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中!
菜雞一隻,歡迎有不同想法的各位留言交流!!