教程·阅读约 3 分钟·
用 pgtestdb 给每个测试一个独立的 Postgres 数据库:模板克隆方案实战

用 pgtestdb 给每个测试一个独立的 Postgres 数据库:模板克隆方案实战

pgtestdb 用 Postgres 模板数据库给每个测试一个完整迁移好的独立数据库,迁移只跑一次、每个测试约 20ms 拿到数据库、天然支持并行——本文讲透原理、用法和实测数据

原文来源:pgtestdb's template cloning approach to testing is fast — River 作者 Brandur 实测 pgtestdb 的模板克隆方案:单次数据库初始化约 100ms,与 schema 迁移方案持平,迁移只跑一次,测试天然隔离

数据库测试一直是软件工程里的一个老大难。用 mock 吧,逻辑绕不开 Postgres 特有的功能;每次测试都跑迁移建库吧,几千个测试跑下来能等到怀疑人生;用 Docker 起整套环境吧,慢且脆弱。Peter Downs 的开源库 pgtestdb 给出了一个优雅的方案:利用 Postgres 自带的模板数据库(template database)功能,让每个测试都拿到一个完整迁移过、完全隔离的数据库,而且创建速度快得惊人。

7 月底,River(一个 Go 的 Postgres 任务队列)作者 Brandur 专门写了篇实测文章,把 pgtestdb 和自己的 schema 方案做对比。这篇文章就带你从头到尾搞懂:模板克隆为什么快、pgtestdb 怎么用、实测数据什么样、什么时候该选它。

模板数据库:Postgres 的老功能,测试的新用法

Postgres 有一个内置功能:创建数据库时可以指定一个模板,新库会复制模板的全部内容。在 psql 里就可以直接试:

code
CREATE DATABASE dbname TEMPLATE template_to_copy;

底层实现是:Postgres 枚举模板的 relation,以 8KB 页为单位复制堆、索引和 catalog 文件。这个过程比"从零跑一遍迁移"快得多,更不用说比那些重型 Docker 方案了。

pgtestdb 的思路就是建立在这个功能上:

  1. 第一次有测试请求数据库时,pgtestdb 检查模板是否已存在;
  2. 不存在就创建一个新数据库,跑一遍你的迁移,然后把它标记为模板;
  3. 之后每个测试从这个模板克隆出专属数据库,用完即删。

关键点在于:迁移只跑一次。pgtestdb 用 advisory lock 加迁移内容的哈希来决定用哪个模板——只要你的迁移没变,不管跑多少测试、多少个 package,迁移都不会再执行。测试失败时数据库会保留下来,日志里直接打印连接字符串,你可以用 psql 连上去查现场。

—— 广告 ——

怎么用:三步上手

pgtestdb 是标准的 Go 库,安装一行命令:

code
go get github.com/peterldowns/pgtestdb@latest

第一步,写一个测试辅助函数。 每个测试都写一遍完整配置太啰嗦,官方推荐封装成 helper:

code
import (
    _ "github.com/jackc/pgx/v5/stdlib"
    "github.com/peterldowns/pgtestdb"
)
 
func NewDB(t *testing.T) *sql.DB {
    t.Helper()
    conf := pgtestdb.Config{
        DriverName: "pgx",
        User:       "postgres",
        Password:   "password",
        Host:       "localhost",
        Port:       "5433",
        Options:    "sslmode=disable",
    }
    // 真实项目里换成你的 migrator,见下文
    var migrator pgtestdb.Migrator = pgtestdb.NoopMigrator{}
    return pgtestdb.New(t, conf, migrator)
}

第二步,在测试里直接用。 每个测试拿到的是独立的、已迁移好的数据库,天然支持并行:

code
func TestAQuery(t *testing.T) {
    t.Parallel()
    db := NewDB(t)
 
    var result string
    err := db.QueryRow("SELECT 'hello world'").Scan(&result)
    // ...
}

第三步,准备一个测试专用 Postgres 服务。 这是最容易踩坑的地方。pgtestdb 需要管理员权限(要建库删库),绝对不要连生产库。官方强烈推荐用 Docker Compose 起一个专用服务,配上 tmpfs 内存盘和关闭 fsync,测试会快得飞起:

code
services:
  pgtestdb:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: password
    restart: unless-stopped
    volumes:
      - type: tmpfs
        target: /var/lib/postgresql/data/
    command:
      - "postgres"
      - "-c"      # 测试环境关掉 fsync 提速
      - "fsync=off"
      - "-c"
      - "log_statement=all"

为什么推荐 Docker Compose 而不是嵌入式方案(如 embedded-postgres)?Brandur 在文章里没有展开,但 pgtestdb 的 README 说得明白:嵌入式方案通常每个 package 起一个服务,导致迁移跑 N 遍;而且进程退出不干净,容易泄漏服务进程或产生冲突。单个共享的 Docker 服务 + tmpfs 是更稳的选择。

迁移框架适配器

pgtestdb 通过 Migrator 接口抽象迁移逻辑,主流的 Go 迁移框架都有现成适配器:

  • pgmigrator — 适配 peterldowns/pgmigrate
  • golangmigrator — 适配 golang-migrate/migrate
  • goosemigrator — 适配 pressly/goose
  • dbmatemigrator — 适配 amacneil/dbmate
  • atlasmigrator — 适配 ariga/atlas
  • sqlmigrator — 适配 rubenv/sql-migrate
  • bunmigrator — 适配 uptrace/bun
  • ternmigrator — 适配 jackc/tern

没有适配器的框架也能用——实现 pgtestdb.Migrator 接口就行,接口很小,官方文档有完整说明。

实测数据:模板克隆到底多快

Brandur 把 pgtestdb 移植进 River 的测试套件做了真实对比。River 的测试方法论本来就被认为是速度标杆:它用自定义 helper 按 schema 隔离测试用例(比事务回滚慢,但能保留失败现场、能测 listen/notify 和跨事务回滚等场景)。

对比结果很有意思。单次初始化时间几乎一样:

方法样本数均值p90p95最大
pgtestdb 克隆46698.4ms247.4ms299.5ms465.1ms
建 schema + 迁移8199.4ms152.1ms209.0ms327.0ms

Brandur 自己都意外:"我一直以为创建新数据库的操作相对较慢,没想到 pgtestdb 居然这么快。"从原理上说,schema 比数据库轻量,所以建 schema 有天然优势;但 schema 无法克隆,每次都得跑迁移,而模板克隆把"迁移"这个最贵的步骤一次性摊销掉了。

但注意,整体测试套件时间上 schema 方案反而快 3.5 倍(14.54s vs 51.07s)。原因不是 schema 本身更快,而是 River 的 helper 有复用优化:测试跑完不删 schema,池化复用——有闲置的 schema 就直接清洗再用,不用重新生成。而 pgtestdb 当前是"用完即删",每个测试都重新克隆一次。

Brandur 在文末提到,复用优化 pgtestdb 也可以做(作为包内功能或调用方增强):100ms 初始化对万级测试的套件来说还是太慢,理想目标是 10-20ms,复用可以做到。目前 River 保留了自己的 schema 方案,因为已有速度优势,且测试 schema 隔离能顺便验证其 schema 配置功能。但他明确在文档里给 pgtestdb 加了推荐,特别是针对要做端到端测试的用户(比如"客户端插入任务 → worker 完整处理完"这种场景)。

什么时候该选 pgtestdb

综合 README 和实测,pgtestdb 的适用场景很清楚:

适合:

  • 需要每个测试有完整、独立、真实数据库的端到端测试;
  • 测试要覆盖数据库级功能(listen/notify、跨事务、锁行为);
  • 测试数量多、并行度要求高——每个测试独立库意味着可以放心 t.Parallel()
  • 想彻底摆脱 mock,又不愿自己写建库清理逻辑。

不适合:

  • 测试规模极大(上万级)且对初始化时间极度敏感——当前版本没有池化复用,可以关注后续版本;
  • 只想快速跑纯逻辑测试——事务回滚(test transactions)方案仍然是最快的;
  • 需要非管理员权限环境——pgtestdb 需要能建库删库的管理员连接。

一些实践建议

  1. 测试库永远独立:专门起一个测试用 Postgres 服务(Docker + tmpfs),跟开发库、生产库彻底分开。pgtestdb 的操作模式(建库、删库、改模板)决定了它必须拥有独立环境。
  2. 优先用 Docker Compose 而非嵌入式库:共享一个服务,迁移只跑一次;避免 embedded-postgres 的进程泄漏和每包一服务问题。
  3. 把 helper 封装好:配置和 migrator 写一次,测试里只调 NewDB(t),代码会干净很多。
  4. 利用失败保留机制:测试失败时数据库不删,连接串会打出来——这是排查数据库测试问题的利器,别忽略它。
  5. 迁移改动会自动触发重建:pgtestdb 按迁移内容哈希选模板,你改迁移后它自然会给新模板,不需要手动清理。

模板克隆不是新概念——Postgres 这个功能存在很多年了。但把它和"每个测试独立数据库"的测试模型组合起来,pgtestdb 提供了一个几乎零成本的优雅方案。如果你的项目正被数据库测试的速度和隔离问题困扰,值得花一个下午试试它。

分享到
微博Twitter

© 2026 四月

原文链接:https://www.aprilzz.com/tutorials/pgtestdb-postgres-testing