数据备份功能.md 11 KB

数据备份功能

状态:已定版(2026-09-06 实施,2026-09-07 定版,契约 1.22.0;2026-09-09 数据库导出/恢复改为自有格式数据脚本,零外部工具,接口形状不变)

实施摘要:

  • 后端(Java com.dms.backup / Python dms/services/backup_service.py 双版对齐):链式全量+增量备份引擎(存储扫描→数据库数据脚本导出→差异拷贝→rsync 推送→链清理)、JSON 配置存储(运行目录 dms-backup-config.json,snake_case,双端互读互通)、定时触发器、备份历史与实时进度快照。
  • 接口(契约 1.22.0 定版,OpenAPI paths/backup.yaml):GET/PUT /api/v1/backup/config、POST /api/v1/backup/jobs(202 受理)、GET /api/v1/backup/jobs/current、POST /api/v1/backup/jobs/current/cancel、GET /api/v1/backup/jobs;仅 ADMIN 可用;审计动作 BACKUP_CREATE(target=BACKUP,字典已含)。
  • 恢复:离线 CLI——Python dms-server restore-backup [--backup-id dms-bak-xxx] [--yes],Java RestoreBackupCommand(--restore-backup);存储文件按清单恢复、数据库脚本驱动直连导入(禁用外键→清空业务表→导入→恢复外键)与 .env 副本落地提示(.env 存为 .env.restored,不覆盖当前配置)。
  • 前端:features/backup/(类型守卫 backupTypes、backupApi、BackupDashboard)+ 管理员专属本地页签「备份管理」(配置表单、全量/增量触发、取消确认、2 秒进度轮询、历史清单表)。
  • 验证:Python pytest 289 通过 / 470 跳过(含 OpenAPI 守卫 1.22.0 与 4 个备份路径白名单);前端 tsc 通过、vitest 424 项全绿、vite 构建成功;Java mvn compile/test 通过。

1. 需求

  • 应用内备份:管理界面按钮手动触发 + 每日定时自动;
  • 全部备份:达梦数据库(自有格式数据脚本全量导出)、全部存储目录(原件/预览/正文/隔离/回收站/授权凭证)、.env 配置副本;
  • 拷贝到异机:不走挂载网盘,使用专用协议 rsync 守护进程(rsync://user@host:873/module/);本机目录模式作为开发/单机回退;
  • 全量与增量(链式):数据量在几十 GB 总量级别,增量基于最近全量做差异传输;
  • 保留策略:保留 N 条完整备份链,超出从最旧链清理;定时任务距最近全量超过 N 天自动升级为全量;
  • 配置持久化为 JSON 文件(不进数据库),双后端共享同一份。

2. 关键设计

2.1 备份链与清单(manifest)

  • 备份 ID:dms-bak-yyyyMMdd-HHmmss-F(全量)/ dms-bak-yyyyMMdd-HHmmss-I(增量);
  • 每次备份为目标根下独立目录 + manifest.json;目标根维护 index.json(全部备份索引);
  • 全量 manifest 的 files 为存储全量清单;增量 manifest 的 files 为合并到链尾的累计清单(= 前驱清单 + 本次差异),恢复任一链尾即可得到完整文件视图;
  • manifest.json 最后原子写入——目标端存在 manifest 才视该备份完成,中断的残缺目录在历史/恢复中不可见;
  • 增量差异判定:文件相对路径 + 大小 + mtime,与前驱清单比对,仅传输差异文件(几十 GB 量级下增量通常为 MB 级)。

2.2 备份内容与阶段

阶段(BackupPhase):SCANNING(扫描存储)→ EXPORTING_DB(数据脚本导出)→ COPYING(拷贝文件)→ PUSHING(推送目标,仅 RSYNC)→ CLEANING(清理旧链)→ SUCCESS / FAILED。

  • 数据库:自有格式数据脚本 database/dms-data.sql(驱动直连导出,零外部工具;实现于 Python sql_dump / Java SqlDataDumper,详见 2.6);
  • 存储:六个子目录全部纳入(含预览缓存与授权凭证);
  • .env:随包备份一份副本;
  • 进度快照 12 字段(backupId/type/trigger/phase/bytesTotal/bytesCopied/filesTotal/filesCopied/pushPercent/error/startedAt/finishedAt),双端形状逐字对齐(从未运行时业务字段为 null/0);时间为业务时区本地时间 yyyy-MM-dd HH:mm:ss(非 UTC)。

2.3 目标模式

  • LOCAL:直接写入本机目录(开发/单机);
  • RSYNC:组包到本地临时目录后 rsync 推送异机(rsync 守护进程协议),整体进度取 rsync 输出百分比(pushPercent);密码存独立文件不回显(接口仅返回 rsync_password_set 布尔);
  • 目标不可达报 BACKUP_TARGET_UNAVAILABLE(HTTP 503);已有任务在跑报 BACKUP_RUNNING(HTTP 409)。

2.4 配置存储与定时

  • dms-backup-config.json(运行目录,snake_case):target_mode / local_path / rsync_url / schedule_enabled / schedule_time / full_interval_days / retain_full_count + 密码独立保存;保存时全量替换,rsync_password 为 null/空串表示保持已存密码;
  • 定时:Java @Scheduled(fixedDelay=30s) / Python 后台线程轮询,到达 schedule_time(业务时区 HH:MM)且当日未触发时启动;类型决策 decideScheduledType:距最近全量 ≥ full_interval_days 天(或无可用链)→ FULL,否则 INCREMENTAL;
  • 清理:完成新备份后,若完整链数 > retain_full_count,从最旧链开始整链删除(链 = 全量 + 其后所有增量)。

2.5 恢复(离线 CLI)

  • Python:dms-server restore-backup [--backup-id dms-bak-xxx] [--yes];Java:RestoreBackupCommand;
  • 从目标端拉取指定备份(缺省取最新链尾)的 manifest 与文件,按清单恢复存储文件;数据库脚本由程序驱动直连导入(见 2.6 恢复语义);.env 副本落为 .env.restored,不覆盖当前 .env(防止恢复机数据库地址不同导致服务起不来)。

2.6 数据库自有格式(dms-data.sql)

背景:达梦客户端工具 dexp/dimp 不随环境安装导致备份失败,且外部子进程难以诊断。改为自有格式数据脚本(参考 MySQL→DM 迁移工作区 temp/sql 的导入器与拓扑排序脚本设计):

  • 格式:database/dms-data.sql UTF-8 纯数据 SQL 脚本(无 DDL),头部注释元信息(格式版本/生成引擎/时间/源模式/表数量),逐表块 = 行数注释 + 可选 SET IDENTITY_INSERT 包裹 + 带列清单 INSERT(列清单保证备份旧/程序新时新列取默认值);manifest.database_file 字段值同步变更,接口形状不变;
  • 导出(Java SqlDataDumper / Python sql_dump,驱动直连零外部工具):表清单来自 ALL_TABLES(含 alembic_version / flyway_schema_history 迁移簿记表,版本戳随备份走);外键拓扑序(Kahn,父表在前、同层字典序、环兜底字母序);单列主键表按 PK 升序取行(自引用表父行必先于子行);IDENTITY 以 SET ON/OFF 探测决定是否包裹;字面量渲染覆盖 NULL/布尔/数值/二进制(0x hex)/datetime(微秒)/date/字符串(单引号转义);
  • 恢复:禁用全部外键(DDL 隐式提交,置于业务事务之外)→ 字典序逆序清空全部业务表 → 单事务执行脚本全部语句 → finally 恢复(启用)外键——启用即完整性校验,失败清单非空报错;相比 dimp IGNORE=Y 的"覆盖不删多余",精确恢复到备份时点;
  • 版本错位守卫:备份中的表缺失于目标库(备份新于程序)报错引导升级;目标库多出的表(程序新于备份)不动;目标库为空时 Python 版自动执行 alembic 建库(run_upgrade_silent),Java 版报错引导先建库(Java 无完整 DM 迁移链);
  • 双端互通:两侧同一 SQL、同一格式、同一恢复语义,任一端导出的脚本另一端可导入。

3. 接口(契约 1.21.0)

方法 路径 说明
GET /api/v1/backup/config 读取配置视图(8 字段,密码永不回显)
PUT /api/v1/backup/config 保存配置(全量替换;rsync_password null=保持)
POST /api/v1/backup/jobs 触发备份 {type: FULL\|INCREMENTAL},202 + 初始进度快照
GET /api/v1/backup/jobs/current 当前进度快照 + running 布尔
POST /api/v1/backup/jobs/current/cancel 请求取消,返回 {canceled}
GET /api/v1/backup/jobs 历史清单 {entries}(按备份 ID 升序,含链尾累计口径的清单统计)

权限:全部仅 ADMIN;触发成功审计 BACKUP_CREATE;查询/进度/取消不产生审计。

4. 前端

  • 导航:管理员专属本地页签「备份管理」(NavigationItem.module = "BACKUP" 本地锚点,不走 allowedModules 下发;普通用户不可见,直接访问渲染「模块权限异常」);
  • BackupDashboard 三区:任务区(全量/增量触发、取消确认弹窗、进度条与阶段/文件/字节统计、失败原因)、配置区(模式/路径/rsync 地址与密码/定时开关与时刻/全量间隔/保留链数)、历史区(ID/类型/前驱/起止时间/本次传输/清单累计/数据库导出大小);
  • 轮询:页面存活期间 2 秒拉取当前任务;running 由真变假时自动刷新历史;
  • 契约守卫:backupTypes.ts 按实际 JSON 形状逐字段校验(配置/历史 snake_case,进度 camelCase)。

5. 验证记录

  • Python:pytest(干净 dms_test 库)289 通过 / 470 跳过(2026-09-07 定版复验,含修复 dmPython 驱动环境后恢复的 20 项),含 test_backup.py(引擎级 7 项真实执行:全量→增量链路、目标目录布局、链尾累计清单、无链升级全量、dexp 缺失 FAILED、单任务互斥、保留链清理与残留清扫、配置 JSON 往返与校验、进度快照形状;接口级 7 项沿用 B2 门控,提供 DMS_TEST_* 环境变量时执行);test_openapi_storage(1.22.0 版本、4 个备份路径白名单、Flask 路由与 OpenAPI 方法集精确一致)及 q1b/q2b/q3b 契约版本守卫;
  • Java:mvn compile / mvn test 通过(既有测试无回归);
  • 前端:tsc --noEmit 通过;vitest run 66 文件 424 项全部通过;vite build 成功;
  • 双端互通:同一 dms-backup-config.json 与备份目录结构,Java 版与 Python 版互为可恢复(备份链清单格式一致)。

5.1 变更记录(2026-09-09:自有格式替代 dexp/dimp)

  • 动因:生产环境未安装达梦客户端工具(dexp/dimp),备份触发即失败"未找到达梦导出工具 dexp";应用本身经 JDBC/dmPython 驱动直连数据库,外部工具并非必需依赖。
  • 变更:删除 Java DmBackupTool(dexp/dimp 子进程封装)与 Python 端同类封装;新增 Java SqlDataDumper、Python sql_dump 模块(双版逐条对齐);BackupEngine / RestoreBackupCommand / backup_service 全部改走驱动直连;manifest database_file 值 database/dms.dmp → database/dms-data.sql(字段与接口形状不变,无需升契约版本)。
  • 验证:Python test_sql_dump.py 新增 19 项(切分/字面量/拓扑/导出块/恢复序列/空库与缺表守卫/外键失败回补),test_backup.py 改造为伪导出全绿,全套 317 通过 / 501 跳过;Java SqlDataDumperTest 新增 11 项(对齐 Python 用例),全套 49 项通过;mvn compile 通过。
  • 兼容:历史备份包中的 database/dms.dmp 不再被恢复命令识别(恢复 CLI 引导重新生成备份);DMS_DEXP_EXECUTABLE/DMS_DM_HOME 相关配置与探测逻辑一并移除(.env 未曾默认携带)。