Конфликт уникальности и upsert
Клиент может повторить создание курса: пользователь снова нажал кнопку, ответ потерялся или импорт прислал прежнюю запись. Если уникальный slug уже существует, обычная вставка завершится конфликтом. Нужно заранее определить смысл повтора: пропустить существующий объект, обновить разрешённые поля или отклонить действие. ON CONFLICT позволяет выразить выбранную реакцию непосредственно в PostgreSQL.
Рассмотрим PostgreSQL 17.11 и независимый снимок catalog-db/lesson-08 из архива серии. Схема и исходные шесть курсов восстанавливаются отдельно. Учебные изменения откатываются в конце транзакции. SQL не запускался; далее разбираем ожидаемое поведение, а не полученные сообщения сервера.
Конфликт и политика повтора
Уникальность slug означает, что две строки курса не могут иметь одинаковый код. Перед вставкой можно было бы выполнить поиск, но между поиском и записью другой клиент может создать тот же объект. Поэтому правило обработки конфликта должно работать в самой операции. Поведение ON CONFLICT определено в документации INSERT.
Сначала выберем политику «ничего не изменять, если код уже занят»:
BEGIN;
INSERT INTO catalog.courses
(topic_id, slug, title, planned_lessons, price)
VALUES (1, 'js-browser', 'Новое название', 20, 1990)
ON CONFLICT (slug) DO NOTHING
RETURNING id, slug;
В исходном наборе js-browser уже существует. Ожидается пустой возвращённый набор, а название прежнего курса сохраняется. Пустой результат не означает, что курс исчез: операция не создала новую строку и потому ничего не вернула. Если клиенту нужен существующий объект, он должен явно прочитать его после такого ответа.
Выбор DO NOTHING также не делает любую входную запись корректной. Конфликт одного уникального ключа не отменяет другие правила предметной модели. Например, отсутствие обязательного названия остаётся ошибкой. Нельзя использовать механизм как универсальный способ «проглотить всё», если приложение должно сообщать редактору, что именно получилось.
Целевой список (slug) показывает, какое ограничение служит основанием повтора. В нашей модели уникальны также первичный ключ и позиции уроков в другой таблице. Слово «конфликт» без указания объекта было бы слишком общим для интерфейса: повтор создания курса и попытка занять позицию уже существующей главы имеют разные последствия.
Обновляем разрешённый черновик
Для редакторского импорта выберем другую политику. Новый slug создаёт черновик. Повтор slug обновляет только название, если существующий курс ещё черновик. Публикацию, цену и тему существующего объекта операция не меняет. Такое ограничение полезно, когда повторное сообщение не должно затронуть уже опубликованную карточку.
INSERT INTO catalog.courses AS current_course
(topic_id, slug, title, planned_lessons, price)
VALUES (1, 'draft-lab', 'Первое название', 8, 0)
ON CONFLICT (slug) DO UPDATE
SET title = EXCLUDED.title
WHERE current_course.status = 'draft'
RETURNING id, slug, title, status;
После свежего reset ожидается новая строка с кодом draft-lab и первым названием. Теперь повторим ту же команду, заменив входное название на «Исправленное название». Уникальный код уже занят, поэтому обновится существующий черновик. Его ключ останется тем же. Это наблюдаемый признак обновления объекта, а не создания второго похожего курса.
EXCLUDED обозначает предложенную для вставки строку. Псевдоним current_course обозначает конфликтующую существующую строку. Их различие позволяет одновременно читать входное название и проверять прежний статус. В этой операции статус не берётся из повторного сообщения: иначе импорт мог бы незаметно изменить публикацию вместе с исправлением текста.
Входные значения всё равно должны соответствовать договору создания: мы передаём допустимую тему, положительный план и неотрицательную цену. Однако при конфликте обновляется только поле, перечисленное в SET. Если редактор ожидал изменения числа глав, такой запрос его не выполнит. Интерфейс должен называть операцию по её действительному результату, а не обещать «полную синхронизацию».
Условие может оставить строку без изменения
Применим ту же политику к опубликованному js-browser:
INSERT INTO catalog.courses AS current_course
(topic_id, slug, title, planned_lessons, price)
VALUES (1, 'js-browser', 'Импортированное название', 20, 1990)
ON CONFLICT (slug) DO UPDATE
SET title = EXCLUDED.title
WHERE current_course.status = 'draft'
RETURNING id, slug, title;
Конфликт найдётся, но условие обновления будет ложным. Ожидается пустой результат RETURNING, а прежнее название JavaScript останется. Это уже не тот же путь, что обычная новая вставка. Клиенту следует различать «создано или обновлено» и «существует, но операция по выбранному правилу ничего не изменила».
Условие не означает отсутствия конкуренции. Для согласования конфликтующей записи база может ждать другое изменение. Если запрос долго не возвращает ответ, нельзя считать его чистым чтением только из-за ожидаемого пустого результата. Более подробную работу блокировок серия будет разбирать позже; сейчас достаточно помнить, что upsert относится к операциям изменения.
После примеров выполняется ROLLBACK. Временный draft-lab не сохраняется, исправления также отменяются. Использованные identity-значения могут остаться потраченными, даже когда операция пошла по пути конфликта. Поэтому нельзя проверять смысл повтора по тому, вырос ли генератор. Проверяют результат операции и состояние конкретного объекта.
Идемпотентность имеет границы
Если одинаковый повтор устанавливает одно и то же название черновика, видимое предметное состояние после повторов одинаково. Это полезное свойство. Но выражение вроде version = version + 1 при каждом повторе изменяло бы состояние снова. Следовательно, сама конструкция upsert не гарантирует идемпотентность всех перечисленных действий.
Тем более нельзя автоматически приравнять slug к идентификатору запроса. В нашем редакторском каталоге код определяет объект. В системе заказов повтор HTTP-запроса может требовать отдельного ключа операции, потому что один пользователь способен законно создать два похожих заказа. Предметный смысл конфликта выбирается до SQL, а не появляется из удобного синтаксиса.
При многорядном импорте заранее нормализуйте входной набор. Два одинаковых slug в одной команде обновления могут попытаться затронуть один объект несколько раз, и PostgreSQL не обязан воспринимать это как два последовательных независимых запроса. В данном снимке каждая команда предлагает одну строку, чтобы результат оставался ясным.
Повтор и последующее чтение
После DO NOTHING приложение иногда читает существующий курс по slug. Это отдельное действие со своим моментом наблюдения. Если другой разрешённый клиент удалил объект между операциями, чтение может не найти строку. Поэтому пустой RETURNING нельзя механически заменить обещанием «существующий курс обязательно доступен сейчас» без учёта дальнейшего жизненного цикла.
Для нашего локального снимка конкурентных клиентов нет, и последовательное чтение показывает известное состояние. На рабочем приложении нужно решить, допустим ли такой раздельный результат или операция требует более строгих границ. Конструкция upsert решает выбранный конфликт вставки, а не всю согласованность пользовательского сценария.
Ещё один полезный случай — повтор с иным названием. Если политика обновляет черновик, такой повтор не является просто подтверждением прежнего запроса: он приносит новые данные и изменяет объект. Если политика пропускает любой занятый код, новое название игнорируется. Обе реакции законны при явном договоре, но интерфейс не должен одинаково сообщать «все изменения сохранены».
Поэтому в клиентском результате полезно разделять обнаруженное существование, фактически изменённые поля и действие, которое было запрещено условиями. Эти сведения появляются из согласованной операции и последующего чтения, а не из самого слова upsert. Статья намеренно сохраняет узкое обновление названия, чтобы причину каждого эффекта можно было объяснить по листингу.
Измените мысленно политику: разрешите обновление любого курса вместо только черновика. Код станет короче, но повтор импорта уже сможет изменить опубликованное название. Такое различие влияет на обещание редактору, даже если оба запроса синтаксически правильны. Теперь вы умеете выбрать и объяснить реакцию на повтор, прочитать пустой ответ и сохранить границы разрешённого изменения вместо механического «вставить или обновить всё».