【文件增量备份系统】备份业务实现与性能优化

🎯 导读:本文探讨了一种改进的数据备份方案,通过对原有逐个文件判断及备份的方法进行优化,引入了缓冲区机制以提升备份效率。改进后,程序通过累积待处理数据至一定量后再进行批量操作,显著减少了与数据库交互的频率,使得备份速度从原先的约1小时缩短至46秒。缺点是,此方法牺牲了一定的实时性,并增加了内存使用,但瑕不掩瑜。文中还提供了具体实现代码示例,包括如何使用NIO技术进行文件传输,以及如何在备份前后检查源文件和目标目录的状态,确保备份流程的健壮性和可靠性。
🏠️ 项目仓库:数据增量备份系统
📙 项目介绍:【文件增量备份系统】系统功能介绍与开源说明

性能优化

原方案

递归扫描数据源的所有文件,每扫描一个,就判断当前文件需不需要备份,如果需要备份,直接执行备份,并将数据插入到数据库中。该实现方式会造成程序与数据库的通讯时间长、索引维护时间长、数据库日志写入次数更多、IO效率较低。从下图发现整个备份时长竟达到了一个小时(备份目录大小:8.15G,文件个数:211470),这个性能肯定是属于不可用的

在这里插入图片描述

缓冲区备份方案

该方案即使用缓冲区来暂存需要插入或者更新的数据,等待缓冲区的数据量较多时,再进行批量插入或批量更新。通过下图可以发现,优化后的程序只需要46秒即可完成备份,备份效率相较于原方案大大提升

在这里插入图片描述

优点

  • 效率高

缺点

  • 实时性不强,原方案每次备份完文件就会将数据插入数据库,但当前方案则是等数据够多才批量存储,如果程序在备份过程中被关闭,则部分备份过程数据会丢失,导致部分文件在下次备份时会替换本次备份已经备份过的文件,注意这里丢失的数据不是指数据源中的数据,而是要存储到数据库的那些数据
  • 占用内存相比原方案会稍微大一点

缓冲区备份方案实现

备份原理

备份原理其实非常简单。在文件第一次备份的时候,会在数据库中存储文件大小修改日期MD5码信息,等第二次备份的时候,会对比文件现在的状态,如判断文件大小、修改日期有没有变化。如果两者都没有变化,说明文件没有被修改,无需替换;如果大小有变化,说明文件被修改了,需要进行替换;如果修改日期变化,文件大小没有变化,则需要进一步判断文件当前的MD5码是否和数据库中存储的一致,因为文件大小相同不能说明文件一定没有修改。如果MD5码不一致说明文件真正被修改了,因为同样的输入通过算法输出的MD5码一定是相同的

Controller

/**
 * 对指定的数据源进行备份
 */
@GetMapping("/backupBySourceId/{sourceId}")
public Result backupBySourceId(@PathVariable Long sourceId) throws IOException {
    if (backupingSourceIDSet.contains(sourceId)) {
        throw new ClientException("当前备份源正在备份中,请稍后再试");
    } 

    // 检查 备份源目录是否存在 和 准备好备份目标目录
    List<Task> taskList = backupService.checkSourceAndTarget(sourceId);
    if (taskList == null || taskList.size() == 0) {
        removeSourceIdFromBacking(backupingSourceIDSet, sourceId);
        return Results.failure();
    }

    // 开始备份
    backupingSourceIDSet.add(sourceId);
    CompletableFuture.runAsync(() -> {
        try {
            backupService.backupBySourceId(sourceId, taskList);
        } catch (ServerException e) {
            try {
                throw new ServerException(e.getMessage());
            } catch (ServerException ex) {
                throw new RuntimeException(ex);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }, executor).exceptionally(throwable -> {
        log.error(throwable.getMessage());
        removeSourceIdFromBacking(backupingSourceIDSet, sourceId);
        return null;
    });

    return Results.success();
}

/**
 * 将数据源Id从正在备份的数据源set中移除
 *
 * @param backupingSourceIDSet
 * @param sourceId
 */
private void removeSourceIdFromBacking(HashSet<Long> backupingSourceIDSet, Long sourceId) {
    if (backupingSourceIDSet.contains(sourceId)) {
        backupingSourceIDSet.remove(sourceId);
    }
}

这里面主要有如下细节:

  • 在备份之前,首先判断当前数据源是否处于备份状态(backupingSourceIDSet可以理解为一个备份ID池,ID在里面则说明数据源正在备份),如果数据源处于备份状态,则直接返回提示告诉用户数据源正在备份,让其稍后再尝试
  • 在真正开始备份之前,需要检测数据源和备份目标目录是否存在,有时候用户可能忘记插上硬盘或者输错目录路径
  • 如果数据量较大,备份需要花费一定的时间,但是用户点击备份按钮之后,系统应该有所提示让用户知道备份是否成功开始,因此使用CompletableFuture来开启异步任务来执行备份,然后给用户返回数据源加入备份成功
  • 备份完成之后,将数据源ID从备份ID池中移除

Service

备份功能的实现需要使用的表如下:

  • backup_source:存储备份数据源
  • backup_target:存储备份目标目录,关联数据源,数据源和备份目标目录是一对多关系
  • backup_task:存储备份任务
  • backup_file:存储已备份的文件
  • backup_file_history:存储已备份文件对应的备份记录
  • sys_param:存储系统在备份时忽略的文件或目录

下面代码开始真正的业务介绍:

/**
 * 对指定的备份源进行备份
 *
 * @param sourceId
 */
@Override
public void backupBySourceId(Long sourceId, List<Task> taskList) throws IOException {
    // 更新数据源备份次数
    backupSourceService.updateBackupNum(sourceId);
    // 查询忽略文件和忽略目录
    List<String> ignoreFileList = sysParamService.getIgnoreFileOrIgnoreDir(SystemParamEnum.IGNORE_FILE_NAME.getParamName());
    List<String> ignoreDirectoryList = sysParamService.getIgnoreFileOrIgnoreDir(SystemParamEnum.IGNORE_DIRECTORY_NAME.getParamName());
    // 执行备份
    CompletableFuture[] futureArr = new CompletableFuture[taskList.size()];
    for (int i = 0; i < taskList.size(); i++) {
        int finalI = i;
        Task task = taskList.get(finalI);
//            backUpByTask(task, ignoreFileList, ignoreDirectoryList);
        futureArr[i] = CompletableFuture.runAsync(() -> {
            try {
                backUpByTask(task, ignoreFileList, ignoreDirectoryList);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }, executor).exceptionally(e -> {
            log.error(e.getMessage());
            // 备份失败(出现异常),移除相应数据源ID
            if (backupController.backupingSourceIDSet.contains(sourceId)) {
                backupController.backupingSourceIDSet.remove(sourceId);
            }
            Map<String, Object> dataMap = new HashMap<>();
            dataMap.put("code", WebsocketNoticeEnum.BACKUP_ERROR.getCode());
            dataMap.put("message", e.getMessage());
            webSocketServer.sendMessage(JSON.toJSONString(dataMap), WebSocketServer.usernameAndSessionMap.get("Admin"));
            return null;
        });
    }
    CompletableFuture.allOf(futureArr).join();

    // 备份完成,移除相应数据源ID
    if (backupController.backupingSourceIDSet.contains(sourceId)) {
        backupController.backupingSourceIDSet.remove(sourceId);
    }
}

该方法业务流程如下:

  1. 执行备份之前先更新数据库中数据源的备份次数
  2. 通过sys_param查询出要忽略的文件和忽略目录,在备份过程中对这些文件和目录进行忽略,因为部分文件是不需要备份的,例如Java项目的.idea文件,该文件使用IDEA启动项目会自动生成,而且不同版本IDEA生成的.idea文件有所区别,因此不需要进行备份
  3. 如果需要将一个数据源的数据同时备份到多个目标目录中,同时开多个线程来分别执行每个备份任务,提高备份效率,一个备份任务负责将数据源的数据备份到一个目标目录中
/**
 * 根据备份任务来进行备份
 *
 * @param task                备份任务
 * @param ignoreFileList      忽略文件名列表
 * @param ignoreDirectoryList 忽略目录名列表
 */
private void backUpByTask(Task task, List<String> ignoreFileList, List<String> ignoreDirectoryList) throws IOException {
    BackupSource backupSource = task.getSource();
    BackupTarget backupTarget = task.getTarget();
    // 找到备份目录下面的所有文件
    BackupStatistic sta = new BackupStatistic(0, 0, 0, 0, new Date().getTime() / 1000);
    // 获取数据源的统计数据
    getStatisticMessage(new File(backupSource.getRootPath()), sta);
//        log.info("当前数据源(id={})下的总文件数量:{},总字节数:{}", backupSource.getId(), sta.totalBackupFileNum, sta.totalBackupByteNum);
    String targetRootPath = getTargetRootPath(task, backupSource, backupTarget);
    // 将任务插入到数据库中
    BackupTask backupTask = new BackupTask(backupSource.getRootPath(), targetRootPath,
            sta.totalBackupFileNum, 0, sta.totalBackupByteNum, 0L,
            0, "0.0", "0.0", 0L, new Date());
    backupTaskService.save(backupTask);
//        log.info("发送任务消息,通知前端任务创建成功");
    Map<String, Object> dataMap = new HashMap<>();
    dataMap.put("code", WebsocketNoticeEnum.BACKUP_START.getCode());
    dataMap.put("message", WebsocketNoticeEnum.BACKUP_START.getDetail());
    dataMap.put("backupTask", backupTask);
    webSocketServer.sendMessage(JSON.toJSONString(dataMap), WebSocketServer.usernameAndSessionMap.get("Admin"));
    log.info("任务创建成功,开始备份");

    /// 查询出数据源和备份目标对应的 备份文件信息
    // 查询出当前数据源中所有已经备份过的文件
    QueryWrapper<BackupFile> backupFileQueryWrapper = new QueryWrapper<BackupFile>()
            .eq("backup_source_id", backupSource.getId())
            .eq("father_id", 0L)
            .select("id", "source_file_path", "target_file_path", "file_name");
    if (backupSource.getBackupType() == 0) {
        // 集中备份的时候,根据目标id查询;分散备份的时候,目标id不确定,所以都查询出来
        backupFileQueryWrapper.eq("backup_target_id", backupTarget.getId());
    }
    List<BackupFile> backupFileList = backupFileService.list(backupFileQueryWrapper);

    // 将数据源的数据备份到多个目标目录下面
    sta.second = new Date().getTime() / 1000;

    /// 开始备份
    List<BackupFile> backupFileBuffer1 = new ArrayList<>();
    List<BackupFile> backupFileBuffer2 = new ArrayList<>();
    List<BackupFileHistory> backupFileHistoryBuffer1 = new ArrayList<>();
    List<BackupFileHistory> backupFileHistoryBuffer2 = new ArrayList<>();
    backUpAllFilesOfFatherFile(task, new File(backupSource.getRootPath()),
            backupSource, backupTarget, task.getTargetList(), sta,
            "", backupTask.getId(), backupTask.getCreateTime(),
            0L, backupFileList, ignoreFileList, ignoreDirectoryList,
            backupFileBuffer1, backupFileHistoryBuffer1,
            backupFileBuffer2, backupFileHistoryBuffer2);
    // 处理缓冲区中残留数据
    buffer1Process(backupFileBuffer1, backupFileHistoryBuffer1);
    buffer2Process(backupTask.getId(), backupSource, backupFileBuffer2, backupFileHistoryBuffer2);
    /// 备份结束
    if (Cache.STOP_TASK_ID_SET.contains(backupTask.getId())) {
        // --if-- 因为备份任务被暂停才结束的
        Cache.STOP_TASK_ID_SET.remove(backupTask.getId());
    } else {
        // --if-- 备份完成了,修改备份任务的状态为完成
        backupTask.setBackupStatus(2);
        backupTask.setFinishFileNum(sta.getTotalBackupFileNum());
        backupTask.setFinishByteNum(sta.getTotalBackupByteNum());
        backupTask.setEndTime(new Date());
        backupTask.setBackupTime(backupTask.getEndTime().getTime() - backupTask.getCreateTime().getTime());
        backupTaskService.updateById(backupTask);
        setProgress(backupTask);
        log.info("发送任务消息,通知前端任务备份完成");
        dataMap = new HashMap<>();
        dataMap.put("code", WebsocketNoticeEnum.BACKUP_SUCCESS.getCode());
        dataMap.put("message", WebsocketNoticeEnum.BACKUP_SUCCESS.getDetail());
        dataMap.put("backupTask", backupTask);
        webSocketServer.sendMessage(JSON.toJSONString(dataMap), WebSocketServer.usernameAndSessionMap.get("Admin"));
    }
}
    
/**
 * 获取一个目录下面的统计信息
 * 1. 需要备份的文件数量
 * 2. 需要备份的字节数量
 *
 * @param file
 * @param sta  用来存储统计信息
 */
private void getStatisticMessage(File file, BackupStatistic sta) {
    File[] fileArr = file.listFiles();
    for (File f : fileArr) {
        if (f.isDirectory()) {
            // --if-- 若是目录,则递归统计该目录下的文件数量
            getStatisticMessage(f, sta);
        } else {
            // --if-- 若是文件,添加到文件夹中
            sta.totalBackupFileNum++;
            sta.totalBackupByteNum += f.length();
        }
    }
}

该方法主要负责一个任务的备份,业务流程如下:

  1. 使用递归方法getStatisticMessage来统计数据源根目录下面一个有多少个文件,方便后面实现进度可视化(大数据量时,这个方法较慢,需要进一步优化)
  2. 将备份任务插入的数据库中进行保存、然后通过Websocket双向通讯技术通知前端备份开始啦,顺便告诉前端当前任务需要备份的文件总数是多少、文件个数是多少,类似下图的效果

在这里插入图片描述

  1. 将当前数据源所备份过第一层深度的备份文件一起查询出来,这些备份文件的father_id为0。现实情况中,目录下面可能会包含子目录和子文件,而子目录下面又可能会有子目录或子文件,可以将此结构理解成一个文件树,所以就有了深度这个概念
  2. 进入递归备份方法backUpAllFilesOfFatherFile,检验每个目录、每个文件是否需要进行备份
  3. 备份完成之后,将缓冲区中残留的数据存储到数据库中
  4. 更新数据库中的备份任务状态
  5. 使用Websocket通知前端当前任务备份完成
/**
     * 将一个 父文件夹 的所有文件 备份到 目标目录中
     *
     * @param fatherFile
     * @param backupSource
     * @param backupTarget
     * @param backupStatistic
     * @param middlePath
     */
    private void backUpAllFilesOfFatherFile(Task task, File fatherFile,
                                            BackupSource backupSource, BackupTarget backupTarget, List<BackupTarget> targetList,
                                            BackupStatistic backupStatistic, String middlePath,
                                            Long backupTaskId, Date taskBackupStartTime,
                                            Long fatherId, List<BackupFile> backupFileList,
                                            List<String> ignoreFileList, List<String> ignoreDirectoryList,
                                            List<BackupFile> backupFileBuffer1, List<BackupFileHistory> backupFileHistoryBuffer1,
                                            List<BackupFile> backupFileBuffer2, List<BackupFileHistory> backupFileHistoryBuffer2) {
//        System.out.println("execSingleFileBackUp_TIME:" + execSingleFileBackUp_TIME * 1.0 / 1000 + "s");
        File[] sonFileArr = fatherFile.listFiles();
        HashMap<String, BackupFile> fileNameAndBackupFileMap = new HashMap<>();
        if (backupFileList != null) {
            // 记录要移除的 文件信息ID
//            List<Long> removeBackupFileIdList = new ArrayList<>();
            // 存储数据源中存在的文件的名称
            HashSet<String> fileNameSet = new HashSet<>();
            for (int i = 0; i < sonFileArr.length; i++) {
                fileNameSet.add(sonFileArr[i].getName());
            }
            for (BackupFile backupFile : backupFileList) {
                fileNameAndBackupFileMap.put(backupFile.getFileName(), backupFile);
                if (!fileNameSet.contains(backupFile.getFileName())) {
//                    removeBackupFileIdList.add(backupFile.getId());
                }
            }
            // 如果数据源中没有相应文件,将其也从数据库中删除
//            backupFileService.recursionRemoveBackupFile(removeBackupFileIdList);
        }
        for (File file : sonFileArr) {
            if (Cache.STOP_TASK_ID_SET.contains(backupTaskId)) {
                // --if-- 如果任务被暂停,退出备份,存储当前备份任务的信息
                BackupTask backupTask = new BackupTask();
                backupTask.setId(backupTaskId);
                backupTask.setBackupStatus(4);
                backupTask.setFinishFileNum(backupStatistic.getFinishBackupFileNum());
                backupTask.setFinishByteNum(backupStatistic.getFinishBackupByteNum());
                backupTask.setEndTime(new Date());
                backupTask.setBackupTime(backupTask.getEndTime().getTime() - taskBackupStartTime.getTime());
                backupTaskService.updateById(backupTask);
                backupTask.setTotalFileNum(backupStatistic.getTotalBackupFileNum());
                backupTask.setTotalByteNum(backupStatistic.getTotalBackupByteNum());
                setProgress(backupTask);
                backupTask.setBackupSourceRoot(backupSource.getRootPath());
                backupTask.setBackupTargetRoot(backupTarget.getTargetRootPath());
                backupTask.setCreateTime(taskBackupStartTime);
                log.info("发送任务消息,通知前端任务暂停");
                Map<String, Object> dataMap = new HashMap<>();
                dataMap.put("code", WebsocketNoticeEnum.BACKUP_STOP.getCode());
                dataMap.put("message", WebsocketNoticeEnum.BACKUP_STOP.getDetail());
                dataMap.put("backupTask", backupTask);
                webSocketServer.sendMessage(JSON.toJSONString(dataMap), WebSocketServer.usernameAndSessionMap.get("Admin"));
                break;
            }
//            if (file.toString().indexOf("/.") != -1 || file.toString().indexOf("\\.") != -1) {
//                continue;
//            }
            if (file.isDirectory()) {
                // --if-- 若是目录,先在目标目录下创建目录,然后递归备份文件
                if (isContainedInIgnoreList(ignoreDirectoryList, file)) {
                    continue;
                }
                String targetFilePath = getTargetFilePath(backupSource, backupTarget, targetList, middlePath, file);
                // 查询备份文件数据表是否已经包含这个记录
                BackupFile backupFile = fileNameAndBackupFileMap.get(file.getName());
                Long curBackupFileId = backupFile == null ? null : backupFile.getId();

                File targetFile = new File(targetFilePath);
                if (!targetFile.exists()) {
                    boolean mkdirs = targetFile.mkdirs();
                    if (mkdirs) {
                        // 将目录插入到数据库中
                        if (curBackupFileId == null) {
                            curBackupFileId = saveBackupFileDir(backupSource, backupTarget, targetFilePath, fatherId, file);
                        }
                    } else {
                        throw new ServiceException("无法创建目录,可能是权限不够");
                    }
                } else {
                    // --if-- 虽然目录已经存在,但是数据库中没有信息,还是需要存储相关信息
                    if (curBackupFileId == null) {
                        curBackupFileId = saveBackupFileDir(backupSource, backupTarget, targetFilePath, fatherId, file);
                    }
                }

                // 是否存在对应的文件信息,如果备份类型不是是分散存储,那么文件信息肯定不存在
                boolean haveBackupFile = fileNameAndBackupFileMap.get(file.getName()) != null;
                List<BackupFile> children = null;
                if (haveBackupFile) {
                    children = new ArrayList<>();
                    long start = System.currentTimeMillis();
                    children.addAll(backupFileService.list(new QueryWrapper<BackupFile>().
                            eq("backup_source_id", backupSource.getId()).
                            eq("father_id", curBackupFileId)));
//                    DATABASE_BACKUP_FILE_SEARCH_TIME += System.currentTimeMillis() - start;
//                    System.out.println("备份文件查询时间:" + DATABASE_BACKUP_FILE_SEARCH_TIME * 1.0 / 1000 + "s");
                }
                backUpAllFilesOfFatherFile(task, file, backupSource, backupTarget,
                        targetList, backupStatistic,
                        middlePath + file.getName() + File.separator, backupTaskId, taskBackupStartTime,
                        curBackupFileId, children,
                        ignoreFileList, ignoreDirectoryList,
                        backupFileBuffer1, backupFileHistoryBuffer1,
                        backupFileBuffer2, backupFileHistoryBuffer2);
            } else {
                // --if-- 若是文件,执行备份操作
                if (isContainedInIgnoreList(ignoreFileList, file)) {
                    continue;
                }
                if (file.getName().contains(".DS_Store")) {
                    // 跳过Macos的Finder创建文件
                    continue;
                }
                try {
                    execSingleFileBackUp(task, backupSource, backupTarget, targetList, file.toString(),
                            backupStatistic, middlePath, backupTaskId, taskBackupStartTime, fatherId,
                            fileNameAndBackupFileMap, backupFileBuffer1, backupFileHistoryBuffer1,
                            backupFileBuffer2, backupFileHistoryBuffer2);
                } catch (SQLException e) {
                    throw new RuntimeException(e);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }

该方法用来递归处理一个目录的备份,业务逻辑如下:

  1. 将目录对应的备份文件集合封装到字典中,优化后续校验文件是否修改的时候查询效率
  2. 在循环处理sonFileArr的时候,首先判断当前任务是否被暂停备份,如果任务ID存在于暂停ID池STOP_TASK_ID_SET中,则暂停当前任务,更新数据库的任务状态,并通知前端任务暂停成功
  3. 判断当前所循环到的子文件是目录还是文件,如果是目录,进入第4步;否则进入第5步
  4. 检查当前目录是否被忽略,如果被忽略直接continue,否则继续执行;检查backup_file中是否有相应信息,没有则存储到数据库中,有则继续执行;若备份目标目录没有对应的目录,则创建目录;查询当前所遍历目录的子备份文件集合children,递归调用backUpAllFilesOfFatherFile
  5. 检查当前文件是否被忽略,如果被忽略直接continue,否则继续执行;调用execSingleFileBackUp执行单个文件的备份
/**
 * 执行一个文件的备份
 * 首先判断文件是否已经备份或者是否有所修改,是则进行备份
 *
 * @param source
 * @param target
 * @param backupSourceFilePath
 * @param backupStatistic
 * @param middlePath
 * @throws SQLException
 * @throws IOException
 */
private void execSingleFileBackUp(Task task, BackupSource source, BackupTarget target,
                                  List<BackupTarget> targetList, String backupSourceFilePath,
                                  BackupStatistic backupStatistic, String middlePath,
                                  Long backupTaskId, Date taskBackupStartTime,
                                  Long fatherId, HashMap<String, BackupFile> fileNameAndBackupFileMap,
                                  List<BackupFile> backupFileBuffer1, List<BackupFileHistory> backupFileHistoryBuffer1,
                                  List<BackupFile> backupFileBuffer2, List<BackupFileHistory> backupFileHistoryBuffer2) throws SQLException, IOException {
    long start = System.currentTimeMillis();
   /* if (backupSourceFilePath.indexOf("/.") != -1 || backupSourceFilePath.indexOf("\\.") != -1) {
        // 不拷贝.开头的文件夹和文件
        return;
    }*/
    // 获取源文件
    File backupSourceFile = new File(backupSourceFilePath);
    if (!backupSourceFile.exists()) {
        int temp = 0;
    }
    Long targetId = source.getBackupType() == 0 ? target.getId() : 0;
    if (fileNameAndBackupFileMap.get(backupSourceFile.getName()) == null) {
        // --if-- 文件还没有备份过,将其插入到数据库中,并取出id

        // 获取备份目标路径
        String targetFilePath = getTargetFilePath(source, target, targetList, middlePath, backupSourceFile);

        int isCompress = 0;
        if (isNeedCompress(source, backupSourceFile)) {
            // --if-- 当数据源设置了压缩,且文件的大小等于10M才进行压缩
            isCompress = 1;
            targetFilePath = updateTargetFilePath(targetFilePath);
        }

        BackupFile backupFile = constructBackupFile(source, backupSourceFilePath, targetFilePath, targetId,
                fatherId, isCompress, backupSourceFile);
        FileInputStream sourceFileInputStream = new FileInputStream(backupSourceFilePath);
        String md5str = DigestUtil.md5Hex(sourceFileInputStream);
        sourceFileInputStream.close();
        // backupFileId 待定,还不是准确的
        BackupFileHistory backupFileHistory = constructBackupFileHistory(backupSourceFilePath, source.getId(), targetId, targetFilePath, 0L, backupTaskId, new Date(), backupSourceFile, md5str);
        addToBuffer1(backupFile, backupFileHistory, backupFileBuffer1, backupFileHistoryBuffer1,
                isCompress, backupSourceFile, targetFilePath);
    } else {
        // 直接从字典中获取
        BackupFile backupFileInDatabase = fileNameAndBackupFileMap.get(backupSourceFile.getName());
        addToBuffer2(source.getId(), targetId, backupTaskId,
                source, backupFileInDatabase,
                backupFileBuffer2, backupFileHistoryBuffer2);
    }

    // 每隔一秒输出一下拷贝进度
    backupStatistic.finishBackupFileNum++;
    backupStatistic.finishBackupByteNum += backupSourceFile.length();
    long curTime = System.currentTimeMillis();
    if ((curTime / 1000) != backupStatistic.second) {
        backupStatistic.second = curTime / 1000;
//            log.info("文件数量:拷贝进度:" + statistic.finishBackupFileNum * 100.0 / statistic.totalBackupFileNum + "%  " + statistic.finishBackupFileNum + "/" + statistic.totalBackupFileNum +
//                    "; 文件大小:拷贝进度:" + statistic.finishBackupByteNum * 100.0 / statistic.totalBackupByteNum + "%  " + statistic.finishBackupByteNum + "/" + statistic.totalBackupByteNum);
        BackupTask backupTask = new BackupTask();
        backupTask.setId(backupTaskId);
        backupTask.setBackupStatus(1);
        backupTask.setFinishFileNum(backupStatistic.finishBackupFileNum);
        backupTask.setFinishByteNum(backupStatistic.finishBackupByteNum);
        backupTask.setBackupTime(curTime - taskBackupStartTime.getTime());
        backupTaskService.updateById(backupTask);
        // 剩下的信息用来给前端看的,不需要更新到数据库中
        backupTask.setBackupSourceRoot(source.getRootPath());
        backupTask.setBackupTargetRoot(getTargetRootPath(task, source, target));
        backupTask.setTotalFileNum(backupStatistic.totalBackupFileNum);
        backupTask.setTotalByteNum(backupStatistic.totalBackupByteNum);
        backupTask.setCreateTime(taskBackupStartTime);
        setProgress(backupTask);
        log.info("发送任务消息,通知前端备份进度变化");
        Map<String, Object> dataMap = new HashMap<>();
        dataMap.put("code", WebsocketNoticeEnum.BACKUP_PROCESS.getCode());
        dataMap.put("message", WebsocketNoticeEnum.BACKUP_PROCESS.getDetail());
        dataMap.put("backupTask", backupTask);
        webSocketServer.sendMessage(JSON.toJSONString(dataMap), WebSocketServer.usernameAndSessionMap.get("Admin"));
    }
//        execSingleFileBackUp_TIME += (System.currentTimeMillis() - start);
}

/**
 * 处理还没有存储到数据库中的备份文件, 这些备份文件 百分之百 是没有进行备份的
 * 1. 将其进行备份
 * 2. 直接给这些备份文件添加备份记录
 *
 * @param backupFile
 * @param backupFileBuffer1
 */
private void buffer1Process(BackupFile backupFile, BackupFileHistory backupFileHistory,
                            List<BackupFile> backupFileBuffer1, List<BackupFileHistory> backupFileHistoryBuffer1,
                            int isCompress, File backupSourceFile, String targetFilePath) {
    // 执行文件备份
    try {
        if (execBackupSingleFile(isCompress, backupSourceFile, targetFilePath)) {
            backupFileBuffer1.add(backupFile);
            backupFileHistoryBuffer1.add(backupFileHistory);
        } else {
            log.error("备份出错");
        }
    } catch (Exception e) {
        log.error("文件备份出错");
        throw new RuntimeException(e);
    }

    if (backupFileBuffer1.size() > this.BATCH_SIZE) {
        buffer1Process(backupFileBuffer1, backupFileHistoryBuffer1);
    }
}

private void addToBuffer2(Long backupTaskId, BackupSource backupSource, BackupFile backupFileInDatabase,
                          List<BackupFile> backupFileBuffer2, List<BackupFileHistory> backupFileHistoryBuffer2) throws IOException {
    backupFileBuffer2.add(backupFileInDatabase);
    if (backupFileBuffer2.size() >= this.BATCH_SIZE) {
        buffer2Process(backupTaskId, backupSource, backupFileBuffer2, backupFileHistoryBuffer2);
    }
}

当前方法主要判断文件是否被备份,或者距上次备份是否有修改,如果没有备份过或者修改过,则需要进行备份。业务流程如下:

  1. 检查fileNameAndBackupFileMap中是否包含当前文件名,包含则说明文件之前已经被备份过,进入第2步;否则进入第3步
  2. 构建backupFilebackupFileHistory对象,并添加到缓冲区buffer1 ,同时执行文件的备份
  3. fileNameAndBackupFileMap中取出backupFile,将其加入缓冲区buffer2
  4. 除了上面步骤之外,每隔一秒需要通知前端当前的备份进度
private void buffer1Process(List<BackupFile> backupFileBuffer1, List<BackupFileHistory> backupFileHistoryBuffer1) {
    backupFileService.saveBatch(backupFileBuffer1);
    for (int i = 0; i < backupFileHistoryBuffer1.size(); i++) {
        backupFileHistoryBuffer1.get(i).setBackupFileId(backupFileBuffer1.get(i).getId());
    }
    // 批量存储备份历史记录
    backupFileHistoryService.saveBatch(backupFileHistoryBuffer1);
    backupFileHistoryBuffer1.clear();
    backupFileBuffer1.clear();
}

该方法是缓冲区1满了之后的处理逻辑,即简单地批量存储备份文件数据以及备份历史记录数据,并清空缓冲区

private void buffer2Process(Long backupSourceId, Long backupTargetId, Long backupTaskId, BackupSource backupSource,
                            List<BackupFile> backupFileBuffer2, List<BackupFileHistory> backupFileHistoryBuffer2) throws IOException {
    String md5str = "";
    List<BackupFile> updateBackupFileBuffer = new ArrayList<>();
    List<Long> backupFileIdList = backupFileBuffer2.stream().map(item -> {
        return item.getId();
    }).collect(Collectors.toList());

    // 获取这些备份文件对应的备份历史记录
    Map<Long, BackupFileHistory> fileIdAndFileHistoryMap = new HashMap<>();
    long start = System.currentTimeMillis();
    List<BackupFileHistory> historyList = backupFileHistoryService.listLastBackupHistoryByBackupFileIdList(backupFileIdList);
//        DATABASE_BACKUP_FILE_HISTORY_SEARCH_TIME += System.currentTimeMillis() - start;
//        System.out.println("备份历史查询时间:" + DATABASE_BACKUP_FILE_HISTORY_SEARCH_TIME * 1.0 / 1000 + "s");
    for (BackupFileHistory fileHistory : historyList) {
        fileIdAndFileHistoryMap.put(fileHistory.getBackupFileId(), fileHistory);
    }

    for (BackupFile backupFile : backupFileBuffer2) {
        FileInputStream sourceFileInputStream = null;
        boolean isNeedBackup = true;
        BackupFileHistory fileHistory = fileIdAndFileHistoryMap.get(backupFile.getId());

        File backupSourceFile = new File(backupFile.getSourceFilePath());
        // 获取备份目标路径
        String targetFilePath = backupFile.getTargetFilePath();

        int isCompress = 0;
        if (isNeedCompress(backupSource, backupSourceFile)) {
            // --if-- 当数据源设置了压缩,且文件的大小等于10M才进行压缩
            isCompress = 1;
            targetFilePath = updateTargetFilePath(targetFilePath);
        }

        if (fileHistory != null) {
            long lastModify = fileHistory.getModifyTime();
            long fileSize = fileHistory.getFileSize();
            String historyMD5 = fileHistory.getMd5();
            if (lastModify == backupSourceFile.lastModified() && fileSize == backupSourceFile.length()) {
                // 如果文件的 修改时间 和 文件大小 都和数据库中的对应,认为文件没有被修改,无需备份
                isNeedBackup = false;
            }
            // 如果修改时间不一样,文件大小一样,追加校验一次hash,如果hash一样,则更新修改时间,不执行备份
            if (lastModify != backupSourceFile.lastModified() && fileSize == backupSourceFile.length()) {
                // 只要输入一样,输出的MD5码就是一样的,如果md5一样,不执行备份
                sourceFileInputStream = new FileInputStream(backupSourceFile);
                md5str = DigestUtil.md5Hex(sourceFileInputStream);
                if (md5str.equals(historyMD5)) {
                    isNeedBackup = false;
                }
            }
        }

        if (isNeedBackup == false) {
            // --if-- 判断备份目标目录中没有文件,也要备份过去
            File file = new File(targetFilePath);
            if (!file.exists()) {
                isNeedBackup = true;
            }
        }

        if (isNeedBackup) {
            Date startDate = new Date();
            try {
                // 检查目标目录的文件对应的目录是否存在,不存在则创建(有可能文件被备份到目标目录之后,目标目录的文件夹被删除)
                String dirPath = targetFilePath.substring(0, targetFilePath.lastIndexOf(File.separator));
                File dir = new File(dirPath);
                if (!dir.exists()) {
                    dir.mkdirs();
                }
                if (!execBackupSingleFile(isCompress, backupSourceFile, targetFilePath)) {
                    log.error("备份出错");
                } else {
                    if (sourceFileInputStream == null) {
                        sourceFileInputStream = new FileInputStream(backupSourceFile);
                        md5str = DigestUtil.md5Hex(sourceFileInputStream);
                    }
                    /// 保存文件备份历史
                    BackupFileHistory history = constructBackupFileHistory(backupFile.getSourceFilePath(), backupSourceId, backupTargetId,
                            targetFilePath, backupFile.getId(), backupTaskId, startDate, backupSourceFile, md5str);
                    history.setId(fileHistory.getId());
                    updateBackupFileHistory(history, backupFileHistoryBuffer2);

                    /// 更新文件信息
                    BackupFile newBackupFile = new BackupFile();
                    // 文件的大小可能会改变
                    newBackupFile.setFileLength(backupSourceFile.length());
                    // 文件大小改变之后,压缩之后的文件大小也会改变
                    if (isCompress == 1) {
                        File targetFile = new File(targetFilePath);
                        newBackupFile.setFileLengthAfterCompress(targetFile.length());
                    }
                    // 本来可以压缩的文件,修改之后可能不再可以压缩,因为空间可能变大
                    newBackupFile.setIsCompress(isCompress);
                    // 更新文件的备份次数
                    int backupNum = backupFile.getBackupNum();
                    newBackupFile.setBackupNum(++backupNum);
                    // 修改文件的上次备份时间
                    newBackupFile.setLastBackupTime(new Date());
                    updateBackupFileBuffer.add(newBackupFile);
                }
            } catch (Exception e) {
                log.error("文件备份出错");
                throw new RuntimeException(e);
            }
        }
        if (sourceFileInputStream != null) {
            sourceFileInputStream.close();
        }
    }

    // 批量更新备份文件信息
    if (updateBackupFileBuffer.size() > 0) {
        backupFileService.updateBatchById(updateBackupFileBuffer);
    }

    backupFileBuffer2.clear();
}

该方法是缓冲区2满了之后的处理逻辑,解释如下:

  1. 根据备份文件集合批量查询出每个备份文件所对应的备份历史记录,并封装成字典fileIdAndFileHistoryMap,方便后续使用
  2. 遍历缓冲区的所有backupFile,从fileIdAndFileHistoryMap中获取对应的fileHistory,根据fileHistory判断文件是否需要重新备份
  3. 如果需要重新备份,调用execBackupSingleFile进行备份,备份成功之后更新备份历史和备份文件,注意这里还是使用批量更新,等攒够一定的数据量再进行更新

注意,如下代码是起到一个兜底作用,即为了避免备份目标目录中的数据被误删,如果备份目标目录中没有对应的文件,说明文件被误删了,也需要重新进行备份

if (isNeedBackup == false) {
    // --if-- 判断备份目标目录中没有文件,也要备份过去
    File file = new File(targetFilePath);
    if (!file.exists()) {
        isNeedBackup = true;
    }
}
/**
 * 执行 单个文件 的拷贝
 *
 * @param isCompress     是否压缩
 * @param targetFilePath 备份的目标文件路径
 * @return
 * @throws IOException
 */
private boolean execBackupSingleFile(int isCompress, File backupSourceFile, String targetFilePath) throws IOException {
//        System.out.println("执行备份");
    try {
        if (isCompress == 1) {
            // 对文件进行压缩
            GzipCompressUtil.compressFile(backupSourceFile, targetFilePath);
        } else {
            // 直接拷贝
            backupWithFileChannel(backupSourceFile, new File(targetFilePath));
        }
//            log.info("备份文件成功,从" + sourceFilePath + " 到 " + targetFilePath);
    } catch (Exception e) {
//            log.info("备份文件失败,从" + sourceFilePath + " 到 " + targetFilePath);
        return false;
    }
    return true;
}

/**
 * 将 source 备份到 target
 *
 * @param source
 * @param target
 * @throws IOException
 */
private static void backupWithFileChannel(File source, File target) throws IOException {
    if (!source.exists()) {
        log.error("备份源文件不存在");
        return;
    }
    FileChannel inputChannel = null;
    FileChannel outputChannel = null;
    try {
        inputChannel = new FileInputStream(source).getChannel();
        outputChannel = new FileOutputStream(target).getChannel();
        outputChannel.transferFrom(inputChannel, 0, inputChannel.size());
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        if (inputChannel != null) {
            inputChannel.close();
        }
        if (outputChannel != null) {
            outputChannel.close();
        }
    }
}

该方法主要使用nio来实现文件的拷贝,当然,如果选择了压缩形式,则直接将文件压缩之后输出到目标路径

/**
 * 检查 备份源目录是否存在 和 准备好备份目标目录
 *
 * @param sourceId
 */
@Override
public List<Task> checkSourceAndTarget(Long sourceId) {
    BackupSource source = backupSourceService.getById(sourceId);
    if (source == null) {
        throw new ClientException("id对应备份源信息不存在于数据库中");
    }
    File sourceFile = new File(source.getRootPath());
    if (!sourceFile.exists()) {
        throw new ServiceException("备份源目录不存在,请检查备份源是否被删除");
    }
    // 查询备份源对应的所有 备份目标目录 准备好相关的目录
    List<BackupTarget> backupTargetList = backupTargetService.list(new QueryWrapper<BackupTarget>().eq("backup_source_id", source.getId()));
    if (backupTargetList.size() == 0) {
        throw new ClientException("没有为 备份源 配置 备份目标目录,请先配置 备份目标目录");
    }
    // 存储不正常的目标目录
    List<BackupTarget> unNormalTargetList = new ArrayList<>();
    for (BackupTarget backupTarget : backupTargetList) {
        File file = new File(backupTarget.getTargetRootPath());
        if (!file.exists()) {
            boolean mkdir = file.mkdir();
            if (!mkdir) {
                unNormalTargetList.add(backupTarget);
                throw new ServiceException("目标目录创建失败,请检查备份目标磁盘是否正常连接电脑");
            }
        }
    }
    backupTargetList.removeAll(unNormalTargetList);
    if (backupTargetList.size() == 0) {
        // --if-- 如果当前数据源没有一个备份目标目录正常,则将当前数据源从正在备份的备份源列表中移除
        if (backupController.backupingSourceIDSet.contains(sourceId)) {
            backupController.backupingSourceIDSet.remove(sourceId);
        }
        return new ArrayList<>();
    }

    List<Task> taskList = null;
    if (source.getBackupType() == 0) {
        taskList = backupTargetList.stream().map(item -> {
            return new Task(source, item, null);
        }).collect(Collectors.toList());
    } else if (source.getBackupType() == 1) {
        Task task = new Task(source, null, backupTargetList);
        taskList = new ArrayList<>();
        taskList.add(task);
    }
    return taskList;
}

该方法主要用来检查数据源和备份目标目录的准备状态,并准备好备份任务

说明

备份业务比较复杂,代码随时会被优化,文章中的代码仅供参考,如果对最新代码感兴趣的话,还请到Git仓库中进行查看

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Hello Dam

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值