在查询中使用 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 行开始。以下受控步骤会让每个事务保持打开状态,以便你直接观察并发情况。
- 在会话 A 中执行
BEGIN ISOLATION LEVEL READ COMMITTED;,然后粘贴上面的完整认领语句。保持事务打开。对于未修改的示例数据,返回的 ID 应为1。 - 在会话 B 中执行
BEGIN ISOLATION LEVEL READ COMMITTED;,然后粘贴同一条认领语句。A 仍持有 ID1时,B 应返回 ID2。 - 在会话 B 中执行
ROLLBACK;,然后在会话 A 中执行ROLLBACK;。 - 在任一会话中运行下面的检查查询。两次认领都已回滚,因此三篇文章的状态都应恢复为
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 在显式锁定中说明了这一限制。
对于这个合成文章任务,关键步骤很明确:选择并锁定一条符合条件的行,在同一条语句中更改其状态,并且只有认领成功后才处理返回的文章。在两个会话中验证并发情况,然后决定如何处理工作进程始终未记录完成状态的文章。
来源
- SELECT — PostgreSQL 全球开发组。
- UPDATE — PostgreSQL 全球开发组。
- 13.3. 显式锁定 — PostgreSQL 全球开发组。
- 13.2. 事务隔离 — PostgreSQL 全球开发组。
- 3.4. 事务 — PostgreSQL 全球开发组。
- 9.5 版本 — PostgreSQL 全球开发组。
- psql — PostgreSQL 全球开发组。
- node-postgres:简介与安装 — node-postgres。
- 连接 — node-postgres。
- 连接池 — node-postgres。
- 事务 — node-postgres。
- 查询 — node-postgres。
- pg.Result — node-postgres。