LIMIT 10 限制了结果数量,但要找到十个不同的值,可能需要读取数千行输入。本指南将介绍如何诊断这部分工作,并通过 Mac 的 Terminal 中生成的合成文章主题数据比较两种方法。

为什么要得到十个结果可能需要读取数千行

假设一张文章表有 100,000 行,分布在 100 个主题中,每个主题有 1,000 篇文章。你的任务是按字母顺序获取最前面的十个不同主题名称。查询需要去除重复名称、保持主题顺序,并返回十个结果。

PostgreSQL 将 DISTINCT 定义为移除重复的输出行,将 LIMIT 定义为限制返回的结果。这些语义不会为底层扫描设置十行的预算。规划器会在选择计划时考虑限制,因此实际工作量取决于所选的执行策略。SELECT

考虑一个有序流,其中先有 1,000 个 topic-001,接着是 1,000 个 topic-002,依此类推。流式去重操作只有在读取完前九组的全部数据,以及第十组的第一行之后,才能看到第十个不同主题:

所需输入行数 = (9 × 1,000) + 1 = 9,001
返回的不同结果数 = 10

这是针对特定流式策略的示例计算,不是实测的 PostgreSQL 计划。不均匀的分组会改变计算结果:即使后面的组都很小,第一个主题组特别大也可能占去大部分工作量。

匹配的 B-tree 索引可以提供有序输入,无需显式排序。PostgreSQL 文档说明,带有 LIMIT 的索引 ORDER BY 可以提前获取数据,但去重还增加了另一项要求:必须有足够多的不同值到达限制节点。索引可以省去排序工作,但处理大量重复值的工作仍可能很可观。Indexes and ORDER BY

查看计划,找出哪些节点在处理这些行

对实际查询和参数值使用 EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)。ANALYZE 会执行语句并报告实际行数;BUFFERS 会报告块活动。下面的完整示例会在合成数据上运行这项诊断。EXPLAIN

下面展示了两种有用的计划模式。这些只是示意结构,不是捕获的输出,也不保证你的安装会选择这些计划。

有序流式路径:
Limit
  Unique
    Index Only Scan 或 Index Scan

聚合路径:
Limit
  Sort
    HashAggregate
      Seq Scan

对于有序路径,应比较扫描节点的输出与 Unique 和 Limit 的输出。差距很大,说明输入中有大量重复值。在聚合路径中,聚合节点必须处理完输入以确定各分组,然后最终排序和限制才能完成。PostgreSQL 的计划文档说明了扫描节点如何向排序和聚合节点提供数据。Using EXPLAIN

阅读实际行数时要结合 loops:如果某个节点反复运行,行数是每次执行的平均值。估算该节点的总输出时,还要乘以循环次数。也要检查排序节点下方的输入;排序节点输出的行数很少,并不能说明其输入也很少。Using EXPLAIN

请分别看待三项指标:节点输出的行数、块活动和耗时。缓冲区计数描述的是块,而不是逻辑行;父节点的缓冲区总数包含其子节点。临时表使用本地缓冲区;排序等操作所用的临时工作文件则显示为 temp blocks。EXPLAIN

在 Mac 上复现比较

使用当前的 Node.js LTS 版本、npm 和现有的本地 PostgreSQL 数据库。本指南以 PostgreSQL 18 文档为基准。程序会打印服务器版本,以便你根据该版本的文档解读结果。node-postgres 的文档介绍了如何通过 npm install pg 安装,以及对当前版和 LTS 版 Node.js 的支持。node-postgres

在 Terminal 中创建工作目录并安装驱动:

mkdir distinct-row-demo
cd distinct-row-demo
npm init -y
npm install pg

将以下完整程序保存为 distinct-demo.mjs。它会在临时表中创建合成文章行,在添加索引前后分别测量普通查询,并与显式的后继值搜索查询进行比较。程序始终使用同一个 Client,因此所有查询都在同一数据库会话中运行。

连接设置来自常用的 PostgreSQL 环境变量。运行程序前,请为现有的本地数据库配置这些变量。node-postgres 文档介绍了基于环境变量的连接方式,以及如何将查询值与 SQL 文本分开传递。Connecting、Queries

import pg from 'pg';
import assert from 'node:assert/strict';

const { Client } = pg;
const client = new Client({ connectionTimeoutMillis: 5000 });
const limit = 10;

const distinctSql = `
  SELECT DISTINCT topic FROM demo_articles
  ORDER BY topic LIMIT $1::integer
`;

const seekSql = `
  WITH RECURSIVE topics(topic, step) AS (
    (SELECT topic, 1 FROM demo_articles ORDER BY topic LIMIT 1)
    UNION ALL
    SELECT n.topic, t.step + 1
    FROM topics AS t
    CROSS JOIN LATERAL (
      SELECT a.topic FROM demo_articles AS a
      WHERE a.topic > t.topic ORDER BY a.topic LIMIT 1
    ) AS n
    WHERE t.step < $1::integer
  )
  SELECT topic FROM topics ORDER BY topic
`;

async function showPlan(label, sql) {
  const plan = await client.query(
    `EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) ${sql}`, [limit]
  );
  console.log(`\n${label}`);
  console.log(plan.rows.map(row => row['QUERY PLAN']).join('\n'));
}

try {
  await client.connect();
  const version = await client.query('SHOW server_version');
  console.log('PostgreSQL', version.rows[0].server_version);

  await client.query(`
    CREATE TEMP TABLE demo_articles (
      article_id integer NOT NULL,
      topic text COLLATE "C" NOT NULL,
      title text NOT NULL
    )
  `);

  await client.query(`
    INSERT INTO demo_articles (article_id, topic, title)
    SELECT n,
           'topic-' || lpad((((n - 1) / 1000) + 1)::text, 3, '0'),
           'Sample article ' || n::text
    FROM generate_series(1, 100000) AS s(n)
  `);

  await client.query('ANALYZE demo_articles');
  await showPlan('Before index', distinctSql);

  await client.query(
    'CREATE INDEX demo_articles_topic_idx ON demo_articles (topic)'
  );
  await client.query('ANALYZE demo_articles');
  await showPlan('After index', distinctSql);
  await showPlan('Successor seeks', seekSql);

  const ordinary = await client.query(distinctSql, [limit]);
  const seeking = await client.query(seekSql, [limit]);
  assert.deepEqual(seeking.rows, ordinary.rows);
  assert.equal(ordinary.rows.length, limit);
  console.log(ordinary.rows.map(row => row.topic));
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
} finally {
  await client.end();
}

数据生成器使用 PostgreSQL 文档中的 generate_series 函数生成从 1 到 100,000 的整数。该算术运算会把每连续 1,000 篇文章分配给同一个主题。ANALYZE 会提供规划器选择计划时使用的表统计信息。Set Returning Functions、ANALYZE

运行程序,并使用现有本地数据库的名称。例如,如果该数据库名为 postgres:

PGHOST=localhost PGPORT=5432 PGDATABASE=postgres node distinct-demo.mjs

预期主题名称为 topic-001 到 topic-010;断言会检查结果数量,并确认两个查询的结果一致。本文使用的 JavaScript 语法和 9,001 行流式计算已做过检查。研究环境无法运行数据库,因此有意省略了耗时数据和捕获的 PostgreSQL 计划。

为什么仅索引扫描仍可能需要大量工作

Index Only Scan 表示查询可以从索引中获取所需的列值。这并不意味着每个不同结果只对应一个索引条目。即使表本身很少被访问,也要检查该节点的实际行数。

访问堆是另一个需要考虑的因素。PostgreSQL 会检查可见性映射;如果对应页面没有标记为全可见,仍可能访问堆元组。因此,即使使用仅索引计划,刚插入的示例行也可能产生堆取数。请同时检查 Heap Fetches 和扫描行数。Index-Only Scans and Covering Indexes

使用 INCLUDE 添加额外的负载列,可以让仅索引扫描获取更多值,但不会改变所需的不同主题数量。应根据已证实的堆访问工作量来选择索引覆盖范围;重复值处理量要单独测量。Index-Only Scans and Covering Indexes

PostgreSQL 18 的跳跃扫描能自动解决这个问题吗?

PostgreSQL 18 引入了 B-tree 跳跃扫描支持。文档说明,它旨在改进某些多列索引搜索:当后续列上的约束可以引导系统针对前面列的各个值反复搜索时,这项功能就能发挥作用。该功能在 18 版中引入;不要假设 PostgreSQL 17 或更早版本也有此功能。Release 18、Multicolumn Indexes

自动从一个不同的首列键跳到下一个,通常称为松散索引扫描。PostgreSQL 项目 Wiki 将此操作与面向谓词的索引跳跃扫描区分开来,并介绍了如何用递归 SQL 模拟。因而,PostgreSQL 18 关于跳跃扫描的公告并不能证明系统会对这个单列 DISTINCT 查询自动执行松散扫描。Loose indexscan

程序中的后继值搜索查询改编自这种模拟方式:先找出最小主题,然后反复查找大于上一个主题的最小主题。递归步骤会在得到十个结果或找不到后继值时停止。使用合适的索引时,每次搜索都可以越过前一个重复值组继续查找。应比较它与普通查询的实际计划和执行时间,不要假定它一定更快。Loose indexscan

此示例有意要求主题值非空、按升序排列、使用声明的 C 排序规则,并且限制值为正数。限制值为零时,仍会返回递归锚点,因此通用实现必须单独处理零值。空主题值需要明确处理,因为大于比较不会枚举空值。如果你添加文章筛选条件,应将相同条件应用于初始搜索和每次后继搜索,否则改写后的查询可能会返回不同的结果集。

根据证据选择下一步改动

收集计划后,可以使用以下决策指南。它将排序索引可以消除的工作,与重复主题值导致的工作区分开来。

观察到的结果 接下来要检查的内容
扫描节点将数据传给聚合和最终排序 将现有计划与匹配主题排序方式的 B-tree 索引进行比较。
有序扫描为了得到十个主题输出了数千行 测量重复值组的大小,并比较显式的后继值搜索。
仅索引扫描报告了大量堆取数 将可见性和堆访问工作与重复值数量分开检查。
估算行数与实际行数相差很大 使用 ANALYZE 更新统计信息,然后再次测量。
带索引的普通查询开销已经很低 除非其他测量得出的需求支持改写,否则保留较简单的查询。

避免将 LIMIT 10 移入子查询,让它先选出十篇原始文章,再应用 DISTINCT。在示例的排序中,这十篇文章都属于 topic-001,所以改写后的查询只会返回一个主题,而不是十个。这会改变任务本身。

还要确保去重键准确。将 article_id 加入所选列后,每篇文章都会成为不同的输出行,从而破坏按主题去重。如果任务改为每个主题选一篇代表文章,PostgreSQL 的 DISTINCT ON 支持这种操作,其表达式必须与排序表达式最左侧的部分相匹配。它本身并不保证扫描的行数会减少。SELECT

要获取最前面的十个不同文章主题,先比较扫描节点的实际输出与最终返回的十个结果。然后测试匹配的索引;如果重复值组占主要部分,再测试有界后继值搜索查询。选择与数据相符、且实测工作量合适的方法,同时保持所需的主题集合和排序顺序。

来源