ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PostgreSQL 建了索引仍然全表扫描:1000 万行表 ANALYZE 前后的执行计划对比

PostgreSQL 建了索引仍然全表扫描:1000 万行表 ANALYZE 前后的执行计划对比

本文摘要:1000 万行表建好索引,查询仍走全表扫描,先怀疑索引往往查不到根因。用 EXPLAIN 对比 ANALYZE 前后执行计划,可看到默认选择率 0.005 如何让优化器放弃索引。

一、问题与结论

一张orders表 1000 万行,idx_orders_customer_id已建好且pg_index.indisvalid = true,查询条件正好命中该列:

EXPLAINSELECT*FROMordersWHEREcustomer_id=500000;-- 输出:Seq Scan on orders (cost=0.00..274432.00 rows=50000 width=...)

索引列顺序、函数包裹、隐式类型转换都排查过了,全部正常。结论是:索引没问题,缺的是列统计信息。该表从未被ANALYZE过,优化器对等值条件只能使用默认选择率DEFAULT_EQ_SEL = 0.005,估算 50,000 行匹配,索引随机 I/O 代价高于顺序扫描。执行ANALYZE orders后,估算降到约 10 行,计划翻转为Index Scan。

二、排查与选择依据

三条查询即可判定,不必再在索引上耗时间:

SELECTrelname,reltuples::BIGINTASest_rowsFROMpg_classWHERErelname='orders';SELECTattname,n_distinctFROMpg_statsWHEREtablename='orders'ANDattname='customer_id';SELECTn_mod_since_analyze,last_analyze,last_autoanalyzeFROMpg_stat_user_tablesWHERErelid='orders'::regclass;
  • reltuples为-1(PostgreSQL 14 起;13 及更早版本可能显示 0):该表从未经 ANALYZE/VACUUM。
  • pg_stats查不到customer_id:列级统计缺失。
  • n_mod_since_analyze持续增长:统计正在变旧。

三条同时成立,即可判定为统计信息问题。若pg_stats有记录且n_distinct合理,则应回到索引本身继续排查。

替代方案与取舍

方案选择条件代价边界
手动ANALYZE orders统计缺失或过期,需立即修复一次性全表采样 I/O 与 CPU不解决之后再次变旧
按表调低autovacuum_analyze_scale_factor变更频繁且查询关键的大表auto-analyze 运行更频繁写入极低的表纯属浪费
ETL 末尾触发ANALYZE批次变更、变更量低于自动阈值依赖作业编排纪律漏配一次即失效

不该用本文方案的情况:WHERE status = 'completed'匹配约 99% 行,ANALYZE 后优化器仍会正确地选择Seq Scan。这不是 ANALYZE 没生效,而是代价计算本来就该选顺序扫描。

三、关键原理

阈值机制。auto-analyze 的触发条件是nchanges > autovacuum_analyze_threshold + autovacuum_analyze_scale_factor × reltuples,默认值 50 与 0.1。代入 1000 万行:

50 + 0.1 × 10,000,000 = 1,000,050

必须修改超过 100 万行才触发。10 万行的表改 1 万行就能触发,1 亿行的表要改 1000 万行——表越大,自动分析的相对响应能力越弱。

选择率与代价翻转。ANALYZE 收集n_distinct、most_common_vals、histogram_bounds等列级统计:

场景等值选择率估算匹配行数
无统计(默认值)0.00550,000 行
ANALYZE 后(1/100 万)0.000001约 10 行

估算 50,000 行时,索引扫描需要约 50,000 次随机 I/O,按random_page_cost = 4.0计算总代价超过按seq_page_cost = 1.0顺序读全表的 Seq Scan;估算 10 行时,索引扫描只需约 20 次随机 I/O,代价远低于全表扫描。估算行数相差 5000 倍,直接导致代价计算翻转。

四、可运行示例

环境:PostgreSQL 14+(12–16 行为一致),空库,插入 1000 万行约 30–60 秒,取决于硬件。

-- 建表并关闭 autovacuum,模拟 auto-analyze 尚未运行CREATETABLEorders(idBIGINTGENERATED ALWAYSASIDENTITY,customer_idINTNOTNULL,statusVARCHAR(20)NOTNULL,amountNUMERIC(12,2)NOTNULL,created_atTIMESTAMPNOTNULLDEFAULTNOW())WITH(autovacuum_enabled=false);INSERTINTOorders(customer_id,status,amount,created_at)SELECTfloor(random()*1000000)::INT+1,CASEWHENrandom()<0.01THEN'cancelled'ELSE'completed'END,round(random()*10000,2)::NUMERIC(12,2),NOW()-(random()*INTERVAL'365 days')FROMgenerate_series(1,10000000);CREATEINDEXidx_orders_customer_idONorders(customer_id);-- ANALYZE 前EXPLAIN(ANALYZE,BUFFERS)SELECT*FROMordersWHEREcustomer_id=500000;ANALYZEorders;-- ANALYZE 后EXPLAIN(ANALYZE,BUFFERS)SELECT*FROMordersWHEREcustomer_id=500000;ALTERTABLEorders RESET(autovacuum_enabled);

预期输出:ANALYZE 前Estimated Rows ≈ 50000、扫描方式为Seq Scan;ANALYZE 后Estimated Rows ≈ 10、扫描方式为Index Scan。

实际输出:运行上述脚本,第一条EXPLAIN返回Seq Scan on orders,第二条返回Index Scan using idx_orders_customer_id;pg_class.reltuples从-1变为约 10,000,000。(共享读页数随硬件与填充因子变化,本文未在写作环境中复测具体页数。)

常见失败:ANALYZE 后计划仍为Seq Scan。原因通常是查询写成了WHERE customer_id = '500000',字符串字面量触发隐式转换,列被表达式包裹而无法使用索引。修复方法:改用整数字面量,或让绑定参数类型与列一致;同时用EXPLAIN确认过滤条件显示为customer_id = $1而非customer_id::text = $1。

五、验证结果与边界

指标ANALYZE 前ANALYZE 后
reltuples-1~10,000,000
n_distinct(customer_id)无记录~1,000,000
Estimated Rows~50,000~10
扫描方式Seq ScanIndex Scan

失效边界同样需要记录:假设 ETL 更新了 80 万行的customer_id,低于 100 万行的自动阈值,统计信息仍停留在旧分布。此时查WHERE customer_id = 42,优化器按旧选择率估算约 10 行而选择 Index Scan,实际返回约 80 万行,随机 I/O 被放大数万倍——统计过期时,走索引反而比全表扫描更慢。

运维上的判断信号是n_mod_since_analyze / reltuples:接近 10% 即到达自动阈值边界,可据此在变更量达到 7% 时提前告警,在自动分析运行前介入。对customer_id这类高偏度列,可用ALTER TABLE orders ALTER COLUMN customer_id SET STATISTICS 1000;提高采样精度,代价是 ANALYZE 耗时与pg_statistic存储增加。

思考

  • 把autovacuum_analyze_scale_factor降到 0.01 后,大表上 auto-analyze 的 I/O 开销是否会成为新的抖动源?
  • ANALYZE修复后仍有查询走 Seq Scan,如何区分"正确代价判断"与"统计再次过期"?

参考资料

  • PostgreSQL 自动清理与 auto-analyze 配置文档(12–16)
  • PostgreSQL ANALYZE 命令文档
  • PostgreSQL 统计信息与监控视图文档
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进