定时备份网站数据,再也不怕被删库了~

定时备份网站数据,再也不怕被删库了~

当前文章收录状态:
查询中...

目的

为了后续迁移方便,方便恢复,防止意外,确保网站迁移时数据不会丢失。无论是因为技术故障、操作失误,还是遭遇黑客攻击,有备份在手,我们就能快速恢复博客,避免数据丢失带来的困扰。

备份方法

我将整个备份过程制作成了一个流程图,简明扼要地展示了每一步操作:

图片[1]-定时备份网站数据,再也不怕被删库了~-明恒博客

我的博客系统中主要是备份 mysql和上传的 资源文件,主要是针对这两方面进行备份

  1. mysql备份
    • 通过mysql自带mysqldump进行备份
    • 程序查询表的所有数据,模拟mysqldump备份
  2. 资源文件备份
    • 压缩备份

mysql备份

mysql 最佳备份方式无疑是使用 mysql 自带的 mysqldump 工具。然而,当你的 MySQL 数据库和项目部署在不同的服务器上,或者使用 Docker 等容器化技术时,通过程序执行命令脚本可能无法正常使用。因此,可以通过模拟 mysqldump 备份的方式来实现 MySQL 的备份。下面我们将先分析 mysqldump,然后通过代码实现备份。

基本备份命令

要备份一个MySQL数据库,可以使用以下命令:

mysqldump -u [username] -p [database_name] > [backup_file].sql
  • -u [username]:数据库用户名
  • -p:提示输入数据库用户密码
  • [database_name]:要备份的数据库名称
  • > [backup_file].sql:备份文件的输出路径和文件名

示例:

mysqldump -u root -p mydatabase > mydatabase_backup.sql

恢复备份命令

从备份文件恢复数据库,可以使用以下命令:

mysql -u [username] -p [database_name] < [backup_file].sql

示例:

mysql -u root -p mydatabase < mydatabase_backup.sql

备份文件结构

通过分析 mysqldump 生成的备份文件,可以看到其中的表结构和数据。下面是对表 b_article_tag 的完整备份结构:

--
-- Table structure for table b_article_tag
--

DROP TABLE IF EXISTS b_article_tag;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE b_article_tag (
  id int NOT NULL AUTO_INCREMENT COMMENT 'id',
  article_id int NOT NULL COMMENT '文章id',
  tag_id int NOT NULL COMMENT '标签id',
  PRIMARY KEY (id) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='文章标签关联';
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Dumping data for table b_article_tag
--

LOCK TABLES b_article_tag WRITE;
/*!40000 ALTER TABLE b_article_tag DISABLE KEYS */;
INSERT INTO b_article_tag VALUES (1,1,1),(2,2,2),(3,3,1),(4,4,2),(5,5,1),(6,6,2),(7,7,1),(8,8,2),(9,9,1),(10,10,2),(12,14,3);
/*!40000 ALTER TABLE b_article_tag ENABLE KEYS */;
UNLOCK TABLES;

让我们逐行解释这个脚本的含义。

表结构的备份

--
-- Table structure for table `b_article_tag`
--

DROP TABLE IF EXISTS `b_article_tag`;

这部分是对表 b_article_tag 的结构进行备份。首先,它会删除现有的同名表(如果存在),以确保在恢复时不会发生冲突。

/*!40101 SET @saved_cs_client     = @@character_set_client */;

这是一条 MySQL 特定的命令,用于保存当前的字符集设置。

/*!50503 SET character_set_client = utf8mb4 */;

这也是一条 MySQL 特定的命令,用于将字符集设置为 utf8mb4

CREATE TABLE `b_article_tag` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT 'id',
  `article_id` int NOT NULL COMMENT '文章id',
  `tag_id` int NOT NULL COMMENT '标签id',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='文章标签关联';

这段代码用于创建表 b_article_tag,并定义了表的结构:

  • id 列是主键,自动递增。
  • article_id 和 tag_id 列是用于关联文章和标签的外键。
  • 表使用 InnoDB 引擎,默认字符集为 utf8mb4,使用 utf8mb4_0900_ai_ci 校对规则,行格式为 DYNAMIC,并带有表的注释。
/*!40101 SET character_set_client = @saved_cs_client */;

这条命令用于恢复先前保存的字符集设置。

表数据的备份

--
-- Dumping data for table `b_article_tag`
--

LOCK TABLES `b_article_tag` WRITE;

这部分用于备份表 b_article_tag 的数据。首先,它会锁定表,以确保在备份期间不会有其他写操作。

/*!40000 ALTER TABLE `b_article_tag` DISABLE KEYS */;

这条命令用于临时禁用键,以加快数据插入速度。

INSERT INTO `b_article_tag` VALUES 
(1,1,1),
(2,2,2),
(3,3,1),
(4,4,2),
(5,5,1),
(6,6,2),
(7,7,1),
(8,8,2),
(9,9,1),
(10,10,2),
(12,14,3);

这段代码用于插入表 b_article_tag 的所有数据。每个括号中的值表示一行数据。

/*!40000 ALTER TABLE `b_article_tag` ENABLE KEYS */;

这条命令用于重新启用先前禁用的键。

UNLOCK TABLES;

最后,这条命令用于解锁表,恢复其正常的读写操作。

特定命令解释

在 MySQL 数据库的备份脚本中,有些命令以 /*! 开头并带有一组数字,用于版本控制和配置。这些命令在备份和恢复过程中确保了数据的一致性和效率,使 MySQL 能在不同版本间保持兼容,并根据需要调整设置。

特殊命令结构

这些命令的结构通常如下:

/*!<version_number> <command> */;
  • <version_number>:这是一个版本号,表示该命令将在特定版本的 MySQL 及其之后的版本中执行。
  • <command>:这是实际的 SQL 命令。

例如,/*!40101 SET @saved_cs_client = @@character_set_client */; 命令表示,如果 MySQL 服务器的版本是 4.1.1 或更高版本,则执行 SET @saved_cs_client = @@character_set_client

具体命令解释

/*!40101 SET @saved_cs_client = @@character_set_client */;
  • 版本号:40101
  • 含义:这条命令用于保存当前的字符集设置。它会将当前的 character_set_client 变量的值存储在用户变量 @saved_cs_client 中,以便稍后恢复。
  • 用途:在修改字符集之前保存当前的字符集设置,确保在备份和恢复过程中字符集的一致性。
/*!50503 SET character_set_client = utf8mb4 */;
  • 版本号:50503
  • 含义:这条命令将客户端字符集设置为 utf8mb4,适用于 MySQL 5.5.3 及更高版本。
  • 用途:在备份表结构时,确保使用 utf8mb4 字符集,以支持更多的字符编码,特别是表情符号等特殊字符。
/*!40101 SET character_set_client = @saved_cs_client */;
  • 版本号:40101
  • 含义:这条命令用于恢复先前保存的字符集设置。它会将 @saved_cs_client 的值重新赋给 character_set_client 变量。
  • 用途:在备份完成后恢复原来的字符集设置,确保系统环境的一致性。
/*!40000 ALTER TABLE \`b_article_tag` DISABLE KEYS */;
  • 版本号:40000
  • 含义:这条命令用于禁用表 b_article_tag 的键,适用于 MySQL 4.0 及更高版本。
  • 用途:在批量插入数据之前禁用键,以加快插入速度。插入完成后,可以重新启用键。
/*!40000 ALTER TABLE \`b_article_tag` ENABLE KEYS */;
  • 版本号:40000
  • 含义:这条命令用于重新启用表 b_article_tag 的键,适用于 MySQL 4.0 及更高版本。
  • 用途:在批量插入数据完成后重新启用键,以确保表的完整性和索引的有效性。

备份流程步骤

  1. 删除现有表(如果存在)。
  2. 保存并设置字符集。
  3. 创建表并定义其结构。
  4. 恢复字符集设置。
  5. 锁定表以防止写操作。
  6. 禁用键以加快数据插入速度。
  7. 插入所有数据。
  8. 重新启用键。
  9. 解锁表。

Java实现备份

以下是一个描述 MySQL 备份过程的横向流程图:

图片[2]-定时备份网站数据,再也不怕被删库了~-明恒博客

废话不多说,上代码

/**
 * sql数据备份
 * @param path 文件路径
 */
private void backupSQL(String path) {

    try (PrintWriter writer = new PrintWriter(new BufferedWriter(new FileWriter(path + BLOG_SQL)))) {
        //获取所有表名
        List<String> tableNames = backupMapper.showTables();
        for (String table : tableNames) {
            // 获取表结构
            Map<String, String> createTableResult = backupMapper.showCreateTable(table);
            String createTableSql = createTableResult.get("Create Table");

            // 写入表结构
            writer.println("--");
            writer.println("-- Table structure for table `" + table + "`");
            writer.println("--");
            writer.println("DROP TABLE IF EXISTS `" + table + "`;");
            writer.println("/*!40101 SET @saved_cs_client     = @@character_set_client */;");
            writer.println("/*!50503 SET character_set_client = utf8mb4 */;");
            writer.println(createTableSql + ";");
            writer.println("/*!40101 SET character_set_client = @saved_cs_client */;");
            writer.println();
            // 运用URI类解析并拆解衔接地址,从头拼装
            URI databaseUrl = new URI(url.replace("jdbc:", ""));
            // 得到衔接地址中的库名
            String databaseName = databaseUrl.getPath().substring(1);
            // 获取表的所有列名
            List<String> columns = backupMapper.getTableColumns(databaseName, table);

            // 获取表数据
            List<LinkedHashMap<String, Object>> rows = backupMapper.selectAll(table);
            // 确保每一行数据包含所有列名,且列顺序与 columns 一致
            for (LinkedHashMap<String, Object> row : rows) {
                LinkedHashMap<String, Object> orderedRow = new LinkedHashMap<>();
                for (String column : columns) {
                    orderedRow.put(column, row.getOrDefault(column, null));
                }
                row.clear();
                row.putAll(orderedRow);
            }
            if (!rows.isEmpty()) {
                writer.println("--");
                writer.println("-- Dumping data for table `" + table + "`");
                writer.println("--");
                writer.println("LOCK TABLES `" + table + "` WRITE;");
                writer.println("/*!40000 ALTER TABLE `" + table + "` DISABLE KEYS */;");
                writer.print("INSERT INTO `" + table + "` VALUES ");
                for (int i = 0; i < rows.size(); i++) {
                    LinkedHashMap<String, Object> row = rows.get(i);
                    StringBuilder rowSql = new StringBuilder("(");
                    for (Object value : row.values()) {
                        if (value == null) {
                            rowSql.append("NULL,");
                        } else {
                            if (value instanceof Long || value instanceof Integer) {
                                rowSql.append(value).append(",");
                            } else if (value instanceof LocalDateTime) {
                                rowSql.append("'");
                                rowSql.append(LocalDateTime.parse(value.toString()).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
                                rowSql.append("'");
                                rowSql.append(",");
                            } else {
                                rowSql.append("'");
                                rowSql.append(valueResult(value));
                                rowSql.append("',");
                            }
                        }
                    }
                    rowSql.deleteCharAt(rowSql.length() - 1);
                    rowSql.append(")");
                    if (i < rows.size() - 1) {
                        rowSql.append(",");
                    } else {
                        rowSql.append(";");
                    }
                    writer.print(rowSql.toString());
                }
                writer.println();
                writer.println("/*!40000 ALTER TABLE `" + table + "` ENABLE KEYS */;");
                writer.println("UNLOCK TABLES;");
                writer.println();
            }
        }

        writer.flush();
    } catch (IOException e) {
        log.error("备份SQL异常:", e);
        throw new ServiceException("备份SQL出现意外");
    } catch (URISyntaxException e) {
        log.error("数据库衔接URL格局过错!", e);
        throw new ServiceException("数据库衔接URL格局过错!");
    }
}
/**
 * 对字符数据处理
 *
 * @param value
 * @return
 */
public static String valueResult(Object value) {
    var str = "";
    if (StringUtil.isNotBlank(value.toString()) && isValidJsonObject(value.toString())) {
        JSONObject jsonObject = JSONObject.parseObject(value.toString());
        str = jsonObject.toJSONString()
                .replace("'", "\'")
                .replace("'", "\\'")
                .replace("\"", "\\\"")
                .replace("\\n", "\\\\n");
        return str;
    }
    str = value.toString().replace("'", "\\'").replace("\n", "\\n");
    return str;
}

/**
 * 验证是不是Json
 *
 * @param jsonString
 * @return
 */
public static boolean isValidJsonObject(String jsonString) {
    try {
        JSONObject.parseObject(jsonString);
        return true;
    } catch (Exception e) {
        return false;
    }
}

注意:对转义的数据进行处理,否则后续执行入库脚本有问题。由于生成sql有误导致我研究了大半天😥

Java备份还原

/**
 * 执行导出的mysqldump脚本
 *
 * @param scriptFilePath SQL脚本文件路径
 * @param dbUrl          数据库连接URL
 * @param dbUser         数据库用户名
 * @param dbPassword     数据库密码
 */
public static void executeSqlScript(String scriptFilePath, String dbUrl, String dbUser, String dbPassword) {
    try (Connection connection = DriverManager.getConnection(dbUrl, dbUser, dbPassword);
         BufferedReader reader = new BufferedReader(new FileReader(scriptFilePath))) {

        StringBuilder sql = new StringBuilder();
        String line;
        try (Statement statement = connection.createStatement()) {
            while ((line = reader.readLine()) != null) {
                // 跳过注释和空行
                if (line.trim().isEmpty() || line.trim().startsWith("--") || line.trim().startsWith("/*")) {
                    continue;
                }

                sql.append(line);
                // 检查是否以分号结束,即一条SQL语句结束
                if (line.trim().endsWith(";")) {
                    log.info("执行SQL: " + sql);
                    statement.execute(sql.toString());
                    // 清空StringBuilder,准备下一条SQL语句
                    sql.setLength(0);
                }
            }
        }
    } catch (IOException e) {
        log.error("读取SQL文件时发生错误: " + e);
        throw new ServiceException("读取SQL文件时发生错误");
    } catch (SQLException e) {
        log.error("执行SQL时发生错误: " + e);
        throw new ServiceException("执行SQL时发生错误");
    }
}

注意:如果无法正常执行请检查生成sql是否有错误。

资源文件备份

当前是将文件排除备份目录进行压缩,压缩完成将文件上传到博客项目中。这个备份就比较简单粗暴,直接代码压缩。

/**
 * 将指定文件夹下的所有文件和子文件夹打包到一个ZIP文件
 *
 * @param sourceDirPath 源文件夹路径
 * @param skipDirectory 跳过文件夹路径
 * @param zipFilePath   目标ZIP文件路径
 * @return
 */
public static void compressDirectories(String sourceDirPath, String skipDirectory, String zipFilePath) {
    long startTime = System.currentTimeMillis();
    log.info("正在进行压缩操作,耐心等待");
    try {
        // Create a temporary file for the zip
        Path zipPath = Files.createFile(Paths.get(zipFilePath));
        try (ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(Files.newOutputStream(zipPath)))) {
            // 设置压缩级别
            zos.setLevel(Deflater.DEFAULT_COMPRESSION);
            Path sourceDir = Paths.get(sourceDirPath);
            Files.walkFileTree(sourceDir, new SimpleFileVisitor<Path>() {
                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                    // Create zip entry
                    zos.putNextEntry(new ZipEntry(sourceDir.relativize(file).toString()));
                    // Write file to zip
                    try (BufferedInputStream bis = new BufferedInputStream(Files.newInputStream(file))) {
                        byte[] buffer = new byte[16384];
                        int length;
                        while ((length = bis.read(buffer)) > 0) {
                            zos.write(buffer, 0, length);
                        }
                    }
                    zos.closeEntry();
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                    if (dir.equals(sourceDir.resolve(skipDirectory))) {
                        // Skip 指定 directory
                        return FileVisitResult.SKIP_SUBTREE;
                    }
                    if (!sourceDir.equals(dir)) {
                        zos.putNextEntry(new ZipEntry(sourceDir.relativize(dir).toString() + "/"));
                        zos.closeEntry();
                    }
                    return FileVisitResult.CONTINUE;
                }
            });
        }
    } catch (IOException e) {
        throw new ServiceException("压缩操作出错", e);
    }
    long endTime = System.currentTimeMillis();
    long duration = endTime - startTime;
    log.info("压缩操作耗时: " + duration + " 毫秒");
}

数据整合

将mysql和资源文件整合到一个压缩包中,压缩包 backup.zip 的目录结构展示:

backup.zip
├── blog.sql
└── file.zip

备份文件都在一个文件夹下面,直接对当前文件夹中的数据进行打包,代码实现如下:

/**
 * 将指定文件夹下的所有文件和子文件夹打包到一个ZIP文件,并转换为MultipartFile
 *
 * @param sourceDirPath 源文件夹路径
 * @param fileName      文件名称
 * @return 目标ZIP文件的MultipartFile表示
 * @throws IOException 如果打包过程中发生错误
 */
public static MultipartFile zipDirectoryToMultipartFile(String sourceDirPath, String fileName) {
    long startTime = System.currentTimeMillis();
    try {
        // 创建一个字节输出流来保存ZIP文件数据
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();

        try (ZipOutputStream zos = new ZipOutputStream(byteArrayOutputStream)) {
            Path sourcePath = Paths.get(sourceDirPath);
            Files.walkFileTree(sourcePath, new SimpleFileVisitor<Path>() {
                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                    ZipEntry zipEntry = new ZipEntry(sourcePath.relativize(file).toString());
                    zos.putNextEntry(zipEntry);
                    Files.copy(file, zos);
                    zos.closeEntry();
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                    if (!sourcePath.equals(dir)) {
                        ZipEntry zipEntry = new ZipEntry(sourcePath.relativize(dir).toString() + "/");
                        zos.putNextEntry(zipEntry);
                        zos.closeEntry();
                    }
                    return FileVisitResult.CONTINUE;
                }
            });
        }

        // 创建MultipartFile
        FileItem fileItem = new DiskFileItem("file", "application/zip", true, fileName + ".zip", (int) byteArrayOutputStream.size(), new File(System.getProperty("java.io.tmpdir")));
        try (OutputStream os = fileItem.getOutputStream()) {
            byteArrayOutputStream.writeTo(os);
        }
        // 创建MultipartFile
        MultipartFile multipartFile = new CommonsMultipartFile(fileItem);
        long endTime = System.currentTimeMillis();
        long duration = endTime - startTime;
        log.info("压缩操作耗时: " + duration + " 毫秒");
        return multipartFile;
    } catch (IOException e) {
        throw new ServiceException("压缩操作出错", e);
    }
}

将生成的MultipartFile上传到博客网盘备份文件中

图片[3]-定时备份网站数据,再也不怕被删库了~-明恒博客
图片[4]-定时备份网站数据,再也不怕被删库了~-明恒博客

数据还原

还原就比较简单了,将之前备份好的数据,解压还原就可以。

系统的迁移也比较简单,点击备份还原,直接将备份文件上传到新的系统就可以了。

图片[5]-定时备份网站数据,再也不怕被删库了~-明恒博客

上述实现都是基于我的个人博客项目,感兴趣的可直接获取源码查看,最后推荐大家Star一下我的博客项目

© 版权声明
THE END
我的博客即将同步至腾讯云+社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=270198dipw4ko
点赞7赞赏 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容