Spring Boot 集成 PostgreSQL 表级备份与恢复实战

目录

一、概述

二、核心知识点详解

[2.1 ProcessBuilder 详解](#2.1 ProcessBuilder 详解)

[2.1.1 基本用法](#2.1.1 基本用法)

[2.1.2 为什么要读取输出流](#2.1.2 为什么要读取输出流)

[2.1.3 环境变量设置](#2.1.3 环境变量设置)

[2.2 pg_dump 命令详解](#2.2 pg_dump 命令详解)

[2.2.1 命令参数说明](#2.2.1 命令参数说明)

[2.2.2 格式选择](#2.2.2 格式选择)

[2.3 pg_restore 命令详解](#2.3 pg_restore 命令详解)

[2.3.1 恢复参数说明](#2.3.1 恢复参数说明)

[2.4 @ConfigurationProperties 配置绑定](#2.4 @ConfigurationProperties 配置绑定)

核心工具类

三、常见问题

[3.1 找不到命令](#3.1 找不到命令)

[3.2 版本不兼容](#3.2 版本不兼容)


一、概述

在企业级应用开发中,数据备份与恢复是必不可少的核心功能。常见的需求包括:

  • 用户误操作导致数据丢失,需要快速恢复

  • 数据迁移到不同的数据库环境

  • 定时备份重要业务表

  • 提供数据导出功能给运维人员

本文采用 Spring Boot + PostgreSQL 原生工具 的方案:

  • 使用 pg_dump 命令导出表结构和数据

  • 使用 pg_restore 命令恢复数据

  • 通过 Java 的 ProcessBuilder 调用系统命令

二、核心知识点详解

2.1 ProcessBuilder 详解

ProcessBuilder 是 Java 中用于创建操作系统进程的类,它提供了一种更灵活、更可控的方式来执行外部命令。

2.1.1 基本用法
java 复制代码
// 创建 ProcessBuilder 实例
ProcessBuilder pb = new ProcessBuilder("pg_dump", "-h", "localhost");

// 设置环境变量
pb.environment().put("PGPASSWORD", "password");

// 合并错误流(将错误输出合并到标准输出)
pb.redirectErrorStream(true);

// 启动进程
Process process = pb.start();

// 等待进程结束
int exitCode = process.waitFor();
2.1.2 为什么要读取输出流

关键点: 必须读取进程的输出流,否则可能导致进程阻塞。

java 复制代码
// ❌ 错误写法:不读取输出流
Process process = pb.start();
int exitCode = process.waitFor(); // 可能永远阻塞

// ✅ 正确写法:读取输出流
try (BufferedReader reader = new BufferedReader(
        new InputStreamReader(process.getInputStream()))) {
    String line;
    while ((line = reader.readLine()) != null) {
        log.info(line);  // 必须消费掉输出
    }
}

原因: 操作系统为进程分配了有限的缓冲区,当缓冲区满时,进程会阻塞等待缓冲区被清空。

2.1.3 环境变量设置
java 复制代码
Map<String, String> env = pb.environment();
env.put("PGPASSWORD", "password");  // PostgreSQL 密码
env.put("PGDATABASE", "mydb");       // 默认数据库

2.2 pg_dump 命令详解

2.2.1 命令参数说明
参数 说明 示例
-h 数据库主机地址 -h localhost
-p 数据库端口 -p 5432
-U 数据库用户名 -U postgres
-d 数据库名称 -d mydb
-t 指定要备份的表 -t users
-F 输出格式(c=自定义格式) -F c
-f 输出文件路径 -f backup.dump
2.2.2 格式选择
格式 参数 特点
自定义格式 -F c 压缩、可并行恢复、可选择性恢复
目录格式 -F d 多文件输出、支持并行
tar 格式 -F t 兼容性好、不支持并行
纯文本 默认 可读性强、文件大、恢复慢

2.3 pg_restore 命令详解

2.3.1 恢复参数说明
参数 说明
--clean 恢复前删除已存在的数据库对象
--if-exists --clean 配合,使用 IF EXISTS
--no-owner 不恢复对象所有者
-t 只恢复指定的表
-j 并行恢复的作业数

2.4 @ConfigurationProperties 配置绑定

java 复制代码
@Data
@Component
@ConfigurationProperties(prefix = "backup")
public class BackupProperties {

    /**
     * pg_restore 执行文件路径
     */
    private String pgRestorePath;

    /**
     * pg_dump 执行文件路径
     */
    private String pgDumpPath;

    /**
     * 数据库地址
     */
    private String pgHost;

    /**
     * 数据库端口
     */
    private String pgPort;

    /**
     * 数据库用户名
     */
    private String pgUserName;

    /**
     * 数据库密码
     */
    private String pgPassword;

    /**
     * 数据库名
     */
    private String pgDbName;
}

application.yml 配置:

bash 复制代码
backup:
  pg-host: localhost
  pg-port: 5432
  pg-user-name: postgres
  pg-password: 123456
  pg-db-name: mydb
  pg-dump-path: /usr/bin/pg_dump
  pg-restore-path: /usr/bin/pg_restore

核心工具类

java 复制代码
package com.example.backup;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.util.Map;

/**
 * PostgreSQL 表级备份恢复工具类
 * 
 * 功能说明:
 * 1. 支持单张表的备份,生成 .dump 格式文件
 * 2. 支持从备份文件恢复表到数据库
 * 3. 提供文件上传恢复的 REST API 接口
 */
@Slf4j
@Component
public class BackUtils {

    @Autowired
    private BackupProperties backupProperties;

    // 临时文件存储目录
    private static final String TEMP_FILE_DIR = System.getProperty("user.dir") + "/backups/";

    /**
     * 备份单张表
     * 
     * @param tableName 要备份的表名
     * @return 备份文件路径,失败返回 null
     */
    private String backupTable(String tableName) {
        // 1. 验证表名合法性,防止路径遍历和命令注入
        if (tableName == null || !tableName.matches("^[a-zA-Z0-9_.-]+$")) {
            log.error("无效的表名:{}", tableName);
            return null;
        }

        log.info("开始备份表:{}", tableName);

        // 2. 创建备份目录
        File backupDir = new File(TEMP_FILE_DIR);
        if (!backupDir.exists() && !backupDir.mkdirs()) {
            log.error("创建备份目录失败:{}", TEMP_FILE_DIR);
            return null;
        }

        // 3. 生成备份文件路径
        String backupFileName = tableName + ".dump";
        File backupFile = new File(backupDir, backupFileName);
        String backupFilePath = backupFile.getAbsolutePath();

        // 4. 构建 pg_dump 命令
        String[] command = {
                backupProperties.getPgDumpPath(),
                "-h", backupProperties.getPgHost(),
                "-p", backupProperties.getPgPort(),
                "-U", backupProperties.getPgUserName(),
                "-d", backupProperties.getPgDbName(),
                "-t", tableName,
                "-F", "c",              // 自定义格式
                "-f", backupFilePath
        };

        // 5. 执行备份命令
        boolean success = executeCommand(command, backupProperties.getPgPassword());

        if (success) {
            long fileSize = backupFile.exists() ? backupFile.length() : 0;
            log.info("备份成功:{}, 文件大小:{} 字节", backupFileName, fileSize);
            return backupFilePath;
        } else {
            log.error("备份失败:{}, 命令:{}", tableName, String.join(" ", command));
            // 清理可能产生的不完整文件
            if (backupFile.exists()) {
                backupFile.delete();
            }
            return null;
        }
    }

    /**
     * 恢复表到目标数据库
     * 
     * @param file 上传的备份文件(格式:表名_backup.dump)
     * @return API 响应结果
     */
    public ApiResponse restoreTable(MultipartFile file) {
        // 1. 文件非空校验
        if (file == null) {
            log.warn("上传文件为空");
            return ApiResponse.error("上传文件为空");
        }

        String fileName = file.getOriginalFilename();
        if (fileName == null || fileName.isEmpty()) {
            log.warn("文件名为空");
            return ApiResponse.error("文件名为空");
        }

        // 2. 文件名安全性校验(防止路径遍历攻击)
        if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
            log.warn("非法的文件名:{}", fileName);
            return ApiResponse.error("非法的文件名");
        }

        // 3. 文件格式校验
        if (!fileName.endsWith("_backup.dump")) {
            log.warn("文件格式不正确,期望以 _backup.dump 结尾:{}", fileName);
            return ApiResponse.error("文件格式不正确,期望以 _backup.dump 结尾");
        }

        // 4. 提取表名并校验
        String tableName = fileName.replaceAll("_backup\\.dump$", "");
        if (!isValidTableName(tableName)) {
            log.error("无效的表名:{}", tableName);
            return ApiResponse.error("无效的表名");
        }

        log.info("开始恢复表:{} 到数据库:{}", tableName, backupProperties.getPgDbName());

        File tempFile = null;
        try {
            // 5. 创建临时目录
            File dir = new File(TEMP_FILE_DIR);
            if (!dir.exists()) {
                dir.mkdirs();
            }

            // 6. 将上传文件保存到临时文件
            tempFile = new File(dir, fileName);
            file.transferTo(tempFile);

            // 7. 构建 pg_restore 恢复命令
            String[] command = {
                    backupProperties.getPgRestorePath(),
                    "-h", backupProperties.getPgHost(),
                    "-p", backupProperties.getPgPort(),
                    "-U", backupProperties.getPgUserName(),
                    "-d", backupProperties.getPgDbName(),
                    "--no-owner",           // 不恢复对象所有者
                    "--clean",              // 恢复前删除已存在的对象
                    "--if-exists",          // 使用 IF EXISTS
                    tempFile.getAbsolutePath()
            };

            // 8. 执行恢复命令
            boolean restoreSuccess = executeCommand(command, backupProperties.getPgPassword());

            if (restoreSuccess) {
                log.info("恢复成功:表 {} 已恢复到 {}", tableName, backupProperties.getPgDbName());
                return ApiResponse.success("恢复成功");
            } else {
                log.error("恢复失败:表 {}", tableName);
                return ApiResponse.error("恢复失败");
            }

        } catch (IOException e) {
            log.error("备份文件写入临时路径失败", e);
            return ApiResponse.error("备份文件写入临时路径失败");
        } finally {
            // 9. 清理临时文件
            deleteTempFile(tempFile);
        }
    }

    /**
     * 验证表名是否合法
     * 
     * @param tableName 表名
     * @return 是否合法
     */
    private boolean isValidTableName(String tableName) {
        if (tableName == null || tableName.isEmpty()) {
            return false;
        }
        // 表名只能包含字母、数字、下划线
        return tableName.matches("^[a-zA-Z0-9_]+$");
    }

    /**
     * 执行系统命令
     * 
     * @param command  命令数组
     * @param password 数据库密码(通过环境变量传递)
     * @return 是否执行成功
     */
    private boolean executeCommand(String[] command, String password) {
        ProcessBuilder processBuilder = new ProcessBuilder(command);

        // 设置密码环境变量(pg_dump/pg_restore 通过此变量读取密码)
        Map<String, String> env = processBuilder.environment();
        env.put("PGPASSWORD", password);

        // 合并错误流到标准输出流,方便统一处理
        processBuilder.redirectErrorStream(true);

        try {
            log.info("执行命令: {}", String.join(" ", command));

            Process process = processBuilder.start();

            // 读取输出流(必须读取,否则进程可能阻塞)
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(process.getInputStream()))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    log.info("命令输出: {}", line);
                }
            }

            // 等待命令执行完成
            int exitCode = process.waitFor();
            log.info("命令执行完成,退出码: {}", exitCode);

            return exitCode == 0;

        } catch (Exception e) {
            log.error("命令执行异常: {}", e.getMessage(), e);
            return false;
        }
    }

    /**
     * 删除临时文件
     * 
     * @param tempFile 临时文件
     */
    private void deleteTempFile(File tempFile) {
        if (tempFile != null && tempFile.exists()) {
            try {
                Files.delete(tempFile.toPath());
                log.info("临时文件已删除: {}", tempFile.getAbsolutePath());
            } catch (IOException e) {
                log.warn("删除临时文件失败: {}", e.getMessage());
            }
        }
    }
}

三、常见问题

3.1 找不到命令

bash 复制代码
Cannot run program "pg_dump": error=2, No such file or directory

解决:配置绝对路径

bash 复制代码
backup:
  pg-dump-path: /usr/local/bin/pg_dump  # Linux
  # Windows: C:\\Program Files\\PostgreSQL\\14\\bin\\pg_dump.exe

3.2 版本不兼容

bash 复制代码
unrecognized configuration parameter "idle_in_transaction_session_timeout"

解决:添加兼容参数

bash 复制代码
"--no-owner", "--no-privileges", "--no-sync"
相关推荐
LucianaiB2 小时前
王炸组合!腾讯云 OpenClaw X 飞书 CLI,开启 Agent 基建狂潮!
后端
白露与泡影2 小时前
探索springboot程序打包docker的最佳方式
spring boot·后端·docker
开心就好20252 小时前
本地执行 IPA 混淆 无需上传致云端且不修改工程的方案
后端·ios
架构师沉默2 小时前
为什么一个视频能让全国人民同时秒开?
java·后端·架构
生命不息战斗不止(王子晗)2 小时前
mysql基础语法面试题
java·数据库·mysql
umeelove352 小时前
Java进阶(ElasticSearch的安装与使用)
java·elasticsearch·jenkins
redaijufeng2 小时前
Node.js(v16.13.2版本)安装及环境配置教程
java
齐齐大魔王2 小时前
linux-线程编程
java·linux·服务器
掘金码甲哥2 小时前
同样都是九年义务教育,他知道的AI算力科普好像比我多耶
后端