五个单价、一张价格表、四种用法。看懂这些,你就能预判 PG 的每一次选择。
公式取自 PostgreSQL 源码 · 四条真实执行计划逐条校验 · 支持 PG 13–18
「PG 为什么不用我的索引」这个问题,绝大多数时候答案不在索引上,而在命中行数上:
| 命中行数 | PG 通常会选 | 原因 |
|---|---|---|
| 几十行 | 索引扫描 | 回表几次而已,随机读的钱可以忽略 |
| 几千到几万行 | 位图扫描 | 回表次数多到该排序合并了,按物理顺序取页更划算 |
| 占全表相当比例 | 顺序扫全表 | 反正大部分页都要读,不如老老实实顺着读一遍 |
而命中行数从哪来?从 n_distinct 来。 PG 默认假设一列的取值均匀分布,于是「col = ? 命中多少行」= 总行数 ÷ n_distinct。这就是为什么上面工具的第一个滑块是它 —— 它是整条链子的源头,拨动它,右边整张地图上的位置就跟着动。
真实世界里最常见的「估错」,就是这个均匀假设不成立:某个值占了半张表,PG 却以为它只占 1/n。 这时 PG 会查 MCV(高频值列表)来修正,本工具不做这一层估算 —— 所以工具算的是「PG 在均匀假设下会怎么选」。
同样命中 5000 行,代价可以差几十倍,区别只在这 5000 行是挤在一起还是撒得到处都是。 这就是 correlation —— 列值顺序和物理存放顺序的相关系数, 从 pg_stats 里查得到。
| correlation | 命中的行长什么样 | 回表要读几个页 |
|---|---|---|
| ≈ ±1 | 挤在连续的一小段里(比如自增主键、按时间追加的表) | 很少,接近顺序读 |
| ≈ 0 | 均匀撒满整张表(比如随机 UUID、哈希分布的外键) | 命中几行就读几个页,全是随机读 |
源码里的折算是 实际 IO = 随机上界 + correlation² × (顺序下界 − 随机上界)。 注意是平方 —— 所以 correlation 从 1 掉到 0.7,代价就已经涨掉一半了。 把工具的纵轴切到「磁盘排列」,你能直接看到索引扫描的地盘随着 correlation 下降怎么被位图扫描吃掉。
这 5 个参数(可用 SHOW 查看)就是全部定价依据,下面是官方默认值:
| 参数 | 单价 | 买到什么 |
|---|---|---|
| seq_page_cost | 1.0 | 顺序读 1 个数据页(8KB),基准分 |
| random_page_cost | 4.0 | 随机读 1 个数据页 —— 默认假设比顺序读贵 4 倍 |
| cpu_tuple_cost | 0.01 | 处理 1 行表数据 |
| cpu_index_tuple_cost | 0.005 | 处理 1 条索引条目 |
| cpu_operator_cost | 0.0025 | 执行 1 次比较 / 运算 |
「随机读贵 4 倍」是机械硬盘时代的假设。SSD 上很多团队把 random_page_cost 调到 1.1~2, 这是最值得按硬件校准的参数 —— 把工具的纵轴切到「随机读单价」,能一眼看出你的判决对它有多敏感。
这是最容易被忽略的一层。看到「索引没被用」之前,先确认你比的是哪一种用法:
| 路径 | 怎么工作 | 什么时候赢 |
|---|---|---|
| 索引扫描 Index Scan |
按索引定位到行的物理地址,一行一行回表取整行 | 命中行少。行一多,随机回表就把它拖垮 |
| 仅索引扫描 Index Only Scan |
索引里就有查询要的全部列,完全不回表(前提是页在可见性图里标记为 all-visible) | 索引盖住了整个查询。这是最便宜的一种,常常快两个数量级 |
| 位图扫描 Bitmap Heap Scan |
先把索引扫完、把行号收进位图,再按物理顺序一次性回表 | 命中行不少不多。回表变得接近顺序读,但启动代价高、且要重查全部条件 |
| 并行扫描 Gather + Parallel … |
多个 worker 分工扫,结果汇总给 leader | 表够大(≥ 8MB)且返回行少。返回行一多,传行的钱就把并行拖垮 |
所以代价条上同一个索引出现好几次是对的 —— 这正是规划器眼里的世界。 地图上每一块颜色对应的也是「某个索引的某一种用法」,而不只是「某个索引」。
流传最广的一条规则,也是最经不起验证的一条。真实环境跑一条 EXPLAIN 就能推翻它:
Bitmap Heap Scan on doc_permission (cost=40199.80..165153.30 rows=40190)
Recheck Cond: (resource_type = $1)
-> Bitmap Index Scan on idx_tenant_grant_type (cost=0.00..40189.75 rows=40190)
Index Cond: (resource_type = $1) ← 条件落在索引第三列
索引是 (tenant_id, grant_type, resource_type),查询只有第三列上的条件。 B-tree 确实定位不了 —— 树的入口定不下来。但 PG 并不因此放弃它,而是 把整个索引扫一遍、逐条比对,把命中的行号收进位图再统一回表。贵,却仍然比全表扫便宜得多。
查 PostgreSQL 源码里建路径的判断(indxpath.c 的 build_index_paths), 规则是三者居其一即可:
| ① 有条件能匹配到这个索引的任意键列 |
| ② 它能提供查询要的顺序(省掉一次排序) |
| ③ 它能盖住整个查询(走仅索引扫描) |
准确的说法应该是:首列没条件 → 定位不了,只能整索引扫,通常贵到不会被选中;但 PG 确实会考虑它, 在别的路径更贵时真的会选它。而到了 PG 18,连「定位不了」都不一定成立 —— 见下一节。
工具左边索引卡片上那道 ⊣ 断口,画的就是「定位前缀到此为止」—— 断口左边的列在缩小范围,右边的列只能逐条过滤。
把 PG 13 到 18 的代价函数逐个 diff 下来,结论比想象中干净: index_pages_fetched(缓存折算)、cost_index、 位图那套公式,13 到 18 一行没变。真正的变化全在 B-tree 的估算里,只有两处:
| 版本 | 变了什么 | 后果 |
|---|---|---|
| 16 → 17 | 数组条件的下潜次数被钳制:不超过索引页数的 1/3 | PG 16 及以前把 IN 的元素个数直接连乘,多个数组条件会把索引估贵到出局。 PG 17 修正后,同一条 SQL 可能从全表扫直接变成走索引 |
| 17 → 18 | skip scan:给缺 = 条件的前导列自动补一个跳跃数组 | 索引 (a, b) 而查询只有 b = … 时, PG 18 等价于执行 a = ANY('{a 的每个取值}') AND b = …。 首列取值越少,跳跃越划算 |
所以「首列必须有条件」这条老规矩,在 PG 18 上要改成「首列的 n_distinct 要足够小」。 左边的版本按钮就是干这个的 —— 同一组输入,切一下版本,看整张地图当场重画。
跑出来把结果(连表头一起)粘进沙盘的输入框。嫌麻烦就用下面那条一次性的; 想看清每个数从哪来,就跑分开的四条 —— 分开粘、顺序随便,粘到哪算到哪。
一条全取 输出是一列文本,四段拼在一起,沙盘照样认。表名只出现一次。
WITH t AS (SELECT '你的表名'::regclass AS c)
SELECT l FROM (
SELECT 1 o, E'relname\trelpages\treltuples\trelallvisible' l
UNION ALL SELECT 2, c.relname||E'\t'||c.relpages||E'\t'||c.reltuples||E'\t'||c.relallvisible
FROM pg_class c, t WHERE c.oid = t.c
UNION ALL SELECT 3, E'attname\tavg_width\tn_distinct\tcorrelation\tnull_frac'
UNION ALL SELECT 4, s.attname||E'\t'||s.avg_width||E'\t'||s.n_distinct||E'\t'||
coalesce(s.correlation,0)||E'\t'||s.null_frac
FROM pg_stats s, t WHERE format('%I.%I', s.schemaname, s.tablename)::regclass = t.c
UNION ALL SELECT 5, E'index_name\trelpages\tindisunique\tindexdef'
UNION ALL SELECT 6, i.relname||E'\t'||i.relpages||E'\t'||x.indisunique||E'\t'||
pg_get_indexdef(x.indexrelid)
FROM pg_index x JOIN pg_class i ON i.oid = x.indexrelid, t WHERE x.indrelid = t.c
UNION ALL SELECT 7, E'name\tsetting'
UNION ALL SELECT 8, g.name||E'\t'||g.setting FROM pg_settings g WHERE g.name IN (
'seq_page_cost','random_page_cost','cpu_tuple_cost','cpu_index_tuple_cost',
'cpu_operator_cost','effective_cache_size','work_mem','parallel_setup_cost',
'parallel_tuple_cost','min_parallel_table_scan_size',
'min_parallel_index_scan_size','max_parallel_workers_per_gather')
) z ORDER BY o;
列之间是真的制表符,psql / DataGrip / DBeaver 复制出来都带得走。 psql 里想要更干净的输出可以先 \pset format unaligned,不加也认得。 沙盘的粘贴抽屉里有「复制取数 SQL」按钮,就是这一条。
① 这张表有多大 决定全表扫的底价。
SELECT relname, reltuples, relpages, relallvisible FROM pg_class WHERE oid = '你的表名'::regclass;
reltuples 行数、relpages 占几个 8KB 的页、 relallvisible 其中有几个页是「全可见」的(决定仅索引扫描能省掉多少回表)。 如果 reltuples 是 −1,说明这张表从没 ANALYZE 过,先跑一次。
② 每一列的数据分布 决定命中多少行、以及回表要跳多少次。这是最关键的一条。
SELECT attname, avg_width, n_distinct, correlation, null_frac FROM pg_stats WHERE tablename = '你的表名';
avg_width 这列平均占几个字节(加起来就是行宽,行宽决定每页装几行); n_distinct 有几种不同的值(负数表示占行数的比例,−1 就是每行都不同); correlation 列值顺序和物理存放顺序有多一致(±1 = 挤在一起,0 = 撒满全表); null_frac 空值比例。
③ 表上有哪些索引
SELECT c.relname AS index_name, c.relpages, x.indisunique,
pg_get_indexdef(x.indexrelid) AS indexdef
FROM pg_index x JOIN pg_class c ON c.oid = x.indexrelid
WHERE x.indrelid = '你的表名'::regclass;
键列、INCLUDE 列、是不是唯一索引,都从 indexdef 里读。 树高不用你查 —— 真值只存在 B-tree 元页里(要 pageinspect 扩展), 工具按索引页数和键宽推算,实测与 EXPLAIN 反推的值一致。
④ 你这台数据库的价格表 不填就按官方默认值算。
SELECT name, setting FROM pg_settings WHERE name IN ( 'seq_page_cost','random_page_cost','cpu_tuple_cost','cpu_index_tuple_cost', 'cpu_operator_cost','effective_cache_size','work_mem','parallel_setup_cost', 'parallel_tuple_cost','min_parallel_table_scan_size', 'min_parallel_index_scan_size','max_parallel_workers_per_gather');
最值得校准的是 random_page_cost:默认 4.0 是机械盘时代的假设, SSD 上很多团队调到 1.1~2,这一个数就能让判决翻面。
权限提醒:pg_stats 只对表的 owner 或有 pg_read_all_stats 角色的账号可见。用只读账号跑 ② 会返回空 —— 沙盘会明确告诉你「还差列的数据分布」, 而不是拿默认值蒙混过去。
准确度与边界:模型按 PostgreSQL 源码实现(btcostestimate /
genericcostestimate / cost_index /
index_pages_fetched / cost_bitmap_heap_scan /
compute_parallel_worker / cost_tuplesort),
并用真实 EXPLAIN 逐条校验过四类路径:
索引扫描、仅索引扫描、位图扫描、并行仅索引扫描 —— 启动代价、总代价、rows 全部完全一致。
没有建模的部分(会明确提示,不硬算):非 B-tree 索引(GIN / GiST / BRIN / hash 各有独立的代价函数);
三个以上索引的位图求交;OR 分支;join 内侧的参数化路径;分区表;
TID / TABLESAMPLE 扫描;表达式索引。
估算而非计算的部分:范围条件的选择率要你自己填(本工具不做直方图估算);
建议索引的大小按列宽推算。另外,带真实参数值的定制计划会查 MCV 而不是按 1/n_distinct 均匀假设,
倾斜列上两者可差几个数量级。
决策地图的形态借鉴 Picasso(VLDB 2010,印度理工班加罗尔)
的 plan diagram —— 在参数平面上逐点跑优化器、按赢家上色。