在实时的需求越来越高的当下,流式处理越来越重要。特别是有些需求,需要流式数据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中!
菜鸡一只,欢迎有不同想法的各位留言交流!!