要确保文章列表及其计数一致,请在同一个 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。
对于文章列表和计数,应明确实现边界:获取一个客户端,以所需特性启动事务,读取所有相关结果,提交事务,然后释放客户端。重复使用相同的筛选条件,并仅在事务完成后发布拼装好的结果。这样,下一步桌面操作就能使用一组连贯的文章数据。
来源
- 事务隔离 — PostgreSQL 全球开发组。
- BEGIN — PostgreSQL 全球开发组。
- SET TRANSACTION — PostgreSQL 全球开发组。
- 系统管理函数:快照同步函数 — PostgreSQL 全球开发组。
- 事务 — node-postgres。
- 连接池 API — node-postgres。
- 连接 — node-postgres。
- 欢迎 — node-postgres。