场景:项目中业务需要导出大批量数据,大到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);
}