本文摘要: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.005 | 50,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 Scan | Index 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 统计信息与监控视图文档