PostgreSQL 执行计划深读:从 EXPLAIN 输出到代价模型驱动的 SQL 改写
一、从一次"慢查询"说起
假设你负责一个订单系统,用户反馈"按客户查最近订单"这个接口偶尔要等两三秒。SQL 大致是这样:
sql
SELECT o.id, o.created_at, o.amount
FROM orders o
WHERE o.customer_id = 12345
ORDER BY o.created_at DESC
LIMIT 20;
单看语句,它很普通:根据客户编号过滤,按时间倒序取 20 条。真正的问题不是"写得对不对",而是 PostgreSQL 在什么条件下选择什么执行路径,以及你如何读到它的选择依据。
执行计划(EXPLAIN 输出的那棵树)就是数据库给出的"打算怎么执行"。代价模型(cost model)是它用来比较不同方案的估算规则:用统计信息估算每个步骤要读多少行、要花多少代价,然后挑一个它认为最便宜的。请注意两个词:估算 和认为。估算可能不准,便宜也是相对估算模型而言。理解这一点,后面所有结论才站得住。
本文以一个可完整执行的实验为主线:先建表造数,再读计划,然后针对性地收集统计信息、改写 SQL,并观察计划是否变化。下文标注为"示意"的数字与计划是推演,用于讲清字段含义,不能当作性能基线;标注为"观察"的部分请在你自己的库上按给定步骤验证,不同机器、参数与数据分布都会给出不同结果。
版本与前提
- 正文结论覆盖 PostgreSQL 17 与 18 两个大版本方向;本文示例的实际执行记录仅来自确切补丁 PostgreSQL 17.10 与 18.6(Debian 构建,x86_64 Linux 官方镜像,各自全新隔离容器)。其他补丁版本、不同构建与生产环境需要自行核对,不能把本文单次观察当作全覆盖结论。两版在 EXPLAIN 的基本结构与主要连接算法上一致,但输出细节存在差异,写教学材料时应当显式说明,而不是假定两版输出相同:
- PostgreSQL 18 的 EXPLAIN 输出改为显示小数行数(fractional row counts),例如
rows=0.15;17 以整数显示,可能因四舍五入显示为rows=0。两者都受显示精度限制,看到rows=0不能推断该节点累计输出为零。 - PostgreSQL 18 中
EXPLAIN ANALYZE默认自动包含 Buffers 信息;17 的BUFFERS选项默认是 FALSE,需要显式写出。 - PostgreSQL 18 的索引节点会多出
Index Searches行,表示该节点内部索引查找次数;17 没有这一行。 - 因此跨版本教学时,应始终显式写
EXPLAIN (ANALYZE, BUFFERS),而不是依赖默认值。
- PostgreSQL 18 的 EXPLAIN 输出改为显示小数行数(fractional row counts),例如
- 本文示例环境:
work_mem=4MB、shared_buffers=128MB、seq_page_cost=1、random_page_cost=4、default_statistics_target=100、track_io_timing=off、max_parallel_workers_per_gather=2,autovacuum 保持默认开启。机器内存、shared_buffers、work_mem等会影响实际选择,本文只讲机制与观察方法,不给"调这三个参数就快"的结论。 - 结论的适用范围是"单机、常规配置、默认代价参数"这类常见场景;分区表、并行查询、复制读节点上的计划会引入额外因素,本文只在需要时点明。
二、先看计划长什么样
最基础的命令是:
sql
EXPLAIN SELECT o.id, o.created_at, o.amount
FROM orders o
WHERE o.customer_id = 12345
ORDER BY o.created_at DESC
LIMIT 20;
不带 ANALYZE 时,它只输出估算计划,不真正执行。典型输出(示意,仅用于讲解字段结构,代价数值不可当作实测):
text
Limit (cost=0.42..82.35 rows=20 width=28)
-> Index Scan using idx_orders_customer_created on orders o
(cost=0.42..205.25 rows=50 width=28)
Index Cond: (customer_id = 12345)
读这棵树要按缩进看:Limit 是最上层节点,箭头指向的是它的子节点,子节点的输出流入父节点。每一行括号里的 cost=启动代价..总代价,单位是抽象的"代价单位",不是毫秒。rows 是每个节点输出行数的估算 ,width 是输出行的平均字节数估算。
这里要特别小心代价层级:子扫描节点仍显示完整输出预计行数时的总代价,而 Limit 节点会按预计需要读取的那部分行数折算代价。因此,父 Limit 的总代价可以小于子扫描显示的完整总代价。上面的示意中,子扫描预计输出50行,Limit 只取20行,总代价约为 0.42 + (205.25 - 0.42) × 20 / 50 = 82.35。实际执行时 取够行数后会停止向下层要数据,子节点的 actual rows 可能远小于其估算 rows;但这不代表索引的启动成本、排序等必须先完成的工作也能按比例省掉。应结合节点类型和实际输出理解,不能把估算代价当作耗时,也不能仅凭父子 cost 大小推断工作量。
关于扫描方向
如果索引定义为 (customer_id, created_at DESC),而查询是 WHERE customer_id = ? ORDER BY created_at DESC,那么 customer_id 等值定位后,索引内部 created_at 本身就是降序存储的,正向扫描即可直接输出正确顺序 ,通常看到的是 Index Scan using ...。
只有当索引方向与查询排序方向相反时,才会看到 Index Scan Backward。例如把索引改成 (customer_id, created_at ASC),同样的 ORDER BY created_at DESC 查询就需要反向扫描。
所以结论要写成:扫描方向由索引声明的列方向与查询排序方向的组合决定,实践中最可靠的办法是看实际计划里有没有 Backward 字样,而不是凭 DESC 查询就猜反向扫描。 这条规则来自官方索引与排序文档的描述,本文示例也做了正向与反向的对照观察,但它不是"所有 DESC 查询必然反向"的推广。
拿到实际值
要拿到实际值,用:
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.created_at, o.amount
FROM orders o
WHERE o.customer_id = 12345
ORDER BY o.created_at DESC
LIMIT 20;
ANALYZE 会真正执行语句,输出里多出 actual time、rows、loops。BUFFERS 会额外显示缓冲区命中与读取情况。请务必明白:ANALYZE 会执行语句 ,对带写操作的语句要放进显式事务并回滚,避免误改数据。下面的 BEGIN / DELETE / ROLLBACK 是独立可选的语义说明,不属于第六节的 plan_lab.sql,不要把它混入主线实验脚本:
sql
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) DELETE FROM orders WHERE status = 'CANCELLED';
ROLLBACK;
另外,PostgreSQL 18 的 EXPLAIN ANALYZE 默认就带 Buffers,17 需要显式写 BUFFERS;两者都写显式选项时行为一致,这是跨版本最稳妥的写法。
三、EXPLAIN ANALYZE 每个字段在说什么
下面是一段计划示意,用来讲解字段之间的换算关系(括号中的数值取自本文示例的一次单次观察,仅用于说明字段关系,不构成性能基线):
text
Nested Loop (cost=0.85..629.25 rows=100 width=52) (actual time=0.036..0.421 rows=50 loops=1)
Buffers: shared hit=101 read=30
-> Index Scan using idx_orders_customer_created on orders o
(cost=0.42..205.25 rows=50 width=16) (actual time=0.032..0.139 rows=25 loops=1)
Index Cond: (customer_id = 42)
Buffers: shared hit=29 read=2
-> Index Scan using idx_items_order on order_items i
(cost=0.42..8.46 rows=2 width=20) (actual time=0.011..0.011 rows=2 loops=25)
Index Cond: (order_id = o.id)
Buffers: shared hit=72 read=28
几个容易读错的地方:
actual time是"每次执行的平均毫秒数",不是总时间。 官方说明actual time与actual rows在节点被重复执行时都是每执行一次的平均值,要用× loops得到该节点的累计值。子节点loops=25、单次actual time=0.011,累计耗时约为0.011 × 25。但要注意:父节点自身的耗时已经包含子节点,不能把各节点耗时简单相加当作查询总耗时。rows有两个含义,且都可能是平均值。 估算计划里是"估算输出行数";加了 ANALYZE 后是"每次执行的平均实际输出行数"。PostgreSQL 17 以整数显示,可能因四舍五入显示为rows=0;18 显示小数,例如rows=0.15。因此看到子节点rows=0不能推断"总共没拿到数据" :它可能是3 / 20 = 0.15被显示为 0。判断总量应结合父节点的实际rows与loops一起看,或用 aggregate 查询独立核对行数。- 估算与实际差得越多,越值得怀疑统计信息。 如果某节点估算
rows=1、实际rows=5000,上层节点多半选错了算法。这里比较的是"每次"的量级,不是单次显示值。 Buffers要分清它的能与不能。shared hit表示所需块已在 PostgreSQL 的共享缓冲区中,无需再取;shared read表示该块不在共享缓冲区、需要从更下层取。但read计数不能直接证明发生过物理磁盘读取 ,也无法单独用它把瓶颈归结为 CPU 或 I/O:I/O 时间还取决于track_io_timing是否开启,且父节点的块数包含子节点,不能逐节点相加。正确用法是:先用actual time找到耗时大的节点,再用Buffers判断该节点是否在反复访问大量块,而不是反过来由命中率推断瓶颈性质。Planning Time与Execution Time分开看。 计划时间过长可能是表的统计信息或约束太多,但通常不是主要矛盾,除非语句极频繁且极简单。
一个容易忽略的显示差异
官方文档对"重复执行的节点"有一个专门说明:actual rows 与 actual time 都是每次执行的平均。用一个横向连接的小查询就能直接看到这个差异。它在本文验证环境中执行的形状是:外层 Nested Loop 父节点实际 rows=3 loops=1,内层 Index Scan 实际 rows=0 loops=20(PG17.10 整数显示)或 rows=0.15 loops=20(PG18.6 小数显示)。父节点 3 行、内层 20 次平均每次 0.15 行,说明整数显示把 3/20 舍成了 0。
结论:不要把整数显示当作真实总量,也不要把小数显示当作精确值,两者都是显示精度下的近似;核对总量用父节点或独立计数查询。
四、三种扫描方式:什么时候选谁
4.1 顺序扫描(Seq Scan)
顺序扫描把整张表从头读到尾。它的问题不是"低效",而是读的行数多。当查询要访问表中很大比例的行时(过滤条件命中比例高),顺序扫描反而比索引快:索引要额外读索引页,再按指针回表,随机 I/O 和重复访问的代价可能超过直接扫一遍。
判断依据是选择率估算。假设 orders 有 50 万行,customer_id = 42 估算命中 50 行;若条件是 status = 'PAID' 而全表 10% 是 PAID,命中 5 万行,优化器可能倾向顺序扫描;若 PAID 占比更高,倾向更明显。具体切换点由代价参数与数据分布共同决定,不能记成固定百分比。
4.2 索引扫描(Index Scan)
索引扫描沿 B-tree 找到匹配项,再回表取需要列。它适合命中行少的等值与范围查询。若索引本身就包含查询需要的列,可以走仅索引扫描(Index Only Scan),减少回表。
这里有个容易说错的前提:仅索引扫描能否避免回表,取决于对应堆页在可见性映射表(visibility map)中的 all-visible 位是否已置位;未置位的页仍要访问堆检查行可见性。计划里的 Heap Fetches 就是核对实际回表次数的线索。定期 VACUUM 有助于把页标记为 all-visible,但它是"有助于",不是充分条件,仍需以实际计划和 Heap Fetches 为准。
4.3 位图扫描(Bitmap Scan)
位图扫描先用索引把满足条件的行位置收集成位图,再按物理顺序批量回表。它介于前两者之间:命中行数中等、或需要对多个条件做交集时常用。因为按物理顺序回表,它比索引扫描的随机 I/O 更友好;但它有位图构建开销。
需要澄清:当命中行数估算很高时,优化器会在规划阶段直接改选顺序扫描,而不是位图扫描节点在运行中"退化为"顺序扫描。位图扫描节点一旦被选中,就按位图执行;决策发生在选计划时。
可以用一张图概括决策方向(示意,不含精确阈值):
text
命中行占比
低 ──────────────► 索引扫描 / 仅索引扫描
中 ──────────────► 位图扫描
高 ──────────────► 顺序扫描 / 并行顺序扫描
这张图只是帮助你记忆方向,不是阈值表。实际切换点由代价参数、缓存命中、表宽、索引宽度共同决定。
五、连接算法:为什么优化器这么选
单表扫描之外,多表连接是计划里最容易出错的部分。三种主要算法:
- 嵌套循环(Nested Loop) :外层每行驱动内层一次查找。外层行数少、内层有高效索引时很快;外层行数大时,内层被调用太多次,代价急剧上升。上文的内层
loops=25就是这种调用次数的体现:外层每输出一行,内层就按order_id查一次。 - 哈希连接(Hash Join) :先对一侧输入构建哈希表,再扫描另一侧探测。适合等值连接、两侧都较大且缺少可用索引的场景。它并不是总把"较小一侧"建哈希表,而是由规划器按估算代价决定哪侧作为构建侧。内存方面,单个哈希操作的上限并非只有
work_mem,而是work_mem × hash_mem_multiplier:官方文档在 PostgreSQL 17 与 18 中都将这一乘积描述为哈希表的最终内存上限,hash_mem_multiplier默认值为 2.0;超出后会分批(Batches)落盘处理,计划中出现Batches: N(N>1)意味着有内存外溢。具体耗时变化需要实测,不能承诺固定降幅。 - 归并连接(Merge Join):要求两侧按连接键有序后归并。输入已有序时很划算;否则要额外排序或依赖有序索引。是否被选中取决于"有序输入是否现成、排序代价多大",而不是简单说"范围连接适合归并连接"。它也不必然比哈希连接更省内存,二者在不同数据规模下各有胜负。
选择哪一个,取决于估算的行数、排序代价、可用索引和内存参数。关键点:优化器基于估算行数选择,估算错了,算法就可能选错。 这也解释了为什么"收集统计信息"往往比反复加索引更有效。
六、贯穿示例:从慢计划到改进
下面用一条完整链路演示。你可以按顺序全部执行,并在自己的环境里观察结果。示例数据是构造出来的,用于稳定复现计划形状,不代表真实业务分布。本节步骤由一次完整脚本在 PostgreSQL 17.10 与 18.6 的隔离容器中执行;具体计划形状、行数、缓冲区计数与耗时都是当前环境的单次观察,不保证你的环境完全一致。
6.1 环境、依赖与建表
环境:PostgreSQL 17 或 18(本文实际执行于 17.10 与 18.6),psql 或任意客户端,全新测试库(不要在业务库上做)。默认参数即可。
sql
-- 在测试库中执行
CREATE TABLE customers (
id bigint PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
status text NOT NULL,
amount numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
id bigint PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(id),
product_id bigint NOT NULL,
qty integer NOT NULL CHECK (qty > 0)
);
CREATE INDEX idx_orders_customer_created
ON orders (customer_id, created_at DESC);
CREATE INDEX idx_items_order ON order_items (order_id);
注意索引列顺序:等值条件列在前,排序列在后。这里 created_at DESC 与查询的 ORDER BY created_at DESC 方向一致,等值定位后正向扫描即可输出正确顺序,因此通常看到 Index Scan,而不是 Index Scan Backward。
6.2 造数据
sql
INSERT INTO customers (id, name)
SELECT g, 'c' || g FROM generate_series(1, 10000) g;
INSERT INTO orders (id, customer_id, status, amount, created_at)
SELECT g,
1 + (g % 10000),
CASE WHEN g % 10 = 0 THEN 'PAID' ELSE 'NEW' END,
(g % 500)::numeric(12,2),
now() - (g || ' minutes')::interval
FROM generate_series(1, 500000) g;
INSERT INTO order_items (id, order_id, product_id, qty)
SELECT g,
1 + ((g - 1) / 2),
1 + (g % 50000),
1 + (g % 3)
FROM generate_series(1, 1000000) g;
此时每个订单恰好对应两条明细(order_id 从 1 到 500000,每个出现两次),orders 共 50 万行,order_items 共 100 万行,status 中 PAID 约 10%、NEW 约 90%。
然后收集统计信息:
sql
ANALYZE customers;
ANALYZE orders;
ANALYZE order_items;
ANALYZE 采集列的数据分布(直方图、最常见值、空值比例等)和表的行数估算,供优化器计算选择率。刚导入大量数据后不分析,计划很可能不准。
6.3 观察第一版计划
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.created_at, o.amount
FROM orders o
WHERE o.customer_id = 42
ORDER BY o.created_at DESC
LIMIT 20;
应当观察的是:
- 扫描方向是
Index Scan还是Index Scan Backward------以实际输出为准 ,不要预设。按上面的索引定义,正向扫描即可满足排序;如果你把索引改成(customer_id, created_at ASC),同一查询会变成Index Scan Backward,这是一个很好的对照实验(改完记得重建索引并重新ANALYZE)。 Limit的估算rows=20,但子节点估算rows可能远大于 20(比如按客户平均约 50 行);实际执行中子节点actual rows会被 Limit 压住,loops通常为 1。这正是估算代价与实际读取量不同的原因。Buffers的hit与read比例。注意 PostgreSQL 17 需显式写BUFFERS,18 默认就会输出。- PostgreSQL 18 中还会看到索引节点的
Index Searches,17 没有这一行。
在本文验证环境里,两个版本都观察到 Limit + idx_orders_customer_created 正向索引扫描、没有额外 Sort;外层实际读取 25 行后就满足 Limit 20 的订单输出。这个数字只是本环境单次观察,不是普适指标。
6.4 改写案例:让"先过滤再连接"成为自然路径
再考虑一个常见查询:查某客户的订单及其明细,按订单时间倒序取 50 条。
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.created_at, i.product_id, i.qty
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.customer_id = 42
ORDER BY o.created_at DESC
LIMIT 50;
这条语句本身写法已经合理:customer_id 有索引,order_items(order_id) 有索引,ORDER BY 与索引方向一致。改写方向不是"换一种写法就快",而是让优化器更容易先按 customer_id 过滤出目标订单、再按索引取明细。重点观察:
- 外层
orders是否走Index Scan using idx_orders_customer_created,而不是对orders全表顺序扫描; - 内层
order_items是否走Index Scan using idx_items_order,loops是否与外层实际行数一致(本文验证环境中 LIMIT 50 需要约 25 个订单 × 每单 2 条明细,内层loops=25、每次约 2 行); - 是否有额外
Sort节点。如果没有,说明索引顺序直接满足了ORDER BY。
如果逐行回表在内层代价过高,另一条思路是一次性批量取明细 :把相关订单的主键先取出,再用 IN 或 = ANY 一次性查 order_items。它不是万能改写,只在明细数量可控时有效,需要结合 EXPLAIN 对比再决定,不要在没有实测的情况下宣称"改写后一定更快"。
6.5 反面案例:统计偏差导致选错算法
6.2 的造数中 status 分布接近 9:1,PAID 不是多数。要复现"统计过期导致估算偏差",更稳妥的做法是构造一个大数据分布明显倾斜的场景,并故意让优化器看到过时的统计。一种可复现的方式是:先把分布调成"几乎全是 NEW"并 ANALYZE,再大批量更新分布,但不重新 ANALYZE,然后对比估算与实际。下面给出这个思路的可执行版本:
sql
-- 先把 status 全改成 NEW 并 ANALYZE,让统计记录"几乎全是 NEW"
UPDATE orders SET status = 'NEW';
ANALYZE orders;
-- 再把大部分行改成 PAID,但不重新 ANALYZE
UPDATE orders SET status = 'PAID' WHERE id % 4 <> 0;
此时实际 status = 'PAID' 约占 75%,但统计信息仍停留在"几乎全是 NEW"。这里需要注意自动统计的干扰:autovacuum 的自动 analyze 在后台可能刷新统计。本文验证环境中,两步 SQL 之间没有触发自动 analyze(两个版本的 last_autoanalyze 均为空,n_mod_since_analyze=0,且过期计划显示出明显的估算偏差),所以能观察到过期统计的效果。如果你在自己的库上执行,应当先确认自动 analyze 没有提前介入,否则这次输出不能当作过期统计的对照。执行:
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE status = 'PAID';
如果此时统计仍然是过期的,应在承载 status 过滤的扫描节点观察到估算 rows 与 actual rows 的明显偏差;若统计已被自动刷新,就不能把该次输出当作过期统计的对照。这里的根 Aggregate 通常只输出一行,它的 rows 不是 count(*) 得到的匹配总数。并行计划还要按工作进程与 loops 的口径解读各扫描节点。
本文验证环境中的具体观察是:两个版本都选了 Parallel Seq Scan,估算 rows=1,实际平均每次 rows=125000、loops=3,Gather 实际输出 375000 行。接着修正统计并重新观察:
sql
ANALYZE orders;
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE status = 'PAID';
重新 ANALYZE 后,同一扫描节点的估算变为约 15.6 万行(PG17.10 为 156639,PG18.6 为 156597),实际仍平均 125000、loops=3。偏差明显缩小,但并行计划把估算工作量分摊到工作进程口径,与实际的进程执行口径不同,不能宣称两者应当精确相等。这一步的重点不是断言计划一定从 A 变成 B,而是对比两次的估算 rows 是否更接近实际,以及是否出现扫描方式变化。是否更换扫描方式取决于数据分布与代价参数,不保证一定变化。
如果还要提高采样精度,可以调高该列统计目标:
sql
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;
SET STATISTICS 设置的是该列后续 ANALYZE 使用的统计目标,取值范围为0到10000;没有列级覆盖时使用 default_statistics_target,该参数的默认值为100。官方文档明确说明:提高统计目标会使 ANALYZE 所需的时间和空间近似按比例增加。 因此它是有明确成本的采样精度调整,通常能帮助刻画不规则分布,但不保证估算一定精确,改完必须重新执行计划验证,SET STATISTICS 后也要重新 ANALYZE 才会采集新目标的统计。
需要强调:这个反面案例使用的是另一次实验状态 ,与 6.3 的初始数据不同。如果你在 6.3 之后继续做 6.5,orders.status 已被修改,请勿混用两次观察的结论。
6.6 运行命令与验证
plan_lab.sql 只按以下范围和顺序组装,不是把全文所有SQL代码块拼接起来:6.1的三张表定义和两个索引 → 6.2的三张表造数与三条 ANALYZE → 6.3和6.4的 EXPLAIN (ANALYZE, BUFFERS) 只读查询 → 6.5中构造过期统计、重新 ANALYZE、SET STATISTICS 后的观察步骤。6.5只在当前独立实验库中修改测试数据,相关状态变化应按该节说明分别记录。第二节的 BEGIN / DELETE / ROLLBACK 为独立可选示例,不收录到这个脚本;下一节的清理SQL也不收录。完成计划记录后,最后再单独执行清理。
在全新独立测试库中执行主线脚本:
bash
psql -h 127.0.0.1 -U postgres -d pg_plan_lab -v ON_ERROR_STOP=1 -f plan_lab.sql
预期可观察结果:
- 6.3 的查询输出一个
Limit节点,其子节点为orders上的索引扫描,且没有额外的Sort节点; - 6.4 的查询输出
Nested Loop(外层索引扫描 + 内层索引扫描),内层loops与外层实际行数一致; - 6.5在过期统计观察条件成立时,比较承载
status过滤的扫描节点估算rows与该节点actual rows的偏差,重新ANALYZE后应更接近;count(*)结果用于核对总匹配数,不能当作根Aggregate的输出行数。是否更换扫描方式取决于数据分布与代价参数,不保证一定变化; - PostgreSQL 17 需显式
BUFFERS才显示缓冲区行,18 默认显示;18 还会出现Index Searches。
如果某一步报错,先看 ON_ERROR_STOP=1 退出的那条语句。最常见的失败原因是重复运行导致表已存在,或未在独立测试库中执行。
6.7 清理(最后独立执行)
主线脚本执行完成且计划输出已保存后,最后在同一独立测试库中单独执行下列清理SQL,避免影响后续实验。不要放入 plan_lab.sql:
sql
DROP TABLE order_items;
DROP TABLE orders;
DROP TABLE customers;
七、代价模型到底在算什么
优化器不是"猜",它按一组代价参数把每个候选计划折算成一个数,取最小的。核心输入有三类:
- 统计信息:表行数、列的选择率、最常见值、直方图。它决定"每个过滤条件大概筛掉多少行"。
- 代价参数 :
seq_page_cost(顺序页代价,默认 1.0)、random_page_cost(随机页代价,默认 4.0)、cpu_tuple_cost(默认 0.01)、cpu_index_tuple_cost(默认 0.005)等。它们把"读页"和"处理行"折算成统一单位。官方强调只有相对值有意义 ,同倍缩放所有参数不会改变计划选择。random_page_cost既反映随机读取代价,也隐含"大量索引读会在缓存中命中"的假设,它不是直接测得的毫秒。在 SSD 上默认假设可能偏保守,但调整它会影响整个实例的所有查询,需要按整体负载评估,不能仅因 SSD 就套固定值。 - 可用路径:有哪些索引、能否并行、是否需要排序。
需要澄清两个常见误解:
- "代价不是时间。"
cost是相对量,只用于同一查询的不同计划之间比较,跨查询、跨环境没有可比性。 - "优化器不保证选到最快计划。" 它选的是估算代价最小的计划。统计信息偏差、参数不符合硬件特性、数据倾斜都会让估算失真。
统计信息什么时候会不准
- 批量导入或大批量更新、删除后没有重新
ANALYZE; - 数据分布高度倾斜,而默认统计目标不足以刻画;
- 使用表达式或函数过滤(如
WHERE lower(name) = 'x'),普通列统计无法直接支持,需要考虑表达式索引; - 多列相关性强的条件,单列统计无法捕捉,可考虑
CREATE STATISTICS的dependencies或mcv类型。官方CREATE STATISTICS文档说明这类扩展统计用于同一张表内相关条件的估算改进,并明确"目前不用于表连接的选择率估算"。因此本文示例只在同一表内使用扩展统计,不能把它当作跨表连接估算的保证,也不能解决所有统计偏差。
一条读计划的实践边界
讨论到这里,可以把"代价驱动"的边界说清楚一些:
- 代价模型比较的是"估算成本",不是实际耗时;单次观察的毫秒数不构成性能承诺。
- 提高统计目标、增加扩展统计,都是让估算更贴近数据的手段,不是让计划固定的开关。
- 参数调整(比如
random_page_cost)作用在实例级或会话级,改动一处会影响一大批查询,必须整体评估。
八、执行与观察的固定流程
把上面的方法收敛成一串可重复的步骤:
- 用
EXPLAIN (ANALYZE, BUFFERS)获取实际计划;只读语句可直接跑,写语句放进事务并回滚。 - 从最内层往上读,先找
actual rows与估算rows偏差最大的节点。 - 看该节点是哪种扫描、哪种连接,以及
loops是否异常大。 - 结合
actual time与Buffers判断该节点是反复访问大量块,还是在处理大量行;注意read计数不等于物理磁盘读取,父节点块数包含子节点。 - 如果怀疑统计信息,先
ANALYZE或提高统计目标,再看计划是否变化。 - 只有在统计信息合理、计划仍差时,才考虑加索引或改写 SQL。
- 每次改动后重新对比计划,并记录改动前的计划,便于回滚。
这里要强调:不能保证优化器一定按你的预期走某个计划。 同样的 SQL 在不同数据量、不同参数、不同版本上可能选择不同算法。你的任务是让估算更接近事实,并理解它为什么这么选,而不是强迫它服从。
九、需要复核与不适用的情况
- 文中标注"示意"的数字与计划是推演,不是实际运行结果,不能当作性能基线。6.3 到 6.7 的步骤可以完整执行,但具体输出需以你所用版本与机器为准。
- 版本差异要单独核对:小数行数、默认 Buffers、
Index Searches属于 18 的输出变化;17 的BUFFERS默认关闭。涉及默认值时请查阅对应大版本的官方文档。 - 示例的执行验证仅覆盖 PostgreSQL 17.10 与 18.6 两个确切补丁,其他补丁或构建的行为未验证,不宜把本文观察直接推广到所有 17/18 部署。
- 并行查询、分区裁剪、JIT 编译会影响计划形状,本文只覆盖单表与简单连接的主干。
- 只读副本上的计划可能因可见性、统计信息刷新时机而与主库不同,不应把某一刻的观察当作长期结论。
- 修改
random_page_cost等参数属于实例级或会话级权衡,会影响其他查询,必须先在测试环境验证并保留回滚方案。
十、结论
读执行计划的正确顺序是:先看实际与估算的偏差,再看扫描与连接方式,最后才谈加索引或改写。代价模型不是神秘的黑盒,它是可以用统计信息和参数解释的相对比较规则;而统计信息是否准确,往往比一两个索引更能决定计划好坏。把"观察 → 定位偏差 → 修正统计 → 验证"做成固定流程,你的 SQL 改写才有依据,而不是靠试。
最后再提醒三个易错点:actual rows 是平均值,整数显示会舍入,看到 0 不等于真的没有输出;扫描方向由索引声明与排序方向共同决定,不能凭 DESC 猜反向;Buffers 的 read 不等于物理磁盘读取,不能单独用它判定 I/O 瓶颈。
参考资料
- PostgreSQL 17 官方文档:Using EXPLAIN --- https://www.postgresql.org/docs/17/using-explain.html
- PostgreSQL 18 官方文档:Using EXPLAIN --- https://www.postgresql.org/docs/18/using-explain.html
- PostgreSQL 17 官方文档:EXPLAIN 命令(ANALYZE、BUFFERS 选项) --- https://www.postgresql.org/docs/17/sql-explain.html
- PostgreSQL 18 官方文档:EXPLAIN 命令(ANALYZE、BUFFERS 选项) --- https://www.postgresql.org/docs/18/sql-explain.html
- PostgreSQL 17/18 官方文档:ANALYZE --- https://www.postgresql.org/docs/17/sql-analyze.html ;https://www.postgresql.org/docs/18/sql-analyze.html
- PostgreSQL 17/18 官方文档:CREATE STATISTICS --- https://www.postgresql.org/docs/17/sql-createstatistics.html ;https://www.postgresql.org/docs/18/sql-createstatistics.html
- PostgreSQL 17/18 官方文档:Query Planning 代价常量 --- https://www.postgresql.org/docs/17/runtime-config-query.html#RUNTIME-CONFIG-QUERY-CONSTANTS ;https://www.postgresql.org/docs/18/runtime-config-query.html#RUNTIME-CONFIG-QUERY-CONSTANTS
- PostgreSQL 17/18 官方文档:work_mem 与 hash_mem_multiplier --- https://www.postgresql.org/docs/17/runtime-config-resource.html#GUC-HASH-MEM-MULTIPLIER ;https://www.postgresql.org/docs/18/runtime-config-resource.html#GUC-HASH-MEM-MULTIPLIER