第 05 章

查询规划与执行

前面给了堆、索引、可见性图——这一章讲规划器如何在这些访问路径里选一条最便宜的,以及怎么从 EXPLAIN 读出它的决策。规划器是 cost-based 的:它给每个候选计划估一个抽象代价,选最小的那个。代价从统计信息算出来,所以查询性能 ≈ 估算精度的函数——这条主线贯穿全章。

同一条 SQL,PG 往往有不止一种方式执行:全表顺序读、走某个索引、把几个索引的结果用位图合起来、两表连接时用嵌套循环还是哈希……规划器(planner / optimizer)的工作,就是在这些方式里挑一条预计最便宜的,交给执行器去跑。它不靠固定规则(「有索引就走索引」那种),而是给每个候选计划算一个代价数字、比大小。这一章把这个过程拆开:代价怎么算(§cost-model)、估算的原料从哪来(§statistics)、有哪几种扫描和连接可选(§scan-types / §bitmap / §joins)、以及怎么用 EXPLAIN 把它的决策和真实执行对照起来(§explain)。

本章你将建立的 schema

  • 规划器是 cost-based,不是 rule-based——它枚举候选计划、给每个估一个抽象代价、选最小的,没有「见索引就用」的死规则。
  • 代价 = 页数 × 页代价 + 行数 × CPU 代价——顺序页、随机页、每行处理各有一个可调权重,加总就是一个计划的 cost。
  • 统计信息(ANALYZE)驱动行数估算——每列的 n_distinct、MCV、直方图写在 pg_statistic 里,规划器靠它估每个节点输出多少行。
  • 四种扫描各有适用区——seq scan、index scan、index-only scan、bitmap heap scan,按命中比例(选择性)分段,没有永远最快的那个。
  • 三种 join 各有适用区——nested loop、hash join、merge join,按表大小、有无序、有无索引、work_mem 选。
  • EXPLAIN 看估算 vs 实际——核心诊断是比 estimated rows 和 actual rows,偏差大就说明统计失真。

§1代价模型:把每个计划折算成一个数字

规划器枚举候选计划,给每个估一个抽象 cost,选最小的那个。

为什么需要它

同一条查询有多种执行路径,必须有个统一的尺子来比较「哪条更快」,否则只能靠死规则,而死规则在数据分布变化时会系统性地选错。代价模型给了这把尺子:把磁盘读和 CPU 处理折算到同一个无量纲的数轴上,所有候选计划在这条数轴上排队,最小者胜出。代价不是毫秒、不是字节,而是一个相对量——只在「计划 A 比计划 B 贵多少」这个意义上有用。理解这一点,才能解释为什么调一个权重参数(如 random_page_cost)能整体改变规划器的偏好。

一个计划节点的代价,由两类成本加权求和:I/O 成本(要读多少个 8KB Page)和 CPU 成本(要处理多少行、算多少表达式)。粗略写成:

代价的构成(示意) text
cost ≈ 顺序读页数 × seq_page_cost      (默认 1.0)
     + 随机读页数 × random_page_cost   (默认 4.0)
     + 处理行数   × cpu_tuple_cost      (默认 0.01)
     + 索引项数   × cpu_index_tuple_cost(默认 0.005)
     + 表达式次数 × cpu_operator_cost   (默认 0.0025)

这几个权重都是 postgresql.conf 里的 planner cost constant,可在会话级用 SET 调整。它们的绝对值不重要,重要的是彼此的比值——比值决定规划器把「读盘」看得比「算 CPU」重多少,以及把「随机读」看得比「顺序读」重多少。

底层机制(比文档深一层)。 random_page_cost : seq_page_cost = 4 : 1 这个默认比值是机械盘时代的遗产:机械硬盘随机寻道要转磁头、等盘片转到位,比顺序读一连串相邻扇区慢得多,4 倍是当年测出来的经验值。这个比值有一个直接后果:随机读一个 Page 在规划器眼里相当于顺序读 4 个 Page,所以「走索引随机回堆取少量行」要和「顺序扫一大片堆」掰手腕时,规划器会把索引路径的每次回堆都按 4 倍计价。SSD 上随机和顺序的差距远没有 4 倍那么大,因此 SSD 部署常把 random_page_cost 调到约 1.1——这一调,索引路径的回堆代价骤降,规划器就更敢用 index scan 而非退回 seq scan。这是生产环境最常见也最有效的规划器调参之一,本章末尾的预测题会再回到它。

类比 · 出行选路

代价模型像导航软件选路线:它不真去开一遍,而是给每条候选路线按「里程 × 油耗权重 + 红灯数 × 等待权重」估一个分,选分最低的。类比边界:导航的权重(油价、时间)有真实物理单位;规划器的 cost 是纯相对量,换台机器、换个参数,同一计划的 cost 数字就变,只在同一次规划的内部排序里有意义。

§2统计信息:行数估算的原料

ANALYZE 把每列的分布写进 pg_statistic,规划器据此估每个节点输出多少行。

为什么需要它

§1 的代价公式里,「读多少页、处理多少行」全是估出来的——规划器不会真去数一遍。它需要一份对数据分布的画像,才能在不碰数据的前提下回答「WHERE status='active' 命中约多少行」。这份画像就是统计信息,由 ANALYZE 采样收集。统计准,行数估得准,代价就估得准,计划就选得对;统计一旦过期或缺失,整条估算链从源头开始偏,后果是选错扫描、选错连接。所以统计信息不是「优化项」,而是规划器赖以工作的输入。

ANALYZE 对表做随机采样,把每列的分布摘要写进系统目录 pg_statistic(可读视图是 pg_stats)。autovacuum 守护进程在表的改动量超过阈值时会自动触发 ANALYZE(与 第 3 章 §autovacuum 同一套触发机制),无需手动盯着。每列收集的关键摘要有四样:

ANALYZE 收集的核心统计量
统计量含义规划器拿它做什么
n_distinct该列不同值的个数(或负比例)估等值/分组的基数:唯一值越多,单个值命中行越少
MCV(most common values)最高频的若干个值及其出现频率对高频值精确估行数,不靠平均拍脑袋
histogram(直方图)把非 MCV 的值域切成等频桶估范围条件(>、BETWEEN)落在多少桶里
correlation(物理相关性)列逻辑序与堆物理序的吻合度(-1~1)判断索引扫描回堆是顺序还是随机,影响代价

估行数的逻辑大致是:等值条件先查 MCV,命中就用它的精确频率;没命中就用「(1 − MCV 总频率) ÷ 非 MCV 的不同值数」摊平估算。范围条件查直方图,看条件覆盖了几个等频桶。correlation 接近 1 表示该列的值在堆里几乎按序物理排列(如自增主键),此时索引范围扫描的回堆近似顺序读、代价低;接近 0 则回堆是满表乱跳的随机读、代价高。

底层机制(比文档深一层)。 行数估错是慢查询的头号根因之一,且错误会沿计划树向上放大:底层一个扫描节点把「命中 2 行」估成「命中 20000 行」,上层的连接节点就会基于这个错误数字选错连接算法(详见 §joins、§explain)。三种典型的统计失真:其一,统计过期——大批量导入后还没跑 ANALYZE,规划器拿着旧画像估新数据;其二,采样精度不足——默认 default_statistics_target=100(每列约采 300×100 行、保留 100 个 MCV 与 100 个直方图桶),对值分布极不均匀的大表未必够,调高它能让 MCV 和直方图更细,代价是 ANALYZE 更慢、pg_statistic 更大;其三,多列相关性丢失——规划器默认假设各列独立,于是 WHERE city='上海' AND area_code='021' 会把两个条件的选择性直接相乘,而现实中这俩高度相关,相乘后估值会严重偏低。这第三种要靠扩展统计(CREATE STATISTICS)显式声明列间的函数依赖或联合分布来纠正。识别「估算为何失真」,就是 §explain 里读 estimated vs actual 偏差的全部意义。

失败模式 · 独立性假设

规划器默认各列相互独立,把多个 AND 条件的选择性相乘。当条件列实际相关(如「省」和「市」、「品牌」和「型号」),相乘会把命中行数估得远低于真实值,进而诱使上层选 nested loop(见 §joins)并把它放大成灾难。对这类相关列组,用 CREATE STATISTICS ... (dependencies, ndistinct) 建扩展统计,再 ANALYZE。

§3四种扫描:按选择性分段

seq scan、index scan、index-only scan、bitmap heap scan——命中比例不同,最优的扫描方式不同。

为什么需要它

「取一张表里满足条件的行」并非只有一种走法。命中极少时,顺着索引精确定位几行最划算;命中大半张表时,挨个查索引再回堆反而比直接顺序扫全表更慢。规划器要在这几种走法里按选择性(条件命中行数占全表的比例)挑一种。理解四种扫描各自的甜区,既能读懂 EXPLAIN 为什么选了某种,也能解释「为什么加了索引反而没走索引」——往往是因为命中比例太高,seq scan 此时确实更便宜。这四种扫描正是第 1、4 章建好的访问路径:堆顺序读、索引随机定位、靠可见性图免回堆、用位图合并。

四种扫描,从「命中极少」到「命中大半」排开:

  • seq scan(顺序扫描):从头到尾顺序读整个堆文件,对每行就地判断条件。读的是连续 Page,是顺序 I/O(每页按 seq_page_cost 计价,便宜)。当条件命中比例高、几乎要碰大部分行时,它最优——反正都要读,顺序读比满表随机跳快。
  • index scan(索引扫描):先在索引里定位到满足条件的索引项,再顺着每一项的 ctid 回堆取整行。高选择性(命中极少行)时最优:只回堆少数几行,省掉了扫全表。代价里每次回堆按随机 I/O(random_page_cost)计,所以命中行一多就不划算。
  • index-only scan(仅索引扫描):查询要的列全在索引里,且目标行所在 Page 在可见性图里标记为 all-visible 时,直接从索引返回、免回堆。这把 index scan 最贵的「随机回堆」整段省掉,机制细节见 第 4 章 §index-only-scan。
  • bitmap heap scan(位图堆扫描):中等选择性的甜区,机制见 §bitmap。它先扫索引建一张按 Page 排序的位图,再按物理顺序成批访问堆,把随机 I/O 摊成顺序 I/O。
命中极少(高选择性) 命中大半(低选择性) index / index-only 精确定位少量行 bitmap heap scan 位图合并·成批回堆 seq scan 顺序扫全表 没有永远最快的扫描——同一查询,数据分布一变就换方式 命中比例(选择性)是规划器选扫描的主轴
图 5.1选择性 → 扫描类型谱。左端命中极少走 index / index-only scan,中段走 bitmap heap scan,右端命中大半走 seq scan。注意:分界点由代价权重(尤其 random_page_cost)和列的 correlation 共同决定,不是固定百分比;同一条查询换一批数据分布,就会从一段滑到另一段。

底层机制(比文档深一层)。 「为什么加了索引却走 seq scan」几乎总能用这张谱回答:当条件命中比例高(例如 WHERE active = true 而九成行都 active),走索引意味着对大半张表逐行随机回堆,按 random_page_cost=4 计价后,总代价反而高于「顺序读一遍全表」。规划器算出 seq scan 更便宜,于是无视那个索引——这是正确决策,不是 bug。反过来,命中极少时它自然会选 index scan。中间地带交给 bitmap,下一节专讲。

预测一下

一张 100 万行的表,WHERE flag = true 命中其中 95 万行。规划器多半会选哪种扫描?为什么不是「有索引就走索引」?

展开答案

seq scan。 命中 95% 意味着几乎每个堆 Page 都得碰,走索引要对 95 万行逐行随机回堆——按 random_page_cost 计价后远贵于顺序读一遍全表。规划器是 cost-based,不是「见索引就用」:它算出顺序扫更便宜,就忽略那个索引。索引的甜区在高选择性(命中极少),不在这里。

§4Bitmap heap scan:把随机 I/O 摊成顺序 I/O

先扫索引建一张按 Page 排序的 ctid 位图,再按物理顺序一次性访问堆,每个 Page 只碰一次。

为什么需要它

index scan 在命中行变多时有两个痛点:回堆是满表乱跳的随机 I/O;同一个 Page 里若有多行命中,会被反复访问多次。bitmap heap scan 专治这两点——它把「先全找出来、排好序、再去取」插在索引和堆之间,于是中等选择性(既不是极少、也不到大半)这段,它比 index scan(随机 I/O 太贵)和 seq scan(白读太多无关 Page)都便宜。这是规划器在图 5.1 中段几乎总选它的原因,也是它单独成节的理由。

它分两个阶段,对应 EXPLAIN 里成对出现的两个节点 Bitmap Index Scan 和 Bitmap Heap Scan:

  1. 建位图(Bitmap Index Scan):扫索引,把所有命中项的 ctid 收集进一张内存位图。位图按 Page 号组织——本质是「哪些 Page 里有命中行」的一张图,天然按物理顺序排好。
  2. 按图取堆(Bitmap Heap Scan):按 Page 号从小到大遍历位图,每个有命中的 Page 只访问一次、取出其中所有命中行。访问顺序与堆的物理布局一致——随机 I/O 就此摊成了近似顺序 I/O。
Bitmap Index Scan 扫索引·收 ctid 位图(按 Page 排序) P0 ■ P2 ■ P5 ■ 哪些页有命中行 Bitmap Heap Scan 按页序成批取 堆(按物理 Page 顺序访问,每页只碰一次) P0 P1 P2 P3 P4 P5 实心 = 位图标记有命中(P0/P2/P5)·按 0→5 顺序取,不回头 还能 bitmap AND / OR 多个索引的位图再取一次堆
图 5.2bitmap heap scan 两阶段:Bitmap Index Scan 建一张按 Page 排序的位图,Bitmap Heap Scan 按物理页序成批回堆、每页只碰一次。注意:随机 I/O 由此摊成近似顺序 I/O,且多个索引的位图可先 AND/OR 合并再统一取堆——这正是它赢在中段选择性的两条底层原因。

底层机制(比文档深一层)。 三件事让它卡在中段最优。其一,排序消除了随机性:index scan 按索引顺序(逻辑序)回堆,物理上满表乱跳;bitmap 先按 Page 号排好再取,访问顺序贴合磁盘布局,I/O 接近顺序。其二,去重消除了重复访问:一个 Page 里多行命中,index scan 会多次拉取该 Page,bitmap 因为按页组织,每页只访问一次。其三,可组合:多个条件各走一个索引,各建一张位图,再做 BitmapAnd / BitmapOr 合并成一张,最后只取一次堆——这让 PG 能同时利用多个单列索引,而不必为每个查询组合都建复合索引。代价是建位图要占内存:命中行太多、位图大到超过 work_mem 时,PG 会把位图降级为「按 Page 粗粒度」(lossy,只记得「这页有命中」而非「这页的哪几行」),回堆时该页要逐行重判条件——这也是为什么命中比例再高就不如直接 seq scan 了,谱的右端由此收束。

§5三种 join:按表形态选算法

nested loop、hash join、merge join——按表大小、有无序、有无索引、work_mem 取一种。

为什么需要它

连接两张表同样不止一种走法,且选错的代价比选错扫描更惨烈——连接是嵌套的,外层一旦把行数估错,内层的工作量会被成倍放大。三种 join 算法各有一套前提:外表小且内表有索引、两表大且能哈希、两边已按键有序。规划器拿着 §2 的行数估算和各表的索引情况,给三种各算一个代价、选最小。读懂它们的适用区,是解释「为什么这条 join 慢」的关键——绝大多数离谱的慢 join,根因都是行数估错导致选了 nested loop。

三种算法,各自的甜区:

  • nested loop join(嵌套循环):外表每取一行,就去内表找匹配。外表小、内表在连接键上有索引时最优——外表行数少,每行用内表索引精确定位,总工作量 ≈ 外表行数 × 一次索引探查。外表一大、或内表无索引退化成逐行全扫,工作量爆炸。
  • hash join(哈希连接):先把较小的一侧(build 端)按连接键建一张内存哈希表,再扫另一侧(probe 端)逐行探哈希表。大表等值连接、数据无序、且 build 端能放进 work_mem 时最优。只支持等值连接(=),不要求任何顺序。build 端放不下 work_mem 时分批落盘(batched),代价上升。
  • merge join(归并连接):要求两边都已按连接键有序(顺序可来自索引扫描,或一个显式 Sort 节点),然后像拉链一样线性归并。两表都大、又恰好都能有序提供时高效——它是线性扫一遍,不像 hash 要建表、也不像 nested loop 要重复探查。代价主要在「让两边有序」上。
两边已按连接键有序? 索引序 / 显式 Sort 是 merge join 线性归并 否 等值连接且较小侧可入内存? build 端 ≤ work_mem 是 hash join 建表·探表 否 外表小且内表连接键有索引? 外表每行探内表索引 是 nested loop 行数估错最危险
图 5.3join 算法决策树(简化):两边已按键有序就 merge;否则等值且较小侧可入内存就 hash;否则外表小且内表有索引就 nested loop。注意:这是直觉化的择优顺序,规划器实际是给三者各算 cost 取最小。nested loop 在行数估错时最危险——外表行数被低估,内层探查会被放大成 N 次。

底层机制(比文档深一层)。 三种算法的代价曲线随「外表行数」分叉,这解释了为什么行数估算精度对 join 这么致命。nested loop 的代价约等于 外表行数 × 单次内表探查代价——是外表行数的线性函数,斜率就是内表那次探查的代价。外表只有几行时斜率乘出来很小,所以它在「外表小」时碾压其他两种;可一旦规划器把外表行数估成 2、实际却是 20000,这条线性代价被放大一万倍,一个本该毫秒级的 nested loop 跑成几分钟。hash join 有一笔固定的「建哈希表」启动成本,但之后每行探查是 O(1),总代价对行数近似线性且斜率极小——所以行数大时它稳。merge join 的主成本在排序(若需要),归并阶段是线性扫一遍。规划器选谁,本质是看这三条代价曲线在「估出来的外表行数」那一点谁最低。结论落到诊断上:join 慢,先去 §explain 比 nested loop 节点的 estimated rows 和 actual rows——偏差大就是它被错选并放大了。

预测一下

两张各上百万行的表做等值连接,连接键上都没有可用的有序索引、且都装不进 work_mem。规划器会选哪种 join?为什么不是 nested loop?

展开答案

hash join(必要时分批落盘),或先 Sort 再 merge join。 两表都大,nested loop 的「外表行数 × 内表探查」会是百万 × 百万量级——天文数字,何况内表无索引、每次探查退化成全扫,直接出局。hash join 即便 build 端放不下 work_mem 要分批,代价仍远低于 nested loop;若两边能借索引或排序变有序,merge join 也是候选。规划器会在 hash 与 merge 之间按各自代价取低者。

§6读 EXPLAIN:把估算和实际对照起来

EXPLAIN (ANALYZE, BUFFERS) 给出一棵计划树,核心诊断是比 estimated rows 和 actual rows。

为什么需要它

前五节讲的全是规划器在「估算世界」里的决策;EXPLAIN 是把这个估算世界和真实执行并排放的唯一窗口。它告诉你规划器选了哪条计划、给每个节点估了多少行多少代价;加上 ANALYZE 真跑一遍,再告诉你实际跑出多少行、花了多久、读了多少 Page。诊断慢查询不是去猜,而是打开这个窗口,找到「估算和实际差最大」的那个节点——那里就是统计失真的源头,也是上层错选计划的起点。这一节把整章主线收口:性能 = 估算精度的函数,而 EXPLAIN 就是度量这个精度的尺子。

计划是一棵节点树,缩进表示父子,自底向上执行:最内层的扫描节点先出数据,往上喂给连接、排序、聚合节点。每个节点带几组数字,分两批来源:

  • 规划器的估算(不加 ANALYZE 也有):cost=启动代价..总代价(启动代价是「吐出第一行前」要花的,总代价是「全部吐完」的累计,单位是 §1 那个抽象量);rows=估算输出行数;width=每行估算字节宽度。
  • 实际执行(加 ANALYZE 才有,会真的跑一遍查询):actual time=启动..结束(毫秒);actual rows=实际输出行数;loops=该节点被执行了几次(nested loop 的内层会等于外表行数)。
  • 缓冲区(加 BUFFERS):shared hit=从 shared_buffers 缓存命中的 Page 数、read=从磁盘实读的 Page 数——命中多说明数据多在内存,read 多说明在打盘。

下面是一条两表 JOIN 的 EXPLAIN (ANALYZE, BUFFERS) 真实形态输出(未在本机执行,数字用于演示该看哪些字段):

EXPLAIN (ANALYZE, BUFFERS) · 两表 JOIN text
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, u.name
FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid';

                                    QUERY PLAN
-------------------------------------------------------------------------------------
 Hash Join  (cost=33.50..1894.20 rows=4820 width=40)
            (actual time=0.412..18.633 rows=4790 loops=1)
   Hash Cond: (o.user_id = u.id)
   Buffers: shared hit=812 read=140
   ->  Seq Scan on orders o  (cost=0.00..1721.00 rows=4820 width=8)
                             (actual time=0.020..14.880 rows=4790 loops=1)
         Filter: (status = 'paid')
         Rows Removed by Filter: 70210
         Buffers: shared hit=700 read=140
   ->  Hash  (cost=21.00..21.00 rows=1000 width=36)
            (actual time=0.340..0.341 rows=1000 loops=1)
         Buckets: 1024  Batches: 1  Memory Usage: 72kB
         ->  Index Scan using users_pkey on users u
                 (cost=0.29..21.00 rows=1000 width=36)
                 (actual time=0.012..0.180 rows=1000 loops=1)
 Planning Time: 0.230 ms
 Execution Time: 18.920 ms
              <- 未在本机执行

逐行解读,把每个数字映射回前几节:

  • Hash Join 顶节点树根是 Hash Join(§joins 的哈希连接)。cost=33.50..1894.20:启动代价 33.50(要先把 users 哈希进内存才能出第一行),总代价 1894.20。rows=4820 估、actual rows=4790 实——两者贴近,说明这一层估得准。
  • Seq Scan on orders左子节点:对 orders 走 seq scan,Filter: status='paid' 就地过滤。Rows Removed by Filter: 70210 说明扫了约 7.5 万行、只留下 4790——命中比例低,但 orders 上若无 status 索引,seq scan 是唯一选择。Buffers: shared hit=700 read=140:700 页命中缓存、140 页打了盘。
  • Hash → Index Scan on users右子树先 Index Scan using users_pkey 取出 users 全部 1000 行,再由 Hash 节点建哈希表。Batches: 1 表示一批装下、没落盘;Memory Usage: 72kB 远小于 work_mem,所以 hash join 走的是内存路径。
  • Planning vs Execution Time底部 Planning Time(规划耗时)与 Execution Time(执行耗时)分列。诊断时不要只盯顶层 Execution Time,要顺着树找 estimated 和 actual 偏差最大的那个节点。

上面是一个「估得准」的健康计划。下面这个相反——一个 estimated 严重低估的节点,看它如何把上层带进坑:

估算失真引发的错误 nested loop(节选) text
 Nested Loop  (cost=0.29..845.10 rows=1 width=40)
              (actual time=0.030..9123.400 rows=50000 loops=1)
   ->  Seq Scan on events e  (cost=0.00..210.00 rows=1 width=8)
                            (actual time=0.015..12.300 rows=50000 loops=1)
         Filter: ((kind = 'click') AND (region = 'east'))
   ->  Index Scan using users_pkey on users u
           (cost=0.29..0.63 rows=1 width=36)
           (actual time=0.001..0.180 rows=1 loops=50000)
                                                  <- 未在本机执行

关键在第一个扫描节点:rows=1(估)对 actual rows=50000(实)——两个相关条件 kind='click' AND region='east' 被规划器按独立假设(§2)相乘,选择性估得离谱地低,于是它以为只会出 1 行。基于「外表只有 1 行」,规划器选了 nested loop:对外表那「1 行」去 users 索引探一次,看起来极便宜。可外表实际有 50000 行,于是内层 Index Scan 的 loops=50000——那次索引探查被放大成五万次,actual time 飙到 9 秒。同样这条查询,若估对了 50000 行,规划器会改选 hash join,一次哈希探表搞定,毫秒级完成。这就是「行数估错 → 错选 nested loop → 被放大」的完整链条,也是 §5 那句「nested loop 在行数估错时最危险」的实证。

预测一下

某节点 EXPLAIN 显示 rows=2 但 actual rows=20000。它正下方喂着一个 nested loop 的外表。接下来最容易出什么问题?

展开答案

上层 nested loop 被错选并放大。 规划器以为外表只有 2 行,于是判定 nested loop(外表 2 行各探一次内表)最便宜。实际外表是 20000 行,内层探查的 loops 随之变成 20000——一次本该轻量的内表查询被执行两万次,查询从毫秒级劣化成秒级甚至分钟级。根因是底层那个节点的行数估算失真(统计过期、或多列相关被当独立相乘),修法是更新统计 / 调 default_statistics_target / 建扩展统计,让规划器估对行数后改选 hash join。

预测一下

把 random_page_cost 从默认 4 调到 1.1(典型 SSD 设置),规划器会更偏向哪种扫描?为什么?

展开答案

更偏向 index scan。 random_page_cost 是「随机读一个 Page」的计价。index scan 的主要开销正是回堆的随机 I/O,把这个权重从 4 降到 1.1,回堆代价骤降,index scan 的总代价随之下降——原本被判「随机回堆太贵、不如 seq scan」的那些查询,现在算下来 index scan 更便宜,规划器就改走索引。这是 SSD 部署最常见的规划器调参:机械盘的 4:1 比值对随机读快得多的 SSD 是过度惩罚。

自测

  1. 规划器选执行计划,靠的是固定规则(如「有索引就走索引」)还是别的什么?一句话说清依据。
  2. random_page_cost 默认是 seq_page_cost 的几倍?这个比值反映的是什么硬件假设?
  3. bitmap heap scan 凭什么在「中段选择性」赢过 index scan 和 seq scan?说出它做的两件关键事。
  4. 读 EXPLAIN ANALYZE 诊断慢查询时,最该优先比较哪两个数字?它们偏差大说明什么?
查看参考答案

1. 靠代价(cost)。规划器是 cost-based:枚举候选计划、用统计信息给每个估一个抽象 cost、选最小的那个。没有「见索引就用」的死规则——命中比例高时它会正确地选 seq scan 而无视索引。

2. 4 倍(random_page_cost=4.0 vs seq_page_cost=1.0)。这个比值是机械盘的遗产:随机寻道远慢于顺序读相邻扇区。SSD 上随机/顺序差距小得多,常把它调到约 1.1,从而让规划器更敢用 index scan。

3. 其一,按 Page 排序:先扫索引把命中 ctid 收进按页排序的位图,再按物理页序回堆,把随机 I/O 摊成近似顺序 I/O。其二,每页只碰一次:同页多行命中只访问该页一次,避免 index scan 的重复回堆。(附带还能 bitmap AND/OR 合并多个索引。)

4. 比 estimated rows 和 actual rows。某节点估 2、实际 20000 这种大偏差,说明统计失真(过期 / 采样不足 / 多列相关被当独立相乘)——它会让上层错选连接算法(典型是把 nested loop 放大成几万次),是慢查询的头号根因。不要只看顶层耗时。

进阶挑战

亲手制造一次「估错行数 → 错选 nested loop」,再用扩展统计救回来

建一张表,放两个强相关的列(例如 city 与 area_code,让每个 city 唯一对应一个 area_code),灌入足够多的行。对 WHERE city=? AND area_code=? 跑 EXPLAIN ANALYZE,证明:规划器把两个条件按独立相乘、estimated rows 远低于 actual rows;当它和另一张表 JOIN 时,因低估而错选了 nested loop。然后用 CREATE STATISTICS 建扩展统计、ANALYZE,再看估算行数和所选计划如何变化。

提示

路径:CREATE STATISTICS s_city (dependencies, ndistinct) ON city, area_code FROM t; 然后 ANALYZE t;。建前,EXPLAIN 里 WHERE city=.. AND area_code=.. 的 rows= 会约等于「单列选择性 × 单列选择性 × 总行数」(两个小数相乘 → 极小);建后,规划器知道这两列函数依赖,rows= 跳到接近真实命中数。对照 pg_stats_ext 看扩展统计是否生效。把「估算行数从离谱到合理」与「所选 join 从 nested loop 切回 hash join」对上,§2/§5/§6 这条链就闭合了。