原文地址:https://boringsql.com/posts/portable-stats/
无需生产数据,即可获取生产查询计划
2026-03-08 · 8分钟阅读 · Radim Marek
目录
在上一篇文章中,我们介绍了 PostgreSQL 规划器如何读取 pg_class 和 pg_statistic 来估计行数、选择连接策略以及判断索引扫描是否值得。信息很明确:当统计信息错误时,其他一切都会随之出错。
流复制提供逐位复制,因此所有副本都与主服务器共享相同的统计信息。但有一件事我们之前没有谈到。统计信息是针对生成它们的数据库集群的。填充它们的主要方式是 ANALYZE,这需要实际的数据。
PostgreSQL 18 改变了这一点。两个新函数:pg_restore_relation_stats 和 pg_restore_attribute_stats 直接将数字写入系统目录表。结合 pg_dump --statistics-only,你可以将优化器统计信息视为可部署的工件。紧凑、可移植、纯 SQL。
这个功能是由升级用例驱动的。在过去,主要版本升级通常会清空 pg_statistic,迫使你运行 ANALYZE。在大型集群上这可能需要数小时。PostgreSQL 18 的升级现在会自动传输统计信息。但这仅仅是开始。同样的逻辑让你可以从生产环境导出统计信息,并将其注入任何地方------测试数据库、本地调试,或作为 CI 流水线的一部分。
问题所在
你的 CI 数据库有 1,000 行。生产环境有 5000 万行。规划器对两者做出的决策完全不同。在 CI 中运行 EXPLAIN 无法告诉你生产环境中的计划是什么样的。这正是 RegreSQL 背后的核心理念。当规划器看到生产规模的统计信息时,在 CI 中捕获查询计划回退会可靠得多。
调试也是如此。某个查询在生产环境中很慢,你想在本地重现这个计划,但你的数据库有不同的统计信息,规划器选择了可预测的路径。移植生产统计信息可以为你提供规划器在生产环境中必须考虑的思维快照,而无需实际进入生产环境。
pg_restore_relation_stats
支持可移植 PostgreSQL 统计信息的第一个函数是 pg_restore_relation_stats。它通过可变的名/值对将表级数据直接写入 pg_class。
sql
SELECT pg_restore_relation_stats(
'schemaname', 'public',
'relname', 'orders',
'relpages', 123513::integer,
'reltuples', 50000000::real,
'relallvisible', 123513::integer,
'relallfrozen', 120000::integer
);
但这只是一个示例。让我们修改一些真实的统计信息来看看其全部价值。我们将创建一个小表,注入类似生产环境的虚假统计信息,并观察规划器改变主意。
sql
CREATE TABLE test_orders (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id integer NOT NULL,
amount numeric(10,2) NOT NULL,
status text NOT NULL DEFAULT 'pending',
created_at date NOT NULL DEFAULT CURRENT_DATE
);
INSERT INTO test_orders (customer_id, amount, status, created_at)
SELECT
(random() * 9999 + 1)::int,
(random() * 5000 + 5)::numeric(10,2),
(ARRAY['pending','shipped','delivered','cancelled'])[floor(random()*4+1)::int],
'2024-01-01'::date + (random() * 365)::int
FROM generate_series(1, 10000);
CREATE INDEX ON test_orders (created_at);
CREATE INDEX ON test_orders (status);
ANALYZE test_orders;
当你检查当前统计信息时,它具有可预测的数据。
sql
SELECT relname, relpages, reltuples
FROM pg_class WHERE relname = 'test_orders';
relname | relpages | reltuples
-------------+----------+-----------
test_orders | 74 | 10000
(1 row)
在 74 页上分布着 10,000 行,规划器选择了顺序扫描。
sql
EXPLAIN SELECT * FROM test_orders WHERE created_at > '2024-06-01';
QUERY PLAN
-----------------------------------------------------------------
Seq Scan on test_orders (cost=0.00..199.00 rows=5891 width=26)
Filter: (created_at > '2024-06-01'::date)
(2 rows)
现在注入生产规模的表统计信息:
sql
SELECT pg_restore_relation_stats(
'schemaname', 'public',
'relname', 'test_orders',
'relpages', 123513::integer,
'reltuples', 50000000::real,
'relallvisible', 123513::integer
);
你可能对结果感到惊讶。
sql
EXPLAIN SELECT * FROM test_orders WHERE created_at > '2024-06-01';
QUERY PLAN
------------------------------------------------------------------
Seq Scan on test_orders (cost=0.00..448.45 rows=17649 width=26)
Filter: (created_at > '2024-06-01'::date)
规划器仍然使用顺序计划。只有估计的行数发生了变化。为什么?如果你还记得上一篇文章,这就是列级统计信息发挥作用的地方。直方图边界仍然与我们插入的原始 10,000 行相匹配。
pg_restore_attribute_stats
此函数将列级统计信息写入 pg_statistic,即 ANALYZE 填充 MCV、直方图和相关性的同一个系统目录。
在上一节中,尽管规划器认为表有 5000 万行,我们却让规划器卡在了顺序扫描上。缺失的部分是列级统计信息。让我们从之前的地方继续,为 created_at 注入直方图边界。
sql
SELECT pg_restore_attribute_stats(
'schemaname', 'public',
'relname', 'test_orders',
'attname', 'created_at',
'inherited', false::boolean,
'null_frac', 0.0::real,
'avg_width', 4::integer,
'n_distinct', -0.05::real,
'histogram_bounds', '{2019-01-01,2019-07-01,2020-01-01,2020-07-01,2021-01-01,2021-07-01,2022-01-01,2022-07-01,2023-01-01,2023-07-01,2024-01-01}'::text,
'correlation', 0.98::real
);
现在规划器知道数据跨越了 5 年。过滤 2024 年最后 6 个月的查询覆盖了一个狭窄的切片。
sql
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)
直方图边界将数据的非 MCV 部分划分为等人口桶。如果 most_common_vals 占了数据的大部分,直方图只覆盖剩余的尾部。桶的数量由 default_statistics_target 控制(默认值为 100,意味着 101 个边界)。
计划翻转了!直方图告诉规划器数据跨越了 2019-2024 年,所以 > '2024-06-01' 匹配一个狭窄的尾部。是 5000 万行中的一小部分。之前被忽略的索引扫描现在成了显而易见的选择。表级统计信息设定了规模,列级统计信息塑造了选择性,两者共同改变了计划。
相关性统计信息告诉规划器物理行顺序与列排序顺序的接近程度。接近 1.0 的值意味着顺序访问模式------使索引扫描更便宜,因为下一行很可能在同一页或相邻页上。对于像 created_at 这样的时间序列数据,行按时间顺序插入,相关性通常非常高。
注入偏斜分布
同一个函数处理 MCV 列表。在生产环境中,你的 status 列不是均匀分布的,95% 的订单是 delivered,1.5% 是 pending。
sql
SELECT pg_restore_attribute_stats(
'schemaname', 'public',
'relname', 'test_orders',
'attname', 'status',
'inherited', false::boolean,
'null_frac', 0.0::real,
'avg_width', 9::integer,
'n_distinct', 5::real,
'most_common_vals', '{delivered,shipped,cancelled,pending,returned}'::text,
'most_common_freqs', '{0.95,0.015,0.015,0.015,0.005}'::real[]
);
你可以看到:
sql
EXPLAIN SELECT * FROM test_orders WHERE status = 'pending';
QUERY PLAN
---------------------------------------------------------------------------------------
Bitmap Heap Scan on test_orders (cost=8.93..90.42 rows=599 width=27)
Recheck Cond: (status = 'pending'::text)
-> Bitmap Index Scan on test_orders_status_idx (cost=0.00..8.78 rows=599 width=0)
Index Cond: (status = 'pending'::text)
(4 rows)
并将其与以下结果比较:
sql
EXPLAIN SELECT * FROM test_orders WHERE status = 'delivered';
QUERY PLAN
------------------------------------------------------------------
Seq Scan on test_orders (cost=0.00..448.45 rows=28458 width=27)
Filter: (status = 'delivered'::text)
(2 rows)
同一列,同一个操作符,不同的计划。规划器对 pending(1.5%,足够稀有,值得使用索引)使用位图索引扫描,对 delivered(95%,占表的大部分)使用顺序扫描。MCV 列表中的选择性比率驱动了计划的选择。
你可能注意到行估计值(599 和 28,458)低于你对 5000 万行表的预期。规划器检查实际的物理文件大小。我们的表在磁盘上只有 74 页,而不是我们注入的 123,513 页。因此,规划器按比例缩小了 reltuples 和 relpages。绝对数字缩小了,但它们之间的比率保持不变,而决定计划形态的正是这些比率。在实践中使用 pg_dump --statistics-only 时,你通常会将统计信息恢复到数据量相当的数据库中,因此估计值会自然地对齐。
pg_regresql 扩展
pg_regresql 扩展修复了这个缩放问题。它钩入规划器,使其信任注入的 relpages 值,而不是读取物理文件大小,因此即使你的测试数据库很小,成本估计也能与生产环境匹配。
pg_dump
我们介绍的函数是核心机制。对于实际操作,pg_dump 提供了你所需的一切。PostgreSQL 18 添加了三个标志。
| 标志 | 作用 |
|---|---|
--statistics |
转储统计信息(必须显式请求) |
--statistics-only |
仅转储统计信息,不转储模式或数据 |
--no-statistics |
不转储统计信息 |
当你导出生产数据库的统计信息时:
bash
pg_dump --statistics-only -d production_db > stats.sql
你会看到输出是一系列 SELECT pg_restore_relation_stats(...) 和 SELECT pg_restore_attribute_stats(...) 调用。正如我们上面解释的那样。
将生产数据转化为可测试计划的完整工作流程可能如下所示:
bash
# 1. 从生产环境转储模式
pg_dump --schema-only -d production_db > schema.sql
# 2. 从生产环境转储统计信息
pg_dump --statistics-only -d production_db > stats.sql
# 3. 创建包含模式的测试数据库
createdb test_db
psql -d test_db -f schema.sql
# 4. 加载测试数据(可选;已脱敏,最小化)
psql -d test_db -f fixtures.sql
# 5. 注入生产统计信息
psql -d test_db -f stats.sql
# 6. 查询计划现在与生产环境匹配
psql -d test_db -c "EXPLAIN SELECT * FROM test_orders WHERE status = 'pending'"
统计信息转储非常小。一个有数百个表和数千个列的数据库产生的统计信息转储小于 1MB。生产数据可能有数百 GB。描述它的统计信息却适合放在一个文本文件中。
保持注入的统计信息有效
现在你可能会问,有什么陷阱?有一个很大的陷阱:autovacuum 最终会启动并运行 ANALYZE。这会将你注入的统计信息覆盖为真实数字,你又回到了起点。
为了防止这种情况,请在你注入统计信息的表上禁用 autovacuum 分析。
sql
-- 禁用 autovacuum
ALTER TABLE test_orders SET (autovacuum_enabled = false);
-- 或者将分析阈值设置得极高,使其永远不会触发
ALTER TABLE test_orders SET (autovacuum_analyze_threshold = 2147483647);
这里要小心。
如果你还在开发环境中向这些表写入数据:运行迁移、加载测试数据、测试插入,那么每次写入都会使注入的统计信息进一步偏离现实。规划器将基于不再反映本地数据的生产分布进行规划。
对于只读查询计划测试,这正是你想要的。对于修改数据的集成测试,你可能需要在每次测试运行后重新注入统计信息。
并且,请永远不要在生产环境中这样做!
未涵盖的内容
正如我们之前看到的,尝试注入 relpages 是徒劳的,因为规划器会检查实际文件大小并按比例缩放。这限制了规划器可能估计的绝对行数。也就是说,要获得与生产环境可比的数字,你仍然需要创建可比的数据量(当谈到此功能的主要用例------恢复备份时,这不是问题)。
同样值得注意的是,用于多变量相关性、跨列组的唯一计数以及列组合的 MCV 列表的 CREATE STATISTICS 在 PostgreSQL 18 中未被涵盖。这些仍然需要在恢复后运行 ANALYZE。PostgreSQL 19 将通过 pg_restore_extended_stats() 填补这一空白。
安全性
恢复函数需要目标表上的 MAINTAIN 权限。这与 PostgreSQL 17 中引入的 ANALYZE、VACUUM、REINDEX 和 CLUSTER 所需的权限相同。
为自动化授予该权限的最简单方法:
sql
GRANT pg_maintain TO ci_service_account;
这将对数据库中的所有表授予 MAINTAIN 权限。足以让 CI 流水线注入统计信息,而无需超级用户权限。