引用信息
在上一篇文章中,我们聊了 PostgreSQL 规划器如何读取 pg_class 和 pg_statistic 来估算行数、选择连接策略、判断索引扫描是否值得。道理很直白:一旦统计信息错了,后面的所有判断都会跟着错。
流复制能做到逐位复制,所以所有副本和主服务器共享同一套统计信息。但这里有个前提之前没细说:这些统计信息是特定于生成它们的那个数据库集群的。填充统计信息的主要方式是 ANALYZE,而 ANALYZE 需要真实的数据才能运行。
PostgreSQL 18 彻底改变了这个局面。它引入了两个新函数—— pg_restore_relation_stats 和 pg_restore_attribute_stats——可以直接把统计数字写进系统目录表。配合 pg_dump --statistics-only,你现在可以把优化器统计信息当成一个可部署的制品来对待:体积小、可移植、而且是纯 SQL。
这个特性最初是为了解决大版本升级的痛点而开发的。过去,升级大版本后 pg_statistic 表会被清空,你不得不重新运行耗时可能长达数小时的 ANALYZE。PostgreSQL 18 在升级时能自动迁移统计信息了。但这只是开始——同样的机制让你可以从生产环境导出统计信息,然后注入到任何你需要的地方:测试数据库、本地调试环境,甚至是 CI 流水线里。
一、核心问题
想象一下:你的 CI 数据库里只有 1000 行数据,而生产环境有 5000 万行。规划器对这两种数据规模会做出完全不同的决策。在 CI 里跑 EXPLAIN,对于了解生产环境的真实执行计划几乎没有参考价值。这正是 RegreSQL 这类工具要解决的核心问题:只有当规划器看到生产级别的统计信息时,在 CI 中捕获查询计划回归才真正可靠。
这个道理同样适用于调试场景:某个查询在生产上很慢,你想在本地复现那个慢速计划。但如果本地数据库的统计信息和生产环境不一样,规划器就可能选择另一条执行路径。迁移生产统计信息,相当于拿到了规划器在生产环境中做决策时所依据的“数据快照”,而你完全不需要直接访问生产数据库。
二、pg_restore_relation_stats
实现可移植 PostgreSQL 统计信息的第一个函数是 pg_restore_relation_stats。它的作用是把表级别的统计数据,以可变参数名/值对的形式,直接写入 pg_class 系统表。
SELECT pg_restore_relation_stats(
'schemaname', 'public',
'relname', 'orders',
'relpages', 123513::integer,
'reltuples', 50000000::real,
'relallvisible', 123513::integer,
'relallfrozen', 120000::integer
);
上面是个例子。为了看清实际效果,我们来动点真格的:创建一个小表,给它注入伪造的生产级统计信息,然后观察规划器的决策是如何变化的。
首先,创建并填充一个测试表:
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;
查看当前的统计信息,结果符合预期:
SELECT relname, relpages, reltuples
FROM pg_class WHERE relname = 'test_orders';
relname | relpages | reltuples
-------------+----------+-----------
test_orders | 74 | 10000
(1 row)
10000 行,74 个数据页。现在,对一个简单的查询做 EXPLAIN,规划器选择了顺序扫描:
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)
接下来,注入我们“虚构”的生产规模表级统计信息:
SELECT pg_restore_relation_stats(
'schemaname', 'public',
'relname', 'test_orders',
'relpages', 123513::integer,
'reltuples', 50000000::real,
'relallvisible', 123513::integer
);
再跑一次 EXPLAIN,结果可能有点出乎意料:
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)
规划器仍然使用了顺序扫描,只是估算的行数变了。为什么?正如上一篇文章所说,这里缺失的关键一环是列级统计信息——created_at 列的直方图边界仍然匹配我们最初插入的那 10000 行数据的真实范围。
三、pg_restore_attribute_stats
这个函数负责将列级统计信息写入 pg_statistic 系统表——也就是 ANALYZE 命令填充 MCV(最常见值列表)、直方图和相关性的同一张表。
上一节中,即使规划器相信表有 5000 万行,它依然卡在顺序扫描上。缺失的部分就是列级统计信息。我们继续上一节的例子,为 created_at 列注入一个跨越数年的直方图边界:
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年。那么,查询 created_at > '2024-06-01' 就只会命中一个很窄的时间切片:
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)
执行计划翻转了! 直方图告诉规划器数据从2019年跨越到2024年,因此 > '2024-06-01' 只命中尾部的很小一部分——5000万行中的一小撮。之前被忽略的索引扫描现在成了显而易见的选择。表级统计信息设定了数据规模,而列级统计信息则塑造了数据的选择性,两者共同作用,彻底改变了执行计划。
另外,correlation(相关性)统计信息告诉规划器物理行顺序与列值排序顺序的吻合程度。接近 1.0 的值意味着顺序访问模式——这让索引扫描的成本更低,因为下一行很可能就在同一页或相邻页上。对于像 created_at 这样按时间顺序插入的时间序列数据,相关性通常非常高。
四、注入倾斜分布
同一个函数也能处理 MCV 列表。在实际生产场景中,status 这类列的分布往往是不均匀的。假设 95% 的订单状态是 delivered,只有 1.5% 是 pending:
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[]
);
现在来做个对比。查询稀有的 pending 状态:
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)
再查询占绝大多数的 delivered 状态:
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)
同一张表,同一列,同一个等值操作符,却产生了不同的执行计划。规划器对仅占 1.5% 的 pending 使用了位图索引扫描(因为足够稀少,值得走索引),而对占据 95% 的 delivered 则使用了顺序扫描(几乎要扫全表了)。正是来自 MCV 列表的选择性比例驱动了规划器的决策。
你可能注意到了,估算的行数(599 和 28458)远低于我们注入的 5000 万行这个表级数据。这是因为规划器会检查实际的物理文件大小。我们的表现在磁盘上只有 74 个页面,而不是注入的 123513 个,所以规划器会按比例缩放 reltuples。绝对数字虽然缩小了,但它们之间的比例保持正确,而决定计划形态的正是这些比例关系。在实际使用 pg_dump --statistics-only 时,你通常会把统计信息恢复到数据量相当的数据库中,因此估算值自然会对齐。
五、pg_dump
我们上面演示的函数是底层机制。对于生产环境操作,pg_dump 工具提供了你所需的一切。PostgreSQL 18 为 pg_dump 新增了三个相关标志:
| 标志 |
效果 |
--statistics |
导出统计信息(需要显式指定) |
--statistics-only |
仅导出统计信息,不导出 schema 或数据 |
--no-statistics |
不导出统计信息 |
从生产数据库导出统计信息非常简单:
pg_dump --statistics-only -d production_db > stats.sql
输出的 stats.sql 文件里,就是一系列 SELECT pg_restore_relation_stats(...) 和 SELECT pg_restore_attribute_stats(...) 的调用,就像我们上面手动执行的那样。
将一个完整的工作流串起来,把生产数据“转化”为可测试的执行计划,可能是这样的:
# 1. 从生产环境导出schema
pg_dump --schema-only -d production_db > schema.sql
# 2. 从生产环境导出统计信息
pg_dump --statistics-only -d production_db > stats.sql
# 3. 创建测试数据库并导入schema
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 analyze:
-- 彻底禁用 autovacuum
ALTER TABLE test_orders SET (autovacuum_enabled = false);
-- 或者,将 analyze 触发阈值设得极高,使其永远不会触发
ALTER TABLE test_orders SET (autovacuum_analyze_threshold = 2147483647);
这里请务必谨慎操作。
如果你在这些表上同时进行数据写入(比如执行数据迁移、加载测试夹具、测试插入操作),那么注入的统计信息会随着每次写入而越来越偏离本地数据的真实情况。规划器将基于一个不再反映本地数据分布的生产环境快照来做决策。
对于只读的查询计划测试场景,这正是你想要的。但对于会修改数据的集成测试,你可能需要在每次测试运行后重新注入一次统计信息。
请永远不要在生产环境执行以上禁用 autovacuum 的操作!
七、未覆盖的内容
如前所示,注入 relpages(关系页数)的意义不大,因为规划器会检查实际文件大小并按比例缩放行数估算。这限制了规划器能够估算的绝对行数上限。也就是说,要获得与生产环境可比的绝对行数估算,你仍然需要创建规模相当的数据量(不过,对于该功能的主要场景——恢复备份后的统计信息注入,这不是问题)。
还需要注意的是,CREATE STATISTICS 语句创建的扩展统计信息——用于多列相关性、列组的唯一值计数以及列组合的 MCV 列表——在 PostgreSQL 18 中尚未被涵盖。这些统计信息仍然需要在恢复后运行 ANALYZE 来生成。预计 PostgreSQL 19 会通过 pg_restore_extended_stats() 函数来填补这一空白。
八、安全与权限
使用这些恢复函数需要在目标表上拥有 MAINTAIN 权限。这与运行 ANALYZE、VACUUM、REINDEX 和 CLUSTER 命令所需的权限相同,该权限是在 PostgreSQL 17 中引入的。
为自动化流程(如 CI 服务)授权的最简单方式是:
GRANT pg_maintain TO ci_service_account;
这授予了对数据库中所有表的 MAINTAIN 权限,足以让 CI 流水线在不需要超级用户权限的情况下注入统计信息。
参考来源
希望这篇对 PostgreSQL 18 可移植统计信息特性的深度解析,能帮助你更好地在测试和CI环境中模拟生产级的查询优化行为。如果你想了解更多类似的数据库内核原理或实战技巧,欢迎来 云栈社区 交流讨论。