查询结果没变,数据库却慢了:用 RegreSQL 2.0 把执行计划纳入回归测试

2026-07-13 23 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:12 分钟

数据库回归测试常常只回答一个问题: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 被误判。

这体现了性能测试的重要边界:当证据不足时,输出“不稳定”或“无法判断”比制造一个确定结论更有价值。

落地时如何定义“验证通过”

绿色结果不能解释为“数据库不会出问题”。更准确的表述是:这些查询仍返回基线结果,在指定统计快照下采用了可接受的计划,并且受监控的实际指标没有突破阈值。

引入计划回归测试时,可以按以下顺序推进:

  1. 先纳入关键接口、批处理和高频查询,不必一次覆盖全部 SQL。
  2. 同时保留结果快照与计划检查,避免只顾性能而漏掉正确性。
  3. 使用经过权限控制的生产统计快照,并标注 PostgreSQL 版本与采集时间。
  4. 将缓冲区、落盘、计划翻转和基数误差作为主要门禁,把耗时作为辅助证据。
  5. 为业务上绝不能顺序扫描的大表设置显式规则,但不要对所有 Seq Scan 一刀切。
  6. 隔离带 ANALYZE 的执行环境,尤其谨慎处理写语句和高成本查询。
  7. 保留 JUnit 或 JSON 产物,让每次失败都能追溯到统计快照、查询和计划差异。

RegreSQL 2.0 的关键价值不是承诺消灭数据库事故,而是让一类原本只能在生产负载下发现的问题,提前变成可评审、可阻断、可追踪的代码变更证据。查询结果通过测试,只说明答案没变;把执行计划和实际行为一起纳入测试,才开始说明系统还能以预期方式得到这个答案。


相关推荐