VVCMS 为什么用 JSON 做备份/还原?—— 跨数据库中间态的设计权衡

一、一句话结论

VVCMS 的备份/还原之所以选择 JSON 而不是各数据库的原生导出(如 SQLite 的 .dump、mysqldump、pg_dump),根本动机是“用一套逻辑数据格式统一 SQLite / MySQL / PostgreSQL 三种数据库的备份与还原”。JSON 在这里充当的是“方言无关的中间态”。代价是:在大数据量场景下,这种全量、单文档、内存解码的方案会暴露出内存、事务、I/O 与还原性能四类问题。

二、核心动机:三库统一

VVCMS 同时支持 SQLite、MySQL、PostgreSQL 三种后端。如果直接采用任意一种原生 dump:

  • SQLite .dump 产出的是 SQLite 专属 SQL;
  • mysqldump 产出 MySQL 专属语法(反引号、AUTO_INCREMENT、引擎声明);
  • pg_dump 产出 PostgreSQL 专属语法(COPY、序列、schema)。

三者互不直接兼容。一旦用户从 SQLite 迁移到 MySQL,或反向,原生 dump 无法复用。用 JSON 作为中间态,则备份文件本身不绑定任何方言:导出时把每种数据库的行数据都映射成同一种 JSON 结构,还原时再按目标数据库方言写回。这样“SQLite 备份 → PostgreSQL 还原”成为可能。

三、实现剖析(代码证据)

3.1 逻辑数据格式:backupFile

backend/app1/internal/service/backup_service.go 里的 backupFile 是备份协议的骨架:

type backupFile struct {
    Version        int
    Format         string              // "vvcms-backup"
    FormatVersion  int                 // 2
    DBType         string              // 仅记录来源方言,不影响数据形态
    Scope          string              // "safe-site"
    TableSchemas   map[string]string
    Tables         map[string][]map[string]backupValue
}

注意 DBType 只是“记录”,真正落地到文件的数据结构是 Tables map[string][]map[string]backupValue——一个与方言无关的“逻辑行”集合。无论来源是哪种库,落盘形态完全一致。

3.2 单元格类型编码:backupValue

关系型数据库有 bool / 各种整数 / 浮点 / 时间 / 二进制 / 字符串等类型,Go 的 interface{} 直接 json.Marshal 会丢精度或把 []byte 变成 base64 字符串。VVCMS 显式定义了一个带类型标签的单元:

type backupValue struct {
    Type string      // null/bool/number/time/bytes/string
    Str  string
    Num  json.Number
    Bool *bool
}

toBackupValue 对每种 Go 类型分派:整数统一走 json.Number(以字符串保存数值,避免 JS/Go 浮点精度漂移),时间统一 RFC3339Nano,二进制 []byte 走 base64。还原时 fromBackupValue 再据此还原。这是跨库保真的关键。

3.3 驱动层的 []byte 陷阱

不同驱动对 TEXT/JSON/UUID/DECIMAL 等列有的返回 []byte,如果不区分就会被 base64 成二进制。代码专门做了白名单判断(databaseValueForBackup):

if strings.Contains(databaseType, "CHAR") || strings.Contains(databaseType, "TEXT") ||
   strings.Contains(databaseType, "JSON") || strings.Contains(databaseType, "UUID") ||
   strings.Contains(databaseType, "DECIMAL") || ... {
    return string(b)   // 当字符串,只真二进制才 base64
}

这一步决定了“同一份内容在三种库里还原后还是可读文本,而不是一堆乱码 base64”。

3.4 方言差异只落在两处

翻遍还原路径,真正“方言相关”的代码只有两处:

  • 标识符引用:quoteIdentFor → base.QuoteTable,PostgreSQL 用 "name",MySQL/SQLite 用 `name`;
  • 自增序列修复:resetBackupIdentities 在还原后修 PostgreSQL 的 serial/identity 序列(setval),SQLite 清 sqlite_sequence,MySQL 靠显式主键自然推进。

数据本身零方言。这就是“中间态统一”的落地方式。

3.5 显式表注册表 + 数据契约

backup_registry.go 用 backupTableRegistry 白名单列出允许备份的表(option / column / content / file / interaction ...),并用独立的 backupSchemaRevision = 2 表示“数据契约版本”,与程序发布号解耦。还原时会校验:

  • 表名是否在白名单内;
  • 列名是否真实存在于目标表;
  • schema_revision 不高于当前支持版本;
  • 表依赖顺序(父表先于子表)。

这把“备份协议”变成封闭、可校验的契约,而不是“把库里任意表都 dump 出来”。

3.6 默认 scope 是 safe-site

当前默认备份范围是 safe-site:明确排除 user / user_identity(身份表)、数据库之外的上传文件、主题文件。也就是说,JSON v2 的定位是“可移植的站点内容与配置”,而不是“整台服务器的完整镜像”。要完整迁移仍需另行打包上传目录与主题。

四、为什么不用原生 dump

  • 跨方言可移植:这是第一优先级,原生 dump 做不到。
  • 可读可调试:JSON 是文本,人能直接看懂、能 diff、能手工修坏行;原生 dump 是大段 SQL 或二进制。
  • 无二进制工具依赖:还原不需要目标机器装 psql/mysql 客户端,服务端用 GORM 直写即可。
  • 结构化强校验:版本号、表名、列名、schema_revision 都可在还原前拦截非法文件。

五、代价:大数据量下的问题(重点)

统一中间态不是免费的。随着数据量增长,当前实现会暴露五类问题:

5.1 解码整体入内存 → 大库 OOM

还原入口 decodeBackupFile 用 json.NewDecoder(rf).Decode(&bf) 把整个文件一次解码进 backupFile 结构体:所有表、所有行、所有单元格都被物化到内存。备份文件越大,内存占用越接近“文件体积的几倍”(每个单元格还包了一层 backupValue 对象)。百万行站点还原时极易触发 OOM。导出侧虽然按行流式写盘,但导出本身跑在单个数据库事务快照里。

5.2 导出单事务快照 → 长事务 / MVCC 膨胀

dumpToJSON 整体包在 db.Transaction 中,目的是保证一致性快照。但大库下这个事务会持续很久:在 PostgreSQL 中长事务会阻止 autovacuum 回收,导致表/索引 MVCC 膨胀;在 MySQL 中长事务同样占用 undo 日志。一致性是保住了,代价是运维风险。

5.3 逐行 JSON 冗余 → 文件大、I/O 慢、无压缩

每一行都是 {"col1":{...},"col2":{...}} 的对象,列名作为字符串键被逐行重复,体积远大于原生 dump 或列式存储。当前实现无压缩,上传还有 512MB(maxBackupUploadSize)硬上限。数据量大时,备份文件动辄数 GB,读写都慢。

5.4 还原 CreateInBatches(200) + 单锁 + 同步

还原循环里每 200 行 CreateInBatches 一批,全程持 t.mu 互斥锁,并且是同步 HTTP 接口。意味着:还原期间整个进程被锁住,其它请求被阻塞;没有维护模式;进度不可见;超大库还原可能超过网关/接口超时。生产环境的“大型恢复”官方说明也已提示应改为异步任务 + 维护模式。

5.5 全表 DELETE + 全量 INSERT,无 upsert/diff

还原按依赖逆序先 DELETE FROM 整表,再全量 INSERT。没有增量、没有 upsert、没有主键冲突处理。即便只改了 10 行,也要重写整张表。对于“迁移/换库”场景可接受,但对于“日常容灾回滚”场景代价偏高。

六、改进方向(可选)

  • 流式分表 / JSONL:把单文档 JSON 改成“每表一个 JSONL 文件 + 清单”,用 json.Decoder.Decode 逐行流式解码,避免一次性入内存。
  • 分卷 + 压缩:导出即 gzip,突破体积与 512MB 限制。
  • 异步任务 + 维护模式:还原拆成后台任务,进入只读维护模式,带进度。
  • 大库走原生 dump 的并行通道:在 safe-site 之上提供一个“完整备份”scope,对大数据量回退到各库原生 dump/restore(仍受 scope 控制),JSON 只负责中小站点与跨库迁移。
  • 增量/upsert:还原支持按主键冲突更新,而不是全表重建。

七、小结

VVCMS 选 JSON 做备份中间态,是一个“用统一逻辑格式换跨数据库可移植性”的取舍:它让同一份备份能在 SQLite / MySQL / PostgreSQL 之间自由迁移,且可读、可校验、无外部工具依赖。这套设计的代价集中在大数据量——内存解码、长事务快照、JSON 体积冗余、同步单锁还原、全表重建。它非常适合中小站点与跨库迁移,而对超大规模或高频容灾,则需要上面那几条演化路径来补位。

本文地址: https://www.vvcms.cn/blog/why-json-backup-format
版权所有 © VVCMS 团队 未经授权不得转载