功能介绍
文件增量备份系统中清理功能的作用是:扫描数据库中已经备份过的文件,查看数据源中是否还有相应的文件,如果没有,说明该文件被删除了,那相应的,也需要将备份目标目录的文件以及相关的备份记录都一并删除掉,以此来减少不必要的磁盘占用并保持数据源和备份目标目录的数据一致性
原始方案
使用分批处理,避免单次加载表中的所有数据,导致内存溢出,每次从备份文件表中查询出2000条备份文件记录,然后对查出来的数据进行检验、清理
/**
* 检查数据,删除 无效备份信息 和 已备份文件
* 什么叫无效?简单来说就是,已备份文件和原文件对应不上,或者说原文件被删除了
*
* @param sourceId
*/
@Override
public void clearBySourceIdv1(Long sourceId) {
long current = 1;
ClearTask clearTask = new ClearTask();
clearTask.setId(snowFlakeUtil.nextId());
// 填充数据源相关信息
BackupSource source = backupSourceService.getById(sourceId);
if (source == null) {
throw new ClientException("所需要清理的数据源不存在");
}
clearTask.setClearSourceRoot(source.getRootPath());
// 存储要删除的文件
List<Long> removeBackupFileIdList = new ArrayList<>();
List<String> removeBackupTargetFilePathList = new ArrayList<>();
BackupFileRequest backupFileRequest = new BackupFileRequest();
backupFileRequest.setBackupSourceId(sourceId);
backupFileRequest.setSize(2000L);
long totalFileNum = -1;
long finishFileNum = 0;
ClearStatistic clearStatistic = new ClearStatistic(0);
while (true) {
查询数据,监测看哪些文件需要被删除
// 分页查询出数据,即分批检查,避免数据量太大,占用太多内存
backupFileRequest.setCurrent(current);
PageResponse<BackupFile> backupFilePageResponse = backupFileService.pageBackupFile(backupFileRequest);
if (totalFileNum == -1 && backupFilePageResponse.getTotal() != null) {
totalFileNum = backupFilePageResponse.getTotal();
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("code", WebsocketNoticeEnum.CLEAR_START.getCode());
dataMap.put("message", WebsocketNoticeEnum.CLEAR_START.getDetail());
clearTask.setTotalFileNum(totalFileNum);
clearTask.setFinishFileNum(0L);
clearTask.setClearStatus(0);
clearTask.setClearNumProgress("0.0");
clearTask.setStartTime(new DateTime());
clearTask.setClearTime(0L);
dataMap.put("clearTask", clearTask);
webSocketServer.sendMessage(JSON.toJSONString(dataMap), WebSocketServer.usernameAndSessionMap.get("Admin"));
}
if (backupFilePageResponse.getRecords().size() > 0) {
for (BackupFile backupFile : backupFilePageResponse.getRecords()) {
// 获取备份文件的路径
// todo 待优化为存储的时候,不存储整一个路径,节省数据库空间,只存储从根目录开始后面的路径,后面获取整个路径再进行拼接
String sourceFilePath = backupFile.getSourceFilePath();
File sourceFile = new File(sourceFilePath);
if (!sourceFile.exists()) {
// --if-- 如果原目录该文件已经被删除,则删除
removeBackupFileIdList.add(backupFile.getId());
removeBackupTargetFilePathList.add(backupFile.getTargetFilePath());
}
}
// 换一页来检查
current += 1;
} else {
// 查不出数据了,说明检查完了
break;
}
执行删除
if (removeBackupFileIdList.size() > 0) {
// 批量删除无效备份文件
backupFileService.removeByIds(removeBackupFileIdList);
// 删除无效的已备份文件
for (String backupTargetFilePath : removeBackupTargetFilePathList) {
File removeFile = new File(backupTargetFilePath);
if (removeFile.exists()) {
boolean delete = FileUtils.recursionDeleteFiles(removeFile, clearStatistic);
if (!delete) {
throw new ServiceException("文件无法删除");
}
}
}
// 批量删除无效备份文件对应的备份记录
backupFileHistoryService.removeByFileIds(removeBackupFileIdList);
removeBackupFileIdList.clear();
removeBackupTargetFilePathList.clear();
}
// 告诉前端,更新清理状态
finishFileNum += backupFilePageResponse.getRecords().size();
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("code", WebsocketNoticeEnum.CLEAR_PROCESS.getCode());
dataMap.put("message", WebsocketNoticeEnum.CLEAR_PROCESS.getDetail());
clearTask.setFinishFileNum(finishFileNum);
clearTask.setClearStatus(1);
clearTask.setFinishDeleteFileNum(clearStatistic.finishDeleteFileNum);
setClearProgress(clearTask, dataMap);
}
// 清理成功
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("code", WebsocketNoticeEnum.CLEAR_SUCCESS.getCode());
dataMap.put("message", WebsocketNoticeEnum.CLEAR_SUCCESS.getDetail());
clearTask.setFinishFileNum(finishFileNum);
clearTask.setClearStatus(2);
clearTask.setFinishDeleteFileNum(clearStatistic.finishDeleteFileNum);
setClearProgress(clearTask, dataMap);
dataMap.put("clearTask", clearTask);
}
测试
经过测试,发现该方案非常慢,清理进度10%竟要花费3分钟
通过观察,发现备份文件数量一共有接近三百多万条
,如此大的数据量,使用分页查询的性能会非常差。这是因为每次分页查询,都需要从头开始扫描,若分页的页码越大, 分页查询的速度也会越慢
想要提高上面的效率,一个很简单的方式是使用多线程并行处理,但是并不能解决根本问题,还是会有很多性能浪费,因此需要流式处理方法
流式处理
流式处理方式即使用数据库的流式查询功能,查询成功之后不是返回一个数据集合,而是返回一个迭代器,通过这个迭代器可以进行循环,每次查询出一条数据来进行处理。使用该方式可以有效降低内存占用,且因为不需要像分页一样每次重头扫描表,每查询一条数据都是在上次查询的基础上面查询,即知道上条数据的位置,因此查询效率较高
处理流程:
- 客户端初始化查询并请求流式输出
- 数据库执行查询并准备结果
- 每次客户端请求时,数据库返回单条记录
- 客户端处理完当前记录后,再请求下一条记录
- 重复此过程直到所有记录都被处理完毕
优点:
- 内存效率:减少了内存占用,尤其适用于需要处理大规模数据的应用场景
- 性能提升:避免了多次全表扫描带来的开销,提高了查询效率
- 灵活性:可以在处理过程中随时中断或更改处理逻辑
应用场景:
- 大数据处理:例如日志分析、实时数据分析等
- 数据导入导出:当需要将大量数据从一个系统移动到另一个系统时
- 长时间运行的任务:例如批处理作业或持续数据处理任务
/**
* 流式处理
* 检查数据,删除 无效备份信息 和 已备份文件
* 什么叫无效?简单来说就是,已备份文件和原文件对应不上,或者说原文件被删除了
*
* @param sourceId
*/
@SneakyThrows
public void clearBySourceIdV2(Long sourceId) {
// 获取 dataSource Bean 的连接
@Cleanup Connection conn = dataSource.getConnection();
@Cleanup Statement stmt = conn.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);
stmt.setFetchSize(Integer.MIN_VALUE);
long start = System.currentTimeMillis();
// 查询sql,只查询关键的字段
String sql = "SELECT id,source_file_path,target_file_path FROM backup_file where backup_source_id = " + sourceId;
@Cleanup ResultSet rs = stmt.executeQuery(sql);
loopResultSetProcessClear(rs, sourceId);
log.info("流式清理花费时间:{} s ", (System.currentTimeMillis() - start) / 1000);
}
/**
* 循环读取,每次读取一行数据进行处理
*
* @param rs
* @param sourceId
* @return
*/
@SneakyThrows
private Long loopResultSetProcessClear(ResultSet rs, Long sourceId) {
// 填充数据源相关信息
BackupSource source = backupSourceService.getById(sourceId);
if (source == null) {
throw new ClientException("所需要清理的数据源不存在");
}
// 中途用来存储需要删除的文件信息
List<Long> removeBackupFileIdList = new ArrayList<>();
List<String> removeBackupTargetFilePathList = new ArrayList<>();
// 查询文件总数
long totalFileNum = backupFileService.count(Wrappers.query(new BackupFile()).eq("backup_source_id", sourceId));
// 已经扫描的文件数量
long finishFileNum = 0;
ClearStatistic clearStatistic = new ClearStatistic(0);
long second = System.currentTimeMillis() / 1000;
long curSecond;
// 发送消息通知前端 清理正式开始
ClearTask clearTask = ClearTask.builder()
.id(snowFlakeUtil.nextId())
.clearSourceRoot(source.getRootPath())
.totalFileNum(totalFileNum)
.finishFileNum(0L)
.clearStatus(0)
.clearNumProgress("0.0")
.startTime(new DateTime())
.clearTime(0L)
.build();
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("clearTask", clearTask);
notify(WebsocketNoticeEnum.CLEAR_START, dataMap);
// 每次获取一行数据进行处理,rs.next()如果有数据返回true,否则返回false
while (rs.next()) {
// 获取数据中的属性
long fileId = rs.getLong("id");
String sourceFilePath = rs.getString("source_file_path");
String targetFilePath = rs.getString("target_file_path");
// 所扫描的文件数量+1
finishFileNum++;
// 获取备份文件的路径
File sourceFile = new File(sourceFilePath);
if (!sourceFile.exists()) {
// --if-- 如果原目录该文件已经被删除,则删除
removeBackupFileIdList.add(fileId);
removeBackupTargetFilePathList.add(targetFilePath);
}
if (removeBackupFileIdList.size() >= 2000) {
clear(removeBackupFileIdList, removeBackupTargetFilePathList, clearStatistic);
}
curSecond = System.currentTimeMillis() / 1000;
if (curSecond > second) {
second = curSecond;
// 告诉前端,更新清理状态
clearTask.setFinishFileNum(finishFileNum);
clearTask.setClearStatus(1);
clearTask.setFinishDeleteFileNum(clearStatistic.finishDeleteFileNum);
setClearProgress(clearTask, dataMap);
notify(WebsocketNoticeEnum.CLEAR_PROCESS, dataMap);
}
}
// 循环结束之后,再清理一次,避免文件数没有到达清理批量导致清理失败
clear(removeBackupFileIdList, removeBackupTargetFilePathList, clearStatistic);
// 告诉前端,清理成功
clearTask.setFinishFileNum(finishFileNum);
clearTask.setClearStatus(2);
clearTask.setFinishDeleteFileNum(clearStatistic.finishDeleteFileNum);
setClearProgress(clearTask, dataMap);
notify(WebsocketNoticeEnum.CLEAR_SUCCESS, dataMap);
return 0L;
}
/**
* 执行清理
* @param removeBackupFileIdList
* @param removeBackupTargetFilePathList
* @param clearStatistic
*/
private void clear(List<Long> removeBackupFileIdList, List<String> removeBackupTargetFilePathList, ClearStatistic clearStatistic) {
// 批量删除无效备份文件
backupFileService.removeByIds(removeBackupFileIdList);
// 删除无效的已备份文件
for (String backupTargetFilePath : removeBackupTargetFilePathList) {
File removeFile = new File(backupTargetFilePath);
if (removeFile.exists()) {
boolean delete = FileUtils.recursionDeleteFiles(removeFile, clearStatistic);
if (!delete) {
throw new ServiceException("文件无法删除");
}
}
}
// 批量删除无效备份文件对应的备份记录
backupFileHistoryService.removeByFileIds(removeBackupFileIdList);
removeBackupFileIdList.clear();
removeBackupTargetFilePathList.clear();
}
/**
* 发送通知给前端
*
* @param noticeEnum
* @param dataMap
*/
private void notify(WebsocketNoticeEnum noticeEnum, Map<String, Object> dataMap) {
dataMap.put("code", noticeEnum.getCode());
dataMap.put("message", noticeEnum.getDetail());
webSocketServer.sendMessage(JSON.toJSONString(dataMap), WebSocketServer.usernameAndSessionMap.get("Admin"));
}
测试
经过测试,发现改进后的程序只需要70秒就可以完成清理,速度是原始方案的25倍左右
功能可用性测试
初始状态,固态硬盘中文件目录结构如下图所示:
在数据源目录中添加如下文件夹和文件
备份结束后,数据源中新创建的数据被同步到固态硬盘中
在数据源中删除测试文件
成功清理了两个文件
固态硬盘中的数据成功被清理
总结
在这篇博客中,我们探讨了一个备份系统的清理功能,该功能旨在清理那些在数据源中已被删除但其备份记录仍然存在于系统中的文件。最初的方法采用了分页查询的方式来处理备份记录,但由于数据量巨大,这种方法显得效率低下且消耗大量的内存资源。
为了改善这一情况,引入了一种新的流式处理方案。流式处理方案利用数据库的流式查询功能,通过创建特定类型的Statement
对象来逐行处理备份记录,从而避免了重复扫描整个表所带来的性能瓶颈。这种方法不仅降低了内存占用,还大大提升了查询效率。
测试结果显示,使用流式处理方案后,清理进度明显加快,对于包含数百万条记录的数据集,清理进度10%的时间从原先的3分钟减少到了更短的时间内,具体时间取决于实际环境和配置。
总结来说,通过采用流式处理方案,我们成功地解决了备份系统清理功能在面对大数据量时所遇到的性能问题,使得该功能更加高效、稳定。这对于任何需要处理大规模数据的应用程序来说都是非常有价值的优化措施。