长时间运行的 PostgreSQL 快照可能会保留它从未读取过的中间行版本。VACUUM 使用保守的事务边界来判断哪些内容可以移除。本指南将说明原因,并在 Mac 上使用合成文章行和 node-postgres 重现这一行为。

任务是区分阻止清理的快照与尚未运行的 vacuum。示例面向 PostgreSQL 18,并使用两个数据库连接:一个持有 Repeatable Read 快照的读取方,以及一个更新单篇文章的写入方。PostgreSQL 文档目前将 18 列为当前受支持的主版本;下文的实现细节明确针对其 18 分支。PostgreSQL 18 文档。

为什么要保留旧读取方看不到的版本?

更新会创建新的行版本。当另一个事务可能仍需要旧版本时,PostgreSQL 会保留它。最终,vacuum 可以回收过时版本,并使其占用的存储空间可重新使用。这是常规清理中描述的基本 MVCC 生命周期。

微妙之处在于,快照可见性和物理可移除性回答的是不同问题。可见性判断某个查询是否应返回某个版本。清理使用的是保守的截止点,而不是逐个证明每个现有快照究竟能否读取该版本。

在 PostgreSQL 18 中,HeapTupleSatisfiesVacuum 会将使某个版本过时的事务与 OldestXmin 进行比较。已提交的删除或替换不会立即使旧版本变得可移除:删除事务 ID 大于或等于该截止点的版本仍会被归类为最近死亡。官方元组可见性实现直接记录了这条规则。

来看下面这个示意序列。每次更新分别提交,且没有其他读取方:

步骤 操作 旧读取方看到的内容 最新已提交版本
1 插入文章修订版 0 — 修订版 0
2 读取方建立 Repeatable Read 快照 修订版 0 修订版 0
3 写入方提交修订版 1 修订版 0 修订版 1
4 写入方提交修订版 2 修订版 0 修订版 2
5 写入方提交修订版 3 修订版 0 修订版 3

读取方直接需要修订版 0。修订版 1 和 2 是中间版本:因为它们是在读取方快照建立后创建的,所以该读取方看不到它们。不过,替换它们的事务也都晚于保留的清理截止点。根据文档所述的截止点规则,就能解释为什么这些版本可以与修订版 0 一并保留。保留这些版本并不意味着读取方可以把它们作为历史查询结果逐个读取。PostgreSQL 18 元组可见性实现。

这个边界的范围比读取方选中的行更广。PostgreSQL 18 根据后端事务 ID 和快照边界计算适用于关系的清理边界;对于普通表数据,通常会考虑同一数据库中的会话。它不会只跟踪某个特定 SELECT 返回的行。这解释了为什么一个小查询可能影响该数据库其他位置的清理。共享关系和其他关系类型有各自的规则,因此说“一个快照会阻止所有地方的 vacuum”就过于宽泛了。PostgreSQL 18 边界实现。

哪个事务实际上持有长时间快照?

Repeatable Read 会在第一条非事务控制语句执行时建立快照,并在后续查询中继续使用该快照。仅仅发出 BEGIN 不足以完成此演示;最初的文章查询才会建立需要保留的视图。PostgreSQL 的默认隔离级别 Read Committed 则会为每条命令获取一个新快照。事务隔离。

因此,已经查询过该表的空闲 Repeatable Read 事务,即使查询结束,也可以继续持有快照。长时间运行的 Read Committed 语句在执行期间也可以保留快照,但该级别下分别完成的语句不会共享同一个固定视图。因此,仅凭事务持续时间无法确定保留的是哪个快照。事务隔离。

只读事务即使没有自己的顶层事务 ID,也可能影响清理边界。实现会同时考虑后端事务 ID 和快照边界。在本例中,backend_xid 为 null 而 backend_xmin 非 null,这与读取方持有快照的情况是一致的。PostgreSQL 18 边界实现。

从 Mac 终端重现这一行为

你需要运行中的本地 PostgreSQL 18 服务器、其命令行工具,以及装有 npm 的 Node.js。node-postgres 兼容性文档列出了 Node.js 20 或 24。本指南假设你现有的本地连接设置可正常使用。它会创建一个独立数据库,并且只使用合成文章文本。node-postgres 简介与安装。

在 Terminal 中创建工作目录、安装驱动程序并创建示例数据库:

mkdir article-snapshot-lab
cd article-snapshot-lab
npm init -y
npm install pg
createdb snapshot_lab

将下面的完整程序保存为 snapshot-demo.cjs。如node-postgres 连接文档所述,程序中的连接会继承 PostgreSQL 环境设置,包括 PGDATABASE、PGHOST 和 PGPORT。为 createdb 和程序使用相同的本地连接设置。

读取方会在一个专用客户端上执行所有事务语句。node-postgres 要求这样做:事务必须始终使用同一个客户端,因此不适合通过 pool.query() 分派事务语句。第二个客户端会独立运行更新和 vacuum 命令。node-postgres 事务。

示例还会为 revision 建立索引,该字段每次更新都会变化。这样做是为了确保这些更新不会使用 HOT,使演示更易于理解。PostgreSQL 文档指出,HOT 要求更新不更改普通索引引用的列,并且原页面上有足够空间。仅堆元组。

const { Client } = require('pg');

async function main() {
  const reader = new Client({
    application_name: 'article_snapshot_reader',
    connectionTimeoutMillis: 5000
  });
  const writer = new Client({
    application_name: 'article_snapshot_writer',
    connectionTimeoutMillis: 5000
  });
  let readerOpen = false;
  writer.on('notice', notice => console.log(notice.message));

  try {
    await reader.connect();
    await writer.connect();
    const version = await writer.query('SHOW server_version_num');
    const major = Math.floor(Number(version.rows[0].server_version_num) / 10000);
    if (major !== 18) throw new Error('This walkthrough targets PostgreSQL 18.');

    await writer.query(
      'CREATE TABLE public.article_snapshot_demo (' +
      'id integer PRIMARY KEY, revision integer NOT NULL, title text NOT NULL)'
    );
    await writer.query(
      'CREATE INDEX article_snapshot_demo_revision_idx ' +
      'ON public.article_snapshot_demo (revision)'
    );
    await writer.query(
      'INSERT INTO public.article_snapshot_demo VALUES (1, 0, $1)',
      ['Article draft']
    );

    await reader.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
    readerOpen = true;
    const selectRow = 'SELECT id, revision, title FROM public.article_snapshot_demo';
    console.log('Reader before:', (await reader.query(selectRow)).rows);

    for (const revision of [1, 2, 3]) {
      await writer.query(
        'UPDATE public.article_snapshot_demo SET revision = $1, title = $2 WHERE id = 1',
        [revision, 'Article revision ' + revision]
      );
    }

    const pid = (await reader.query('SELECT pg_backend_pid() AS pid')).rows[0].pid;
    console.log('Reader activity:', (await writer.query(
      'SELECT state, backend_xid, backend_xmin FROM pg_stat_activity WHERE pid = $1',
      [pid]
    )).rows);
    console.log('Writer latest:', (await writer.query(selectRow)).rows);
    console.log('Reader still:', (await reader.query(selectRow)).rows);

    console.log('Vacuum with snapshot open:');
    await writer.query('VACUUM (VERBOSE) public.article_snapshot_demo');

    await reader.query('COMMIT');
    readerOpen = false;

    console.log('Vacuum after reader commit:');
    await writer.query('VACUUM (VERBOSE) public.article_snapshot_demo');
    console.log('Reader fresh:', (await reader.query(selectRow)).rows);
  } finally {
    if (readerOpen) await reader.query('ROLLBACK').catch(console.error);
    await Promise.allSettled([reader.end(), writer.end()]);
  }
}

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

明确指定数据库后运行程序:

PGDATABASE=snapshot_lab node snapshot-demo.cjs

程序的 JavaScript 语法已使用 Node.js 检查。数据库操作序列是依据检索到的文档核对的;本文未在 PostgreSQL 服务器上实际执行该序列。

如何解读两次 vacuum

预期查询结果为:Reader before 得到修订版 0,Writer latest 得到修订版 3,Reader still 再次得到修订版 0。读取方提交后,其下一条独立查询应返回修订版 3。这些是合成序列的预期结果,并非实测输出。

写入方检查读取方时,活动事务结果应显示读取方处于 idle in transaction 状态,并持有 backend_xmin。PostgreSQL 将该状态定义为事务正在等待另一条命令,并将 backend_xmin 定义为后端当前的清理边界。累积统计系统。

查看第一次 vacuum 报告,找出仍不可移除的过时版本。读取方提交后,再对照第二份报告,查看这些版本是否已被移除。在数据库其他活动很少的情况下,三次已提交的替换会产生三个可供检查的过时版本。其他保留的边界或并发维护可能影响具体报告,因此应关注前后变化模式,而不要要求输出文本完全相同。

VACUUM (VERBOSE) 会生成详细的活动报告。程序通过文档中说明的 node-postgres notice 事件打印服务器通知。两次 vacuum 调用都在显式事务块之外运行,因为 PostgreSQL 不允许在事务块内执行 VACUUM。PostgreSQL VACUUM、node-postgres 客户端 API。

如果结果与预期不同,可参考以下判断方法:

观察结果 下一步检查
提交前读取方看到修订版 3 确认已启动 Repeatable Read,并且第一次文章查询是在更新之前执行的。
第一次 vacuum 仍保留过时版本 与读取方提交后的那次操作对照;快照仍打开时版本被保留是预期行为。
读取方提交后版本仍然存在 检查是否有其他保留的边界;结束此读取方事务只会移除它造成的影响。
表统计中的计数似乎不一致 将 n_dead_tup 视为估算值,并对照 vacuum 报告。
表文件没有变小 检查存储空间是否已可重新使用;文件缩小是另一种结果。

PostgreSQL 明确说明,n_dead_tup 是估算计数。记录的 vacuum 时间也能证明维护已经运行,但不能证明所有过时版本都可以移除。这些字段有助于诊断,但都不能替代对保留快照和 vacuum 报告的检查。累积统计系统。

普通 vacuum 通常会使表内空间可重新使用,而不会将其归还给 macOS。完全空闲的尾部数据页是文档中列出的例外。因此,清理后文件大小不变,并不能证明清理失败。常规清理。

应该如何调整应用程序?

如果多个文章查询必须共享一致的数据库视图,应将事务范围限定为这些查询,并在它们完成后结束事务。根据快照保留的影响,在任务允许时,应在事务结束后再执行无关处理和桌面交互。

如果每条查询都可以独立使用最新的已提交视图,Read Committed 会为每条命令提供快照。更改隔离级别或在批次之间提交会改变一致性语义:后续查询可能会看到新提交的文章修订版。只有当这些变化后的结果适合读取方任务时,才采用这种方式。事务隔离。

增加 vacuum 频率无法让某个版本越过未改变的可移除性截止点。结束不必要的快照可以消除它对版本保留的影响。同样,VACUUM FULL 会重写表并需要排他锁,还需要额外空间;它并不是处理本例的常规做法。PostgreSQL 18 元组可见性实现、PostgreSQL VACUUM。

脚本会保留合成表供检查。如果该表已存在,脚本会故意报错,而不是替换已有内容。检查完成后,如果要移除示例表,请在连接到 snapshot_lab 的查询会话中运行以下命令。如果运行过程中在设置阶段出错,请先检查同一张表,再将其移除并重新运行。

DROP TABLE public.article_snapshot_demo;

有用的诊断方法是观察整个变化过程:建立旧快照,提交多个替换,运行 vacuum,结束快照,再运行一次 vacuum。看到原始修订版仍可读取,同时中间版本也暂时无法清理,就能直观理解两者的区别。PostgreSQL 的可见性规则决定查询返回什么;其清理边界决定何时可以重新使用过时存储空间。

来源