在查询中使用 FOR UPDATE SKIP LOCKED,同时将选中行的状态从 ready 更改为 processing。这样,并发工作进程可以认领不同的合成文章行,而保存的状态会在锁结束后阻止再次认领。

本指南适用于在 Mac 上使用 Terminal 且已建立 PostgreSQL 连接的开发者。你将创建三篇合成文章,在两个数据库会话中验证并发认领,并运行一个完整的 node-postgres 示例。

为什么两个工作进程会选中同一篇文章?

普通的 SELECT 不会保留其查询结果。在 PostgreSQL 默认的 Read Committed 隔离级别下,两个工作进程都可能在任何一方修改之前读取到同一条已提交的 ready 行。将这种普通读取及后续更新放在事务中,本身并不能让该选择具有排他性。PostgreSQL 在事务隔离文档中说明了快照行为。

认领需要两项保护:选择文章时锁定行,并在释放该锁之前更改状态。事务结束时,PostgreSQL 会释放行锁。因此,如果单独执行加锁读取,然后再处理文章,那么读取事务结束后,该行又会重新符合条件。请参阅显式锁定。

每个工作进程都应使用相同的认领协议。任何读取 ready 文章并在未成功认领的情况下开始处理的应用路径,仍可能导致重复工作。

创建一个小型合成文章队列

前提条件:运行中的 PostgreSQL 数据库、Terminal 中可用的 psql 命令,以及现有的连接配置。对于 JavaScript 部分,还需要安装当前受支持的 Node.js 版本和 npm。本文从已建立的数据库连接开始;具体安装方式取决于你现有的 Mac 环境。

SKIP LOCKED 功能从 PostgreSQL 9.5 开始提供。这是语法要求的最低版本,并不意味着建议运行已停止支持的版本。请使用受支持的 PostgreSQL 版本;检索到的当前文档显示 PostgreSQL 18 是当前版本。请参阅 9.5 发行说明和当前的 SELECT 文档。

打开 Terminal,并使用你常用的连接设置运行 psql。它会提供一个交互式提示符,用于执行 SQL 并查看结果,详见 psql 文档。在一个不存在 article_claim_demo 的临时数据库中运行以下设置代码一次。它不会替换现有表。

SHOW server_version;
SHOW transaction_isolation;

CREATE TABLE article_claim_demo (
    id integer PRIMARY KEY,
    title text NOT NULL,
    state text NOT NULL DEFAULT 'ready'
        CHECK (state IN ('ready', 'processing', 'done')),
    rendered_title text
);

INSERT INTO article_claim_demo (id, title) VALUES
    (1, 'Paper shapes'),
    (2, 'Desk arrangements'),
    (3, 'Color combinations');

示例假设使用 Read Committed 隔离级别,且该简单表没有其他触发器。完成双会话测试之前,请保持这三条示例行不变。

使用一条 SQL 语句认领一篇文章

工作进程每次需要一篇文章时都运行此语句。候选查询会锁定一条可用行;更新操作会记录认领状态,并返回该行的输入字段。PostgreSQL 的 UPDATE 文档介绍了这种 CTE 与更新结合的模式,并确认 RETURNING 会报告实际更新的行。

WITH candidate AS (
    SELECT id
    FROM article_claim_demo
    WHERE state = 'ready'
    ORDER BY id
    LIMIT 1
    FOR UPDATE SKIP LOCKED
)
UPDATE article_claim_demo AS a
SET state = 'processing'
FROM candidate AS c
WHERE a.id = c.id
  AND a.state = 'ready'
RETURNING a.id, a.title;

锁定操作应放在候选 CTE 中。SKIP LOCKED 允许其他工作进程跳过当前无法立即锁定的候选行。LIMIT 1 限制每次认领的行数,而主键连接则确定唯一的更新目标。PostgreSQL 在 SELECT:锁定子句中明确说明了在 WITH 查询中进行锁定,以及在队列场景中跳过行的用法。

如果没有显式的 BEGIN,PostgreSQL 会在该语句自己的事务中执行它。语句成功后,关联的选中行更新及其状态更改都会先提交,然后才开始后续处理。提交后,行锁会消失,但 state = 'ready' 会排除该文章。请参阅事务。

只处理认领语句返回的文章。结果为空表示本次尝试没有获取到文章;这并不能证明所有工作都已完成。其他工作进程可能正持有符合条件的行。此外,SKIP LOCKED 会跳过行锁等待,但表级锁仍可能导致等待。相关限制见 SELECT。

在两个 Terminal 会话中验证认领行为

打开两个 Terminal 窗口,通过 psql 连接到同一个数据库。从最初的三条 ready 行开始。以下受控步骤会让每个事务保持打开状态,以便你直接观察并发情况。

  1. 在会话 A 中执行 BEGIN ISOLATION LEVEL READ COMMITTED;,然后粘贴上面的完整认领语句。保持事务打开。对于未修改的示例数据,返回的 ID 应为 1。
  2. 在会话 B 中执行 BEGIN ISOLATION LEVEL READ COMMITTED;,然后粘贴同一条认领语句。A 仍持有 ID 1 时,B 应返回 ID 2。
  3. 在会话 B 中执行 ROLLBACK;,然后在会话 A 中执行 ROLLBACK;。
  4. 在任一会话中运行下面的检查查询。两次认领都已回滚,因此三篇文章的状态都应恢复为 ready。
SELECT id, title, state, rendered_title
FROM article_claim_demo
ORDER BY id;

这些是根据文档所述的锁定和回滚行为推导出的预期结果,并非实际运行数据库测试所得。回滚会取消每次状态更新并释放锁。如果改用 COMMIT,这些文章会保持 processing,后续认领也不会选中它们。请参阅显式锁定和事务。

此测试展示了两个有用的现象:会话 B 会跳过当前已锁定的文章;已提交的状态更改会让文章不再出现在后续认领中。将示例应用到现有工作进程时,请验证这两项行为。

使用 node-postgres 运行三个工作进程

回滚两个测试事务后,在 Terminal 中创建一个新的本地项目目录。使用文档中所述的 node-postgres 安装命令安装软件包:

mkdir article-claim-demo
cd article-claim-demo
npm init -y
npm install pg

将下面的完整程序保存为 workers.mjs。它使用与 psql 相同的数据库连接环境变量;node-postgres 在连接文档中说明了这些共享连接变量。每个工作进程都会尝试认领一篇文章,将合成标题转换为大写,并保存结果。

import pg from 'pg';

const { Pool } = pg;
const pool = new Pool({ max: 3 });

const claimSQL = `
WITH candidate AS (
  SELECT id
  FROM article_claim_demo
  WHERE state = 'ready'
  ORDER BY id
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE article_claim_demo AS a
SET state = 'processing'
FROM candidate AS c
WHERE a.id = c.id AND a.state = 'ready'
RETURNING a.id, a.title;
`;

async function worker(label) {
  const claimed = await pool.query(claimSQL);

  if (claimed.rows.length === 0) {
    console.log(`${label}: no row available now`);
    return;
  }

  const article = claimed.rows[0];
  const renderedTitle = article.title.toUpperCase();

  const completed = await pool.query(
    `UPDATE article_claim_demo
     SET rendered_title = $2, state = 'done'
     WHERE id = $1 AND state = 'processing'
     RETURNING id`,
    [article.id, renderedTitle]
  );

  if (completed.rowCount !== 1) {
    throw new Error(`${label}: completion did not update one row`);
  }

  console.log(`${label}: completed article ${article.id}`);
}

try {
  const results = await Promise.allSettled([
    worker('A'),
    worker('B'),
    worker('C')
  ]);

  for (const result of results) {
    if (result.status === 'rejected') {
      console.error(result.reason);
      process.exitCode = 1;
    }
  }
} finally {
  await pool.end();
}

运行 node workers.mjs,然后在 psql 中再次运行检查查询。若三篇未修改的文章都处于 ready 状态,且没有其他活动,预期三篇文章的状态都会变为 done,并且 rendered_title 中的值会是大写形式。工作进程标签及控制台输出顺序不必与文章顺序一致。

这里适合使用 pool.query,因为每次认领都是一条完整语句,而每次完成操作也是一条独立语句。连接池负责检出并归还连接;pool.end() 会在各次尝试完成后关闭连接池。请参阅连接池。

完成查询使用 $1 和 $2 单独传递参数,符合 Queries 的说明。程序会检查返回的 rows 数组是否为空认领,并检查 rowCount 中完成操作更新的行数,这与 pg.Result 的说明一致。

准备期间,JavaScript 通过了语法检查。研究环境中没有 PostgreSQL 可执行文件,因此我们依据文档检查了 SQL 和并发数据库行为,但未在此处实际执行。

选择合适的事务边界并处理未完成的工作

示例会在转换标题之前提交短暂的认领操作。这样可以缩短锁的持有时间,但也明确划定了恢复边界:如果工作进程在认领后、完成前停止,该文章会保持 processing。重新运行程序不会重新认领它。

对于此桌面演示,请停止所有工作进程,并检查任何 processing 文章,然后再有意地让它们重新符合认领条件。如果先前的工作进程仍可能完成操作,就不要自动重置这些文章:该示例没有认领代次字段,无法区分较早的尝试和较晚的尝试。仅防止重复认领并不能提供自动恢复或恰好一次处理。

将示例用于实际场景时,可参考以下决策指南:

你的任务 事务方式 结果
认领文章,然后执行较长时间的应用层工作 先提交单语句认领操作 保留可见的 processing 状态,并单独设计恢复机制
完全在 PostgreSQL 中执行小型转换 在同一个事务中认领并完成 数据库更改可以一起提交或回滚
在 Node.js 中将多条 SQL 语句作为一个事务运行 检出一个客户端,并在整个过程中使用它 在 BEGIN、各条语句以及 COMMIT 或 ROLLBACK 期间保持使用同一个连接

对于最后一种方式,node-postgres 要求事务中的每条语句都使用同一个已检出的客户端。单独的 pool.query 调用可能会使用不同连接,因此无法组成该事务。完成或回滚后,请释放客户端。请参阅 node-postgres 的事务和连接池文档。

明确保留示例所采用的隔离级别假设。在 Repeatable Read 和 Serializable 隔离级别下,如果事务开始后待锁定的行发生变化,就可能出现错误。使用这些级别的应用需要处理事务重试。PostgreSQL 在显式锁定中说明了这一限制。

对于这个合成文章任务,关键步骤很明确:选择并锁定一条符合条件的行,在同一条语句中更改其状态,并且只有认领成功后才处理返回的文章。在两个会话中验证并发情况,然后决定如何处理工作进程始终未记录完成状态的文章。

来源