Чтобы транзакция node-postgres выполнялась целиком, получите один клиент с помощью pool.connect(), выполните все запросы через него и освободите его после commit или rollback. Передавайте этот клиент и вспомогательным функциям. В официальном руководстве по транзакциям требуется именно такой шаблон.
Это руководство предназначено для разработчика, который работает в Терминале на Mac и хочет, чтобы несколько изменений строк статей либо применялись вместе, либо вместе отменялись. В нем приведена полная программа с синтетическими строками статей, намеренной проверкой отката и последовательностью диагностики запросов, скрытых внутри вспомогательных функций.
Почему pool.query() нарушает область действия транзакции?
Пул управляет подключениями, но не объединяет все запросы в одну транзакцию. Каждый вызов pool.query() сам получает доступный клиент. Поэтому нет гарантии, что последовательные вызовы будут использовать одно и то же соединение. В описании API Pool для node-postgres прямо предостерегают от использования этого метода для транзакций.
Например, отправка BEGIN через пул, а затем отправка запроса на вставку через пул не гарантирует, что эта вставка попадет в начатую вами транзакцию. Обычно PostgreSQL выполняет операторы вне явного блока транзакции в отдельных транзакциях. Более поздний откат на другом соединении не сможет отменить эти уже зафиксированные изменения. См. документацию PostgreSQL по BEGIN.
Граница транзакции определяется выданным клиентским объектом. Название вспомогательной функции, модуль, в котором она находится, и ожидание ее результата вызывающим кодом не определяют принадлежность к транзакции. Вспомогательная функция участвует в транзакции, когда ее запросы используют клиент, начавший эту транзакцию. Это напрямую следует из требования использовать один и тот же клиент в руководстве по транзакциям.
Выполните на Mac полный пример с фиксацией и откатом
Вам понадобятся Node.js с npm, запущенный сервер PostgreSQL и существующая локальная база данных для разработки. В этом руководстве предполагается, что база называется article_demo, а обычные параметры подключения для локальной среды уже настроены. Установка базы данных не входит в эту задачу по работе с транзакциями.
В Терминале создайте новую рабочую директорию и установите драйвер:
mkdir pg-transaction-demo
cd pg-transaction-demo
npm init -y
npm install pg
Сохраните следующую полную программу в файл transaction-demo.mjs. Временная таблица содержит только синтетические идентификаторы и заголовки статей. Временные таблицы PostgreSQL обычно сохраняются после фиксации транзакций и удаляются при завершении сеанса; благодаря этому программа может проверить успешную группу запросов после отката второй группы. См. CREATE TABLE.
Приведенный ниже помощник транзакций следует документированной последовательности BEGIN, COMMIT и ROLLBACK. Он также явно обрабатывает случай, когда откат тоже завершается ошибкой, сохраняя обе ошибки вместо подмены исходной. Значения запросов передаются через API текста запроса и значений, описанный в разделе Queries.
import pg from 'pg';
import assert from 'node:assert/strict';
const { Pool } = pg;
async function inTransaction(client, work) {
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
try {
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (error) {
try {
await client.query('ROLLBACK');
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
'Transaction failed; rollback also failed'
);
}
throw error;
}
}
async function insertArticle(client, article) {
const result = await client.query(
`INSERT INTO pg_temp.transaction_demo_articles (id, title)
VALUES ($1, $2)
RETURNING id, title`,
[article.id, article.title]
);
return result.rows[0];
}
async function insertBatch(client, articles) {
const inserted = [];
for (const article of articles) {
inserted.push(await insertArticle(client, article));
}
return inserted;
}
async function main() {
const pool = new Pool({ connectionTimeoutMillis: 5000 });
let client;
let discardClient = false;
pool.on('error', (error) => {
console.error('Idle client error:', error);
process.exitCode = 1;
});
try {
client = await pool.connect();
await client.query(`
CREATE TEMP TABLE transaction_demo_articles (
id text PRIMARY KEY,
title text NOT NULL
) ON COMMIT PRESERVE ROWS
`);
const saved = await inTransaction(client, (tx) =>
insertBatch(tx, [
{ id: 'article-001', title: 'A guide to paper shapes' },
{ id: 'article-002', title: 'Choosing a notebook layout' }
])
);
assert.equal(saved.length, 2);
await assert.rejects(
() => inTransaction(client, (tx) =>
insertBatch(tx, [
{ id: 'article-003', title: 'Arranging a reading desk' },
{ id: 'article-001', title: 'Deliberate duplicate ID' }
])
),
(error) => error.code === '23505'
);
const remaining = await client.query(`
SELECT id, title
FROM pg_temp.transaction_demo_articles
ORDER BY id
`);
assert.deepEqual(
remaining.rows.map((row) => row.id),
['article-001', 'article-002']
);
console.log('Commit and rollback checks passed.');
console.table(remaining.rows);
} catch (error) {
discardClient = true;
throw error;
} finally {
if (client) client.release(discardClient);
await pool.end();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Запустите программу из той же директории:
PGDATABASE=article_demo node transaction-demo.mjs
Пул считывает переменные окружения для подключения к PostgreSQL. Если для локальной настройки нужны другие параметры подключения, задайте подходящие значения PGHOST и PGPORT вместе с PGDATABASE. Эти переменные и значения по умолчанию описаны в официальном руководстве по подключению.
При успешном запуске ожидайте сообщение Commit and rollback checks passed. и две исходные строки статей. Во второй группе сначала вставляется article-003, а затем программа пытается повторно использовать article-001. Дубликат вызывает ошибку SQLSTATE 23505, описанную как unique_violation в разделе Обработка ошибок сериализации. Откат группы удаляет и предшествующую вставку; проверка подтверждает, что article-003 отсутствует.
Успешный путь, путь ошибки при выполнении работы и путь ошибки отката в помощнике транзакций проверены с помощью детерминированного mock-клиента. Пример с базой данных здесь не запускался, поскольку в среде не было пакета pg и тестового подключения к PostgreSQL; выше описан ожидаемый результат.
Как вспомогательным функциям совместно использовать клиент транзакции?
Явно передавайте клиент параметром по всей цепочке вызовов. В программе функция main() получает клиент, inTransaction() передает тот же объект своей функции обратного вызова, а insertBatch() передает его дальше в insertArticle(). Ни одна из функций вставки не получает соединение из пула.
В этой реализации обязанности распределены так:
| Функция | Назначение |
|---|---|
main() |
Получить клиент, выполнить настройку примера, освободить клиент и закрыть пул скрипта. |
inTransaction() |
Начать и завершить одну транзакцию на предоставленном клиенте. |
insertBatch() |
Дождаться завершения каждой операции со статьей и передать предоставленный клиент дальше. |
insertArticle() |
Выполнить вставку через предоставленный клиент. |
Контракт помощника намеренно узкий: вызывайте inTransaction() только тогда, когда у клиента нет активной транзакции, и позволяйте ошибкам выходить из функции обратного вызова. Вызов BEGIN внутри существующей транзакции PostgreSQL не создает отдельную вложенную транзакцию; для вложенной работы PostgreSQL предлагает точки сохранения. См. BEGIN.
Дождитесь завершения каждой операции, относящейся к транзакции, прежде чем возвращаться из функции обратного вызова. В этом примере последовательный цикл явно показывает этот момент. Если запустить асинхронные вставки, не возвращая и не ожидая их промисы, функция обратного вызова завершится, не получив результаты всех операций.
Что должно происходить при ошибке запроса или отката?
Обычная ошибка запроса попадает в блок catch помощника транзакций. Тот пытается выполнить откат на том же клиенте и повторно выбрасывает исходную ошибку. Намеренный дубликат считается допустимым только после завершения отката. Если откат завершается ошибкой, помощник выбрасывает AggregateError; проверка на ошибку дубликата не принимает ее, поэтому выполнение переходит во внешний обработчик ошибок.
При любой неожиданной ошибке пример на всякий случай уничтожает клиент с помощью client.release(true). В штатном режиме клиент возвращается в пул через client.release(false). Аргумент для уничтожения описан в API Pool.
Всегда освобождайте полученный клиент. Иначе последующие вызовы могут ждать соединение, которое так и не станет доступным. После завершения этого отдельного скрипта закройте пул; в приложении пул следует закрывать при завершении работы приложения. В руководстве по использованию пула описаны требования к освобождению клиентов и то, как pool.end() ожидает возврата выданных клиентам соединений.
Гарантирует ли один клиент стабильный снимок данных?
Непрерывное использование одного клиента и уровень изоляции отвечают на разные вопросы. Клиент определяет, в какую транзакцию попадут ваши операторы. Уровень изоляции определяет, какие данные увидят эти операторы, пока выполняются другие транзакции. В документации PostgreSQL по изоляции транзакций определены следующие ограничения:
| Уровень изоляции | Важное ограничение |
|---|---|
READ COMMITTED |
Значение по умолчанию. Последовательные обычные чтения могут видеть разные данные, зафиксированные параллельными транзакциями. Для каждого оператора создается новый снимок. |
REPEATABLE READ |
Используется снимок, созданный при первом операторе, не управляющем транзакцией. PostgreSQL предотвращает фантомные чтения, но аномалии сериализации остаются возможными. |
SERIALIZABLE |
Зафиксированные сериализуемые транзакции ведут себя так, будто были выполнены последовательно в некотором порядке; транзакция может завершиться ошибкой, и ее потребуется повторить. |
READ UNCOMMITTED |
В PostgreSQL работает как READ COMMITTED. |
Чтения также могут видеть изменения, сделанные ранее в рамках собственной транзакции. Поэтому видимость собственной вставки не означает, что все последующие чтения будут использовать застывшее представление изменений в других транзакциях. См. Изоляция транзакций.
В начале примера явно выбирается READ COMMITTED. Чтобы использовать другой уровень, измените этот оператор перед запуском транзакции. PostgreSQL позволяет задавать характеристики транзакции в BEGIN; если же вы используете SET TRANSACTION, уровень изоляции нужно установить до первого запроса или оператора изменения данных. См. BEGIN и SET TRANSACTION.
Если вы выберете REPEATABLE READ или SERIALIZABLE, будьте готовы к ошибке SQLSTATE 40001. Повторите всю транзакцию, включая логику выбора операторов и значений, а не только неудавшийся запрос. В этом примере такие ошибки намеренно передаются дальше, а повторные попытки не реализованы. В разделе Обработка ошибок сериализации PostgreSQL объясняет это требование и отмечает, что повторная попытка тоже может завершиться ошибкой.
Как найти запрос, который вышел за пределы транзакции?
Проследите одну планируемую группу операций от получения соединения до окончательной фиксации или отката. Проверьте свой код в следующем порядке:
- Найдите вызов
pool.connect()и определите, какой клиент он возвращает. - Проследите каждый вызов вспомогательных функций между
BEGINи завершением транзакции. Убедитесь, что каждая получает и использует этот клиент. - Проверьте общие обертки для запросов на наличие скрытых вызовов
pool.query()или дополнительных получений соединения. - Убедитесь, что каждая операция ожидается, а ошибки передаются дальше из вспомогательных функций.
- Адаптируйте тест с синтетическими строками и дубликатом к своей группе операций и убедитесь, что после ошибки в более позднем операторе предыдущая вставка исчезает.
Успешный тест отката служит полезным свидетельством для проверенного пути, но не доказывает, что во всех ветвях приложения используется нужный клиент. Проверьте и условные вызовы вспомогательных функций. Если запросы используют один клиент, но повторные чтения все равно различаются, прежде чем менять обработку соединений, сравните это поведение с выбранным уровнем изоляции.
Явно показывайте получение и освобождение клиента, передавайте клиент как параметр и завершайте транзакцию только после выполнения всех запланированных операций. Так вы получите понятную структуру для проверки и тест отката, который можно повторять по мере развития процесса обработки статей.
Источники
- Transactions — node-postgres.
- pg.Pool — node-postgres.
- Queries — node-postgres.
- Connecting — node-postgres.
- Pooling — node-postgres.
- BEGIN — PostgreSQL Global Development Group.
- CREATE TABLE — PostgreSQL Global Development Group.
- 13.2. Transaction Isolation — PostgreSQL Global Development Group.
- SET TRANSACTION — PostgreSQL Global Development Group.
- 13.5. Serialization Failure Handling — PostgreSQL Global Development Group.