一个数据库测试库,把"建一整个数据库"和"建一个schema"放在一起比速度,结果是平局:都在100毫秒左右。这本该是个反直觉的结果——克隆整个物理数据库,怎么可能不比建一个轻量级schema慢?但把整套测试跑下来再看,平局瞬间被打破:Schema方案反而快了3.5倍

这组数字来自 Brandur 把 Peter Downs 写的 Go/Postgres 测试库 pgtestdb 接入 River 项目测试套件后的实测。pgtestdb 的思路是用 Postgres 内建的 CREATE DATABASE ... TEMPLATE 把一份迁移好的模板数据库整体克隆给每个测试用例,理论上应该比"每次都重新建schema、重新跑迁移"慢。结果没有。

单次打平,整体却拉开差距

先看单次操作的耗时:

方法次数均值p90p95最大值
pgtestdb 克隆46698.4ms247.4ms299.5ms465.1ms
Create+migrate schema8199.4ms152.1ms209.0ms327.0ms

均值几乎一致,p90往后 schema 方案更稳。但整套测试跑完:pgtestdb 用了 51.07 秒,schema 方案只用 14.54 秒。

差距不是克隆变慢了,而是 River 的 schema 测试助手做了一件 pgtestdb 没做的事:池化复用。测试用例用完的 schema 不会被扔掉,而是清空后放回池子,下一个用例直接认领,不用重新建、重新迁移。省下的不是克隆开销,而是"每次都从零开始"这件事本身。

克隆一整个数据库,为什么不再"慢"

这才是原文没细讲、却真正解释反直觉结果的地方。Postgres 15 给 CREATE DATABASE ... TEMPLATE 加了一个 STRATEGY 选项,可选 FILE_COPY 或现在默认的 WAL_LOG

两种模板拷贝策略 FILE_COPY(旧策略) 1. 强制 checkpoint 2. 逐表空间拷贝文件 3. 再次 checkpoint 风险:I/O 抖动、延迟不稳 WAL_LOG(现默认) 1. 按 8kB 页逐块拷贝 2. 每块拷贝写入 WAL 无需强制 checkpoint 更适合中小模板库

FILE_COPY 需要在拷贝前后各做一次 checkpoint,系统级 I/O 压力大、延迟容易抖动。WAL_LOG 省掉了这道工序,逐块拷贝、逐块写日志,天然更轻。这解释了为什么"克隆一整个数据库"这件听起来很重的事,实际耗时能追平"建个schema"——它省的不是活儿,是流程

100毫秒的基准数字,能信到什么程度

这里要泼一点冷水。Brandur 的测试模板体积很小,测的是模板已经建好、反复复用的"温启动"路径。模板里有多少张表、多少索引、多少 fixture 数据,直接决定克隆要拷贝多少页——真实项目的模板往往比这重得多,100毫秒未必能原样复制过去。

并发度也是变量。多个测试进程或多个 CI Job 同时尝试初始化同一个模板,会撞上模板本身的连接限制——Postgres 拷贝模板期间会限制到它的常规连接,pgtestdb 用的迁移哈希、内部锁只能协调同一个 Go 测试二进制内部,协调不了跨进程、跨容器的竞态。

  • 风险.迁移哈希只覆盖了迁移脚本本身,扩展版本、Postgres 版本变化不会触发重建,模板可能悄悄"过期"却没人发现;失败清理不彻底还会造成数据库泄漏,长期跑下来 CI 机器上堆积一堆孤儿库。

该怎么选,不是二选一

pgtestdb、schema隔离、事务回滚、Testcontainers,不是互相替代的四个选项,而是按测试类型分层使用的工具箱。

测试隔离,按层选工具 Testcontainers — 真实起一套服务器,测部署与配置 pgtestdb — 数据库级隔离,端到端全流程真实提交 Schema隔离 — 多连接、LISTEN/NOTIFY、跨事务交互 事务回滚 — 纯查询、单事务,追求极致速度
工欲善其事,必先利其器,但器不对活儿,再利也白搭。

端到端测试(客户端插入任务、worker 完成任务这类)交给 pgtestdb,图的是它能真实提交、真实跨连接可见。要测 LISTEN/NOTIFY 或多事务交互,schema隔离更合适。纯查询逻辑,事务回滚最快。没有一种方案能通吃。

River 没有换成 pgtestdb,不是因为它不够快,而是因为 schema 隔离顺带验证了 River 自己的 schema 级配置能力——这是 pgtestdb 给不了的额外价值。这提醒一件事:选测试工具之前,先问清楚这套测试到底在验证什么,而不是只看谁的基准数字更好看。