要拒绝过期的文档保存,请在更新条件中包含编辑器最初加载的修订版本,然后以原子方式递增它。仅靠 SERIALIZABLE 无法表达这一要求,因为文档是在更早的事务中加载的。
本教程面向在 Mac 的 Terminal 中工作的开发者,他们需要让 PostgreSQL 文章编辑器拒绝过时的替换文本。教程使用合成文章行,并提供完整的 node-postgres 示例。目标很明确:只有当存储的修订版本仍与所提交草稿所依据的修订版本一致时,才保存。
为什么 SERIALIZABLE 可能允许过时的替换?
PostgreSQL 将可串行化执行定义为:其效果等同于按某种顺序逐个运行已提交的事务。这一保证针对数据库事务及其操作。PostgreSQL:事务隔离。
下面是一个示意序列:
- 编辑器在修订版本 1 时加载文章 1,随后读取事务结束。
- 另一次保存替换了文章文本,并提交修订版本 2。
- 原编辑器提交基于修订版本 1 的替换文本。
- 一个新的可串行化事务仅凭文章 ID 更新文章 1。
最后一个事务可以成功:这些事务已经按顺序运行。仅以 id = 1 为条件的更新中,没有任何内容表明提交的文本要求修订版本 1。这是对已记录的可串行化定义的直接推论,并非对该定义的例外。
事务重叠时情况不同。如果并发更新与事务快照冲突,或者依赖关系会妨碍串行执行,PostgreSQL 可以拒绝可串行化事务。快照由第一条非事务控制语句建立;仅仅提前发出 BEGIN 并不能保留编辑器此前读取的数据。PostgreSQL:事务隔离。
因此,诊断时应问:保存操作在哪里明确要求使用编辑器加载的修订版本? 如果答案是没有任何地方,那么更改隔离级别并不会补上缺失的条件。
修订版本检查必须保证什么?
将每个已加载的草稿视为一对数据:可编辑文本和基准修订版本。保存请求携带文章 ID、替换文本以及未更改的基准修订版本。数据库更新必须同时匹配 ID 和该修订版本,替换文本,并在同一条语句中递增存储的修订版本。
例如,条件 WHERE id = $1 AND revision = $2 表达了前置要求,而 SET body = $3, revision = revision + 1 执行了状态转换。PostgreSQL 会更新满足条件的行,RETURNING 则返回实际更新行中的值。更新零行仍是成功的 SQL 命令,因此应用必须明确处理这种结果。PostgreSQL:UPDATE。
这种模式通常称为乐观并发控制或比较并交换。对于本文的设计,所有会更改文章可编辑内容的操作都必须递增修订版本。修订版本应设为非空,不要重置,也不要用旧修订令牌复用文章标识;否则,相等的版本号可能会错误地表明旧草稿仍然匹配。
先单独读取,再仅凭 ID 更新,等于没有把这一要求写进更新操作本身。同样,在保存前获取最新修订版本并用它替换提交的修订版本,也会改变请求的含义:这样就允许替换编辑器未曾用作基准的版本。
在 Mac 上运行完整的合成示例
准备一个正在运行的 PostgreSQL 实例、一个已有的临时数据库,以及当前版本或受支持的 LTS 版 Node.js 安装和 npm。打开 Terminal,创建工作文件夹,并使用文档中的 npm install pg 命令安装驱动程序。node-postgres:简介与安装。
mkdir revision-write-demo
cd revision-write-demo
npm install pg
将下面的完整程序保存为该文件夹中的 revision-demo.mjs。程序使用文档中兼容 ESM 的默认导入方式。Client 和 Pool 都可以从环境变量读取 PostgreSQL 连接设置;运行前,请将这些设置配置为连接到你已有的临时数据库。node-postgres:ESM,node-postgres:连接。
程序比较两篇合成文章。文章 1 连续执行两次无条件的可串行化保存。文章 2 则使用相同的原始修订版本进行两次修订版本检查保存。示例使用临时表,避免影响永久文章表。JavaScript 语法已通过 Node.js 检查;研究环境中未执行数据库示例。
import pg from 'pg';
import assert from 'node:assert/strict';
const { Client } = pg;
const client = new Client();
async function saveArticle(id, expectedRevision, body) {
for (let attempt = 1; attempt <= 3; attempt += 1) {
let active = false;
try {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
active = true;
const result = await client.query(
`UPDATE revision_demo_articles
SET body = $3, revision = revision + 1
WHERE id = $1 AND revision = $2
RETURNING id, body, revision`,
[id, expectedRevision, body]
);
if (result.rowCount === 0) {
await client.query('ROLLBACK');
active = false;
return { status: 'stale-or-missing' };
}
await client.query('COMMIT');
active = false;
return { status: 'saved', article: result.rows[0] };
} catch (error) {
if (active) await client.query('ROLLBACK');
if (error.code !== '40001' || attempt === 3) throw error;
}
}
}
await client.connect();
try {
await client.query(`
CREATE TEMP TABLE revision_demo_articles (
id integer PRIMARY KEY,
body text NOT NULL,
revision integer NOT NULL CHECK (revision > 0)
)
`);
await client.query(
`INSERT INTO revision_demo_articles (id, body, revision)
VALUES (1, 'Opening draft', 1), (2, 'Opening draft', 1)`
);
const loaded = await client.query(
'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
);
const expectedRevision = loaded.rows[1].revision;
// Illustrative blind saves: each is a separate transaction.
for (const body of ['First edit', 'Stale replacement']) {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
try {
await client.query(
`UPDATE revision_demo_articles
SET body = $1, revision = revision + 1 WHERE id = 1`,
[body]
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
}
const first = await saveArticle(2, expectedRevision, 'First edit');
const stale = await saveArticle(2, expectedRevision, 'Stale replacement');
assert.equal(first.status, 'saved');
assert.equal(first.article.revision, 2);
assert.equal(stale.status, 'stale-or-missing');
const final = await client.query(
'SELECT id, body, revision FROM revision_demo_articles ORDER BY id'
);
assert.deepEqual(final.rows, [
{ id: 1, body: 'Stale replacement', revision: 3 },
{ id: 2, body: 'First edit', revision: 2 }
]);
console.table(final.rows);
console.log(stale.status);
} finally {
await client.end();
}
如果已有的临时数据库名为 revision_demo,请运行下面的命令。将其替换为你的实际数据库名,并保留本地实例所需的任何连接环境设置。node-postgres:连接。
PGDATABASE=revision_demo node revision-demo.mjs
预期的最终值如下:
| 文章 | 正文 | 修订版本 | 含义 |
|---|---|---|---|
| 1 | Stale replacement |
3 | 两次无条件保存都已提交 |
| 2 | First edit |
2 | 第二次修订版本检查保存被拒绝 |
最终打印的状态应为 stale-or-missing。
这个序列刻意避免依赖时序的并发情况。它直接展示了关键点:即使每个保存事务都是可串行化的,过时的替换仍可能成功,因为这些保存操作可以按有效的串行顺序运行。
node-postgres 应如何处理每种结果?
程序使用驱动程序文档所述的参数化查询 API,将值与 SQL 文本分开传递。它还会检查 rowCount,该属性会报告更新处理的行数;仅检查空的 rows 数组,并不能普遍替代对更新行数的检查。node-postgres:查询,node-postgres:pg.Result。
将该函数集成到应用时,可参考下面的决策表:
| 结果 | 应用操作 |
|---|---|
| 更新一行且成功提交 | 接受保存并保留返回的修订版本 |
| 更新零行 | 将本次保存判定为过时或文章不存在;保留已提交的草稿 |
SQLSTATE 40001 |
回滚并重试整个事务,并采用有次数上限的策略 |
| 其他错误或已达到重试上限 | 显示失败,不要报告保存成功 |
零行结果无法确定文章是已删除还是修订版本已更改。此示例有意将这两种情况合并处理。如果界面需要更详细的信息,可在之后重新加载当前文章,并将结果作为之后观察到的状态呈现。不要将其描述为失败更新发生时精确状态的证明。
序列化失败使用 SQLSTATE 40001。PostgreSQL 要求重试整个事务,包括决定要执行哪些语句和使用哪些值的逻辑。重试可能需要多次尝试,也不保证最终一定完成。示例将三次尝试作为一种应用策略示例。PostgreSQL:序列化失败处理。
在这些尝试中,expectedRevision 必须保持不变。它代表所提交草稿的前置条件。如果另一次保存已经递增了修订版本,新的事务便可以通过更新零行拒绝旧令牌。自动将令牌替换为最新修订版本会失去对过时写入的保护。
只有在 COMMIT 完成后,函数才会返回成功。所有事务语句都使用同一个 Client,这是 node-postgres 的要求。将此模式用于连接池时,请通过 pool.connect() 获取客户端,在整个事务中使用同一个客户端,并在结束后释放它。node-postgres:事务,node-postgres:pg.Pool。
还需要 SERIALIZABLE 或 SELECT FOR UPDATE 吗?
对于范围有限的单行条件更新,Read Committed 同样可以强制执行修订版本前置条件:在等待某个并发更新提交后,PostgreSQL 会针对更新后的行重新评估更新条件。但这并不意味着 Read Committed 能为复杂的多行规则提供保证。PostgreSQL:事务隔离。
如果外围事务依赖更广泛的读写操作,而这些操作需要其保证,请保留 SERIALIZABLE。修订版本条件表达了编辑器的前置要求;隔离级别则控制事务数据库操作之间的交互。请根据实际任务选择两者。
SELECT FOR UPDATE 可用于需要检查某行并执行多项相关操作的事务。该行锁会阻止冲突写入,直到事务结束。在 Repeatable Read 或 Serializable 隔离级别下,如果事务快照建立后某行发生了更改,锁定该行可能会导致错误。PostgreSQL:显式锁定。
保存请求到达时才获取该锁,并不能免除应用比较所提交基准修订版本的责任。锁定当前行无法告知数据库生成草稿时所依据的早期修订版本。对于这里展示的单行替换,条件更新已经将比较和更改合并在一起。
集成此模式前,请检查所有写入内容的路径,在保存被拒绝时保留编辑器中的草稿,并且只有在确认保存后才更新编辑器的基准修订版本。过时的保存被拒绝时,应同时向读者提供当前文章和已保留的草稿,以便读者协调文本,并基于实际查看过的修订版本提交新的保存。
来源
- 13.2. 事务隔离 — PostgreSQL 全球开发组。
- UPDATE — PostgreSQL 全球开发组。
- 13.5. 序列化失败处理 — PostgreSQL 全球开发组。
- 13.3. 显式锁定 — PostgreSQL 全球开发组。
- node-postgres:简介与安装 — node-postgres。
- ESM — node-postgres。
- 连接 — node-postgres。
- 查询 — node-postgres。
- pg.Result — node-postgres。
- 事务 — node-postgres。
- pg.Pool — node-postgres。