选择文章正文可能会排除仅索引扫描:PostgreSQL 必须检索索引中不包含的列。本指南将介绍如何在 Mac 上诊断这一变化,并在 Node.js 中测量其影响。仅索引扫描的要求。

为什么更改所选列会改变执行计划?

假设有一个文章表,包含 id、category 和 body,并且在 (category, id) 上有一个 B-tree 索引。按类别选择 ID 的查询,可以从该索引中同时获得筛选值和结果。添加 body 后,该索引就不足以执行仅索引扫描。PostgreSQL 索引与表的主存储分开,后者称为堆;普通索引扫描也会检索表数据。仅索引扫描和覆盖索引。

此时,规划器会比较检索所需表行的不同方式。它可能保留 Index Scan,将 Bitmap Index Scan 与 Bitmap Heap Scan 组合使用,或者选择 Seq Scan。位图扫描会按物理位置对堆访问进行分组。检索大量分散的行时,扫描整张表可能比逐条获取这些行更便宜。即使位图计划的顶层扫描节点提到了堆,它仍然使用了索引。使用 EXPLAIN。

还要仔细检查原始计划。Index Only Scan 可能报告非零的 Heap Fetches:PostgreSQL 有时需要访问表行来确认可见性。是否能避免这些访问,取决于表的可见性映射,也取决于索引是否包含所需列。仅索引扫描的可见性检查。

在 Mac 上复现对比

使用正在运行的本地 PostgreSQL 数据库,以及已安装且仍受支持的 Node.js 版本和 npm。基准示例使用 PostgreSQL 14–18 均支持的功能;当前获取的 PostgreSQL 手册标明版本为 18。下面的额外序列化诊断需要 PostgreSQL 17 或更高版本。node-postgres 文档介绍了其 Node.js 兼容性和安装命令。PostgreSQL 文档、node-postgres 安装与兼容性。

打开 Terminal,并在一个新文件夹中操作。以下命令会创建项目并安装数据库驱动:

mkdir article-plan-lab
cd article-plan-lab
npm init -y
npm install pg

程序通过 PGHOST、PGPORT、PGDATABASE 和 PGUSER 使用现有的本地连接设置。请将它们配置为可供你连接的数据库。创建 Client 时如果没有显式连接选项,node-postgres 会读取这些环境变量。使用环境变量连接。

将下面的完整程序保存为 article-plan.cjs。它会在临时表中创建模拟行、构建索引、打印两个执行计划,然后测量两个普通查询。两项对比使用同一个连接和类别值。查询配置通过 text 提供 SQL,通过 values 提供参数,符合 node-postgres 文档中的 API。查询配置。

const { Client } = require('pg');
const { performance } = require('node:perf_hooks');

async function main() {
  const client = new Client();
  try {
    await client.connect();
    const version = await client.query('SHOW server_version');
    console.log(version.rows[0]);

    await client.query(`CREATE TEMP TABLE article_plan_demo (
      id integer PRIMARY KEY,
      category integer NOT NULL,
      body text NOT NULL
    )`);
    await client.query(`INSERT INTO article_plan_demo
      SELECT g, g % 10, repeat('Synthetic article paragraph. ', 40)
      FROM generate_series(1, 50000) AS s(g)`);
    await client.query(`CREATE INDEX article_plan_demo_category_id
      ON article_plan_demo (category, id)`);
    await client.query('VACUUM (ANALYZE) article_plan_demo');

    const queries = [
      ['IDs', 'SELECT id FROM article_plan_demo WHERE category = $1'],
      ['Bodies', 'SELECT id, body FROM article_plan_demo WHERE category = $1']
    ];

    for (const [label, text] of queries) {
      const plan = await client.query({
        text: `EXPLAIN (ANALYZE, BUFFERS, VERBOSE) ${text}`,
        values: [3]
      });
      console.log(label);
      console.log(plan.rows.map(row => row['QUERY PLAN']).join('\n'));

      const start = performance.now();
      const result = await client.query({ text, values: [3] });
      const elapsed = performance.now() - start;
      const bodyBytes = result.rows.reduce((sum, row) =>
        sum + (row.body === undefined ? 0 : Buffer.byteLength(row.body)), 0);
      console.log({
        rows: result.rows.length,
        elapsedMs: elapsed.toFixed(2),
        bodyBytes
      });
    }
  } finally {
    await client.end();
  }
}

main().catch(error => {
  console.error(error.message);
  process.exitCode = 1;
});

在同一文件夹中运行它:

node article-plan.cjs

初始化会将 VACUUM (ANALYZE) 作为单独的命令发送。该命令先执行清理,再收集统计信息,且不能在事务块内运行。请勿将它放入你为示例添加的任何事务包装器中。VACUUM。

JavaScript 通过了语法检查,另一次确定性计算也确认了样例数据的行数和正文总字节数。研究环境中没有 PostgreSQL,因此这里只检查了这些数据库查询,并未实际执行。本文没有将任何特定计划或运行时间作为观测结果呈现。

按有用的顺序解读执行计划

先看扫描名称,再比较估算行数与实际行数。接着检查表访问和缓冲区活动。按此顺序检查,可以区分可用访问方式的变化、估算不准确和结果获取成本高这几种情况。VERBOSE 还会显示每个节点的输出列。使用 EXPLAIN、EXPLAIN 选项。

观察结果 解释 下一步检查
ID 使用 Index Only Scan;正文使用 Index Scan 正文查询需要表数据 比较缓冲区和普通查询的耗时
正文使用 Bitmap Heap Scan 检查其子节点是否使用了索引 比较堆块数和返回的行数
正文使用 Seq Scan 规划器倾向于扫描整张表 比较估算的匹配行数和实际行数
ID 查询显示大量 Heap Fetches 仅索引执行仍然访问了堆 检查这是否由近期的表变更造成
估算值与实际行数差异很大 统计信息可能无法准确描述数据 收集统计信息后再次比较

ANALYZE 会收集规划器用于选择计划的统计信息。如果你更改了模拟数据的类别分布,请重新收集统计信息,再解读下一次对比。由于采用了抽样,不同运行之间的估算值可能略有差异。ANALYZE。

将 width 视为每行输出字节数的估算值,而不是传输字节数的测量值。同样,规划器成本使用相对单位,而非毫秒。缓冲区命中表示在 PostgreSQL 缓存中找到块;这并不意味着没有进行任何操作。计划估算值、缓冲区报告。

对于此样例,两个查询都应返回 5,000 行。每条模拟正文包含 1,160 个 ASCII 字节,因此正文查询会返回 5,800,000 个正文字节,即约 5.53 MiB;这还不包括 ID 和协议开销。这是根据样例输入计算得出的结果,并非数据库性能测量。程序中的 bodyBytes 字段可帮助你确认这一区别。

普通查询在其执行计划测量之后运行,因此其耗时反映了已预热的执行顺序。在得出微小的计时差异结论之前,请重复对比并交替执行顺序。单次运行有助于诊断,但不足以确定可靠的性能比率。

为什么获取正文可能比 EXPLAIN 所示耗时更长?

大型文本值可能会被压缩,也可能通过 PostgreSQL 的 TOAST 机制存储在主表之外。因此,检索行的主表记录和向客户端生成完整正文,可能需要不同的工作量。上面的简短重复样例用于展示列覆盖情况,并不代表所有正文存储模式。TOAST 存储。

默认情况下,EXPLAIN ANALYZE 会丢弃查询结果,不会将其转换后发送。对于 PostgreSQL 17 及更高版本,可以添加 SERIALIZE TEXT 来检查输出转换;必要时,这也包括获取行外存储的值。PostgreSQL 17 引入了此选项。EXPLAIN 序列化行为、PostgreSQL 17 发布说明。

要尝试此选项,请将程序中现有的 EXPLAIN 模板行替换为以下代码片段,然后重新运行整个程序:

text: `EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SERIALIZE TEXT) ${text}`,

在 PostgreSQL 14–16 上,保留原来的行,并使用普通查询计时器测量接收结果所需的时间。SERIALIZE 仍不会向客户端发送结果数据,因此无法测量数据传输或 JavaScript 处理耗时。EXPLAIN 的限制。

node-postgres 通常会将结果行收集为 JavaScript 对象。其 rowMode: 'array' 选项会改变结果的表示形式,但不会从 SQL 结果中移除选定的正文值。因此,当此查询添加 body 后,它无法恢复丢失的列覆盖。行模式。

选择符合文章任务的改动

如果桌面任务只需要 ID 列表,请保持投影范围较窄。如果需要完整文章文本,请评估正文查询的实际耗时和结果大小。诊断的目标是在保留所需结果的同时找出不必要的工作。

如果屏幕只显示某个类别中前 50 篇文章,限制请求的结果数量就是一种有效替代方案。在程序的 queries 数组中,将正文查询字符串替换为以下 SQL:

SELECT id, body
FROM article_plan_demo
WHERE category = $1
ORDER BY id
LIMIT 50

这会刻意将任务从检索所有匹配文章改为检索一个有序页面。类别固定后,现有的 (category, id) 索引可提供按 ID 排序的候选访问路径。匹配的 B-tree 索引可以高效执行带 LIMIT 的 ORDER BY,因为找到足够多的行后即可停止执行。仍需检查实际选择的计划。索引与 ORDER BY。

不要将 INCLUDE (body) 当作自动修复方案。包含列会重复存储表数据、增大索引;如果索引元组超过大小限制,还可能导致插入失败。小型负载列可以作为经过测量的实验候选项。INCLUDE 在 PostgreSQL 11 中引入;更早版本不支持。覆盖索引注意事项、PostgreSQL 11 发布说明。

调查结束时,请得出三个具体结论:选择正文是否导致失去仅索引扫描资格、新的扫描方式性能是否可接受,以及完整结果到达 Node.js 需要多长时间。这些结论可以帮助你决定是保留该计划、减少请求的文章数量,还是针对实际查询测试其他索引。

来源