Предотвращайте дубли при одновременном выполнении проверки и вставки с помощью ограничения уникальности на логический ключ статьи и INSERT ... ON CONFLICT. Если вам также нужна существующая строка, используйте способ её получения, соответствующий уровню изоляции транзакции.
Это руководство предназначено для разработчика, работающего в Terminal в macOS и которому нужна функция Node.js, создающая синтетическую статью один раз и возвращающая её строку параллельным вызывающим сторонам. В примерах сохраняется заголовок первой статьи. Используется актуальная документация PostgreSQL и официальная документация node-postgres.
Почему одной транзакции недостаточно для предотвращения дублей
Рассмотрим два вызова, пытающихся создать статью со слагом paper-shapes. Каждый сначала проверяет, существует ли этот слаг, а затем вставляет запись, если запрос ничего не вернул. При стандартном для PostgreSQL уровне изоляции Read Committed обычный запрос видит зафиксированные данные на момент снимка своего оператора. Поэтому обе проверки могут показать, что статья отсутствует. PostgreSQL: изоляция транзакций
Эта иллюстративная последовательность показывает гонку на уровне приложения:
| Шаг | Вызов A | Вызов B |
|---|---|---|
| 1 | Проверяет paper-shapes: отсутствует |
|
| 2 | Проверяет paper-shapes: отсутствует |
|
| 3 | Вставляет статью | |
| 4 | Пытается вставить статью с тем же слагом | |
| 5 | Фиксирует транзакцию | Пытается зафиксировать транзакцию |
Без ограничения уникальности на слаг эти вставки могут создать отдельные строки. Первичный ключ на автоматически сгенерированном идентификаторе не выражает правило, согласно которому слаги должны быть уникальными. PostgreSQL поддерживает ограничения уникальности как для одного столбца, так и для комбинации столбцов. PostgreSQL: ограничения
Обёртывание проверки и вставки в BEGIN и COMMIT не резервирует отсутствующий слаг. Добавление FOR UPDATE к поиску, который ничего не нашёл, тоже не решает проблему: блокируются найденные строки, поэтому для отсутствующей статьи нет строки, которую можно заблокировать. Это следует из документированного поведения блокировок строк в PostgreSQL. PostgreSQL: явные блокировки
Точно определите, что считается дублем
В этом примере одна статья определяется точным совпадением слага. Заголовок — это содержимое, поэтому второй запрос с тем же слагом должен получить сохранённую статью, а не заменить её заголовок.
Для начала нужна существующая локальная установка PostgreSQL, её инструменты командной строки и Node.js с npm. В Terminal macOS создайте новую демонстрационную базу данных и откройте приглашение ввода SQL:
createdb article_concurrency_demo
psql -d article_concurrency_demo
Выполните эту схему один раз в новой базе данных:
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
slug text NOT NULL,
title text NOT NULL,
CONSTRAINT articles_slug_key UNIQUE (slug)
);
Отдельное ограничение UNIQUE (slug) обеспечивает соблюдение правила для статей. Важно также указать NOT NULL: по умолчанию обычные ограничения уникальности допускают несколько значений NULL. PostgreSQL автоматически создаёт для этого ограничения уникальный индекс. PostgreSQL: ограничения
Если вы адаптируете существующую таблицу, сначала проверьте наличие повторяющихся ключей, а не удаляйте строки автоматически. Этот диагностический запрос использует приведённую выше схему и не изменяет содержимое:
SELECT slug, count(*) AS row_count
FROM articles
GROUP BY slug
HAVING count(*) > 1;
Прежде чем добавлять ограничение к существующей таблице, решите, какое содержимое оставить. Также определитесь с ключом: если статьи в разных коллекциях могут иметь одинаковый слаг, понадобится составной ключ, например коллекция вместе со слагом. Поиск и цель конфликта должны соответствовать одному и тому же правилу.
Выберите нужное поведение при конфликте
Конфликт — обычная ветвь этого сценария. Прежде чем выбирать SQL, определите, что он должен означать. PostgreSQL поддерживает пропуск конфликтующей вставки или обновление существующей строки; RETURNING возвращает строки, фактически вставленные или обновлённые. PostgreSQL: INSERT
| Требуемый результат | Подход | Возвращаемый результат |
|---|---|---|
| Создать, если записи нет; иначе пропустить | ON CONFLICT (slug) DO NOTHING |
С RETURNING пропущенная вставка не возвращает строку |
| Создать, если записи нет; иначе заменить заголовок | ON CONFLICT (slug) DO UPDATE |
С RETURNING — вставленная или обновлённая строка |
| Создать, если записи нет; иначе сохранить и получить её | DO NOTHING, затем отдельный поиск |
Реализовано в следующем разделе |
Для операции только со вставкой подойдёт этот полный SQL-оператор, использующий демонстрационную схему:
INSERT INTO articles (slug, title)
VALUES ('paper-shapes', 'Paper Shapes')
ON CONFLICT (slug) DO NOTHING
RETURNING id, slug, title;
Если заголовок нужно заменять, используйте этот альтернативный оператор:
INSERT INTO articles (slug, title)
VALUES ('paper-shapes', 'Paper Shapes — Revised')
ON CONFLICT (slug) DO UPDATE
SET title = EXCLUDED.title
RETURNING id, slug, title;
Здесь EXCLUDED.title ссылается на предлагаемый заголовок. Безусловный DO UPDATE обеспечивает документированное атомарное поведение вставки или обновления с учётом отдельных ошибок и обработки сбоев на уровне изоляции. Условное обновление, для которого условие WHERE запрещает обновление, может ничего не вернуть. Выбирайте обновление, если изменение сохранённой статьи уместно. PostgreSQL: INSERT
Реализуйте создание или получение существующей записи при Read Committed
Есть нюанс видимости: DO NOTHING может пропустить вставку из-за конкурентно созданной строки, невидимой снимку оператора. Поэтому поиск в том же операторе в качестве запасного варианта тоже может её не найти. Отдельный запрос при Read Committed получает новый снимок. На этом основана приведённая ниже реализация. PostgreSQL: изоляция транзакций
Выйдите из приглашения ввода SQL с помощью \q. В новой рабочей папке установите зависимость Node.js:
mkdir article-concurrency-demo
cd article-concurrency-demo
npm init -y
npm install pg
Сохраните следующую полную программу в файл demo.mjs. Она предполагает, что база данных и таблица созданы, как описано выше. Пул подключается к указанной базе данных и использует остальные стандартные настройки подключения; node-postgres поддерживает переменные окружения для подключения к PostgreSQL, если локальные настройки отличаются. node-postgres: подключение
import pg from 'pg';
import assert from 'node:assert/strict';
const { Pool } = pg;
const pool = new Pool({
database: 'article_concurrency_demo',
max: 8
});
async function getOrCreateArticle(slug, title) {
const client = await pool.connect();
try {
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
for (let attempt = 0; attempt < 5; attempt += 1) {
const inserted = await client.query(`
INSERT INTO articles (slug, title)
VALUES ($1, $2)
ON CONFLICT (slug) DO NOTHING
RETURNING id, slug, title
`, [slug, title]);
if (inserted.rows.length === 1) {
await client.query('COMMIT');
return inserted.rows[0];
}
const existing = await client.query(`
SELECT id, slug, title
FROM articles
WHERE slug = $1
FOR UPDATE
`, [slug]);
if (existing.rows.length === 1) {
await client.query('COMMIT');
return existing.rows[0];
}
}
throw new Error('Article kept disappearing; retry limit reached');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
try {
const results = await Promise.allSettled(
Array.from({ length: 8 }, () =>
getOrCreateArticle('paper-shapes', 'Paper Shapes'))
);
const failures = results.filter(result => result.status === 'rejected');
if (failures.length) {
throw new AggregateError(failures.map(result => result.reason));
}
const rows = results.map(result => result.value);
assert.equal(new Set(rows.map(row => row.id)).size, 1);
const count = await pool.query(
'SELECT count(*)::integer AS n FROM articles WHERE slug = $1',
['paper-shapes']
);
assert.equal(count.rows[0].n, 1);
console.log({ calls: rows.length, matchingRows: count.rows[0].n });
} finally {
await pool.end();
}
Запустите программу из этой папки:
node demo.mjs
При указанной настройке и отсутствии других процессов, изменяющих демонстрационные строки, ожидаемый итоговый вывод будет таким:
{ calls: 8, matchingRows: 1 }
Синтаксис JavaScript в программе проверен с помощью Node.js. Демонстрация с базой данных здесь не запускалась, поскольку инструменты PostgreSQL были недоступны; приведённый выше вывод — ожидаемый результат.
Для всех операторов в каждой транзакции используется один полученный из пула клиент. node-postgres требует именно этого: выполнение операторов транзакции через pool.query может распределить их по разным клиентам. Проверочный запрос использует pool.query только после завершения транзакций. node-postgres: транзакции
Текст запроса и значения передаются отдельно с помощью $1 и $2, в соответствии с интерфейсом параметризованных запросов драйвера. node-postgres: запросы
Цикл обрабатывает ещё одно иллюстративное чередование операций: другая транзакция может удалить конфликтующую статью до того, как поиск её получит. Если поиск ничего не находит, попытка вставки повторяется; пять попыток указаны здесь как примерный лимит. При исчерпании попыток возникает ошибка, а не возвращается неопределённая строка.
После получения FOR UPDATE защищает строку от изменений или удаления другой транзакцией до завершения этой транзакции. Такая блокировка защищает найденную запись; ограничение уникальности обрабатывает конкурирующие вставки. После фиксации другая транзакция снова сможет изменить строку. Если зависимые операции с базой данных должны использовать заблокированную статью, выполните их до COMMIT. PostgreSQL: явные блокировки
Учитывайте ограничения уровней изоляции
В примере явно выбран уровень Read Committed. В Repeatable Read снимок транзакции создаётся первым содержательным оператором, поэтому другой запрос не может просто обновить его. Serializable обеспечивает результаты зафиксированных транзакций, эквивалентные последовательному выполнению, но может прерывать транзакции и, в описанных случаях пересекающихся операций, всё ещё может приводить к ошибкам уникальности. MERGE также не обеспечивает такой же гарантии для конкурентной вставки или обновления. PostgreSQL: изоляция транзакций
Для Repeatable Read или Serializable будьте готовы повторить транзакцию целиком после ошибки с SQLSTATE 40001, включая решения приложения, на основании которых формировались её операторы. Повторить только последний запрос недостаточно. PostgreSQL также указывает 40P01 как потенциально устранимую повтором ошибку взаимной блокировки. PostgreSQL: обработка ошибок сериализации
Не считайте автоматически каждую ошибку уникальности 23505 временной. Она может означать устойчивый конфликт. Политика повторов должна определять ожидаемое условие, ограничивать число попыток и сообщать об исчерпании лимита; PostgreSQL не гарантирует, что повторные попытки в итоге завершатся успешно. В демонстрации ожидаемая коллизия слага обрабатывается через ON CONFLICT, а остальные ошибки передаются дальше. PostgreSQL: обработка ошибок сериализации
Для сценария создания или получения существующей записи разделение ответственности простое: ограничение базы данных задаёт идентичность статьи, действие при конфликте определяет, что делать с существующей статьёй, а транзакция управляет получением записи и зависимыми операциями. Согласуйте эти решения, и параллельные вызывающие стороны смогут использовать одну статью без незаметной замены её содержимого.
Источники
- 5.5. Ограничения — PostgreSQL Global Development Group
- INSERT — PostgreSQL Global Development Group
- 13.2. Изоляция транзакций — PostgreSQL Global Development Group
- 13.3. Явные блокировки — PostgreSQL Global Development Group
- 13.5. Обработка ошибок сериализации — PostgreSQL Global Development Group
- Транзакции — node-postgres
- Запросы — node-postgres
- Подключение — node-postgres