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
要获取最前面的十个不同文章主题,先比较扫描节点的实际输出与最终返回的十个结果。然后测试匹配的索引;如果重复值组占主要部分,再测试有界后继值搜索查询。选择与数据相符、且实测工作量合适的方法,同时保持所需的主题集合和排序顺序。
来源
- SELECT — PostgreSQL Global Development Group。
- Indexes and ORDER BY — PostgreSQL Global Development Group。
- Using EXPLAIN — PostgreSQL Global Development Group。
- EXPLAIN — PostgreSQL Global Development Group。
- node-postgres — node-postgres。
- Connecting — node-postgres。
- Queries — node-postgres。
- Set Returning Functions — PostgreSQL Global Development Group。
- ANALYZE — PostgreSQL Global Development Group。
- Index-Only Scans and Covering Indexes — PostgreSQL Global Development Group。
- Release 18 — PostgreSQL Global Development Group。
- Multicolumn Indexes — PostgreSQL Global Development Group。
- Loose indexscan — PostgreSQL Wiki。