外观
执行计划漂移排查实录:ANALYZE 与统计目标调优
问题背景:同一条 SQL,为什么昨天走索引今天 Seq Scan
线上最棘手的性能问题,往往不是 SQL 本身写法错误,而是执行计划漂移:同样的语句、同样的索引,数据库昨天选择 Index Scan,今天突然改成 Seq Scan 或错误的 Join 顺序,响应时间从几十毫秒飙升到数秒。
PostgreSQL 优化器是基于成本的优化器(CBO),它并不实时读取全表数据,而是依赖 pg_statistic 中保存的统计信息估算行数。估算行数与真实行数偏差过大时,成本模型就会选错计划。常见诱因包括:大批量写入后未及时 ANALYZE、数据分布严重倾斜、默认统计目标对高基数列不够精细,以及 autovacuum 触发阈值不适配大表。
下面通过一个可复现实验,完整演示计划漂移的发现、定位与修复过程。
方案对比:手动 ANALYZE 还是调大统计目标
处理统计信息问题通常有三类手段,适用场景不同:
- 手动
ANALYZE table:立即刷新统计信息,适合批量导入、数据修复后的紧急处置,成本低、见效快。 - 调整
default_statistics_target:全局改变采样桶数量(默认 100),影响所有列,调得过大将拉长 ANALYZE 时间并增加pg_statistic占用。 - 设置列级
STATISTICS:仅对倾斜列、关联列或高频过滤列提高精度,是最精准的做法,可设 0 到 10000。
排查的总体流程是:先用 EXPLAIN (ANALYZE, BUFFERS) 对比估算行数与实际行数,再查 pg_stats 确认统计信息新鲜度与分布,最后决定刷新统计或提高统计目标。
实操步骤:构造一次真实的统计信息误判
第一步:建立倾斜数据表
sql
-- 构造订单表:status 数据高度倾斜,绝大多数为 'paid'
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 写入 100 万行已支付订单
INSERT INTO orders (user_id, status, created_at)
SELECT g, 'paid', now() - (g % 720) * interval '1 hour'
FROM generate_series(1, 1000000) AS g;
-- 仅写入 100 行退款订单
INSERT INTO orders (user_id, status, created_at)
SELECT g, 'refund', now()
FROM generate_series(1, 100) AS g;
CREATE INDEX idx_orders_status ON orders(status);
ANALYZE orders;此时查询 status = 'refund' 应走索引,因为条件选择率极低。
第二步:观察错误估算与计划变化
先查看优化器对不同取值的行数估算:
sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE status = 'refund';正常情况下,rows=100 附近的估算会触发 Index Scan。随后模拟业务批量把大量订单改为退款,但不执行 ANALYZE:
sql
-- 批量更新:refund 从 100 行激增到 20 万行
UPDATE orders
SET status = 'refund'
WHERE id <= 199900 AND status = 'paid';
-- 注意:此时不手动 ANALYZE,模拟统计信息滞后
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'refund';计划中常见的矛盾是 rows=... 估算很小,但 actual rows=200000。优化器误以为索引回表成本低,实际大量回表反而更慢;在另一些参数组合下,它也可能对高频值错误选择 Seq Scan。判断核心不是“索引一定更好”,而是估算行数与实际行数是否同数量级。
第三步:检查统计信息新鲜度
sql
-- 查看最后一次手动/自动统计与分析时间
SELECT
relname,
n_live_tup,
n_dead_tup,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'orders';
-- 查看 status 列的常见值、频率与直方图边界
SELECT
tablename,
attname,
n_distinct,
most_common_vals,
most_common_freqs,
null_frac
FROM pg_stats
WHERE tablename = 'orders'
AND attname = 'status';如果 last_autoanalyze 停留在更新之前,或 most_common_freqs 中 refund 频率仍接近 0,就可以确认计划漂移来自统计信息滞后。
第四步:刷新统计并验证计划
sql
-- 立即收集全表统计信息
ANALYZE orders;
-- 再次验证估算行数是否接近实际值
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'refund';ANALYZE 后,优化器能识别 refund 已不是稀有值,从而重新评估 Seq Scan、Index Scan 或 Bitmap Heap Scan 的成本。
第五步:对高基数倾斜列设置列级统计目标
默认 100 个桶对状态码这类低基数字段足够,但对时间范围、地区、租户 ID、标签等列可能过粗。不要一上来全局调参,应先针对问题列提升精度:
sql
-- 将 status 与 created_at 的统计目标提高到 500
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ALTER TABLE orders ALTER COLUMN created_at SET STATISTICS 500;
-- 下一次 ANALYZE 按新目标采样
ANALYZE orders;
-- 验证当前列级统计目标
SELECT attname, attstattarget
FROM pg_attribute
WHERE attrelid = 'orders'::regclass
AND attname IN ('status', 'created_at');如果确实需要全局调整,应在观察库整体负载后逐步修改,例如从 100 提到 200,而不是直接设为 1000:
sql
-- 查看当前全局默认统计目标
SHOW default_statistics_target;
-- 会话级验证,不建议未经压测直接写入 postgresql.conf
SET default_statistics_target = 200;
ANALYZE orders;第六步:让 autovacuum 跟上大表变化
大表即使变化比例不高,绝对变更行数也可能很大。默认 autovacuum 触发阈值约为 50000 + 表行数 * 0.1(受参数影响),千万级表要变更约百万行才触发。可对热点表单独调优:
sql
-- 将自动分析触发比例降到 2%,基础阈值 10000 行
ALTER TABLE orders SET (
autovacuum_analyze_scale_factor = 0.02,
autovacuum_analyze_threshold = 10000
);
-- 写入、更新特别密集的表,可结合 autovacuum 成本限制
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_vacuum_threshold = 10000
);避坑总结:统计信息调优的三个高频错误
坑一:只看执行节点,不对比 rows 与 actual rows
很多同学看到 Seq Scan 就判定异常,盲目加索引或强制 SET enable_seqscan = off。正确做法是执行 EXPLAIN (ANALYZE, BUFFERS),重点检查估算行数与实际行数。若两者偏差超过数倍,应先怀疑统计信息;若估算准确但计划仍慢,再考虑索引、SQL 改写或成本参数问题。随意关闭优化器开关只是掩盖问题,可能让其他 SQL 更差。
坑二:批量导入后依赖 autovacuum,立即跑核心任务
现象是数据迁移或定时任务刚结束,报表 SQL 全部选择极差计划,过一段时间又自动恢复。原因是 autovacuum/aut oanalyze 是异步触发的,不能保证在大批量 COPY、INSERT ... SELECT、UPDATE 后立刻完成。修正方法是在迁移脚本、数据修复脚本末尾显式执行 ANALYZE 表名;大表可先分析关键 schema,再对核心表执行。
坑三:把 default_statistics_target 当作万能开关
全局调到 1000 甚至 10000 后,ANALYZE 时间明显变长,pg_statistic 膨胀,autovacuum 调度更频繁,业务高峰可能出现额外 I/O 抖动。更稳妥的方式是先用 pg_stats 与错误估算定位具体列,再对倾斜列、范围查询列设置列级 STATISTICS,调整后用真实查询验证,而不是追求“越大越准”。
此外还要注意,ANALYZE 本身收集的是采样统计,不解决跨列相关性问题。例如 status='refund' AND created_at > now() - interval '1 day' 两列存在强关联时,独立条件相乘会低估行数。PostgreSQL 较新版本可评估扩展统计,必要时使用 CREATE STATISTICS ... (dependencies, ndistinct, mcv),这是列级统计目标之外的另一条修复路径。
统计信息准确,优化器才有稳定决策的基础。排查执行计划漂移时,遵循“复现计划—对比行数—检查统计—精准 ANALYZE 或调优目标—回归验证”的路径,通常能把不可控的偶发慢查询,转化为可度量、可预防的常规性能治理工作。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。