大批量(十万级别)数据导出excel方法代码,分批次查询数据库和写入文件,防止IO溢出,接口异步导出到服务器后上传oss

场景:项目中业务需要导出大批量数据,大到4,50w的数据到excel中,将需求写成功能
难点:

  • 大批量数据一次性查询出较为耗时,需要对表结构设计要求高方可提升查询速度。
  • 内存溢出,IO异常,一次性几十万的数据读取和写入很大,并发如果上来的场景下可能导致系统接口挂掉。
  • 接口设计响应速度考虑

实现思路:

  • 以异步接口的形式完成导出的需求功能,将导出业务分为两个过程:1.申请导出、2.下载导出文件。
  • 文件分离设计,单个文件数据量最大为10w,总文件数为:n/n + n%n>0?1:0。
  • 即使对文件做分离处理依旧无法解决IO溢出问题,对单个文件数据做边查边写处理,通过手动分页设置偏移量的形式,对单个文件的数据做分页处理。

老规矩直接上代码,注释写的比较清晰,评论区交流见解和问题
接口层代码:

 /**
     * excel导出记录(异步)
     *
     * @param request
     * @return
     */
    @PostMapping("/export")
    public Rest<BaseResponse> export(@RequestBody exportRequest request) {
        Long accountId = ContextHolder.currentAccountId();

        // 获取锁
        RBucket<Integer> bucket = redissonClient.getBucket("EXPORT:LOCK:" + accountId);
        if (bucket.isExists()) {
        	// 抛出自定义异常,提醒导出请求在执行中,
        	// 抛出异常方式跟随各自公司习惯,本文中的自定义异常均使用RuntimeException代替
            throw new RuntimeException();
        }
        bucket.set(1, 5, TimeUnit.MINUTES);

        // 计算总数,在操作io之前校验查询结果数据是否合理
        long count = 0;
        try {
            count = PageHelper.count(() -> exportRecordService.exportList(request));
        } catch (Exception e) {
            bucket.delete();
            log.error("统计数量sql出错", e);
            throw new RuntimeException();
        }
        if (count <= 0) {
            bucket.delete();
            // 数据为零不满足导出条件,抛出自定义异常
            throw new RuntimeException();
        }
		// 计算查询分片数量,方法会在exportRecordService中展示
        int shardNum = exportRecordService.getShardNum((int) count, 100000);
        // 例:2022-01-01 10:00:00~2022-01-02 11:00:00-数据记录-shardNum-1...3
        // 预处理文件属性,设置文件名每批次后缀加上"-数量"用作标识
        String title = DATE_TIME_FORMATTER.format(request.getStartTime()) + "~" + DATE_TIME_FORMATTER.format(request.getEndTime()) + "-采样记录" + "-" + shardNum + "-";
        List<ExportDTO> fileDTOList = new ArrayList<>();
        for (int i = 1; i <= shardNum; i++) {
            String fileName = title + i;
            ExportDTO fileDTO = new ExportDTO()
                    .setFileName(fileName)
                    .setFileNum(i);
             // 计算单个文件循环查询次数 每个文件100000条,每次查询2000条导出到excel,最大50
            int pageNum = 50;
            if (i == shardNum && count % 100000 > 0) {
                pageNum = samplingRecordService.getShardNum(Math.toIntExact(count % 100000), 2000);
            }
            fileDTO.setPageNum(pageNum);
            fileDTOList.add(fileDTO);
        }
		// 因为是异步处理service,使用父子线程交互的ThreadLocal,传递上下文用户属性ID
		// 用于在需要的时候删除锁
        InheritableThreadLocal<Long> threadLocal = new InheritableThreadLocal<>();
        threadLocal.set(accountId);
        // 将request查询条件透传过去
        exportRecordService.exportList(request, fileDTOList, threadLocal);
        return Rest.success();
    }

service层代码:

	@Async
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void exportList(ExportRequest request, List<ExportDTO> fileDTOList, InheritableThreadLocal<Long> threadLocal) {
        long startTime = System.currentTimeMillis();
        RBucket<Integer> bucket = redissonClient.getBucket("EXPORT:LOCK:" + threadLocal.get());

        for (ExportDTO fileDTO : fileDTOList) {
            // 获取target/resource/目录路径
            String dirPath = new ApplicationHome(getClass()).getSource().getParentFile().toString();
            File file = new File(dirPath + File.separator + fileDTO.getFileName() + ".xlsx");

			// 自增对象,用于记录文件中数据的序号
            AtomicInteger atomicInteger = new AtomicInteger();
            // 记录实际总数
            int num = 0;
            ExcelWriter excelWriter = null;
            try {
                // 指定class,存在表头注解则使用,不存在则使用对象字段名
                excelWriter = EasyExcel.write(file, ExportResponse.ExportVo.class).build();
                // 同一个sheet只需要创建一次
                WriteSheet writeSheet = EasyExcel.writerSheet().build();
                for (int i = 1; i <= fileDTO.getPageNum(); i++) {
                    // 分页说明:总数据分为n个文件,文件分为50次查询数据并导出,第a文件的第i次的页数则为:(a-1)*50 + i
                    PageHelper.startPage((fileDTO.getFileNum() - 1) * 50 + i, 2000, false);

                    List<ExportResponse.ExportVo> data = this.exportList(request);
                    if (CollUtil.isEmpty(data)) {
                        break;
                    }
                    for (ExportResponse.ExportVo vo : data) {
                        vo.setNo(atomicInteger.incrementAndGet());
                    }
                    excelWriter.write(data, writeSheet);
                    num += data.size();
                    PageHelper.clearPage();
                }

                excelWriter.finish();
                log.info("{}写入完成路径:{}", fileDTO.getFileName(), file.getAbsolutePath());

                // 文件上传oss,这个就不用展开了吧
                String objectId = ossUtil.upload(file, "记录导出");
                log.info(file.getAbsolutePath() + "上传完成,objectId:{}", objectId);

                // 保存对象存储记录,存储相关联信息,操作人
                exportRecordService.save(objectId, num, threadLocal.get(), fileDTO.getFileName());
                log.info("{}保存至记录表", fileDTO.getFileName());

            } catch (Exception e) {
                log.error("记录导出出错", e);
                // 注意这是for循环,所以不在finally里面删除锁
                bucket.delete();
                throw new RuntimeException();
            } finally {
                // 千万别忘记finish 会帮忙关闭流
                if (excelWriter != null) {
                    excelWriter.finish();
                }
            	if (file.exists()) {
                	file.delete();
            	}
            }
        }

        long endTime = System.currentTimeMillis();
        bucket.delete();
        log.info("耗时:{}", endTime - startTime);
    }

    @Override
    public List<ExportResponse.ExportVo> exportList(ExportRequest request) {
        return recordMapper.exportList(request);
    }

	// 根据总数和单片大小,计算分片数量
    @Override
    public int getShardNum(Integer total, Integer shardSize) {
        return (int) Math.ceil((double) total / (double) shardSize);
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值