在 Spring Boot 中实现定时备份 MySQL 数据库

应用系统中最重要的东西就是 “数据”,定期备份数据的重要性就不言而喻了。本文将会带你了解如何在 Spring Boot 应用中实现定期备份 MySQL 数据库。

mysqldump

MYSQL本身提供了一个工具 mysqldump,通过它可以完成数据库的备份。

简单来说就是一个命令,可以把数据库中的表结构和数据,以 SQL 语句的形式输出到标准输出:

mysqldump -u[用户名] -p[密码] [数据库] > [备份的SQL文件]

注意,命令中的 > 符号在linux下是重定向符,在这里的意思就是把标准输出重定向到文件。

例如,备份 demo 库到 ~/mysql.sql,用户名和密码都是 root

mysqldump -uroot -proot demo  > ~/mysql.sql 

mysqldump 的详细文档:https://dev.mysql.com/doc/refman/en/mysqldump.html

创建应用

创建任意 Spring Boot 应用,并添加 commons-exec 依赖。

<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-exec -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-exec</artifactId>
    <version>1.3</version>
</dependency>

由于我们的备份是通过新启动一个子进程调用 mysqldump 来完成,所以建议使用 apache 的 commons-exec 库。它的使用比较简单,且设计合理,包含了子进程超时控制,异步执行等等功能。

应用配置

spring:
  # 基本的数据源配置
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/demo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2b8&allowMultiQueries=true
    username: root
    password: root

app:
  # 备份配置
  backup:
    # 备份数据库
    db: "demo"
    # 备份文件存储目录
    dir: "backups"
    # 备份文件最多保留时间。如,5分钟:5m、12小时:12h、1天:1d
    max-age: 3m

如上,我们配置了基本的数据源。以及自定义的 “备份配置”,其中指定了备份文件的存储目录,要备份的数据库以及备份文件滚动存储的最大保存时间。

数据备份

BackupService

创建 BackupService 服务类,用于备份服务。如下:

package cn.springdoc.demo.service;

import java.io.OutputStream;
import java.io.BufferedOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.apache.commons.exec.ExecuteWatchdog;
import org.apache.commons.exec.PumpStreamHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

/**
 * 
 *  数据库备份服务
 * 
 */
@Component
public class BackupService {

    static final Logger log = LoggerFactory.getLogger(BackupService.class);

    // 用户名
    @Value("${spring.datasource.username}")
    private String username;

    // 密码
    @Value("${spring.datasource.password}")
    private String password; 

    // 备份数据库
    @Value("${app.backup.db}")
    private String db;

    // 备份目录
    @Value("${app.backup.dir}")
    private String dir;

    // 最大备份文件数量
    @Value("${app.backup.max-age}")
    private Duration maxAge;

    // 锁,防止并发备份
    private Lock lock = new ReentrantLock();

    // 日期格式化
    private DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss.SSS");

    /**
     * 备份文件
     * @return
     * @throws Exception 
     */
    public Path backup() throws Exception {
        
        if (!this.lock.tryLock()) {
            throw new Exception("备份任务进行中!");
        }
        
        try {
            
            LocalDateTime now = LocalDateTime.now();
            
            Path dir = Paths.get(this.dir);

            // 备份的SQL文件
            Path sqlFile = dir.resolve(Path.of(now.format(formatter) + ".sql"));
            
            if (Files.exists(sqlFile)) {
                // 文件已经存在,则添加后缀
                for (int i = 1; i >= 1; i ++) {
                    sqlFile = dir.resolve(Path.of(now.format(formatter) + "-" + i + ".sql"));
                    if (!Files.exists(sqlFile)) {
                        break;
                    }
                }
            }
            
            // 初始化目录
            if (!Files.isDirectory(sqlFile.getParent())) {
                Files.createDirectories(sqlFile.getParent());
            }
            
            // 创建备份文件文件
            Files.createFile(sqlFile);

            // 标准流输出的内容就是 SQL 的备份内容
            try (OutputStream stdOut = new BufferedOutputStream(
                    Files.newOutputStream(sqlFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING))) {

                // 监视狗。执行超时时间,1小时
                ExecuteWatchdog watchdog = new ExecuteWatchdog(TimeUnit.HOURS.toMillis(1));

                // 子进程执行器
                DefaultExecutor defaultExecutor = new DefaultExecutor();
                // defaultExecutor.setWorkingDirectory(null); // 工作目录
                defaultExecutor.setWatchdog(watchdog);
                defaultExecutor.setStreamHandler(new PumpStreamHandler(stdOut, System.err));

                // 进程执行命令
                CommandLine commandLine = new CommandLine("mysqldump");
                commandLine.addArgument("-u" + this.username); 	// 用户名
                commandLine.addArgument("-p" + this.password); 	// 密码
                commandLine.addArgument(this.db); 				// 数据库

                log.info("备份 SQL 数据");

                // 同步执行,阻塞直到子进程执行完毕。
                int exitCode = defaultExecutor.execute(commandLine);

                if (defaultExecutor.isFailure(exitCode)) {
                    throw new Exception("备份任务执行异常:exitCode=" + exitCode);
                }
            }

            
            if (this.maxAge.isPositive() && !this.maxAge.isZero()) {
                
                for (Path file : Files.list(dir).toList()) {
                    // 获取文件的创建时间
                    LocalDateTime createTime = LocalDateTime.ofInstant(Files.readAttributes(file, BasicFileAttributes.class).creationTime().toInstant(), ZoneId.systemDefault());
                    
                    if (createTime.plus(this.maxAge).isBefore(now)) {
                        
                        log.info("删除过期文件:{}", file.toAbsolutePath().toString());
                        
                        // 删除文件
                        Files.delete(file);
                    }
                }
            }
            
            return sqlFile;
        } finally {
            this.lock.unlock();
        }
    }
}

如上,我们在 Service 类中通过 @Value 注入了 application.yaml 文件中的配置信息。使用 ReentrantLock 锁来保证备份任务不会被并发执行。备份文件的名称使用 yyyy-MM-dd-HHmmss.SSS 格式,包含了年月日时分秒以及毫秒,如:2023-10-22-095300.857.sql。如果文件名称冲突,则在末尾递增编号。

使用 commons-exec 启动新进程,调用 mysqldump 执行备份,备份成功后,尝试删除备份目录下那些已经 “过期” 的备份文件,从而达到滚动存储的目的。

备份成功后,返回 SQL 备份文件的 Path 对象。

测试

新建测试类,测试 BackupService 的备份方法:

package cn.springdoc.demo.test;

import java.nio.file.Path;

import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;

import cn.springdoc.demo.service.BackupService;

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class DemoApplicationTests {

    static final Logger log = LoggerFactory.getLogger(DemoApplicationTests.class);

    @Autowired
    BackupService backupService;

    @Test
    public void test() throws Exception {
        
        Path path = this.backupService.backup();
        
        log.info("备份文件:{}", path.toAbsolutePath().toString());
    }
}

运行测试,输出日志如下:

2023-10-22T09:56:59.913+08:00  INFO 15352 --- [           main] c.springdoc.demo.service.BackupService   : 备份 SQL 数据
2023-10-22T09:57:00.062+08:00  INFO 15352 --- [           main] c.s.demo.test.DemoApplicationTests       : 备份文件:D:\eclipse\eclipse-jee-2023-09-R-win32-x86_64\app\springdoc-demo\backups\2023-10-22-095659.912.sql

查看该备份文件:

mysqldump 生成的 SQL 备份文件

备份成功,只需要把该 SQL 文件通过客户端导入,即可恢复数据。

定时备份

配合 spring-task 就可以实现定时备份。

启用定时任务

在启动类上添加注解 @EnableScheduling 以启用定时任务。

package cn.springdoc.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class DemoApplication {

    public static void main(String[] args) {

        SpringApplication.run(DemoApplication.class, args);

    }
}

定时备份实现

创建 BackupTask 任务类,定时执行备份任务。

package cn.springdoc.demo.task;

import java.nio.file.Path;
import java.util.concurrent.TimeUnit;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import cn.springdoc.demo.service.BackupService;

@Component
public class BackupTask {

    static final Logger log = LoggerFactory.getLogger(BackupTask.class);

    @Autowired
    private BackupService backupService;

    // 1 分钟执行一次
    @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES)
    public void backup () {
        
        try {
            
            Path file = this.backupService.backup();
            
            log.info("备份成功:{}", file.toAbsolutePath().toString());
            
        } catch (Exception e) {
            log.error("备份任务执行异常: {}", e.getMessage());
        }
    }
}

通过 @Scheduled 注解指定执行周期。在这里为了简单,设置的是 1 分钟执行一次。如果你需要在具体的时间执行备份任务,可以使 cron 表达式,如:@Scheduled(cron = "0 0 2 1/1 * ? ") 表示每天凌晨 2 点执行备份任务。

总结

通过 mysqldump 进行备份的 限制 就是 应用和数据库必须在同一个机器上,适用于资源有限、刚起步的小项目。