要确保文章列表及其计数一致,请在同一个 node-postgres 客户端上,通过一个 REPEATABLE READ READ ONLY 事务执行这两次读取。本教程介绍这种模式,以及如何在 Mac 上通过两个会话进行检查。

为什么文章列表可能与其计数不一致?

假设一个桌面脚本先获取 craft 主题中已发布的文章,然后统计符合条件的文章。在这两次读取之间,另一个会话更改了一篇文章的主题。返回的列表可能包含两篇文章,而计数却显示一篇。

PostgreSQL 的默认隔离级别 READ COMMITTED 会在每条普通 SELECT 语句开始时为其提供一个快照。因此,即使在同一个事务中,连续读取也可能看到不同的已提交数据。PostgreSQL:事务隔离。

对于这个任务,我们需要获得文章行的一个连贯视图。之后的编辑可以在下一次运行时显示。PostgreSQL 的 REPEATABLE READ 会让连续读取使用由事务中第一条非事务控制语句建立的快照;仅执行 BEGIN 并不会建立该快照。PostgreSQL:事务隔离。

实现时需要满足两个要求:读取前设置隔离级别,并确保所有相关查询都使用同一个客户端。node-postgres 明确要求事务使用单个客户端,并提醒不要对其中的语句使用 pool.query()。node-postgres:事务。

在 Mac 上准备少量文章测试数据

需要一个正在运行的本地 PostgreSQL 服务器、psql 命令,以及安装了 npm 的受支持 Node.js 版本。以下命令假定现有本地连接可使用 postgres 数据库。如果你的工作数据库名称不同,请始终一致地替换它。安装 PostgreSQL 并配置连接是前置条件。

打开 Terminal 并创建一个项目文件夹。node-postgres 的文档包名为 pg。node-postgres:欢迎。

mkdir article-snapshot-demo
cd article-snapshot-demo
npm init -y
npm install pg
export PGDATABASE=postgres
psql -d postgres

在 psql 提示符下运行一次以下设置。它会创建一个专用表,其中只包含合成文章行。显式指定标识符也让后续练习更容易理解。

CREATE TABLE public.article_snapshot_demo (
    id integer PRIMARY KEY,
    title text NOT NULL,
    topic text NOT NULL,
    published boolean NOT NULL
);

INSERT INTO public.article_snapshot_demo
    (id, title, topic, published)
VALUES
    (1, 'Paper Shapes', 'craft', true),
    (2, 'Color Studies', 'craft', true),
    (3, 'Desk Layouts', 'design', true);

如果该表已经存在,请先停止并检查之前的测试数据,不要自动删除或替换它。示例假定起始时恰好有以下三行。运行 Node.js 脚本前,确保 psql 已设置为 \q。

通过同一个已签出的客户端读取列表和计数

将以下完整程序保存为项目文件夹中的 read-articles.mjs。new Pool() 使用文档所述的 PostgreSQL 连接环境变量,包括上文导出的 PGDATABASE 值。node-postgres:连接。

程序会获取一个客户端,以预先指定事务特性的方式启动事务,执行两次读取,并在返回拼装好的结果前提交事务。PostgreSQL 支持直接在 BEGIN 中指定这些特性;事务以 COMMIT 或 ROLLBACK 结束。PostgreSQL:BEGIN。

import pg from 'pg';
const { Pool } = pg;

const pool = new Pool({ connectionTimeoutMillis: 5000 });
pool.on('error', (error) => {
  console.error('Idle database client error:', error.message);
  process.exitCode = 1;
});

async function readArticles(topic) {
  const client = await pool.connect();
  let began = false;
  let discardClient = false;

  try {
    await client.query(
      'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY'
    );
    began = true;

    const articles = await client.query(
      `SELECT id, title, topic
       FROM public.article_snapshot_demo
       WHERE topic = $1 AND published = true
       ORDER BY id`,
      [topic]
    );

    const count = await client.query(
      `SELECT count(*)::text AS total
       FROM public.article_snapshot_demo
       WHERE topic = $1 AND published = true`,
      [topic]
    );

    await client.query('COMMIT');
    began = false;

    return {
      topic,
      articles: articles.rows,
      total: count.rows[0].total
    };
  } catch (error) {
    if (began) {
      try {
        await client.query('ROLLBACK');
      } catch (rollbackError) {
        discardClient = true;
        throw new AggregateError(
          [error, rollbackError],
          'Article read failed; rollback also failed'
        );
      }
    } else {
      discardClient = true;
    }
    throw error;
  } finally {
    client.release(discardClient);
  }
}

try {
  const result = await readArticles('craft');
  console.log(JSON.stringify(result, null, 2));
} catch (error) {
  console.error(error);
  process.exitCode = 1;
} finally {
  await pool.end();
}

在同一个 Terminal 会话中运行程序:

node read-articles.mjs

如果测试数据未更改,预期结果包含文章 1 和 2,且 total 的值为字符串 \"2\"。计数会显式以文本形式返回,因此此示例无需将其转换为 JavaScript 数字。两个查询使用相同的主题和发布状态筛选条件;隔离级别无法修正不匹配的谓词。

清理工作也是这个可用模式的一部分。读取失败时会触发回滚;如果回滚也失败,则会丢弃该客户端。node-postgres 文档中,client.release(true) 用于销毁一个已签出的客户端,pool.end() 用于关闭脚本的连接池。node-postgres:连接池 API。

这是一个独立脚本,因此每次运行后都会关闭连接池。在现有应用程序中,应沿用应用程序的连接池生命周期,并将事务函数放在其中。所有参与这些读取的辅助函数都必须接收已签出的 client;如果某个辅助函数悄悄调用 pool.query(),就会破坏这一边界。node-postgres:事务。

使用两个 Terminal 会话验证快照

快速运行的脚本可能会在另一个会话更改数据之前就结束。按以下特意安排好顺序的步骤操作,可以明确展示相关时序。这些是在已创建表上按会话执行的 SQL 步骤,不是互相独立的设置程序。预期数据库结果依据有文档说明的隔离行为推断,并非声称这是实际运行过的 PostgreSQL 测试。

打开两个 Terminal 窗口,并在每个窗口中运行 psql -d postgres。开始读取之前,先在会话 B 中将合成测试数据的主题恢复原状。等待此语句执行完毕:

-- Session B: restore the fixture before beginning the exercise.
UPDATE public.article_snapshot_demo
SET topic = 'craft'
WHERE id = 2;

在会话 A 中启动读取事务并获取列表。查询完成后暂停:

-- Session A: run these statements, then pause.
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;

SELECT id, title
FROM public.article_snapshot_demo
WHERE topic = 'craft' AND published = true
ORDER BY id;

列表应包含 Paper Shapes 和 Color Studies。现在切换到会话 B,并通过以下独立语句提交主题更改。在显式事务块之外,PostgreSQL 会自动提交成功执行的语句。PostgreSQL:BEGIN。

-- Session B: run while session A remains open.
UPDATE public.article_snapshot_demo
SET topic = 'design'
WHERE id = 2;

返回会话 A 并完成相关读取:

-- Session A: finish the original reader transaction.
SELECT count(*)::text AS total
FROM public.article_snapshot_demo
WHERE topic = 'craft' AND published = true;

COMMIT;

预期计数为 2,与会话 A 先前获取的列表相符。之后重新运行 Node.js 脚本,应该只返回 Paper Shapes,总数为 \"1\"。旧结果和新结果各自代表一个连贯视图。

如需进行可控比较,请重复整个练习,包括在会话 B 中先恢复数据,然后让会话 A 通过 BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY; 启动。保持暂停和更新顺序不变。预期首次列表包含两篇文章,而后续计数为 1。此比较只改变事务设置这一条件。

为相关读取选择合适的边界

调整示例时可参考以下决策指南。先确定哪些输出必须一致,再选择能够提供这些输出的最小数据库操作。

读取任务 合适的做法 需要保持的条件
通过一条普通查询返回一个文章列表 可以使用 READ COMMITTED 执行一条查询 后续查询无需共享该快照。
返回列表并单独查询计数 使用一个 REPEATABLE READ READ ONLY 事务 使用同一个客户端和相同的筛选条件。
分多批次获取同一连贯结果 在同一个事务中完成这些读取 新事务会建立新的视图。
跨多个独立数据库会话协调读取 显式导出和导入快照 独立事务不会自动共享快照。

对于多个会话,PostgreSQL 提供了 pg_export_snapshot() 和 SET TRANSACTION SNAPSHOT。在其他事务导入快照之前,导出事务必须保持打开状态。导入事务必须使用 REPEATABLE READ 或 SERIALIZABLE,并且要在其第一条查询或数据修改语句之前导入快照。命令参考文档还说明了序列化导入的其他限制。PostgreSQL:快照同步函数,PostgreSQL:SET TRANSACTION。

对于这个桌面端的列表与计数任务,保留同一个客户端即可避免额外协调。完成数据库读取并关闭事务后,再等待用户选择其他操作。

这种隔离级别还有哪些问题未解决?

PostgreSQL 的 REPEATABLE READ 可防止幻读,但仍可能出现序列化异常。因此,稳定的快照并不能证明并发工作流的行为等同于依次执行这些操作。它也不会冻结序列值;序列有特殊的事务行为。PostgreSQL:事务隔离。

READ ONLY 禁止更改普通表,但文档说明了某些例外,例如对特定临时表的操作。它本身不会选择稳定快照。应在读取前设置这两个特性:事务执行第一条查询或数据修改语句后,PostgreSQL 不允许再更改隔离级别。PostgreSQL:SET TRANSACTION。

如果报表需要更强的可串行化语义,PostgreSQL 支持 SERIALIZABLE READ ONLY DEFERRABLE。获取快照时它可能会等待,之后则会在运行期间避免因序列化失败而取消。这种权衡不同于本文演示的一般文章读取需求。PostgreSQL:SET TRANSACTION。

对于文章列表和计数,应明确实现边界:获取一个客户端,以所需特性启动事务,读取所有相关结果,提交事务,然后释放客户端。重复使用相同的筛选条件,并仅在事务完成后发布拼装好的结果。这样,下一步桌面操作就能使用一组连贯的文章数据。

来源