数据库回归测试常常只回答一个问题:SQL 返回的行是否正确。但生产事故还有另一种典型形态:结果完全正确,执行方式却从索引扫描悄悄变成全表扫描。测试全部通过,接口则随着数据增长逐渐超时。
RegreSQL 2.0 将测试边界从“返回了什么”扩展到“怎样返回”:除了比较结果集,还检查执行计划、实际读取的缓冲区、磁盘溢出、节点实际行数和基数估计误差,并允许使用生产环境的统计信息在本地重现规划器决策。
正确结果不等于可接受的执行方式
假设应用通过 customer_id 查询某位客户的订单:
-- orders-by-customer.sql
SELECT id, total
FROM orders
WHERE customer_id = :cid
ORDER BY id;
表上原本存在索引:
CREATE INDEX orders_customer_id_idx ON orders (customer_id);
如果一次迁移误删了这个索引,SQL 文本、返回行以及排序都可能保持不变。传统结果快照测试仍然是绿色的,但 PostgreSQL 可能把位图堆扫描或索引扫描改成顺序扫描。
来源案例中的计划检查发现,查询读取的缓冲区从 109 个增加到 3898 个,增幅超过 34 倍,同时明确指出 orders 表从 Bitmap Heap Scan 变成了 Seq Scan。这类差异适合直接出现在拉取请求中,因为评审者看到的不再是笼统的“性能下降”,而是具体的表、计划节点和实际 I/O 变化。
结果快照与计划检查解决的是不同问题:
- 结果差异捕获错误行、缺失行和顺序变化。
- 计划形态捕获索引扫描变成顺序扫描、连接顺序改变等执行策略漂移。
- 实际指标捕获缓冲区读取暴增、排序或哈希落盘,以及节点行数异常。
- 基数误差揭示规划器预测与真实数据之间正在扩大的裂缝。
这些检查不应互相替代。一个查询可能返回正确结果,却以不可扩展的方式完成;也可能执行得很快,却返回错误结果。
为什么空开发库会给出错误的安全感
PostgreSQL 规划器根据表规模和统计分布选择执行计划。开发库只有几百行时,顺序扫描可能确实比访问索引便宜;生产表拥有数千万行时,同一个选择则可能拖垮接口。
因此,仅在小数据集上运行 EXPLAIN,最多能证明规划器为小数据选择了合理方案。它不能说明生产环境会选择什么。
解决办法不一定是复制生产数据。规划器真正依赖的是行数、直方图和最常见值等统计信息。PostgreSQL 18 可以单独导出统计信息:
pg_dump \
--statistics-only \
--dbname="$PRODUCTION_DATABASE_URL" \
--file=production-stats.sql
这里的 PRODUCTION_DATABASE_URL 需要替换为具备必要只读权限的连接串。统计文件仍可能泄露数据分布特征,应像其他生产派生物一样控制访问权限,不要默认提交到公开仓库。
随后可以在本地数据库上让 RegreSQL 使用这份统计快照:
export DATABASE_URL='postgres://localhost/yourdb'
regresql test --stats production-stats.sql
这样,即使本地表中只有少量测试行,EXPLAIN 也能基于更接近生产环境的分布做规划。需要注意的是,统计信息不是生产数据的完整替身:硬件、PostgreSQL 配置、扩展、参数值和数据相关性仍可能影响最终行为。
门禁应该依赖实际行为,而不是估算成本
规划器成本是估算值。恰恰在统计失真或基数估计恶化时,成本也最可能误导测试。RegreSQL 2.0 因而通过带 ANALYZE 的基线记录实际行为:
export DATABASE_URL='postgres://localhost/yourdb'
regresql baseline --analyze
regresql test
执行前要确认测试 SQL 可以安全运行。EXPLAIN ANALYZE 会真正执行查询;对于写操作,应使用隔离数据库、事务回滚或只读语句,不能把生产写入查询直接交给测试工具。
基线可以关注以下信号:
- 实际读取的缓冲区数量。
- Sort 或 Hash 节点是否溢出到磁盘。
- 每个节点实际流过的行数。
- 估算行数与实际行数之间的 q-error。
- 执行计划是否从索引相关扫描切换成顺序扫描。
例如,估算误差从 2 倍扩大到 200 倍,即使当前延迟尚未明显上升,也说明计划对数据变化十分敏感。这个信号往往比估算成本更早暴露风险。
墙上时钟时间则不适合作为刚性门禁。缓存冷热、机器负载和后台任务都会制造噪声。RegreSQL 2.0 会交错运行样本、比较每条查询的中位数,并根据样本置换推导噪声阈值;无法超过自身噪声阈值的变化会被归入不稳定结果,而不是直接让构建失败。时间可以提供证据,但不决定退出码。
可以这样把计划回归接入 CI
下面是一个可改造的 GitHub Actions 示例。假设仓库已经包含 RegreSQL 项目文件和测试 SQL,并且 CI 环境能够安装对应命令行工具。请根据项目实际安装方式修改安装步骤和 PostgreSQL 版本。
name: SQL regression
on:
pull_request:
push:
branches: [main]
jobs:
regresql:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U app -d app_test"
--health-interval=5s
--health-timeout=5s
--health-retries=10
env:
DATABASE_URL: postgres://app:app@localhost:5432/app_test
steps:
- uses: actions/checkout@v4
- name: Install RegreSQL
run: |
# Replace this line with the installation command used by your project.
regresql --version
- name: Load schema and fixtures
run: |
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f db/schema.sql
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f db/fixtures.sql
- name: Run result and plan regression tests
run: |
regresql test --stats ci/production-stats.sql
RegreSQL 测试失败时会返回非零退出码,并可输出 JUnit、GitHub Actions 或 JSON 格式的机器可读证据。由此,索引被误删或计划发生危险翻转时,合并请求会像遇到错误结果一样被阻止。
统计快照也需要治理。可以这样实践:定期由受控流水线更新快照,记录来源环境、PostgreSQL 版本和采集时间,并在评审中单独检查统计变化。否则,陈旧快照只会把过去的生产分布伪装成现在的现实。
跨版本比较需要承认“不确定”
计划差异不一定来自代码变化。ANALYZE 使用抽样统计;对于位于连接顺序临界点的查询,少量采样差异就可能让两个数据库选择不同计划。如果两个 PostgreSQL 构建分别分析各自的数据副本,测试可能把采样噪声误报成版本回归。
RegreSQL 2.0 的处理方式包含两层约束:向两边注入相同统计信息,让剩余差异更可能来自代码;多次重新执行基线侧的 ANALYZE,剔除本身就会摇摆的查询。只有同时通过这些可信度检查的差异才值得报告。
同样的机制也能用于测试规划器本身:
- 建立跨 PostgreSQL 版本的查询评分板。
- 切换理论上不应改变结果的优化项,执行变形测试,检查结果是否变化。
- 只接纳在不同计划下仍能稳定返回相同结果的查询,避免带并列值的无确定顺序
LIMIT被误判。
这体现了性能测试的重要边界:当证据不足时,输出“不稳定”或“无法判断”比制造一个确定结论更有价值。
落地时如何定义“验证通过”
绿色结果不能解释为“数据库不会出问题”。更准确的表述是:这些查询仍返回基线结果,在指定统计快照下采用了可接受的计划,并且受监控的实际指标没有突破阈值。
引入计划回归测试时,可以按以下顺序推进:
- 先纳入关键接口、批处理和高频查询,不必一次覆盖全部 SQL。
- 同时保留结果快照与计划检查,避免只顾性能而漏掉正确性。
- 使用经过权限控制的生产统计快照,并标注 PostgreSQL 版本与采集时间。
- 将缓冲区、落盘、计划翻转和基数误差作为主要门禁,把耗时作为辅助证据。
- 为业务上绝不能顺序扫描的大表设置显式规则,但不要对所有
Seq Scan一刀切。 - 隔离带
ANALYZE的执行环境,尤其谨慎处理写语句和高成本查询。 - 保留 JUnit 或 JSON 产物,让每次失败都能追溯到统计快照、查询和计划差异。
RegreSQL 2.0 的关键价值不是承诺消灭数据库事故,而是让一类原本只能在生产负载下发现的问题,提前变成可评审、可阻断、可追踪的代码变更证据。查询结果通过测试,只说明答案没变;把执行计划和实际行为一起纳入测试,才开始说明系统还能以预期方式得到这个答案。