
当你的测试库只有 1 万行数据,而生产环境有 5000 万行时,EXPLAIN 的输出还能信吗?
即使你注入了生产环境的 relpages,规划器也会无视它。
为什么呢?因为规划器会直接调用 smgrnblocks() 读取磁盘上的实际页面数,然后按实际文件大小等比缩放 reltuples。
举个具体例子:你的表在磁盘上是 74 页,但 pg_class.relpages 说它是 123,513 页。规划器会怎么做?它会用 74/123513 这个比例把 reltuples 缩小,选择比(selectivity)虽然对了,但绝对成本估算全偏了。
对于日常调试单个查询,这通常没啥问题。但如果你在做自动化回归测试,要跨运行对比 EXPLAIN 成本——那就麻烦了。成本阈值 2× 在基线数据是按假数字缩放过的前提下,意义完全变了。
PostgreSQL 规划器调用 get_relation_info()(在 plancat.c 中)时,会委托给 estimate_rel_size(),最终落到 tableam.c 的 table_block_relation_estimate_size()。
关键代码在这里:
curpages = RelationGetNumberOfBlocks(rel);实际页面数是从存储管理器读出来的。然后函数用 pg_class 中的数据计算元组密度(reltuples / relpages),再乘以 curpages 来估算元组数。 所以 pg_class.reltuples 并没有被忽略——它被缩放到匹配真实文件大小了。 这个设计在日常运行时是合理的:catalog 可能过期,但文件系统永远是最新的。 索引也一样——规划器会从磁盘读取它们的实际大小。
这个扩展 hook 到了 get_relation_info_hook——一个在 PostgreSQL 读取完物理文件统计信息后执行的规划器回调。
hook 做的事情很简单:用 pg_class 中存储的值替换基于文件的数字:
rel->pages ← pg_class.relpages
rel->tuples ← pg_class.reltuples
rel->allvisfrac ← pg_class.relallvisible / pg_class.relpages对 rel->indexlist 中的每个索引也做同样处理。每个索引的页面数和元组数都从索引自己的 pg_class 条目覆盖。
守卫条件很简单:
relpages == 0(空表或从未分析过)→ 跳过reltuples == -1(从未分析过)→ 跳过
只有被 ANALYZE 过或注入过统计信息的表才会触发 hook。从源码编译,使用 PGXS:
cd pg_ext
make
make install零配置:不需要 GUCs、不需要后台 worker、不需要共享内存。
在你的会话中加载扩展:
LOAD 'pg_regresql';就这么多。这个会话中的每一个 EXPLAIN 现在都会使用 catalog 统计信息而不是文件大小。没有函数要调用,没有表要配置。
你也可以按数据库加载,在 postgresql.conf 中添加 session_preload_libraries:
ALTER DATABASE test_db SET session_preload_libraries = 'pg_regresql';用之前文章的 test_orders 例子:实际 10,000 行,注入了生产环境的统计信息——声称有 5000 万行、123,513 页。
EXPLAIN SELECT * FROM test_orders WHERE created_at > '2024-06-01'; QUERY PLAN
----------------------------------------------------------------------------------------------------
Index Scan using test_orders_created_at_idx on test_orders (cost=0.29..153.21 rows=6340 width=26)
Index Cond: (created_at > '2024-06-01'::date)计划形状是对的(因为有直方图,所以走了索引扫描),但行数估算只有 6,340。对于一个 5000 万行的表,过滤条件覆盖直方图大约 10% 的范围,预期估算应该是百万级别。
规划器看到了磁盘上的 74 个真实页面,把 reltuples 缩放到约 30,000,再应用选择性比例。比例保留了,但绝对数字错了。
LOAD 'pg_regresql';
EXPLAIN SELECT * FROM test_orders WHERE created_at > '2024-06-01'; QUERY PLAN
-----------------------------------------------------------------------------------------------------------
Index Scan using test_orders_created_at_idx on test_orders (cost=0.29..153212.27 rows=10791836 width=27)
Index Cond: (created_at > '2024-06-01'::date)
(2 rows)成本数字现在反映了完整的 5000 万行。行数估算从 6,340 变成了 10,791,836——这才对。
如果你在对比不同 schema 版本之间的 EXPLAIN 成本(RegreSQL 做的就是这个),你需要绝对数字稳定且真实。
有缩放行为的情况下,基线成本跟你的测试库大小成正比,而不是生产环境。一个在生产中成本翻倍的迁移,在 CI 中可能只显示 1.3× 增长——因为缩小后的数字压缩了范围。
有时候执行计划形状本身就会因为绝对数字而改变。有多个 JOIN 的查询,在规划器看到 5000 万行 vs 3 万行时,可能得到完全不同的 JOIN 顺序。因为 hash join 和 nested loop 之间的成本交叉点取决于绝对行数,不只是比例。
allvisfrac(全可见页面的比例)对索引仅扫描的成本估算很重要。没有 hook 时,allvisfrac 是用真实的 relallvisible catalog 值除以真实页面数计算的。
如果注入的统计信息中 relallvisible 是 120,000,但实际页面数只有 74,比例会被钳位到 1.0,规划器就会高估索引仅扫描有多便宜。hook 用注入的 relpages 作为分母,修正了这个问题。
列级统计信息和 ANALYZE 行为完全不变。扩展只影响规划器如何读取表和索引大小。
有一点值得注意:EXPLAIN ANALYZE 仍然会显示真实(小规模)数据的实际行数。扩展改变的是规划器的成本估算,不是查询执行本身。
把 PostgreSQL 18 的可移植统计信息和 pg_regresql 结合起来,完整工作流长这样:
# 1. 从生产环境导出 schema 和统计信息
pg_dump --schema-only -d production_db > schema.sql
pg_dump --statistics-only -d production_db > stats.sql
# 2. 创建测试数据库
createdb test_db
psql -d test_db -f schema.sql
# 3. 加载最小 fixture 数据(可选)
psql -d test_db -f fixtures.sql
# 4. 注入生产环境统计信息
psql -d test_db -f stats.sql
# 5. 安装 pg_regresql 并防止统计信息被覆盖
psql -d test_db <<SQL
ALTER DATABASE test_db SET session_preload_libraries = 'pg_regresql';
ALTER TABLE orders SET (autovacuum_enabled = false);
-- 对其他表重复上面的操作
SQL
# 6. 重新连接并验证(计划现在匹配生产环境)
psql -d test_db -c "EXPLAIN SELECT * FROM orders WHERE status = 'pending'"扩展支持 PostgreSQL 13 到 18。
可移植统计信息函数(pg_restore_relation_stats、pg_restore_attribute_stats)需要 PostgreSQL 18,但 pg_regresql 本身适用于任何写入 pg_class 的方法,包括旧版本的直接 catalog 更新。
PostgreSQL 19 需要注意:pg_regresql 使用的 get_relation_info_hook 已经被替换为 build_simple_rel_hook。新 hook 运行时机稍晚、参数不同,但覆盖逻辑不变。
不要在生产环境使用这个扩展!
扩展让规划器"无视现实"——这正是你用注入统计信息做测试时想要的。但在生产环境,你需要规划器看到实际文件大小,这样才能适应数据增长、膨胀和 vacuum 活动。
pg_regresql 只应该出现在 dev/test 和 CI 数据库中。