吃透文件上传入库设计

本文主要分享营销领域文件上传场景和技术优化介绍,同时介绍几种业界流行的解决方案,以及项目开发过程设计思路和总结思考。

一、业务背景

今天我们来谈谈一个老生常谈的大文件上传入库话题,主要涉及文件上传数据去重数据入库。如果文件相对较小的情况下,使用字节流方式上传文件到服务器,通过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/HttpServletRequestMultipartHttpServletRequest请求。

其中,如果使用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操作类实现,主要包括RandomAccessFileMappedByteBuffer,本文就介绍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分钟。
在这里插入图片描述

六、总结

经过数据上传入库问题拆解为上传、解析、数据去重和入库,然后集中精力解决各个环节的瓶颈,找到有很多优秀方案,但是项目方案也要集合业务采用最优技术方案,而不是一来就使用代价最高的方案。其次,可以通过固化通用能力建设团队技术力,减少重复造轮子的行为。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值