一个数据库测试库,把"建一整个数据库"和"建一个schema"放在一起比速度,结果是平局:都在100毫秒左右。这本该是个反直觉的结果——克隆整个物理数据库,怎么可能不比建一个轻量级schema慢?但把整套测试跑下来再看,平局瞬间被打破:Schema方案反而快了3.5倍。
这组数字来自 Brandur 把 Peter Downs 写的 Go/Postgres 测试库 pgtestdb 接入 River 项目测试套件后的实测。pgtestdb 的思路是用 Postgres 内建的 CREATE DATABASE ... TEMPLATE 把一份迁移好的模板数据库整体克隆给每个测试用例,理论上应该比"每次都重新建schema、重新跑迁移"慢。结果没有。
单次打平,整体却拉开差距
先看单次操作的耗时:
| 方法 | 次数 | 均值 | p90 | p95 | 最大值 |
|---|---|---|---|---|---|
| pgtestdb 克隆 | 466 | 98.4ms | 247.4ms | 299.5ms | 465.1ms |
| Create+migrate schema | 81 | 99.4ms | 152.1ms | 209.0ms | 327.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 需要在拷贝前后各做一次 checkpoint,系统级 I/O 压力大、延迟容易抖动。WAL_LOG 省掉了这道工序,逐块拷贝、逐块写日志,天然更轻。这解释了为什么"克隆一整个数据库"这件听起来很重的事,实际耗时能追平"建个schema"——它省的不是活儿,是流程。
100毫秒的基准数字,能信到什么程度
这里要泼一点冷水。Brandur 的测试模板体积很小,测的是模板已经建好、反复复用的"温启动"路径。模板里有多少张表、多少索引、多少 fixture 数据,直接决定克隆要拷贝多少页——真实项目的模板往往比这重得多,100毫秒未必能原样复制过去。
并发度也是变量。多个测试进程或多个 CI Job 同时尝试初始化同一个模板,会撞上模板本身的连接限制——Postgres 拷贝模板期间会限制到它的常规连接,pgtestdb 用的迁移哈希、内部锁只能协调同一个 Go 测试二进制内部,协调不了跨进程、跨容器的竞态。
- 风险.迁移哈希只覆盖了迁移脚本本身,扩展版本、Postgres 版本变化不会触发重建,模板可能悄悄"过期"却没人发现;失败清理不彻底还会造成数据库泄漏,长期跑下来 CI 机器上堆积一堆孤儿库。
该怎么选,不是二选一
pgtestdb、schema隔离、事务回滚、Testcontainers,不是互相替代的四个选项,而是按测试类型分层使用的工具箱。
工欲善其事,必先利其器,但器不对活儿,再利也白搭。
端到端测试(客户端插入任务、worker 完成任务这类)交给 pgtestdb,图的是它能真实提交、真实跨连接可见。要测 LISTEN/NOTIFY 或多事务交互,schema隔离更合适。纯查询逻辑,事务回滚最快。没有一种方案能通吃。
River 没有换成 pgtestdb,不是因为它不够快,而是因为 schema 隔离顺带验证了 River 自己的 schema 级配置能力——这是 pgtestdb 给不了的额外价值。这提醒一件事:选测试工具之前,先问清楚这套测试到底在验证什么,而不是只看谁的基准数字更好看。
