PostgreSQL не создает вложенную транзакцию, когда вы отправляете вторую команду BEGIN. Он выдает предупреждение и продолжает текущую транзакцию; первый успешный COMMIT завершает ее. Используйте точки сохранения, если вспомогательной функции нужен частичный откат. PostgreSQL: BEGIN

Это руководство поможет разработчику, работающему в Mac Terminal, выяснить, почему вспомогательная функция фиксирует операцию редактирования статьи слишком рано. В примерах используются синтетические строки статей; они показывают, как сохранить одного владельца транзакции и при этом позволить вспомогательной функции отменить собственный пакет изменений. Примеры соответствуют актуальной документации PostgreSQL и node-postgres.

Что происходит, когда BEGIN и COMMIT выполняются внутри другой транзакции?

Команды управления транзакциями SQL работают с текущей транзакцией соединения. PostgreSQL не считает повторные вызовы BEGIN, ожидая соответствующего количества вызовов COMMIT. Вторая команда BEGIN не меняет транзакцию; обычная успешная команда COMMIT фиксирует текущую транзакцию. Еще одна обычная команда COMMIT, отправленная после этого, приводит к предупреждению, поскольку открытой транзакции уже нет. PostgreSQL: BEGIN, PostgreSQL: COMMIT

Для обычной транзакции последовательность границ выглядит так:

Команда, отправленная через одно соединение Результирующая граница
Первая команда BEGIN Открывает транзакцию
Первая вставка статьи Входит в эту транзакцию
Вторая команда BEGIN Выдает предупреждение; внутреннюю границу не создает
Вторая вставка статьи Входит в ту же транзакцию
Первая команда COMMIT Фиксирует обе вставки и завершает транзакцию
Еще один отдельно отправленный оператор Выполняется в собственной транзакции при обычном режиме автокоммита
Последующая команда ROLLBACK Не может отменить уже зафиксированные вставки

Так объясняется распространенный сбой приложения: вызывающий код открывает транзакцию, а затем вызывает вспомогательную функцию, которая отправляет собственные BEGIN и COMMIT. Команда COMMIT во вспомогательной функции также завершает транзакцию вызывающего кода. Последующее исключение в вызывающем коде уже не может вернуть исходную атомарную операцию. Этот вывод следует из документированного поведения границ транзакций в PostgreSQL. PostgreSQL: Transactions

Воспроизведение преждевременной фиксации в Mac Terminal

Вам нужна запущенная база данных PostgreSQL и установленная команда psql с рабочими параметрами подключения. Откройте Terminal и запустите новый сеанс с помощью psql -X. Параметр -X пропускает файлы запуска, благодаря чему пример меньше зависит от локальных настроек. Выполните в этом сеансе следующий сценарий; он явно включает автокоммит. PostgreSQL: psql

Таблица временная и содержит только синтетические названия статей. ON COMMIT PRESERVE ROWS сохраняет строки доступными для проверки при переходе между границами транзакций; PostgreSQL удаляет временную таблицу при завершении сеанса. Это демонстрирует поведение отката внутри сеанса, а не сохранение данных после отключения. PostgreSQL: CREATE TABLE

\set AUTOCOMMIT on

CREATE TEMP TABLE article_boundary_demo (
    id integer PRIMARY KEY,
    title text NOT NULL
) ON COMMIT PRESERVE ROWS;

BEGIN;
INSERT INTO article_boundary_demo VALUES (1, 'Opening draft');

-- Deliberately incorrect attempt to open an inner transaction.
BEGIN;
INSERT INTO article_boundary_demo VALUES (2, 'Middle draft');
COMMIT;

-- The previous COMMIT has already ended the transaction.
INSERT INTO article_boundary_demo VALUES (3, 'Closing draft');
ROLLBACK;

SELECT id, title FROM article_boundary_demo ORDER BY id;

Ожидаемый результат содержит все три строки. Вторая команда BEGIN выдает предупреждение о том, что транзакция уже выполняется. Команда COMMIT фиксирует строки 1 и 2; затем строка 3 фиксируется через автокоммит. Последней команде ROLLBACK нечего отменять, поскольку открытой транзакции нет. Это ожидаемые результаты, выведенные из документированного поведения, а не результат выполнения в базе данных. PostgreSQL: BEGIN, PostgreSQL: Transactions

Выполняйте этот пример в интерактивном psql, как показано. Способ отправки команд имеет значение: несколько операторов SQL, переданных вместе одной строкой через psql -c, могут выполняться в общей неявной транзакции, если их не разделить явными командами управления транзакциями. Если нужно отдельное выполнение, документация рекомендует использовать отдельные команды или стандартный ввод. PostgreSQL: psql

Как точки сохранения сохраняют внешнюю транзакцию?

Точка сохранения отмечает позицию внутри существующей транзакции. ROLLBACK TO SAVEPOINT отменяет изменения после этой позиции, сохраняя предыдущую работу. RELEASE SAVEPOINT удаляет метку и сохраняет уцелевшие изменения в содержащей их транзакции. Сама по себе эта команда их не фиксирует. PostgreSQL: SAVEPOINT, PostgreSQL: RELEASE SAVEPOINT

Выполните этот пример целиком в том же сеансе или в другом новом сеансе psql. Он создает собственную временную таблицу:

CREATE TEMP TABLE article_savepoint_demo (
    id integer PRIMARY KEY,
    title text NOT NULL
) ON COMMIT PRESERVE ROWS;

BEGIN;
INSERT INTO article_savepoint_demo VALUES (1, 'Opening draft');

SAVEPOINT optional_article;
INSERT INTO article_savepoint_demo VALUES (2, 'Optional draft');
ROLLBACK TO SAVEPOINT optional_article;
RELEASE SAVEPOINT optional_article;

INSERT INTO article_savepoint_demo VALUES (3, 'Closing draft');
COMMIT;

SELECT id, title FROM article_savepoint_demo ORDER BY id;

Ожидаемые строки — 1 и 3. Строка 2 отбрасывается при откате к точке сохранения, а начальная и заключительная строки остаются частью одной транзакции до ее последней команды COMMIT. Если заменить последнюю команду COMMIT на ROLLBACK, обе уцелевшие вставки будут отменены. PostgreSQL: SAVEPOINT, PostgreSQL: Transactions

При совместном использовании вспомогательных функций важно также учитывать время жизни точек сохранения. Откат к точке сохранения оставляет ее доступной, но уничтожает более поздние точки. Освобождение точки удаляет ее и все активные точки, созданные после нее. В примере после восстановления явно освобождается метка. PostgreSQL: ROLLBACK TO SAVEPOINT, PostgreSQL: RELEASE SAVEPOINT

Как должны совместно использовать транзакцию вспомогательные функции node-postgres?

node-postgres требует, чтобы все операторы в транзакции выполнялись с использованием одного и того же экземпляра клиента; автоматической абстракции для вложенных транзакций библиотека не предоставляет. Передавайте вспомогательным функциям клиент вызывающего кода. Если приложение использует пул, получите клиент с помощью pool.connect(), используйте его на протяжении всей транзакции, а затем освободите; pool.query() может отправлять последовательные запросы разным клиентам. node-postgres: Transactions, node-postgres: Pool

Следующая программа целиком использует один прямой Client. Вызывающий код управляет командами BEGIN, COMMIT и полным откатом ROLLBACK. Вспомогательная функция создает точку сохранения для необязательного пакета из двух строк. Во второй вставке намеренно повторяется идентификатор статьи 1, что вызывает ошибку уникальности; при восстановлении отбрасывается весь необязательный пакет, включая первую успешную вставку.

Подготовка и запуск полного примера

Используйте актуальную поддерживаемую версию Node.js с установленным npm. В рабочей папке выполните npm install pg, затем сохраните программу ниже как article-transactions.mjs. Она использует существующие переменные среды PostgreSQL для подключения, в том числе PGHOST, PGPORT, PGDATABASE и PGUSER, если они настроены. Перед запуском примера эти параметры должны указывать на работающую базу данных. node-postgres: Welcome, node-postgres: Connecting

import pg from 'pg'
const { Client } = pg

async function addOptionalArticles(client) {
  await client.query('SAVEPOINT optional_articles')
  try {
    await client.query(
      'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
      [2, 'Optional draft']
    )
    await client.query(
      'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
      [1, 'Duplicate draft']
    )
  } catch (error) {
    if (error.code !== '23505') throw error
    await client.query('ROLLBACK TO SAVEPOINT optional_articles')
    await client.query('RELEASE SAVEPOINT optional_articles')
    return false
  }
  await client.query('RELEASE SAVEPOINT optional_articles')
  return true
}

async function main() {
  const client = new Client({ connectionTimeoutMillis: 5000 })
  await client.connect()
  try {
    await client.query(`
      CREATE TEMP TABLE article_demo (
        id integer PRIMARY KEY,
        title text NOT NULL
      ) ON COMMIT PRESERVE ROWS
    `)
    await client.query('BEGIN ISOLATION LEVEL READ COMMITTED')
    try {
      await client.query(
        'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
        [1, 'Opening draft']
      )
      const optionalKept = await addOptionalArticles(client)
      console.log('Optional batch kept:', optionalKept)
      await client.query(
        'INSERT INTO article_demo (id, title) VALUES ($1, $2)',
        [3, 'Closing draft']
      )
      await client.query('COMMIT')
    } catch (error) {
      try {
        await client.query('ROLLBACK')
      } catch (rollbackError) {
        throw new AggregateError(
          [error, rollbackError], 'Transaction and rollback failed'
        )
      }
      throw error
    }
    const result = await client.query(
      'SELECT id, title FROM article_demo ORDER BY id'
    )
    console.table(result.rows)
  } finally {
    await client.end()
  }
}

main().catch(error => {
  console.error(error)
  process.exitCode = 1
})

Запустите node article-transactions.mjs. В ожидаемом выводе указано Optional batch kept: false и показаны идентификаторы статей 1 и 3. Для значений используется документированный интерфейс параметров query(text, values). Синтаксис JavaScript проверен с помощью Node.js; сценарий для базы данных сверен с документацией, но здесь не запускался. node-postgres: Queries

Восстанавливайтесь только после явно принятой ошибки

PostgreSQL присваивает 23505 код unique_violation и рекомендует проверять коды ошибок, а не текст сообщений. Здесь принятие этого кода — пример правила для необязательного синтетического пакета. Остальные ошибки передаются вызывающему коду, который пытается выполнить полный откат транзакции. Вспомогательная функция возвращает явный результат, чтобы вызывающий код знал об отброшенной необязательной работе. PostgreSQL: PostgreSQL Error Codes

Перехват одного лишь исключения JavaScript не восстанавливает состояние сбойной транзакции PostgreSQL. Для восстановления нужно выполнить откат к ранее созданной точке сохранения либо полный откат. В частности, отмененная транзакция не восстанавливается простым освобождением точки сохранения. PostgreSQL: Transactions, PostgreSQL: RELEASE SAVEPOINT

Вспомогательная функция предполагает наличие активной транзакции и последовательное использование этого клиента. Это не универсальная рекурсивная обертка для транзакций: для повторно используемой абстракции вложенности потребовалось бы также явно управлять владельцем и именами точек сохранения.

Какие ограничения изоляции остаются после исправления границ?

Точки сохранения позволяют частично откатывать изменения в текущей транзакции, но не задают отдельную политику изоляции. Выберите уровень изоляции на внешней границе. PostgreSQL не разрешает менять его после первого запроса или оператора изменения данных в транзакции, поэтому вспомогательная функция не может повысить уровень изоляции уже используемой транзакции, отправив еще одну команду BEGIN. PostgreSQL: SAVEPOINT, PostgreSQL: SET TRANSACTION

Для обычных запросов учитывайте следующие различия:

Уровень изоляции Существенное ограничение
READ COMMITTED Последовательные запросы могут видеть новые изменения, зафиксированные другими транзакциями. Одна транзакция не означает использования одного неизменного снимка.
REPEATABLE READ Запросы используют снимок, установленный первым оператором, не управляющим транзакцией, и видят собственные предыдущие изменения. Сбои сериализации все еще возможны.
SERIALIZABLE Эффект успешно зафиксированных транзакций соответствует некоторому последовательному порядку. Приложения все равно должны обрабатывать сбои сериализации.
READ UNCOMMITTED PostgreSQL трактует этот уровень как READ COMMITTED.

PostgreSQL: Transaction Isolation, PostgreSQL: SET TRANSACTION

При сбое сериализации нужно повторить всю транзакцию, включая решения, на основании которых были сформированы SQL-запросы. Повтор только работы вспомогательной функции в точке сохранения не обновит снимок внешней транзакции. PostgreSQL обозначает сбои сериализации кодом SQLSTATE 40001. Программа в примере передает эти сбои вызывающему коду; автоматические повторы в ней не реализованы. PostgreSQL: Transaction Isolation

Как выбрать подходящую границу для вспомогательной функции?

При анализе вызывающего кода и вспомогательных функций для работы со статьями используйте такую последовательность решений:

  1. Все должно успешно выполниться вместе: вызывающий код управляет транзакцией и передает свой клиент вспомогательным функциям, которые только выполняют запросы.
  2. Один пакет можно отбросить, а вызывающий код продолжает работу: создайте точку сохранения перед этим пакетом, восстанавливайтесь только после допустимых ошибок и явно возвращайте результат.
  3. Пакет должен фиксироваться независимо: используйте отдельный клиент и транзакцию. Их фиксация сохранится при откате внешней транзакции, поэтому этот вариант не подходит для исходной атомарной операции.
  4. Параллельное выполнение приводит к сбою сериализации: перезапустите всю транзакцию, а не рассматривайте вспомогательную функцию как отдельную границу для повторного запуска.

Эти решения следуют из семантики точек сохранения PostgreSQL и требования node-postgres выполнять все команды транзакции на одном клиенте. PostgreSQL: SAVEPOINT, node-postgres: Transactions, PostgreSQL: Transaction Isolation

Для показанной здесь задачи редактирования статей оставьте одного владельца транзакции, передавайте один клиент по цепочке вызовов и создавайте точку сохранения для необязательной работы. Проверяйте команды управления транзакциями во всех вспомогательных функциях: неожиданная команда COMMIT меняет границу для всего соединения, а освобождение точки сохранения все равно оставляет вызывающий код ответственным за окончательный результат.

Источники