💾 达梦数据库(DM)同机 & 异机备份到 MinIO(Java 实现 · 干货直给)

💾 达梦数据库(DM)同机 & 异机备份到 MinIO(Java 实现 · 干货直给)

目的:记录一套可复用的达梦数据库备份方案,支持本地/远程部署,自动执行 dexp 导出 Schema,打包上传 MinIO,仅用于个人查阅。


📦 核心逻辑

  • ✅ 支持 同机(直接调用 dexp)和 异机(通过 SSH 执行 dexp + SFTP 下载);
  • ✅ 每个 Schema 单独导出,打包为 .tar.gz
  • ✅ 上传至 MinIO 的 backup/dm/ 路径下;
  • ✅ 自动 保留最近 7 份备份,超量自动删除旧备份;
  • ✅ 配置驱动,支持多个数据源、多个 Schema。

📄 Java 代码(完整)

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;

@Component
@ConfigurationProperties(prefix = "dm")
public class DmBackupConfig {
    private List<DataSource> backups;

    public static class DataSource {
        private String name;
        private String host;
        private int port = 15236;
        private String user;
        private String password;
        private String schemas; // 如 "USER_A" 或 "USER1,USER2"
        private String home = "/opt/dmdbms";
        private String sshUser;
        private String sshPassword;

        // getters & setters
        public String getName() { return name; }
        public void setName(String name) { this.name = name; }
        public String getHost() { return host; }
        public void setHost(String host) { this.host = host; }
        public int getPort() { return port; }
        public void setPort(int port) { this.port = port; }
        public String getUser() { return user; }
        public void setUser(String user) { this.user = user; }
        public String getPassword() { return password; }
        public void setPassword(String password) { this.password = password; }
        public String getSchemas() { return schemas; }
        public void setSchemas(String schemas) { this.schemas = schemas; }
        public String getHome() { return home; }
        public void setHome(String home) { this.home = home; }
        public String getSshUser() { return sshUser; }
        public void setSshUser(String sshUser) { this.sshUser = sshUser; }
        public String getSshPassword() { return sshPassword; }
        public void setSshPassword(String sshPassword) { this.sshPassword = sshPassword; }
    }

    public List<DataSource> getBackups() {
        return backups;
    }

    public void setBackups(List<DataSource> backups) {
        this.backups = backups;
    }
}
// DmBackupService.java
import com.jcraft.jsch.*;
import io.minio.StatObjectResponse;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.*;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Service
public class DmBackupService {

    protected final static String TEMP_DIR = System.getProperty("java.io.tmpdir") + File.separator;

    @Value("${dm.ssh.user}")
    private String sshUser;

    @Value("${dm.ssh.password:}")
    private String sshPassword;

    @Autowired
    private DmBackupConfig dmBackupConfig;

    @Autowired
    private MinioService minioService;

    private static final String bucketName = "backup";

    // 入口:备份所有配置的数据源
    public Long backupAllDatabases() throws Exception {
        List<DmBackupConfig.DataSource> sources = dmBackupConfig.getBackups();
        if (sources == null || sources.isEmpty()) {
            throw new IllegalArgumentException("未配置任何达梦备份源(dm.backups)");
        }
        Long size = 0L;
        for (DmBackupConfig.DataSource source : sources) {
            System.out.println("🚀 开始备份: " + source.getName());
            Long l = backupSingleDatabase(source);
            size += l;
        }
        return size / (1024 * 1024); // 返回 MB
    }

    // 备份单个数据源下的所有 Schema
    private Long backupSingleDatabase(DmBackupConfig.DataSource source) throws Exception {
        List<String> schemas = parseSchemas(source.getSchemas());
        if (schemas.isEmpty()) {
            throw new IllegalArgumentException("[" + source.getName() + "] 未指定要备份的 Schema");
        }
        Long size = 0L;
        for (String schema : schemas) {
            Long l = backupSingleSchema(source, schema);
            size += l;
        }
        return size;
    }

    private List<String> parseSchemas(String schemasStr) {
        if (schemasStr == null || schemasStr.trim().isEmpty()) return Collections.emptyList();
        return Arrays.stream(schemasStr.split(","))
                .map(String::trim)
                .filter(s -> !s.isEmpty())
                .collect(Collectors.toList());
    }

    // 主逻辑:备份单个 Schema
    private Long backupSingleSchema(DmBackupConfig.DataSource source, String schema) throws Exception {
        boolean isLocal = isSameMachine(source.getHost());
        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
        String backupDirName = source.getName().replaceAll("[^a-zA-Z0-9_-]", "_").toLowerCase()
                + "_" + schema.toLowerCase() + "_" + timestamp;
        String localBackupPath = TEMP_DIR + backupDirName;

        try {
            if (isLocal) {
                System.out.println("📁 达梦数据库在本地,直接执行 dexp...");
                executeDexpForSchemaLocally(source, schema, localBackupPath);
            } else {
                System.out.println("🌐 达梦数据库在远程,通过 SSH 执行 dexp...");
                executeDexpForSchemaRemotely(source, schema, localBackupPath);
            }

            String tarGzPath = localBackupPath + ".tar.gz";
            createTarGz(localBackupPath, tarGzPath);

            String objectName = "dm/" + backupDirName + ".tar.gz";
            minioService.uploadObject(bucketName, objectName, tarGzPath);
            deleteOldBackups(source, schema);

            StatObjectResponse stat = minioService.statObject(bucketName, objectName);
            System.out.println("✅ [" + source.getName() + "] Schema [" + schema + "] 备份成功 → " + objectName
                    + " (" + FileUtil.convertFileSize(stat.size()) + ")");
            return stat.size();
        } finally {
            deleteDirectory(new File(localBackupPath));
            new File(localBackupPath + ".tar.gz").delete();
        }
    }

    // 删除旧备份(保留最近 7 份)
    private void deleteOldBackups(DmBackupConfig.DataSource source, String schema) {
        List<BackupObject> backups = minioService.listObjectsRecursive(bucketName,
                "dm/" + source.getName() + "_" + schema, true);
        int excess = backups.size() - 7;
        if (excess > 0) {
            backups.sort(Comparator.comparing(BackupObject::getModifiedTime));
            for (int i = 0; i < excess; i++) {
                minioService.deleteObject(bucketName, backups.get(i).getObjectName());
            }
        }
    }

    // 判断是否为本机
    private boolean isSameMachine(String dmHost) {
        return isLocalhost(dmHost);
    }

    private boolean isLocalhost(String host) {
        if (host == null) return false;
        if ("localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) || "::1".equals(host)) {
            return true;
        }
        try {
            Set<String> localIps = getLocalIpAddresses();
            return localIps.contains(host);
        } catch (Exception e) {
            return false;
        }
    }

    private Set<String> getLocalIpAddresses() throws Exception {
        return Collections.list(NetworkInterface.getNetworkInterfaces()).stream()
                .flatMap(ni -> {
                    try {
                        return Collections.list(ni.getInetAddresses()).stream();
                    } catch (Exception e) {
                        return Stream.of();
                    }
                })
                .filter(ia -> ia instanceof java.net.Inet4Address)
                .map(InetAddress::getHostAddress)
                .collect(Collectors.toSet());
    }

    // 本地执行 dexp
    private void executeDexpForSchemaLocally(DmBackupConfig.DataSource source, String schema, String outputDir)
            throws IOException, InterruptedException {
        new File(outputDir).mkdirs();
        String dmpFile = outputDir + "/" + schema + ".dmp";
        String logFile = outputDir + "/" + schema + ".log";

        String dmHome = source.getHome();
        String dexpPath = dmHome + "/bin/dexp";
        String connectStr = String.format("%s/\"%s\"@%s:%d", source.getUser(), source.getPassword(),
                source.getHost(), source.getPort());

        String cmd = String.format("%s USERID='%s' FILE=\"%s\" LOG=\"%s\" OWNER=\"%s\" COMPRESS=Y",
                dexpPath, connectStr, dmpFile, logFile, schema);

        ProcessBuilder pb = new ProcessBuilder("/bin/bash", "-c", cmd);
        Map<String, String> env = pb.environment();
        String currentLdLibPath = env.get("LD_LIBRARY_PATH");
        String newLdLibPath = dmHome + "/bin" + (currentLdLibPath != null ? ":" + currentLdLibPath : "");
        env.put("LD_LIBRARY_PATH", newLdLibPath);

        Process process = pb.start();
        int exitCode = process.waitFor();

        try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
            StringBuilder errors = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                errors.append(line).append("\n");
            }
            if (exitCode != 0) {
                throw new RuntimeException("dexp 退出码 " + exitCode + ",错误: " + errors.toString());
            }
        }
    }

    // 远程执行 dexp(通过 SSH)
    private void executeDexpForSchemaRemotely(DmBackupConfig.DataSource source, String schema, String localOutputDir)
            throws Exception {
        String remoteTempDir = "/tmp/dm_" + schema + "_" + System.currentTimeMillis();
        String remoteDmp = remoteTempDir + "/" + schema + ".dmp";
        String remoteLog = remoteTempDir + "/" + schema + ".log";

        executeRemoteCommand("mkdir -p " + remoteTempDir, source.getHost());

        String connectStr = String.format("%s/\"%s\"@127.0.0.1:%d", source.getUser(), source.getPassword(),
                source.getPort());
        String dexpPath = source.getHome() + "/bin/dexp";
        String envSetup = "export LD_LIBRARY_PATH=" + source.getHome() + "/bin:$LD_LIBRARY_PATH";
        String remoteCmd = String.format("%s && %s USERID='%s' FILE=\"%s\" LOG=\"%s\" OWNER=\"%s\" COMPRESS=Y",
                envSetup, dexpPath, connectStr, remoteDmp, remoteLog, schema);

        executeRemoteCommandWithOutput(remoteCmd, source.getHost());

        new File(localOutputDir).mkdirs();
        downloadFileViaSsh(remoteDmp, localOutputDir + "/" + schema + ".dmp", source.getHost());
        downloadFileViaSsh(remoteLog, localOutputDir + "/" + schema + ".log", source.getHost());

        executeRemoteCommandWithOutput("rm -rf " + remoteTempDir, source.getHost());
    }

    // SSH 执行命令(无输出)
    private void executeRemoteCommand(String command, String dmHost) throws JSchException, IOException {
        JSch jsch = new JSch();
        Session session = jsch.getSession(sshUser, dmHost, 22);
        if (!sshPassword.isEmpty()) session.setPassword(sshPassword);
        session.setConfig("StrictHostKeyChecking", "no");
        session.connect();

        ChannelExec channel = (ChannelExec) session.openChannel("exec");
        channel.setCommand(command);
        channel.setErrStream(System.err);
        InputStream in = channel.getInputStream();
        channel.connect();

        byte[] tmp = new byte[1024];
        while (in.read(tmp, 0, tmp.length) != -1) {
            // 可选:记录 stdout
        }

        channel.disconnect();
        session.disconnect();
    }

    // SSH 执行命令(带输出 & 退出码)
    private String executeRemoteCommandWithOutput(String command, String dmHost) throws Exception {
        JSch jsch = new JSch();
        Session session = jsch.getSession(sshUser, dmHost, 22);
        if (!sshPassword.isEmpty()) session.setPassword(sshPassword);
        session.setConfig("StrictHostKeyChecking", "no");
        session.connect();

        String fullCommand = command + " 2>&1; echo \"EXIT_CODE:$?\"";
        ChannelExec channel = (ChannelExec) session.openChannel("exec");
        channel.setCommand(fullCommand);
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        channel.setOutputStream(output);
        channel.connect();

        while (!channel.isClosed()) Thread.sleep(100);

        channel.disconnect();
        session.disconnect();

        String outputStr = output.toString();
        int exitCodeIndex = outputStr.lastIndexOf("EXIT_CODE:");
        if (exitCodeIndex == -1) {
            throw new RuntimeException("无法获取远程命令退出码: " + outputStr);
        }
        int exitCode = Integer.parseInt(outputStr.substring(exitCodeIndex + 10).trim());
        String actualOutput = outputStr.substring(0, exitCodeIndex);

        if (exitCode != 0) {
            throw new RuntimeException("远程命令失败 (exit=" + exitCode + "): " + actualOutput);
        }

        return actualOutput;
    }

    // SFTP 下载文件
    private void downloadFileViaSsh(String remoteFile, String localFile, String dmHost)
            throws JSchException, SftpException {
        JSch jsch = new JSch();
        Session session = jsch.getSession(sshUser, dmHost, 22);
        if (!sshPassword.isEmpty()) session.setPassword(sshPassword);
        session.setConfig("StrictHostKeyChecking", "no");
        session.connect();

        ChannelSftp sftp = (ChannelSftp) session.openChannel("sftp");
        sftp.connect();
        sftp.get(remoteFile, localFile);
        sftp.disconnect();
        session.disconnect();
    }

    // 打包目录为 tar.gz
    private void createTarGz(String sourceDir, String tarGzPath) throws IOException {
        try (FileOutputStream fOut = new FileOutputStream(tarGzPath);
             BufferedOutputStream bOut = new BufferedOutputStream(fOut);
             GzipCompressorOutputStream gzOut = new GzipCompressorOutputStream(bOut);
             TarArchiveOutputStream tOut = new TarArchiveOutputStream(gzOut)) {

            tOut.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
            addFilesToTarGz(tOut, new File(sourceDir), "");
        }
    }

    private void addFilesToTarGz(TarArchiveOutputStream tOut, File file, String base) throws IOException {
        String entryName = base + file.getName();
        TarArchiveEntry tarEntry = new TarArchiveEntry(file, entryName);
        tOut.putArchiveEntry(tarEntry);

        if (file.isFile()) {
            try (FileInputStream fIn = new FileInputStream(file)) {
                byte[] buffer = new byte[4096];
                int bytesRead;
                while ((bytesRead = fIn.read(buffer)) != -1) {
                    tOut.write(buffer, 0, bytesRead);
                }
            }
            tOut.closeArchiveEntry();
        } else if (file.isDirectory()) {
            tOut.closeArchiveEntry();
            File[] children = file.listFiles();
            if (children != null) {
                for (File child : children) {
                    addFilesToTarGz(tOut, child, entryName + "/");
                }
            }
        }
    }

    // 递归删除目录
    private void deleteDirectory(File dir) {
        if (dir.exists() && dir.isDirectory()) {
            File[] children = dir.listFiles();
            if (children != null) {
                for (File child : children) {
                    deleteDirectory(child);
                }
            }
        }
        dir.delete();
    }
}

⚙️ 配置文件(application.yml)

dm:
  backups:
    - name: "USER1"
      host: 192.168.1.111
      port: 5236
      user: USER1
      password: "1234"
      schemas: SYS               # 支持多个:SYS,DATA
      home: /opt/dmdbms

    - name: "USER2"
      host: 192.168.1.111
      port: 5236
      user: USER2
      password: "1234"
      schemas: DATA
      home: /opt/dmdbms

  ssh:
    user: root
    password: "1234"   # 可为空(使用密钥认证时)

📌 注意事项

  • 达梦 dexp 路径:确保 home 配置正确,且 bin/dexp 存在;
  • 环境变量:远程执行时需设置 LD_LIBRARY_PATH,否则 dexp 启动失败;
  • SSH 权限:确保 SSH 用户有权限访问 /tmp 和执行 dexp
  • MinIO 权限:确保 backup bucket 存在且可写;
  • 密码含特殊字符:建议用双引号包裹(YAML 中);
  • Schema 名大小写:达梦默认大写,配置时建议统一用大写。

✅ 此方案已在生产环境稳定运行,备份粒度细、恢复灵活、存储成本低。

posted @ 2025-10-23 17:19  Comfortable  阅读(7)  评论(0)    收藏  举报