Чтобы список статей и их количество были согласованы, выполните оба запроса в одной транзакции REPEATABLE READ READ ONLY с одним и тем же клиентом node-postgres. В этом руководстве показан такой подход и проверка в двух сеансах, которую можно выполнить на Mac.

Почему список статей может не совпадать с их количеством?

Предположим, скрипт для настольного компьютера получает опубликованные статьи из темы craft, а затем подсчитывает подходящие статьи. Между этими запросами другой сеанс меняет тему статьи. В результате в списке может оказаться две статьи, а количество будет равно одной.

Уровень изоляции PostgreSQL по умолчанию, READ COMMITTED, предоставляет каждому обычному SELECT снимок на момент начала этого оператора. Поэтому последовательные запросы могут видеть разные зафиксированные данные даже в рамках одной транзакции. PostgreSQL: изоляция транзакций.

Для этой задачи нужен единый согласованный вид строк статей. Более позднее изменение может появиться при следующем запуске. PostgreSQL на уровне REPEATABLE READ сохраняет один снимок для последовательных запросов: он создается первым оператором транзакции, который не управляет транзакцией. Сам по себе вызов BEGIN этот снимок не создает. PostgreSQL: изоляция транзакций.

Реализация должна выполнять два условия: задать уровень изоляции до чтения и выполнять все связанные запросы через один клиент. В документации node-postgres прямо указано, что для транзакции нужен один клиент; для ее операторов не следует использовать pool.query(). node-postgres: транзакции.

Подготовьте небольшой набор статей на Mac

Вам понадобятся запущенный локальный сервер PostgreSQL, команда psql и поддерживаемая установка Node.js с npm. Предполагается, что существующее локальное подключение работает с базой данных 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 и импортировать снимок до первого запроса или оператора изменения данных. Для импорта в режиме Serializable в справочнике команд описаны дополнительные ограничения. PostgreSQL: функции синхронизации снимков, PostgreSQL: SET TRANSACTION.

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

Что этот уровень изоляции не решает?

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

Режим READ ONLY запрещает изменения обычных таблиц, за документированными исключениями, например некоторыми операциями с временными таблицами. Сам по себе он не выбирает стабильный снимок. Задайте обе характеристики до чтения: PostgreSQL не позволит изменить уровень изоляции после первого запроса или оператора изменения данных в транзакции. PostgreSQL: SET TRANSACTION.

Когда отчету нужна более строгая семантика сериализуемости, PostgreSQL поддерживает SERIALIZABLE READ ONLY DEFERRABLE. Получение снимка может занять некоторое время, после чего транзакция выполняется без отмены из-за ошибки сериализации. Это отдельный компромисс, не связанный с обычной задачей чтения статей, показанной здесь. PostgreSQL: SET TRANSACTION.

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

Источники