💾 达梦数据库(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 权限:确保
backupbucket 存在且可写; - 密码含特殊字符:建议用双引号包裹(YAML 中);
- Schema 名大小写:达梦默认大写,配置时建议统一用大写。
✅ 此方案已在生产环境稳定运行,备份粒度细、恢复灵活、存储成本低。

浙公网安备 33010602011771号