当前位置: 首页 > news >正文

达梦数据库(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 & setterspublic 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;@Autowiredprivate DmBackupConfig dmBackupConfig;@Autowiredprivate 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}// 备份单个数据源下的所有 Schemaprivate 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());}// 主逻辑:备份单个 Schemaprivate 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());}// 本地执行 dexpprivate 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.gzprivate 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.111port: 5236user: USER1password: "1234"schemas: SYS               # 支持多个:SYS,DATAhome: /opt/dmdbms- name: "USER2"host: 192.168.1.111port: 5236user: USER2password: "1234"schemas: DATAhome: /opt/dmdbmsssh:user: rootpassword: "1234"   # 可为空(使用密钥认证时)

📌 注意事项

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

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

http://www.jsqmd.com/news/20521/

相关文章:

  • Ubuntu布署Blazor Server
  • Day22-C:\Users\Lenovo\Desktop\note\code\JavaSE\Basic\src\com\File-FileTest1~4
  • 实用指南:计算机中用8位如何计算最大值和最小值-128~127
  • 权威调研榜单:徐州CCC产品认证公共服务平台TOP3榜单好评深度解析
  • 2025 年最新弹力丝机生产厂家推荐榜单:全面盘点国内优质品牌,为纺织企业提供精准选型参考荣腾弹力丝机/普来得弹力丝机/高速弹力丝机公司推荐
  • 数据库锁-及事务隔离级别对应
  • 权威调研榜单:落地立式护眼灯厂家TOP3榜单好评深度解析
  • 详细介绍:C++面向对象编程——引用
  • 2025管道电预热/热力管道电预热设备厂家推荐新疆泓浩机电,专业高效施工保障
  • 2025年10月国内仪器类检测厂家全景解析报告,基于专业测评的技术、性能及市场优势深度分析
  • 2025二手发电机回收/买卖厂家推荐新疆泓浩机电,专业高效值得信赖
  • 2025发电机/发电机组出租厂家推荐新疆泓浩机电,专业维修保养服务
  • 本地部署低代码构建平台 Langflow 并实现外部访问
  • 2025 年旋转木马生产厂家最新推荐榜:聚焦企业专利技术、品质管控及知名客户合作案例的权威解析
  • 2025 年电动门实力厂家最新推荐排行榜:聚焦智能创新与多场景适配,精选优质品牌助力选购电动悬浮门/电动大门/电动平移门/小区电动门公司推荐
  • 2025年10月全息投影沙盘生产厂家全景解析报告,基于专业测评的技术、性能及市场优势深度分析
  • 进制基础及位运算
  • 2025年10月国内平开门厂家全景解析报告,基于专业测评的技术、性能及市场优势深度分析
  • 2025 年道闸源头厂家最新推荐排行榜:聚焦专利技术与多场景适配的权威优选指南直杆/ 栅栏道闸/道闸停车场系统/道闸识别一体机/道闸系统公司推荐
  • 2025 年最新冷水机厂家推荐榜:覆盖风冷式 / 水冷式 / 螺杆式等多类型,为企业精选高性价比控温设备
  • 为什么要学习PostgreSQL?新手怎么从入门到精通
  • 编译Duckdb机器学习插件QuackML - 详解
  • 数字音频作曲软件-钢琴窗-网格数量计算公式
  • 基于梯度下降、随机梯度下降和牛顿法的逻辑回归MATLAB实现
  • 2025年海外仓企业最新推荐榜,英国海外仓/法国海外仓/西班牙海外仓/意大利海外仓/波兰海外仓/聚焦企业服务品质与平台适配力深度剖析
  • 精控DRC,成就高质量IC设计精品
  • 完整教程:【uniapp】uniapp+uview-ui+mixins实现搜索+上拉加载+加载动画功能:
  • 攻壳机动队 动画电影12部合集
  • 2025 年天津老味儿刨冰最新推荐榜:精选 5 家世代传承与新兴热门品牌,附品质选择指南
  • 详细介绍:OpenLayers地图交互 -- 章节十四:拖拽缩放交互详解