- 数据库
- 灾备
【免费下载链接】databasus
PostgreSQL backup tool with Point-In-Time-Recovery and restore verification
ADR-0013(Reuse one in-memory test database per version instead of one container per test)记录了 databasus 后端测试基建的一次关键决策:面对 PostgreSQL / MySQL / MariaDB / MongoDB 多引擎、多版本的测试矩阵,如何在"测试速度快"与"内存占用有界"之间取得平衡。本文以该 ADR 为主体,结合仓库内 ADR 模板、容器启动实现 postgres.go、worker 槽位分配实现 config.go 与测试编排 backup_restore_test.go,完整展开决策的来龙去脉、隔离原理与工程代价。读完本文,你将理解一套"按版本复用 + tmpfs 内存数据目录 + 包级并行 + 槽位隔离"的数据库 E2E 测试方案,以及它在 databasus 仓库中的具体落地形态。
一、ADR 是什么:databasus 的架构决策规范
在进入 ADR-0013 之前,先明确 databasus 对 ADR 的约定。仓库在 adr/AGENTS.md 中定义了 ADR 的写作规则:ADR 记录的是"为什么做出某个决策"(why),而不是"代码怎么写的"(how)——代码才是 how 的唯一事实来源。因此,一份合格的 ADR 必须同时满足三个条件:
- 撤销代价高昂(expensive to reverse);
- 未来的读者看到代码时会问"为什么这么做";
- 当时存在真实的备选方案,且我们因为明确的原因拒绝了它。
显而易见的决策不写 ADR。风格上要求一页以内、现在时陈述、以"决策本身"命名而非"技术"命名("Export logs over OTLP"优于"OpenTelemetry"),且 ADR 是决策快照而非活文档——当决策被取代时,不修改历史,而是写新 ADR 并把旧 ADR 标记为Superseded by ADR-NNNN。0000-adr-template.md 提供了统一模板:Context(问题与约束)、Decision(决策陈述)、Alternatives considered(被拒方案及理由)、Consequences(正/负/中性影响)、References(相关 ADR 与证据)。
ADR-0013 正是这一规范的产物,它回答了测试基建中一个代价高昂、事后难以推翻的问题:多版本数据库测试到底该怎么起容器?
二、Context:测试矩阵为什么把前两套方案都压垮了
databasus 的数据库测试有一个显著特征:同一套备份/恢复校验要在每个引擎的多个大版本上运行——PostgreSQL 12 到 18、MySQL、MariaDB、MongoDB 各多版本。仓库中逻辑测试的版本矩阵即为例证:backup_restore_test.go 定义了postgres:12到postgres:18共 7 个版本。
在采用 ADR-0013 的方案前,团队试过两套设置,都失败了:
失败方案一:docker-compose 一次性把全部版本拉起
早期做法是把所有引擎的所有版本一次性docker-compose up并保持运行。按每个引擎 4~7 个版本估算,同时存活的容器约50 个:
docker-compose up (everything, all the time) postgres:12 .. postgres:18 mysql:5.7 .. mysql:8.4 mariadb:10.6 .. mariadb:12.0 mongo:4.0 .. mongo:8.0 ────────────────────────────── ~50 containers alive at once → out of RAM一台 16 GB 的 CI 机器直接内存耗尽。50 个数据库进程同时驻留,无论怎么调优,内存账都算不过来。
失败方案二:每个测试一个全新容器(朴素修复)
直觉上的修复是"用完即焚":每个测试自己启动一个数据库、跑完立刻销毁。问题是冷启动成本——一个数据库容器冷启动需要 20~60 秒,而一个测试包(package)里有 40+ 个测试,逐个起容器直接撞上 15 分钟的测试超时:
test 1 → boot DB → run → kill DB test 2 → boot DB → run → kill DB ... (40+ times per package) → too slow目标
团队想要的不是二选一,而是同时具备:
- 快:整套测试能在 CI 时限内跑完;
- 内存有界:峰值内存可预测、不超机器上限;
- 互不干扰:并行执行的测试包之间完全隔离。
三、Decision:每个版本一个容器 + RAM 数据目录 + 包级并行
ADR-0013 的核心决策可以浓缩为三句话:
每个数据库版本启动一个容器,该版本的所有测试函数复用它;测试数据目录挂在 tmpfs(RAM)上;测试包并行执行,靠 worker 槽位保持隔离。
流程形态如下:
boot postgres:16 (once) ├─ run test 1 ├─ run test 2 └─ run test 3 shut down postgres:16 → boot postgres:17 ...三个支柱缺一不可,下面逐一展开。
3.1 每版本一容器:用外层t.Run编排生命周期
矩阵包(matrix package)把版本列表声明一次,用外层t.Run循环;在子测试内部调用StartPostgres/StartMysql/StartMariadb/StartMongodb(实现位于 containers 目录),再把原来的测试函数作为内层子测试挂进去。
关键机制是StartXxx注册的t.Cleanup:Go 在外层版本子测试返回时自动执行清理,容器随之销毁——因此同一时刻每个包只存活一个矩阵容器。同时,每个go test包是独立进程,它的容器只属于它自己,天然与别的包隔离。
真实编排代码见 backup_restore_test.go:
func Test_PostgresqlBackupRestore_AcrossSupportedVersions(t *testing.T) { for _, dbVersion := range postgresVersions { t.Run(dbVersion.name, func(t *testing.T) { endpoint := containers.StartPostgres(t, dbVersion.image) // 内层子测试:恢复成功、加密、schema 选择、排除扩展…… t.Run("Test_BackupAndRestorePostgresql_RestoreIsSuccesful", func(t *testing.T) { ... }) // ... }) } }文件注释明确标注了"每个版本启动一次、跑完全部测试函数、下一个版本前关闭,同一时刻每包只存活一个矩阵容器,参见 ADR-0013",这正是 ADR 与代码互相印证的典型形态。
由于容器在版本内被复用、子测试顺序执行,给测试作者留下一条硬性规则:创建不带随机后缀的固定名对象(如表、用户)时,必须先DROP ... IF EXISTS,否则同一版本内的下一个子测试会撞名。
3.2 数据目录挂 tmpfs:把冷启动和 fsync 从关键路径上移走
数据库文件放在RAM上,是"每个容器复用"方案能跑得快的第二块拼图。实现见 postgres.go:
func postgresRequest(image string) testcontainers.ContainerRequest { return testcontainers.ContainerRequest{ Image: image, ExposedPorts: []string{postgresPort}, Env: postgresEnv(), Cmd: []string{"-c", "fsync=off", "-c", "full_page_writes=off", "-c", "synchronous_commit=off"}, Tmpfs: map[string]string{postgresDataDir(image): dataDirTmpfsOptions}, WaitingFor: postgresReady(), } }要点有三:
- tmpfs 挂载:数据目录以
rw,size=512m挂到 tmpfs(见 containers.go 的dataDirTmpfsOptions)。512m 是钉死的上限——如果不限 size,Docker 会按宿主机一半 RAM 给每个容器预留,8 个并行包会直接把机器吃穿。 - 关闭崩溃安全:PostgreSQL 使用
fsync=off / full_page_writes=off / synchronous_commit=off;ADR 中记载 MySQL & MariaDB 对应innodb-flush-log-at-trx-commit=0 / innodb-doublewrite=0 / sync-binlog=0 / skip-log-bin。这些服务器反正是用完即弃的,关闭持久化换来的是写密集的恢复流程大幅提速。 - 版本差异处理:
postgresDataDir注意到 postgres:18 把 PGDATA 与数据卷位置从/var/lib/postgresql/data上移到/var/lib/postgresql,若仍按旧路径挂 tmpfs 会留下一个多余空目录,导致 entrypoint 的布局检测拒绝启动。该函数按镜像 tag 解析主版本号(PostgresMajorVersion),≥18 挂父路径、否则挂旧路径。
3.3 包级并行:go test -p=8换回速度
复用容器省掉了 40+ 次冷启动,但每个版本内测试是串行的。为了换回速度,测试包之间并行:backend/Makefile定义TEST_PARALLEL_WORKERS ?= 8,测试目标执行go test -p=$(TEST_PARALLEL_WORKERS) ...(见 backend/Makefile)。
因为"每包只存活一个矩阵容器",8 个包并行时的峰值内存保持在约10~11 GB——这正是当初"全量拉起 50 容器"方案会 OOM 的量级之下。
四、并行隔离的底层机制:Postgres advisory lock 抢占 worker 槽位
并行跑起来之后,最棘手的问题是共享基建的隔离。8 个包同时运行,共享同一套 Postgres(内部状态库)、缓存与备份基础设施,必须保证每个包拿到自己的私有副本。ADR-0013 给出的答案是worker 槽位(worker slot):每个包用一条Postgres advisory lock抢占一个槽位,槽位号派生出一整套私有资源。
实现位于 config.go:
func claimTestWorkerSlotAndSelectMetadataDatabase() { baseDbName, _, err := RewriteDbName(env.TestDatabaseDsn, systemDbName) // ... slot := claimTestWorkerSlot(env.TestDatabaseDsn, env.TestParallelWorkers) slotDbName := fmt.Sprintf("%s_w%d", baseDbName, slot) // ... env.DatabaseDsn = slotDsn } func claimTestWorkerSlot(testDsn string, pool int) int { // 打开系统库连接,循环尝试 SELECT pg_try_advisory_lock($1) // 键为 testSlotAdvisoryLockBase + slot,即 945_000_000 + slot // 抢到后把连接保存在 slotLockConn(GC root,进程存活期间持有锁) }细节非常讲究:
- 槽位键空间:
testSlotAdvisoryLockBase = 945_000_000,槽位 N 使用base+N(config.go)。锁是会话级的,持有连接直到进程退出,避免中途被 GC 释放导致别人抢占。 - 重试窗口:
testSlotClaimTimeout = 60s,循环扫描[0, pool)找第一个空闲槽,每 100ms 重试,用于吸收go test -p中"旧进程刚释放、新进程尚未启动"的交接窗口;超时则报错并给出提示TEST_PARALLEL_WORKERS must be >= the go test -p value。 - 槽位派生资源:
- 私有元数据库:
{base}_w{slot}; - 私有缓存数据库(原 Valkey/Redis 的槽位号,后被 process-local 替代,见下文);
- 私有缓存前缀
"w{slot}:",同时用于标记备份节点注册表 nodes/registry.go 的每个 Redis key 与 pub/sub 频道。
- 私有元数据库:
因此两个并行的测试包永远不会碰到对方的元数据库、缓存条目、备份节点或 pub/sub 频道。Makefile 侧的配套动作是:go run ./cmd/cleanup_test_db清理旧槽位库,并循环goose对每个_w{i}槽位库执行迁移(backend/Makefile)。
五、Alternatives considered:三个被拒方案及理由
ADR 的价值很大程度体现在"拒绝过什么"。ADR-0013 明确记录了三组被拒方案:
| 方案 | 形态 | 拒绝理由 |
|---|---|---|
| docker-compose 全版本常驻 | ~50 容器同时存活 | 16 GB CI 内存耗尽;且所有包共享同一套数据库,毫无隔离 |
| 每个测试一个全新容器 | 40+ 次启动/销毁 | 20~60s 冷启动 × 40+ 测试 → 撞 15 分钟超时 |
| 数据目录放磁盘而非 RAM | 冷启动与写密集恢复落在 fsync 上 | 慢的恰恰是冷启动 fsync 与恢复写盘;反正服务器用完即弃,RAM 直接移除该开销 |
前两条是"已经试过并且失败"的历史教训,第三条是"考虑过但方向错了"的理性排除——三者共同把"每版本一容器 + tmpfs + 并行"逼成了唯一同时满足速度与内存约束的形态。
六、Consequences:决策的收益、代价与后续演进
正面影响
- 性能跃升:原来两个超时于 900s 的包现在约 30 秒完成;整套测试从"超时"变成约1.5~4 分钟。
- 内存有界且可预测:每包只存活一个矩阵容器,峰值内存可推算;并行包之间完全隔离,杜绝了"别人的测试破坏我的数据"这类经典 flaky 源。
负面影响
- 测试作者必须守规矩:服务器在版本内被复用,固定名对象必须先
DROP ... IF EXISTS;同版本内的子测试禁止t.Parallel()。 - RAM 数据易失:对一次性测试服务器没问题,但没有任何崩溃安全可言。
- CI 内存吃紧时需降级:可下调
TEST_PARALLEL_WORKERS(例如 6)。
中性 / 后续事项
- 在硬
-timeout或os.Exit场景下t.Cleanup会被跳过,残留容器依赖 Makefile/CI 的标签清理(label-sweep)兜底。
决策的自我演进(Replacement note)
ADR-0013 末尾附有 2026-09-06 的补充说明:原由 Valkey 承载的测试状态已替换为进程本地(process-local)的缓存、pub/sub 与限流 provider,每个测试二进制自己持有这部分瞬态状态;worker 槽位机制仍然用于隔离共享的元数据库。这与仓库当前整体方向一致——openspec 的 single-instance-runtime 规范与 ADR-0006 的弃用注记都表明项目已收敛为"单应用进程/安装"模型,Valkey 这类外部运行时可被本地实现替代。物理备份测试则采取另一形态:每个 PostgreSQL 大版本拆成一个独立测试包(physical/postgresql 下 pg17、pg18 目录),各版本作为相互隔离的并行测试二进制运行,各自拥有独立的控制平面与一次性源库/恢复目标容器。
七、给读者:如何在仓库中复现与扩展这套方案
若要在本地复现或改进这套测试基建,可按以下路径深入仓库:
- 理解容器启动契约:containers 目录 下的
postgres.go、mysql.go、mariadb.go、mongodb.go是StartXxx系列助手所在,注意Tmpfs、Cmd中的持久化关闭参数与 READY 等待策略(postgres.go 等待"database system is ready to accept connections"出现两次,以避开 initdb 阶段临时 socket 服务器的干扰)。 - 复刻编排模式:backup_restore_test.go 是"外层版本
t.Run+ 内层测试t.Run"的参考实现。 - 调整并行度与隔离:修改
TEST_PARALLEL_WORKERS(backend/Makefile),并在 config.go 中阅读槽位抢占与_w{slot}数据库选择逻辑。 - 读懂 ADR 模板本身:若要为项目新增架构决策,从 adr/0000-adr-template.md 出发,遵循 adr/AGENTS.md 的"写 why 不写 how、一页以内、现在时"原则,并将新决策编号为
NNNN-slug.md。
结语
ADR-0013 是"架构决策文档驱动工程实现"的一个典型样本:一句话的决策(按版本复用内存容器、包级并行、槽位隔离)背后,是两套失败方案的教训、三个支柱的协同、一组被拒方案的理性排除,以及一套可以在源码中逐行核对的落地实现。对任何需要维护多版本数据库测试矩阵的项目而言,它的核心经验可以迁移复用:容器复用消除冷启动成本、tmpfs 消除 fsync 成本、包级并行换回吞吐、advisory-lock 槽位保证隔离——四个杠杆缺一不可,而把约束"钉死"(如 512m tmpfs 上限、禁用t.Parallel的守则)比事后排查内存爆炸和测试互踩要便宜得多。
- 数据库
- 灾备
【免费下载链接】databasus
PostgreSQL backup tool with Point-In-Time-Recovery and restore verification
相关推荐
Databasus ADR-0013 深度解析:按版本复用共享内存测试数据库,平衡测试速度与 CI 内存
Databasus ADR 0013 深度解析:按版本复用共享内存测试数据库,平衡测试速度与 CI 内存 本文基于 Databasus 仓库中的架构决策记录 A
数据库灾备Databasus Docker 存储兼容性规范详解:用真实容器文件系统测试覆盖每一种挂载布局
Databasus Docker 存储兼容性规范详解:用真实容器文件系统测试覆盖每一种挂载布局 本文围绕 Databasus 仓库中归档的 OpenSpec 需
数据库灾备单容器架构:Databasus 如何将 PostgreSQL 内嵌进应用镜像(ADR-0003 架构决策解析)
单容器架构:Databasus 如何将 PostgreSQL 内嵌进应用镜像(ADR 0003 架构决策解析) 本文围绕仓库架构决策记录 ADR 0003(Em
数据库灾备
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考