PostgreSQL 行数估算 1 行、实际 1 万行:多列相关导致 Nested Loop 扫了 999 万次,CREATE STATISTICS 后 1 秒降到 12ms
在 PostgreSQL 17.11 测试库(orders 100 万行自造数据,省/城市/区县/仓库/大区五列强相关)复现多列相关导致的行数低估:四个等值条件估算 rows=1、实际 10000 行,规划器选了 Nested Loop + 对小表反复 Seq Scan,Rows Removed by Join Filter 9,990,000,约 1 秒。实测 CREATE STATISTICS (dependencies / mcv) 后估算回到约 1 万行、同一条 SQL 11–19ms;只调大 statistics target 没用;只给小表加主键能降到约 0.4 秒但计划不变。附只读排查 SQL 和可复跑脚本。
PostgreSQL 17.11 实测(测试库、自造数据):orders 100 万行,WHERE region_id=4 AND province_id=4 AND city_id=37 AND warehouse_id=37,规划器按「各列独立」把选择率相乘,估算 rows=1,实际 10000 行;于是 JOIN 一张 1000 行的小表时选了 Nested Loop,小表被 Seq Scan 了 10000 遍,Rows Removed by Join Filter 9,990,000,四次 990–1038ms。CREATE STATISTICS (dependencies) 加 ANALYZE(100 万行约 0.2 秒)后估算变成 10000 / 10100,计划换了驱动表,11–19ms。把四列统计目标调到 1000 再 ANALYZE,估算仍是 1。
适用对象
- PostgreSQL 默认假设 WHERE 里的各列互相独立,把选择率直接相乘;列之间强相关时会严重低估,本次估算 1 行、实际 10000 行。
- 低估本身不慢,慢在它把后面的 JOIN 带偏:本次因此选了 Nested Loop,对 1000 行小表 Seq Scan 了 10000 遍,约 1 秒。
- CREATE STATISTICS (dependencies) 或 (mcv) 后重新 ANALYZE,估算回到约 1 万行,同一条 SQL 11–19ms;只调大 statistics target 不解决,因为单列统计再准也不知道列之间的关系。
- 排查顺序:先看 EXPLAIN ANALYZE 里最底层扫描节点的 rows 估算和 actual rows 差多少倍,差两个数量级以上再看是不是多列相关。
为什么估算是 1 行?规划器把四列的选择率直接乘起来了
pg_stats 里这几列的 n_distinct:region_id 10、province_id 10、city_id 100、warehouse_id 100。规划器默认各列独立,选择率 ≈ 1/10 × 1/10 × 1/100 × 1/100 = 1/100 万,乘以 100 万行,再向上取整就是 rows=1。真实情况是 city_id = 37 已经决定了省和仓库,另外三个条件一行都过滤不掉,实际 10000 行。
只看前两列也能看到偏差:索引条件 province_id = 4 AND city_id = 37 的 Bitmap Index Scan 估算 904–1097 行(每次 ANALYZE 抽样不同,数字会变),实际 10000 行;再叠上 region_id、warehouse_id 两个过滤条件,估算一路掉到 1。
我先试了最常见的「统计不准就加大统计目标」:把四列 SET STATISTICS 1000 再 ANALYZE,估算仍然是 rows=1,JOIN 仍然 943.5ms。单列的直方图和高频值再准,也只描述单列,不知道 city_id 和 province_id 的关系。
| 状态 | 估算 rows | 实际 rows |
|---|---|---|
| 默认统计(target 100) | 1 | 10000 |
| 四列 SET STATISTICS 1000 + ANALYZE | 1 | 10000 |
| CREATE STATISTICS (dependencies) + ANALYZE | 10000(第二轮 10100,脚本复跑 10700) | 10000 |
| CREATE STATISTICS (mcv) + ANALYZE | 9367(第二轮 9300) | 10000 |
低估怎么把 JOIN 带偏:Nested Loop 对小表 Seq Scan 了 10000 遍
JOIN 语句:SELECT count(*), sum(o.amount), sum(t.monthly_target) FROM orders o JOIN district_targets t ON t.district_id = o.district_id WHERE 上面四个条件。规划器认为 orders 只出 1 行,对 1 行做 Nested Loop、内层扫一遍 1000 行的小表(cost 18)是最便宜的方案,Hash Join 还要多建一次哈希表。
实际是 10000 行,内层 Seq Scan on district_targets 的 loops=10000、每次 1000 行,Join Filter 一共丢掉 9,990,000 行。orders 这边的 Bitmap Heap Scan 只用了 18–21ms,剩下 970ms 左右都耗在反复扫小表上。
这也是我觉得这类问题难查的地方:两张表都不大,单独查都很快,SQL 也没写错,慢的只是计划。看 EXPLAIN ANALYZE 时盯住两处:最底层 orders 扫描的 rows=1 vs actual rows=10000,以及 Rows Removed by Join Filter 的数量级。
| 修法 | JOIN 计划 | Rows Removed by Join Filter | 第 1 轮 | 第 2 轮 |
|---|---|---|---|---|
| 不处理(默认统计) | Nested Loop:orders 外层 + 小表 Seq Scan × 10000 | 9,990,000 | 996.5 / 1038.0 ms | 990.3 / 989.8 ms |
| 四列统计目标调到 1000 | 同上 | 9,990,000 | 943.5 ms | — |
| 只给小表加主键 | 同上,但 Inner Unique: true,找到匹配就停 | 3,645,000 | 373.0 / 387.6 ms | 382.5 / 495.4 ms |
| CREATE STATISTICS (dependencies) | Nested Loop:小表外层 + orders Index Scan × 1000 | 无(关联条件变成索引条件) | 12.7 / 11.2 ms | 15.0 / 18.8 ms |
| CREATE STATISTICS (mcv) | 同 dependencies | 无 | 11.0 / 11.6 ms | 18.7 / 20.1 ms |
JOIN 执行时间(第 1 轮第 1 次,ms)
默认统计
996.5 ms
只加主键
373.0 ms
dependencies
12.7 ms
mcv
11.0 ms
PostgreSQL 17.11,测试库、自造数据;下载版脚本复跑:默认 884.8 ms,dependencies 11.9 ms。
我先想到的是给小表加主键,结果只快了一半多,计划没变
看到小表被 Seq Scan 了 10000 遍,我第一反应是「小表没索引」,于是 ALTER TABLE district_targets ADD PRIMARY KEY (district_id)。结果计划还是 Nested Loop + Seq Scan,没有换成索引扫描;时间从约 1 秒降到 373–495ms。
用 EXPLAIN (ANALYZE, VERBOSE) 看才明白:Nested Loop 多了一行 Inner Unique: true。主键告诉规划器小表每个 district_id 只有一行,内层扫到匹配就停,不用扫完 1000 行;本次匹配的区县编号是 361–370,平均每次扫 366 行,Rows Removed by Join Filter 从 9,990,000 降到 3,645,000。省下来的是这部分,根因(估算 1 行)没动,所以规划器仍然觉得「扫一遍小表很便宜」。
所以加主键是对的(这张表本来就该有),但它不是这条 SQL 的解法;估算不改,换一组条件或数据量再大一点,同样的问题还会出现。
CREATE STATISTICS:dependencies 和 mcv 都能修好,我怎么选
CREATE STATISTICS st_orders_area_dep (dependencies) ON region_id, province_id, city_id, warehouse_id FROM orders; 然后 ANALYZE orders。建统计对象本身 1.5ms,ANALYZE 100 万行 210.6ms / 233.5ms。pg_stats_ext 里能看到函数依赖系数,例如 "3 => 2": 1.000000(city_id 决定 province_id),本例全部是 1.0,因为数据就是这么造的。
之后单表估算 10000(第二轮 10100),JOIN 计划换成:district_targets 做外层(1000 行),每个 district_id 到 orders 的 idx_orders_area 上做 Index Scan(loops=1000,平均每次 10 行),12.7 / 11.2ms、15.0 / 18.8ms。
换成 (mcv) 也能修:ANALYZE 116.3ms / 159.2ms,估算 9367 / 9300,计划和耗时与 dependencies 基本一样。我的取舍(经验判断):列之间是「上级决定下级」这种层级关系,用 dependencies,体积小;值分布很偏、某几个组合特别多时,用 mcv;也可以两种一起建。dependencies 只对等值条件(以及 IN)生效,范围条件用不上,这一点见文末官方文档。
本节判断
- 建统计对象本身只是写一条目录记录(本次 1.5ms),真正花时间的是之后的 ANALYZE;大表上 ANALYZE 只抽样,耗时主要看 statistics target,不随表行数线性增长(经验判断,本次只测了 100 万行)。
- 扩展统计要等 ANALYZE(或 autovacuum 的自动 analyze)跑过才有数据;只读 SQL 第 1 段的 has_data 列能看出来。
在自己的库里怎么找:先比估算和实际,再查哪些列相关
只读 SQL 一共 4 段,开头 SET statement_timeout = '30s'。第 1 段列出已有扩展统计和类型(stxkind:d=ndistinct、f=dependencies、m=mcv),以及 ANALYZE 后有没有数据;第 2 段列出有多列索引、但还没建任何扩展统计的表(多列一起出现在条件里,最可能相关);第 3 段列这些索引列各自的 n_distinct;第 4 段用 TABLESAMPLE SYSTEM (1) 抽约 1% 的页,比较两列各自的去重数和组合去重数。
测试库上的输出:第 2 段找到 orders.idx_orders_area(province_id, city_id, district_id);第 3 段 n_distinct 分别是 10 / 100 / 1000;第 4 段抽到约 1 万行,city 100 个、district 1000 个、组合也是 1000 个,组合数等于其中较大的那个,说明 district 决定 city。
我写第 1 段时把 stxkind 的字母记反了,以为 d 是 dependencies。实际建了一个 (dependencies, mcv) 的统计对象一查,kinds 显示 {f,m}:f 才是函数依赖(dependencies),d 是 ndistinct。脚本里的注释已按实测改正。
我排查行数估算问题的四步
- 01
1
EXPLAIN (ANALYZE) 找最底层的偏差
逐个扫描节点比 rows 和 actual rows,差 100 倍以上的先记下来;偏差往往从最底层开始放大。
- 02
2
看偏差节点的条件
同一张表上有两个以上等值条件、而且业务上有层级关系(省市区、类目、租户 + 门店),多半是多列相关。
- 03
3
跑只读 SQL 第 3、4 段确认
组合去重数接近较大一列的去重数,就是强相关。
- 04
4
在测试环境建扩展统计验证
CREATE STATISTICS + ANALYZE 后重跑 EXPLAIN ANALYZE,估算和计划都对了再上生产。
觉得有用?转给正在做这件事的同事或朋友
参考依据
以下来源用于确认市场趋势、政策背景和术语边界;具体落地方案仍以客户的数据范围、权限和交付目标为准。
常见问题
PostgreSQL 执行计划 rows 估算比实际少很多是什么原因?
常见原因之一是多个条件的列互相相关,规划器默认把各列选择率相乘。本次 PostgreSQL 17.11、100 万行测试表上四个相关条件估算 1 行、实际 10000 行。
ANALYZE 之后估算还是不准,调大 default_statistics_target 有用吗?
对多列相关没用。本次把四列统计目标调到 1000 再 ANALYZE,估算仍是 1 行,JOIN 仍要 943.5ms;需要 CREATE STATISTICS 建多列统计。
CREATE STATISTICS 选 dependencies 还是 mcv?
本次两种都把估算修到约 1 万行、JOIN 降到 11–20ms。层级关系(上级决定下级)用 dependencies,值分布偏斜用 mcv,也可以两种一起建;dependencies 只对等值和 IN 条件生效。
建了扩展统计为什么计划没变?
扩展统计要 ANALYZE 之后才有数据。另外要确认条件里的列都在同一个统计对象里,且是等值条件;可以用只读 SQL 第 1 段看 has_data。
这些毫秒数能直接当生产结论吗?
不能。这是本机测试库、自造数据,相关性是人为造到 100% 的,只说明机制和量级。生产上请先在测试环境用 EXPLAIN ANALYZE 比对估算和实际。
下一步
推荐动作
你提供慢 SQL 的 EXPLAIN (ANALYZE, BUFFERS) 输出和这份只读 SQL 的结果(可脱敏),我们判断是统计估算、索引还是参数问题,给出建扩展统计或改写的验证步骤;生产变更由你方授权执行。