PostgreSQL 无法将 ON CONFLICT DO UPDATE 用作通用的“匹配任一唯一键”操作。选择一个定义身份的键,或者在事务中显式检查两个键,并拒绝同时指向不同两行的输入。
本教程帮助在 Mac 终端中工作的开发者使用 node-postgres 实现第二种策略:两个键都不匹配时插入,恰好匹配一篇文章时更新,如果两个键指向不同文章,则明确返回冲突。所有行均为合成数据。
SQL 示例以 PostgreSQL 18 为目标版本,这是检索到的当前文档所对应的版本。实现使用一张普通的非分区表,并设置两个立即生效的唯一约束。根据其他版本的手册改编示例前,请先检查服务器的主版本号。PostgreSQL 18 INSERT 文档
为什么两个唯一约束不能合并成一个冲突目标
假设一篇文章有一个外部引用和一个 slug,两者都必须各自唯一。在一个空的演示数据库中,先运行以下完整设置,并将其保存为 setup.sql。
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
external_key text NOT NULL,
slug text NOT NULL,
title text NOT NULL,
CONSTRAINT articles_external_key_key UNIQUE (external_key),
CONSTRAINT articles_slug_key UNIQUE (slug)
);
INSERT INTO articles (external_key, slug, title)
VALUES
('feed-101', 'blue-notes', 'Blue notes'),
('feed-202', 'green-notes', 'Green notes');
这些声明分别强制执行两条规则。如果将它们替换为 UNIQUE (external_key, slug),数据模型就会改变:只有这两个字段的组合必须唯一,因此不同组合中可以重复外部键,也可以重复 slug。让两列都保持 NOT NULL,也可以避免在本示例中引入可空键的行为。PostgreSQL 约束
对于 DO UPDATE,PostgreSQL 要求指定冲突目标。列列表描述匹配唯一索引所包含的列;它并不表示“任一列”。因此,ON CONFLICT (external_key, slug) 无法从上述两个独立约束中推断出仲裁索引。指定约束名称只会选中该约束,而且语法只提供一个 ON CONFLICT 子句,并不支持串联多个备选处理程序。PostgreSQL INSERT:ON CONFLICT 子句
更深层的问题是要判断传入行代表哪篇现有文章。考虑传入值 feed-101 和 green-notes:外部键属于蓝色文章,而 slug 属于绿色文章。如果用这两个传入键更新任一行,就会与另一行冲突。选择第一个匹配项会掩盖尚未解决的输入决策。
确定每种匹配情况应如何处理
编写查询前,请先使用以下决策表。这是根据此架构提出的应用策略,并非 PostgreSQL 自动提供的行为。
| 传入的外部键 | 传入的 slug | 匹配到的不同记录数 | 本教程中的操作 |
|---|---|---|---|
| 未使用 | 未使用 | 0 | 插入一篇文章 |
| 匹配一篇文章 | 未使用 | 1 | 更新该文章,包括其 slug |
| 未使用 | 匹配一篇文章 | 1 | 更新该文章,包括其外部键 |
| 匹配文章 A | 匹配文章 A | 1 | 更新文章 A |
| 匹配文章 A | 匹配文章 B | 2 | 拒绝输入;保留两篇文章 |
在只有一个匹配项的情况下,策略有意允许任一键识别文章,并允许更改另一个键。请确认这符合你的导入需求。如果外部键绝不能更改,那么仅匹配到 slug 时应改为应用层冲突。
如果你的实际规则更简单——“外部键始终决定文章身份”——请使用常规的指定冲突目标 upsert。下面这条完整语句可在运行上述设置后执行:
INSERT INTO articles (external_key, slug, title)
VALUES ('feed-101', 'blue-notes', 'Revised blue notes')
ON CONFLICT (external_key) DO UPDATE
SET slug = EXCLUDED.slug,
title = EXCLUDED.title
RETURNING id, external_key, slug, title;
这里的 EXCLUDED 提供了拟写入的值。另一个唯一约束仍然有效:如果将拟写入的 slug 改为 green-notes,就会产生独立的唯一性冲突,而不会改为更新另一篇文章。指定冲突目标的 upsert 仅在不存在其他独立错误时,才保证原子地插入或更新。PostgreSQL INSERT 文档
在并发写入期间保持任一键决策的稳定性
先查询再插入,会在做出判断和写入之间产生间隙。另一笔事务可能在此间隙中插入匹配的键。查询中添加 FOR UPDATE 可以锁定检索到的行;如果没有匹配行,就没有检索到的行可锁。因此,仅靠行锁不足以处理这种零匹配情况。PostgreSQL 显式锁定
对于小型导入或偶尔执行的桌面操作,一个直接的方案是在检查任一键之前,对表加 SHARE ROW EXCLUSIVE 锁。该模式会与普通插入、更新和删除操作获取的 ROW EXCLUSIVE 锁冲突,也会与另一个相同模式的锁冲突。普通读取仍可并行进行。PostgreSQL 表级锁模式
最终的执行顺序是:开始事务、获取表锁、查找不同的匹配行、根据决策表处理,然后提交。显式锁会等待与之冲突的锁,并一直持有到事务结束。PostgreSQL LOCK
此处请显式使用 READ COMMITTED。查询会看到在该查询开始前已提交的数据,包括等待获取锁期间完成的写入。将这一快照行为与已获取的表锁结合起来,可为本实现的判断和写入提供稳定依据。PostgreSQL 事务隔离
这是一个范围较广的锁:不相关的文章写入也会等待。请缩短事务,在加锁到提交之间不要进行交互式提示或外部操作。对于频繁的并发导入,在采用此设计前,请重新考虑是否可以用一个规范键来定义身份。
使用 node-postgres 实现事务
你需要一个正在运行的 PostgreSQL 18 演示数据库、psql 命令,以及与你安装的 node-postgres 软件包兼容的 Node.js 版本。在终端中安装软件包并加载设置文件。第一个命令会在演示目录尚无软件包元数据时创建这些元数据。node-postgres 安装与兼容性
npm init -y
npm install pg
psql -v ON_ERROR_STOP=1 -f setup.sql
这些命令假设你当前的本地连接设置已选择演示数据库。程序使用 new Pool(),它读取与 psql 相同的 PostgreSQL 连接环境变量;如有需要,将 PGDATABASE 设置为演示数据库名称。node-postgres 连接
将以下完整程序保存为 upsert-articles.mjs。程序假设设置表没有会改变匹配或更新行为的其他触发器或规则。值通过与 SQL 文本分开提供的位置参数传入。node-postgres 查询
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool();
async function upsertArticle(input) {
const client = await pool.connect();
let discardClient = false;
try {
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
await client.query(
'LOCK TABLE articles IN SHARE ROW EXCLUSIVE MODE'
);
const matches = await client.query(
`SELECT id FROM articles
WHERE external_key = $1 OR slug = $2
ORDER BY id`,
[input.externalKey, input.slug]
);
if (matches.rows.length > 1) {
const error = new Error(
'Incoming keys identify two different articles'
);
error.code = 'ARTICLE_KEY_CONFLICT';
throw error;
}
const values = [input.externalKey, input.slug, input.title];
const result = matches.rows.length === 0
? await client.query(
`INSERT INTO articles (external_key, slug, title)
VALUES ($1, $2, $3)
RETURNING id, external_key, slug, title`,
values
)
: await client.query(
`UPDATE articles
SET external_key = $1, slug = $2, title = $3
WHERE id = $4
RETURNING id, external_key, slug, title`,
[...values, matches.rows[0].id]
);
if (result.rows.length !== 1) {
throw new Error('Expected exactly one article');
}
await client.query('COMMIT');
return result.rows[0];
} catch (error) {
try {
await client.query('ROLLBACK');
} catch {
discardClient = true;
}
throw error;
} finally {
client.release(discardClient);
}
}
try {
console.log(await upsertArticle({
externalKey: 'feed-101',
slug: 'blue-notes',
title: 'Revised blue notes'
}));
} finally {
await pool.end();
}
根据 node-postgres 的要求,每条事务语句都使用同一个已获取的客户端。通过不同的 pool.query() 调用发送这些语句,无法满足这一连接要求。node-postgres 事务
finally 代码块始终会释放已获取的客户端。如果回滚本身失败,release(true) 会丢弃该连接。桌面脚本结束时,外层的 pool.end() 会关闭连接池。node-postgres 连接池 API
运行程序:node upsert-articles.mjs。预期结果是蓝色文章的标题更新,而键保持不变。JavaScript 语法已通过 Node.js 检查;数据库示例已对照文档审阅,但未在 PostgreSQL 服务器上实际执行。
检查各个分支并显式处理失败
使用设置中的行,修改传给 upsertArticle() 的对象,以测试以下情况。每种情况都应针对原始设置状态运行,或考虑之前测试造成的更改。
externalKey |
slug |
预期结果 |
|---|---|---|
feed-303 |
amber-notes |
插入一篇新文章 |
feed-101 |
blue-revised |
更新蓝色文章并替换其 slug |
feed-303 |
blue-notes |
更新蓝色文章并替换其外部键 |
feed-101 |
blue-notes |
更新蓝色文章的标题 |
feed-101 |
green-notes |
抛出 ARTICLE_KEY_CONFLICT;两篇文章都不更改 |
查询返回的是行,因此同时满足两个条件的文章只计数一次。对于这两个独立且非空的唯一键,最多有两行不同的记录可以匹配。代码绝不会通过使用 LIMIT 1、删除文章或静默跳过输入来处理两行结果。
ARTICLE_KEY_CONFLICT 是应用自定义代码。PostgreSQL 的 23505 表示 unique_violation;它是另一种独立情况。改编示例后如果出现后者,请检查违反了哪个约束以及是否增加了任何表行为,不要把所有重复情况都视为相同结果。PostgreSQL 建议检查错误代码,而不是解析错误消息文本。PostgreSQL 错误代码
获取锁可能需要等待。如果希望此桌面任务立即失败,可以将加锁语句替换为 LOCK TABLE articles IN SHARE ROW EXCLUSIVE MODE NOWAIT。如果无法立即获取锁,PostgreSQL 会报错,程序随后执行回滚流程。PostgreSQL LOCK
DO NOTHING 或 MERGE 能简化任务吗?
不指定冲突目标的 ON CONFLICT DO NOTHING 可以跳过与任一可用唯一约束或索引冲突的插入。只有在预期行为就是跳过时才选择它;它无法实现此处制定的更新策略。PostgreSQL INSERT 文档
MERGE 在 PostgreSQL 15 中引入。虽然它允许联接条件涉及任一键,但如果源行匹配到两篇不同的目标文章,该联接仍需有相应的处理策略。其操作仍受普通约束限制,并且其并发行为不能与指定冲突目标的 upsert 互换。PostgreSQL 15 发行说明、PostgreSQL MERGE
对于任一键匹配的任务,应明确根据匹配数量做出决策:零个则插入,一个则更新,两个则停止并返回有用的冲突信息。保留唯一约束,并选择一种并发策略,使这一决策在写入完成前保持有效。如果可以接受对文章写入进行串行化,表锁实现可作为一个具体的起点。
来源
INSERT — PostgreSQL Global Development Group。
约束 — PostgreSQL Global Development Group。
显式锁定 — PostgreSQL Global Development Group。
LOCK — PostgreSQL Global Development Group。
事务隔离 — PostgreSQL Global Development Group。
PostgreSQL 错误代码 — PostgreSQL Global Development Group。
版本 15 — PostgreSQL Global Development Group。
MERGE — PostgreSQL Global Development Group。
node-postgres:简介、兼容性与安装 — node-postgres。
连接 — node-postgres。
查询 — node-postgres。
事务 — node-postgres。
连接池 API — node-postgres。