本文主要分享营销领域文件上传场景和技术优化介绍,同时介绍几种业界流行的解决方案,以及项目开发过程设计思路和总结思考。
一、业务背景
今天我们来谈谈一个老生常谈的大文件上传入库话题,主要涉及文件上传、数据去重和数据入库。如果文件相对较小的情况下,使用字节流方式上传文件到服务器,通过HashMap或者HashSet去重即可完成入库操作。但是,遇到数据量比较大文件情况下,就会暴露很多难以预料的问题,比如文件上传失败或者超时,去重内存不足或者OOM,以及入库耗时很长时间等问题。
二、文件上传原理
文件上传到服务器,主要方案包含单文件直接上传,以及文件分片上传。单文件上传,主要是通过文件流方式将文件上传到服务器,然后进行处理。然而,文件分片上传却是需要通过前端拆分为固定大小文件,然后通过文件流方式上传到服务器,服务器通过组装分片文件实现。
2.1 单文件上传原理
2.1.1 前端上传
<form method="post" action="${user_upload_service_url}" enctype="multipart/form-data">
Choose a file:
<input type="file" name="image" accept="image/*" />
<input type="file" name="image" accept="image/*" />
<input type="submit" value="Upload" />
</form>
2.1.2 Java Servlet文件处理
@MultipartConfig(fileSizeThreshold = 5 * 1024 * 1024,
maxFileSize = 1024 * 1024 * 5,
maxRequestSize = 1024 * 1024 * 5)
@WebServlet(name = "MultipartServlet", urlPatterns = "/servlet-upload")
public class MultipartServlet extends HttpServlet {
@Override
public void init(ServletConfig config) throws ServletException {
super.init(config);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
try {
final Collection<Part> parts = req.getParts();
for (Part part : parts) {
if (part.getSize() <= 0) {
continue;
}
// 指定文件路径
String uploadedFilePath = "文件路径";
part.write(uploadedFilePath);
resp.getWriter().write("saved to " + uploadedFilePath);
}
} catch (ServletException se) {
}
resp.setStatus(HttpServletResponse.SC_OK);
resp.getWriter().flush();
resp.getWriter().close();
}
}
2.1.3 Spring文件处理
2.1.3 Spring文件处理
@Controller
public class UploadController {
@PostMapping("/upload")
public void upload(@RequestParam("image")MultipartFile[] files, HttpServletResponse response)
throws IOException {
StringBuilder sb = new StringBuilder();
for (MultipartFile file : files) {
...
}
}
}
Spring 3.1版本提供MultipartResolver(默认值为StandardServletMultipartResolver),用于解析请求头ContentType包含multipart/的HttpServletRequest为MultipartHttpServletRequest请求。
其中,如果使用SpringBoot,那么MultipartResolver是通过MultipartAutoConfiguration进行Bean配置注入。
public class DispatcherServlet extends FrameworkServlet {
@Nullable
private MultipartResolver multipartResolver;
private void initMultipartResolver(ApplicationContext context) {
this.multipartResolver = context.getBean(MULTIPART_RESOLVER_BEAN_NAME, MultipartResolver.class);
}
protected HttpServletRequest checkMultipart(HttpServletRequest request) throws MultipartException {
if (this.multipartResolver != null && this.multipartResolver.isMultipart(request)) {
if (WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class) != null) {
...
}
else if (hasMultipartException(request)) {
...
}
else {
return this.multipartResolver.resolveMultipart(request);
}
}
return request;
}
}
2.2 文件分片上传原理
2.2.1 前端分片
在JavaScript中,文件FIle对象是Blob子类,Blob对象包含一个重要的方法slice,通过这个方法,我们就可以对二进制文件进行拆分。
function slice(file, splitSize = 1024 * 1024 * 5) {
// 文件总大小
let totalSize = file.size
// 文件编码
let no = 0
// 文件开始位置
let startPosition = 0
// 文件结束位置
let endPosition = startPosition + splitSize
// 分片
let fileChunks = []
while (start < totalSize) {
// file对象继承自Blob对象, 因此包含slice方法
let fileBlob = file.slice(startPosition, endPosition)
fileChunks.push({
"no": no,
"file": fileBlob,
"fileSize": endPosition - startPosition + 1
})
order += 1
startPosition = endPosition
endPosition = startPosition + splitSize
if(endPosition >= totalSize) {
endPosition = totalSize
}
}
return fileChunks
}
2.2.2 服务端组装
服务端接收前端分片文件之后,可以采用位点或者组装技术实现,组装就是合并上传多个分散文件合并为一个大文件,位点是通过寻址实现文件合并。注意,还需要检查分片顺序和上传完整性。
1、检查分片完整性
public class UploadFileUtils {
/**
* 检查分片完整性
* @param 分片参数
* @checkFile 分片配置文件
*/
public boolean checkUploadProgress(FileUploadRequest param, String checkFile) {
byte isComplete = 0;
String fileName = param.getFile().getOriginalFilename();
RandomAccessFile checkAccessFile = null;
try {
checkAccessFile = new RandomAccessFile(checkFile, "rw");
// 检查文件内容填充
// 1、格式:分片数-[分片状态, ..., 分片状态]
// 2、数据填充:每上传一份数据, 分片位置填充127内容
checkAccessFile.setLength(param.getChunks());
checkAccessFile.seek(param.getChunk());
checkAccessFile.write(Byte.MAX_VALUE);
// 分片内容
byte[] completeContent = FileUtils.readFileToByteArray(checkAccessFile);
isComplete = Byte.MAX_VALUE;
for (int i = 0; i < completeContent.length && isComplete == Byte.MAX_VALUE; i++) {
//与运算, 如果有部分没有完成则 isComplete 不是 Byte.MAX_VALUE
isComplete = (byte) (isComplete & completeContent[i]);
System.out.println("check part " + i + " complete?:" + completeContent[i]);
}
} catch (IOException e) {
...
} finally {
FileUtil.close(checkAccessFile);
}
return isComplete;
}
}
2. 组装方案
public class MultifileCombineUploadStrategy {
public boolean upload(FileUploadRequest param) {
RandomAccessFile uploadFile = null;
try {
// 分片命名: 文件名-序号
String targetFile = ...;
String targetCheckFile = ...;
InputStream in = param.getFile().getInputStream();
FileUtils.copyInputStreamToFile(in, new File(targetFile));
boolean uploadProgress = checkUploadProgress(param, targetCheckFile);
if (uploadProgress) {
// 获取文件, 按照文件名排序
List<String> listFileNames = ...;
// 读取数据
int len = 0;
int off = 0;
byte[] bytes = new byte[1024];
// 合并文件
String destFileName = ...;
File destFile = new File(destFileName);
for (String fileName : listFileNames) {
try (FileInputStream ins = new FileInputStream(fileName)) {
while ((len = ins.read(bytes)) != -1) {
FileUtils.writeByteArrayToFile(destFile, bytes, off, len, true);
off += len;
}
}
}
}
} catch (IOException e) {
...
} finally {
FileUtil.close(uploadFile);
}
return false;
}
}
3. 位点方案
位点方案就是通过java文件随机IO操作类实现,主要包括RandomAccessFile和MappedByteBuffer,本文就介绍RandomAccessFile实现,MappedByteBuffer使用也是类似,只是原理有些区别。
RandomAccessFile支持随机读写文件数据,也就是可以指定指针位置,从给定位置读取一定长度字节数据,或者写一定长度字节数据。所以,从其特性可以推断RandomAccessFile可以使用在多线程下载或者断点续传场景。
需要注意,RandomAccessFile仅支持本地IO随机操作,不支持网络或者其他IO节点。
public class RandomAccessUploadStrategy {
public boolean upload(FileUploadRequest param) {
RandomAccessFile uploadFile = null;
try {
String targetFile = ...;
String targetCheckFile = ...;
uploadFile = new RandomAccessFile(targetFile, "rw");
long chunkSize = param.getChunkSize();
long offset = chunkSize * param.getChunk();
// 定位分片偏移量
uploadFile.seek(offset);
// 写入分片数据
uploadFile.write(param.getFile().getBytes());
return checkUploadProgress(param, targetCheckFile);
} catch (IOException e) {
...
} finally {
FileUtil.close(uploadFile);
}
return false;
}
}
三、文件解析与入库
3.1 文件解析
客户端上传Excel文件到服务器,通常解析可以使用Java原生提供的POI直接整个文件到内存,也可以使用阿里EsayExcel方式按需解析进行操作
3.1.1 POI读取整个文件到内存
CsvReader reader = CsvUtil.getReader();
CsvData data = reader.read(new File(tagSftpConfig.getTempDir() + loopName));
3.1.2 EsayExcel按需读取
EasyExcelFactory.read(fileName, ExcelReadDemo.class, new AnalysisEventListener<ExcelReadDemo>() {
public static final int BATCH_COUNT = 3;
//缓存
private List<ExcelReadDemo> cachedDataList = new ArrayList<>(BATCH_COUNT);
@Override
public void onException(Exception e, AnalysisContext analysisContext) throws Exception {
}
@Override
public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
}
@Override
public void invoke(ExcelReadDemo data, AnalysisContext analysisContext) {
cachedDataList.add(data);
if (cachedDataList.size() >= BATCH_COUNT) {
// 业务代码
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
// 结束处理
}
}).sheet().doRead();
经过对比可以发现,如果按照POI方式将文件直接读取入库,会存在内存占用过大,严重可能会出现内存OOM,所以建议使用EsayExecel批量解析。
3.2 数据入库
解析获得文件之后,需要将数据进行存入MySQL数据库,入库可以使用单条入库,或者批量入库。其中,批量入库可以使用MyBatis,也可以使用原生JDBC模式。
3.1.1 单条入库
insert person (id, name, age, sex) values(?, ?, ...)
3.1.2 批量入库
<insert id="saveBatchData">
insert into person (
id, name, age, sex
)
<foreach collection="list" item="item" separator="," open="values">
(#{item.id}, #{item.name}, #{item.age}, #{item.sex})
</foreach>
</insert>
经验结论,大家也不妨抽空测试,批量可以提高数据入库速度,而批量方式中JDBC会比MyBatis快,主要原因还是在于MyBatis框架已存在一部分性能损耗,如果公司内部通过MyBatis做过脱敏处理,不建议直接使用JDBC方式。
四、数据去重
数据去重方式比较多,重点介绍谷歌提供的布隆过滤器,布隆过滤器可以通过很小内存就可以解决绝大部分去重问题
public class BloomFilterMain {
public static void main(String[] args) {
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 2_000_000, 0.0001);
Map<String, Integer> map = new HashMap<>();
for(int i=0; i<1_000_000; i++) {
String format = String.format("%020d", i);
bloomFilter.put(format);
map.put(format, map.getOrDefault(format, 0) + 1);
}
System.setProperty("java.vm.name","Java HotSpot(TM) ");
System.out.println(RamUsageEstimator.humanSizeOf(bloomFilter));
System.out.println(RamUsageEstimator.humanSizeOf(map));
}
}
经过测试分析200W条数据,每个字符串为20位长度,谷歌Guava布隆过滤器占用4.6MB内存,HashMap占用114.8MB内存
五、项目应用
客群上传入库项目,人工拆分为多个文件分开上传,使用阿里EsayExcel每次读取一批数据,然后通过Mybatis批量入库方式插入数据库。通过测试得到结果,200W数据上传每批按照5K入库,采用新方案需要花费4分钟。
六、总结
经过数据上传入库问题拆解为上传、解析、数据去重和入库,然后集中精力解决各个环节的瓶颈,找到有很多优秀方案,但是项目方案也要集合业务采用最优技术方案,而不是一来就使用代价最高的方案。其次,可以通过固化通用能力建设团队技术力,减少重复造轮子的行为。