PostgreSQL 是怎么给索引定价的

五个单价、一张价格表、四种用法。看懂这些,你就能预判 PG 的每一次选择。

公式取自 PostgreSQL 源码 · 四条真实执行计划逐条校验 · 支持 PG 13–18

← 回到判官,边拖边看

一句话结论:cost 没有单位,它是以「顺序读一个数据页 = 1 分」为基准的相对分数 —— PostgreSQL 把一次查询要做的事拆成「读多少页、处理多少行、做多少次比较」, 按一张只有 5 个单价的价格表加权求和,谁分低选谁。 难点从来不是这个加法,而是同一个索引有好几种用法,PG 要把每一种都算一遍再挑; 而选哪一种,几乎完全由你的数据分布决定。

① 为什么第一步是看数据分布

「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 个单价

这 5 个参数(可用 SHOW 查看)就是全部定价依据,下面是官方默认值:

参数单价买到什么
seq_page_cost1.0顺序读 1 个数据页(8KB),基准分
random_page_cost4.0随机读 1 个数据页 —— 默认假设比顺序读贵 4 倍
cpu_tuple_cost0.01处理 1 行表数据
cpu_index_tuple_cost0.005处理 1 条索引条目
cpu_operator_cost0.0025执行 1 次比较 / 运算

「随机读贵 4 倍」是机械硬盘时代的假设。SSD 上很多团队把 random_page_cost 调到 1.1~2, 这是最值得按硬件校准的参数 —— 把工具的纵轴切到「随机读单价」,能一眼看出你的判决对它有多敏感。

④ 同一个索引,PG 有四种用法

这是最容易被忽略的一层。看到「索引没被用」之前,先确认你比的是哪一种用法:

路径怎么工作什么时候赢
索引扫描
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.cbuild_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 要足够小」。 左边的版本按钮就是干这个的 —— 同一组输入,切一下版本,看整张地图当场重画。

取你自己数据库的数字

四条小查询,每条两三行。跑出来把结果(连表头一起)粘进判官的输入框 —— 分开粘、顺序随便。

① 这张表有多大 决定全表扫的底价。

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 —— 在参数平面上逐点跑优化器、按赢家上色。

→ 按症状对号入座:PG 没用我的索引?(10 种原因)

→ 打开索引选择判官,把这些拖一遍
→ 按症状对号入座:PG 没用我的索引?(10 种原因)