原文:https://boringsql.com/posts/regresql20/
测试通过了。计划却没通过。
2026-07-12
· 9分钟 · Radim Marek
目录
TL;DR - RegreSQL 1.0 测试的是你的查询是否返回了正确的行。2.0 版本则测试它们是否以正确的方式返回,并且它会依据生产环境的真实统计信息进行检查,而不是你那空荡荡的、会撒谎的开发数据库。
一次迁移清理删除了一个没人认为会承重的索引。所有测试都通过了:相同的行,相同的顺序,一片绿色。三天后,API 开始在一个未曾改动过一个字符的查询上超时,因为查询规划器已经悄悄地将它从索引扫描切换为对一张一直在增长的表进行顺序扫描。
RegreSQL 的第一个版本也会让那次变更通过测试。它测试的是你的查询返回了什么:运行查询,将结果行与提交的预期文件进行差异比较,输出变化时变红。这能捕获到那些现在返回了错误行的查询。但它对一个返回了正确行却用了错误方式的查询却无话可说,而这恰恰是导致数据库宕机的大部分原因。
2.0 版本测试的就是这个。
测试执行计划,而不仅仅是测试结果行
这是在笔记本电脑上的一次失败示例。一张 orders 表,一个 customer_id 上的索引,以及一个读取单个客户订单的查询:
-- orders-by-customer.sql
select id, total
from orders
where customer_id = :cid
order by id;
建立基线并运行测试。绿色通过:
$ regresql test
✓ 2 passing
现在,像那次迁移所做的那样删除索引,再次运行相同的测试:
$ regresql test
✓ 1 passing
✗ 1 failing
FAILING:
orders-by-customer.1.buffers (3898 > 109 * 102%, +3476.1%)
Expected buffers: 109
Actual buffers: 3898 (+3476.1%)
Cost (info): 7962.41 (baseline: 421.03)
⚠️ Table 'orders': Bitmap Heap Scan → Seq Scan
这是从开始到失败的完整循环,按实际运行过程展示:
动画终端录制:regresql init,baseline --analyze,以及 regresql test 通过,显示 2 个测试通过;然后一个迁移删除了 orders_customer_id_idx,下一次 regresql test 失败,显示 orders-by-customer.1.buffers 读取数为 3898,而预期为 109(+3476.1%),并且表 'orders' 从 Bitmap Heap Scan 变为 Seq Scan
输出检查仍然通过;结果行没有变化。计划检查捕获了输出检查无法捕获的问题:相同的查询,返回相同的结果,现在却执行顺序扫描而不是之前使用的位图索引扫描,读取的缓冲区数量增加了三十五倍。这次失败会阻止合并。并且差异信息会指明表名和确切的变化,其格式与你已经在代码审查中阅读过的行差异相同。计划偏移不再是你只能在生产环境中发现的问题,而是变成了你在拉取请求中就能审查的内容。
空数据库是个骗子
那个演示之所以能捕获到回归,是因为表足够大,索引才显得重要。在你的开发数据库中,情况通常并非如此。在那里运行 EXPLAIN,它会说每个查询都很好,而且它并非有意撒谎:在只有几百行的情况下,最廉价的计划确实是顺序扫描,规划器就会选择它。生产环境的规划器读取不同的统计信息,会选择不同的计划。在小数据上全绿的测试套件只能证明你的查询在小数据上是正确的,而对于生产环境将运行的执行计划则一无所知。这个差距,以及为什么代码审查无法发现它,本身就是一个故事。
你无法将生产数据复制到你的笔记本电脑上。你也不需要这么做。你需要的是规划器所读取的内容:行数、直方图、最常见值。PostgreSQL 18 可以自行转储和加载这些统计信息(使用 pg_dump --statistics-only),而 RegreSQL 2.0 会注入这些统计信息,因此,在你的笔记本电脑上针对十行数据运行 EXPLAIN 时,看到的是真实表的分布情况:
regresql test --stats production-stats.sql
现在,你建立基线的计划就是生产环境会选择的计划,而计划回归在你将其部署到服务器之前,就会在你的笔记本电脑上暴露出来。这就是测试你的查询与针对现实情况进行测试之间的区别。
基于已发生的事实进行把关,而非基于猜测
成本估算是规划器的"意见"。这些意见会变化,而且它们最可能发生变化的时候,恰恰是计划即将出错的时候,因为糟糕的计划通常始于糟糕的估算。基于估算成本来把关测试,意味着要相信那个最先出问题的数字会在出问题时告诉你。
因此,2.0 版本基于可测量的行为进行把关。regresql baseline --analyze 会真实地运行查询,并记录实际发生的情况:实际读取了多少缓冲区,排序或哈希是否溢出到磁盘,实际有多少行流经每个节点,以及基数误差------即规划器预测值与实际值之间的比率("q-error")。一个估算偏差为 2 倍,而现在偏差为 200 倍的查询,其计划可能正处在因一次数据变更而崩溃的边缘,而行数统计信息会比成本估算更早地表明这一点。基线存储的是实际值;测试比较的也是实际值;估算值永远不会成为判决的依据。
执行时间是每个人都关心的实际指标,但它也是最会撒谎的,因为它会随着缓存状态、机器上的其他负载以及运气而变化。2.0 会测量它,并将其视为证据,而非定论:交错运行,计算每个查询的中位数,并通过置换样本推导出噪声阈值,因此只有当查询超过其自身的测量噪声时,才会被认定为变慢。任何无法超过阈值的结果都会被归入"不稳定"桶中,而不会导致构建失败。执行时间只提供参考信息;它永远不会决定退出码。
知道何时无法判断
这是花费时间最长才做对的部分,也是我最确信的部分。
将 RegreSQL 指向两个不同 PostgreSQL 版本上的同一语料库,它会向你展示两者之间计划不同的每一个查询。早期,有一个查询看起来像是一个明显的回归:较新版本读取的缓冲区几乎增加了三倍,而且每次运行都确定性地复现。它具备真正发现的全部特征。
但它并不是。两个版本都对各自的数据副本运行了 ANALYZE,而该查询恰好处于一个连接顺序的刀锋边缘,少数几个被抽样的行就会导致结果倾向于某一边。将其中一个版本的精确统计信息注入到另一个版本中,它们的计划就完全相同了。重新运行 ANALYZE,"回归"现象就会转移到另一个版本上。这只是采样噪声,而一个不够审慎的工具会将其报告为回归。
因此,2.0 版本不会在其不信任的统计信息上做出跨版本的断言。它会向两个版本注入相同的统计信息,因此任何剩余差异都源于代码而非运气,并且它会多次对基线重新执行 ANALYZE,以剔除那些计划本身就像抛硬币一样的查询。只有当差异能经受住这两种考验时,才会被报告。在那次调查中,诚实的输出是零回归,而零才是正确的答案。
一个只会说"发现了问题"的工具,是一个你最终不会再相信的工具。在一个充斥着各种自信满满的仪表盘的领域里,"根据这些数据,我无法告诉你那个结论"是更有用的一句话,而在这里,这是一个头等的答案,而不是一句道歉。
测试规划器本身
沿着这些问题追查下去,你最终会触及 PostgreSQL 自身的测试套件。pg_regress,这个守护数据库数十年的测试框架,只检查一件事:查询是否返回了预期的文本。它对计划、成本或基数误差只字不提,因为它将所有这些都折叠到一个单一的 .out 文本文件中,要么匹配,要么不匹配。它从设计上就只关注正确性,而标准工具链中没有任何东西可以衡量规划器的变更是否让真实查询变得更好或更差。
上面所有将结果、计划形态、可测量的实际值和基数误差分层分离,以便每一层都能独立判断的方法,正是回答那个问题所需要的东西。这也是为什么 2.0 版本也包含了一些功能,它们指向 PostgreSQL 本身而非你的应用程序时才更有意义:
带有上述信任过滤器的跨版本记分板,这样规划器补丁就可以针对语料库进行衡量,而不会淹没在 ANALYZE 的噪音中。
一项蜕变检查:翻转一个应该保证结果不变的优化(如急切聚合、记忆化、增量排序),并确认结果行没有变化。当它们发生变化时,你就发现了一个优化器中的结果错误 bug,而无需第二个数据库来进行对比。
确定性准入机制,只保留那些在不同计划下结果都稳定的查询,这样在有并列行的 LIMIT 查询就不会伪装成回归。
这是一条更长的路,也是一条开放的路。但是,同样的机制,既能告诉你你的应用程序查询变慢了,也能告诉 PostgreSQL 开发者他们的补丁是否让一千个真实查询变慢了,而这是该项目目前无法在每次变更时看到的。RegreSQL 最初是作为一种测试你编写的查询的方式。事实证明,要诚实地做到这一点,已经完成了通往底层规划器测试工具的大部分路程。
留下证据
如果这一切只在你自己的笔记本电脑上运行,那就毫无用处。regresql test 在失败时会以非零状态退出,并输出 JUnit、GitHub Actions 或 JSON 格式的结果,并且它会读取 DATABASE_URL,因此同一个项目可以在 CI 数据库上运行,而无需编辑已提交的配置。计划变更导致构建失败,就像结果行错误变更一样,并且运行后会留下机器可读的记录,说明针对哪个快照检查了什么。在编辑器中给你建议的检查,与阻止合并的检查是同一个。
你还可以更进一步,指定那些永远不允许进行顺序扫描的表,并让构建直接因此而失败。但是哪些表属于此类,以及这些信号中哪些是发布阻断器,哪些可以忽略,这是规划器所不具备、也永远无法做出的判断。这部分是个更长的故事,需要另当别论。
"已验证"意味着什么
RegreSQL 2.0 对这个词的使用非常谨慎。它不保证你的数据库是安全的。它验证它所能测量的内容,并在无法验证时明确说明,而在那个关键时刻,这意味着不报告任何内容。一次绿色的 RegreSQL 运行并不意味着"什么都不会出问题"。它意味着"这些查询返回了它们之前返回的结果,以它们之前计划的方式执行,并且是基于实际适用的统计信息"。这是一个比大多数工具所做的更小的承诺,但也是一个你能信守的承诺。
它可以在你的笔记本电脑上运行,使用你已经拥有的数据库,无需生产访问权限:
regresql init postgres://localhost/yourdb
从那里开始,SQL 语句会通过该测试框架,就像你的代码已经通过类型检查器一样。入门指南会引导你完成整个流程,从零开始,到在 CI 中实现计划检查,大约需要十分钟。