diff --git a/backend/services/snapshot-service/src/infrastructure/backup/postgres-backup.handler.ts b/backend/services/snapshot-service/src/infrastructure/backup/postgres-backup.handler.ts index b6772d3d..524048bd 100644 --- a/backend/services/snapshot-service/src/infrastructure/backup/postgres-backup.handler.ts +++ b/backend/services/snapshot-service/src/infrastructure/backup/postgres-backup.handler.ts @@ -29,63 +29,81 @@ export class PostgresBackupHandler implements BackupHandler { async execute(outputDir: string, onProgress: ProgressCallback): Promise { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const fileName = `postgres-${timestamp}.tar.gz`; + const fileName = `postgres-${timestamp}.sql.gz`; const filePath = path.join(outputDir, fileName); fs.mkdirSync(outputDir, { recursive: true }); - onProgress(0, 'pg_basebackup 开始...'); + onProgress(0, 'pg_dump --all-databases 开始...'); return new Promise((resolve, reject) => { - const outputStream = fs.createWriteStream(filePath); - - const proc = spawn('pg_basebackup', [ + // pg_dumpall 输出所有数据库,通过 gzip 压缩 + const dumpProc = spawn('pg_dumpall', [ '-h', this.host, '-p', this.port, '-U', this.user, - '-D', '-', - '-Ft', - '-z', - '-P', + '--clean', + '--if-exists', '-v', ], { env: { ...process.env, PGPASSWORD: this.password }, }); - proc.stdout.pipe(outputStream); + const gzipProc = spawn('gzip', ['-6']); + const outputStream = fs.createWriteStream(filePath); + + // pipe: pg_dumpall stdout → gzip stdin → file + dumpProc.stdout.pipe(gzipProc.stdin); + gzipProc.stdout.pipe(outputStream); let stderrBuffer = ''; - proc.stderr.on('data', (data: Buffer) => { + let tableCount = 0; + dumpProc.stderr.on('data', (data: Buffer) => { const text = data.toString(); stderrBuffer += text; - // pg_basebackup 进度格式: "12345/67890 kB (18%), 0/1 tablespace" - const match = text.match(/\((\d+)%\)/); - if (match) { - const percent = parseInt(match[1], 10); - onProgress(percent, `PostgreSQL 备份中 ${percent}%`); + // pg_dumpall 的 -v 输出 "dumping contents of table ..." 行 + const tableMatches = text.match(/dumping contents of table/gi); + if (tableMatches) { + tableCount += tableMatches.length; + // 估算进度(假设 ~100 张表) + const percent = Math.min(90, Math.floor((tableCount / 100) * 90)); + onProgress(percent, `PostgreSQL 备份中... 已处理 ${tableCount} 张表`); } }); - proc.on('close', (code) => { - if (code === 0) { + let dumpExitCode: number | null = null; + let gzipExitCode: number | null = null; + + const checkDone = () => { + if (dumpExitCode === null || gzipExitCode === null) return; + + if (dumpExitCode === 0 && gzipExitCode === 0) { const stat = fs.statSync(filePath); this.logger.log(`PostgreSQL 备份完成: ${fileName}, 大小: ${stat.size} bytes`); onProgress(100, 'PostgreSQL 备份完成'); resolve({ fileName, filePath, fileSize: stat.size }); } else { - const error = `pg_basebackup 退出码: ${code}, stderr: ${stderrBuffer.slice(-500)}`; + const error = `pg_dumpall 退出码: ${dumpExitCode}, gzip 退出码: ${gzipExitCode}, stderr: ${stderrBuffer.slice(-500)}`; this.logger.error(error); - // 清理不完整的文件 if (fs.existsSync(filePath)) fs.unlinkSync(filePath); reject(new Error(error)); } + }; + + dumpProc.on('close', (code) => { dumpExitCode = code; checkDone(); }); + gzipProc.on('close', (code) => { gzipExitCode = code; checkDone(); }); + + dumpProc.on('error', (err) => { + this.logger.error(`pg_dumpall 启动失败: ${err.message}`); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + reject(new Error(`pg_dumpall 启动失败: ${err.message}`)); }); - proc.on('error', (err) => { - this.logger.error(`pg_basebackup 启动失败: ${err.message}`); + gzipProc.on('error', (err) => { + this.logger.error(`gzip 启动失败: ${err.message}`); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); - reject(new Error(`pg_basebackup 启动失败: ${err.message}`)); + reject(new Error(`gzip 启动失败: ${err.message}`)); }); }); }