Содержание урока · 10 разделов
- ON CONFLICT DO NOTHING: атомарный пропуск
- DO UPDATE и EXCLUDED: upsert без гонки
- Дружелюбный API: 23505 превращается в 409
- Три способа обращения с дубликатом
- Несколько констрейнтов и цена неверной цели
- Правила работы с 23505
- Конкурентность: апсерты выстраиваются в очередь
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Пользователь нажимает «зарегистрироваться» второй раз, воркер перезапускается и обрабатывает ту же задачу, два запроса приходят одновременно — и база отвечает SQLSTATE 23505, unique violation. В шестнадцатом уроке этот код был одним из многих; здесь он главный, потому что у него два пути решения, и выбор между ними определяет архитектуру. Путь первый — ловить ошибку: INSERT падает, код раскладывается через errors.As, Detail честно показывает конфликтное значение вида Key (email)=(a@b.example) already exists., а ConstraintName называет нарушенный констрейнт. Путь второй — предупреждать: конструкция on conflict в самом INSERT говорит серверу, что делать при встрече с дубликатом, и гонка исчезает, потому что проверка и вставка сливаются в одну атомарную команду. Оба пути рабочие и часто живут в одном сервисе: upsert для счётчиков и синхронизаций, ловля кода для форм регистрации. Источник кода тоже стоит проговорить: 23505 прилетает не только от PRIMARY KEY и UNIQUE-констрейнтов, но и от любых уникальных индексов, включая частичные и индексы-выражения — серверу всё равно, как именно вы попросили уникальность. Один и тот же INSERT может поймать её и в голом Exec из пятого урока, и внутри транзакции из тринадцатого, и в Batch из восемнадцатого — механика распаковки не меняется. Разберём механику on conflict, псевдотаблицу EXCLUDED, возврат данных через RETURNING и правила превращения 23505 в понятный ответ API.
ON CONFLICT DO NOTHING: атомарный пропуск#
Конструкция вешается на INSERT: INSERT INTO users (email, name) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING. Скобки после ON CONFLICT — цель конфликта: колонка или список колонок, по которым существует уникальный индекс. При встрече с дубликатом сервер молча пропускает строку, ошибки нет, RowsAffected в CommandTag равен нулю — так отличают вставку от пропуска без второго запроса. Механика экономит и код, и время: не нужен SELECT на проверку, не нужна транзакция на два запроса, не нужен ретрай — один Exec из пятого урока закрывает сценарий повторной отправки формы, повторной доставки задачи в очередь и перезапуска воркера на середине списка. Цель указывать необязательно: голое DO NOTHING глушит любой конфликт уникальности, и это удобно в скриптах и опасно в продакшене — новый констрейнт, о котором вы забыли, тоже будет заглушен, и баг осядет в данных без единой ошибки в логе. Существует и вариант по имени ограничения: ON CONFLICT ON CONSTRAINT users_email_key — имя берётся из Detail ошибки или из миграции. Главное свойство конструкции — атомарность: не существует окна между проверкой и вставкой, два параллельных запроса с одинаковым email дадут один INSERT и один пропуск, без гонки и без падений. Любой SELECT перед INSERT такой гарантии не даёт — об этом отдельное живое место ниже.
Upsert: DO UPDATE с EXCLUDED и RETURNING
GOvar id int64
var inserted bool
err := conn.QueryRow(ctx, `
INSERT INTO product_stats (sku, views)
VALUES ($1, 1)
ON CONFLICT (sku) DO UPDATE
SET views = product_stats.views + 1
RETURNING id, (xmax = 0) AS inserted`,
sku).Scan(&id, &inserted)
if err != nil {
log.Fatal(err)
}Одна команда делает и вставку, и обновление: новая строка получает views 1, существующая — инкремент. RETURNING приносит id в обоих случаях, а сравнение xmax с нулём — трюк различения: строка только что вставлена или обновлена. Курсор QueryRow сам закроется после Scan, ошибки конфликта не будет вовсе.
DO UPDATE и EXCLUDED: upsert без гонки#
Режим DO UPDATE превращает INSERT в upsert: при конфликте вместо вставки выполняется UPDATE конфликтующей строки. Псевдотаблица EXCLUDED хранит предложенную к вставке строку — значения, которые вы передали аргументами: SET views = EXCLUDED.views + product_stats.views складывает новое значение со старым, SET name = EXCLUDED.name перезаписывает. Квалификация обязательна: без префикса имени таблицы поле views в правой части неоднозначно, и сервер ответит ошибкой про ambiguous column. У DO UPDATE есть необязательное WHERE: условие не сошлось — строка не обновится, RowsAffected будет нулём, RETURNING её не вернёт, и это штатная ситуация, а не ошибка. Апсерт атомарен и при конкурентности: два параллельных upsert одной строки выстроятся в очередь на блокировку строки, второй дождётся и выполнится поверх первого; потерянных обновлений нет. Опасность обратного рода: два upsert, которые в одном запросе трогают строку A, а в другом — строку B и vice versa, могут встретиться лбами и получить 40P01 deadlock — класс 40 из шестнадцатого урока, лечится единым порядком строк во всех апсертах и ретраем транзакции. Справедливости ради: с пятнадцатой версии у Postgres есть и отдельная команда MERGE, решающая смежные задачи условного слияния; pgx ей не мешает — это просто SQL-текст в том же Exec, и вся анатомия ошибок отсюда переносится на неё без изменений.
Проверка: DO NOTHING
INSERT с ON CONFLICT (email) DO NOTHING встретил дубликат. Что вернёт tag.RowsAffected()?
Дружелюбный API: 23505 превращается в 409#
Там, где on conflict не подходит — например, конфликт сообщает пользователю о занятом email, — код 23505 ловится и переводится на язык клиента. Схема из шестнадцатого урока: errors.As распаковывает PgError, ветка по pgerrcode.UniqueViolation, дальше — маппинг ConstraintName на понятный ответ: users_email_key значит «email занят», поле формы подсвечивается, HTTP-статус 409 Conflict. Имя констрейнта хардкодить можно, но осторожно: переименование в миграции сломает маппинг молча, поэтому реестр имён держите рядом с миграциями или сверяйте тестом на реальной базе. Пользователю Detail не отдаётся: строка Key (email)=(a@b.example) already exists. содержит значение, которое прилетело от клиента, — отражать ввод обратно в ответе значит кормить XSS и утечки. Правило распространяется на всю структуру: Message содержит имена таблиц, Hint — серверные советы, и всё это внутренняя кухня, которую клиенту знать не нужно. Формулировка своя, поле своё, код статуса по семантике: 409 для конфликта ресурса, 422 для проблем валидации. Один нюанс: на один INSERT с двумя уникальными индексами могут падать два разных 23505 — различать их по ConstraintName, а не только по коду, иначе занятый email и занятый телефон получат одинаковый ответ.
Обработчик регистрации с 409
GOfunc register(ctx context.Context, conn *pgxpool.Pool,
email, name string) error {
_, err := conn.Exec(ctx,
"INSERT INTO users (email, name) VALUES ($1, $2)",
email, name)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) &&
pgErr.Code == pgerrcode.UniqueViolation {
switch pgErr.ConstraintName {
case "users_email_key":
return ErrEmailTaken
case "users_phone_key":
return ErrPhoneTaken
}
}
return err
}Код проверяется первым, имя констрейнта — вторым: так один код 23505 раскладывается на разные бизнес-ситуации вместо одного обезличенного ответа. Каждая ветка возвращает доменную ошибку, которую HTTP-слой переводит в 409 Conflict с подсветкой нужного поля формы. Нераспознанные констрейнты падают в общий возврат наверх: новая миграция с новым уникальным индексом не пройдёт незамеченной в код-ревью.
Что вернёт Scan?
Прочитайте код и выберите, что он выведет:
// В таблице уже есть строка sku = "A-1", id = 7, views = 5
var id int64
err := conn.QueryRow(ctx, `
INSERT INTO product_stats (sku, views) VALUES ($1, 1)
ON CONFLICT (sku) DO UPDATE
SET views = product_stats.views + 1
RETURNING id`, "A-1").Scan(&id)
fmt.Println(id, err)Три способа обращения с дубликатом#
| Способ | Что происходит | Когда выбирать | Цена ошибки |
|---|---|---|---|
| Голый INSERT + ловля 23505 | ошибка падает, код решает | регистрация, уникальный email, нужен ответ клиенту | лишняя поездка на сбой, но полный контроль |
| ON CONFLICT DO NOTHING | конфликт заглушается | идемпотентные воркеры, повторные задачи | ноль в RowsAffected как сигнал |
| ON CONFLICT DO UPDATE | конфликт обновляет строку | счётчики, кэши, синхронизация справочников | EXCLUDED и RETURNING дают данные за один рейс |
Несколько констрейнтов и цена неверной цели#
Цель в ON CONFLICT должна опираться на существующий уникальный индекс: напишете ON CONFLICT (email) там, где индекса по email нет, — сервер ответит 42P10 с текстом про отсутствующий matching constraint, и это ошибка разработки, а не данных. Частичный уникальный индекс — например, UNIQUE (email) WHERE deleted_at IS NULL — требует совпадающего WHERE в цели конфликта, иначе арбитр не найдётся. Таблица с двумя уникальными индексами создаёт главную развилку: DO NOTHING без цели заглушит оба, и телефонный дубликат пройдёт молча там, где вы ждали только почтовый. Правило: в бизнес-кодах цель пишется явно, безцельный DO NOTHING остаётся для служебных сценариев идемпотентности, где любой повтор допустим. Ловля кода 23505 при этом всегда различает констрейнты по ConstraintName — реестр из прошлого блока растёт вместе со схемой. И не забывайте про первичный ключ: upsert по id трогает PK-индекс так же, как email-констрейнт, — на одной таблице спокойно живут три цели конфликта для трёх разных сценариев. Составные цели тоже легальны: ON CONFLICT (tenant_id, slug) опирается на составной уникальный индекс целиком и часто правильнее цели по одной колонке, когда уникальность гарантируется только парой значений.
Классическая гонка: сначала SELECT WHERE email = $1, потом INSERT. Проверка говорит «нет», между ней и вставкой вклинивается параллельный запрос того же клиента, оба INSERT уезжают — один падает с 23505, а если констрейнта нет вовсе, в базе дубликат, который потом ищут неделями. Если проверка существования раздельно с вставкой, то она всегда гонка на конкурентном клиенте. Рабочие замены: ON CONFLICT DO NOTHING или DO UPDATE — атомарны по построению; либо честная ловля 23505 после INSERT с повтором логики «уже есть». SELECT-первым оправдан только для UX-подсказок в форме, не для гарантий.
Правила работы с 23505#
- Гарантии даёт только атомарная команда: on conflict или ловля кода после INSERT
- Цель конфликта пишите явно — безцельный DO NOTHING глушит и будущие констрейнты
- DO UPDATE с EXCLUDED — для инкрементов и перезаписи, RETURNING — за данными без второго SELECT
- Разные констрейнты одного кода различаются по ConstraintName
- Detail не покидает бэкенда: в нём значение, присланное клиентом
- Нулевой RowsAffected у DO NOTHING — штатный сигнал «уже было»
- Перекрёстные апсерты двух строк — путь к 40P01: единый порядок и ретрай
- 42P10 — цель конфликта не совпала с индексом: сверьте миграцию
Проверка: цель конфликта
На таблице есть уникальный индекс по (tenant_id, email). INSERT пишет ON CONFLICT (email) DO NOTHING. Что произойдёт?
Соберите апсерт счётчика
Восстановите upsert: цель, ветка обновления, псевдотаблица с новым значением.
Соберите обработчик с 409
Расставьте строки: вставка, распаковка, ветка кода, доменная ошибка.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: конфликты уникальности
Один конфликт валит всю команду: в pgx.Batch провал одной вставки уронит все результаты за ней — об этом восемнадцатый урок, в COPY FROM оборвётся всё копирование — девятнадцатый. Если поток данных заранее не вычищен от дубликатов, ставьте staging-таблицу: COPY без констрейнтов, затем INSERT ... SELECT ... ON CONFLICT из неё в целевую. Так массовая загрузка переживает дубликаты, а 23505 остаётся сигналом для точечных случаев. Если staging-путь кажется усложнением, то сравните его с ценой перезапуска загрузки на середине миллиона строк — выбор обычно очевиден.
Конкурентность: апсерты выстраиваются в очередь#
DO UPDATE берёт блокировку конфликтующей строки, и это следствие важно при нагрузке. Два горутины апсертят одну строку — вторая ждёт первую, задержка в миллисекунды, данные корректны. Десять тысяч горутин апсертят одну и ту же строку — очередь на блокировку станет бутылочным горлышком, и время ответа вырастет независимо от железа: для сверхгорячих счётчиков буферизуйте инкременты в приложении и сбрасывайте пачкой реже. Перекрёстный порядок — упомянутый deadlock — проверяется код-ревью: все транзакции, трогающие несколько строк upsert-ом, обязаны идти в одном порядке сортировки. WHERE в DO UPDATE добавляет ещё один слой: обновление только при устаревших данных, SET views = EXCLUDED.views WHERE product_stats.views < EXCLUDED.views, — конкурирующие записи перестают затирать свежие, а пропущенные строки честно показываются нулём RowsAffected. Числа для калибровки: очередь на блокировке стоит миллисекунды при десятках горутин, секунды — при тысячах, и граница, где буферизация становится обязательной, обычно наступает раньше, чем кажется при чтении кода. Всё это продолжение мысли шестнадцатого урока: серверная ошибка — не приговор, а переговоры с базой на языке кодов.
23505 перестал быть пугающим: теперь вы превращаете его в пропуск, апсерт или 409 по делу. Проверьте навык локально — честная оговорка: песочница не исполняет pgx, поэтому Postgres в Docker и два параллельных INSERT в небольшой main-программе — ваш полигон. Дальше по курсу: в восемнадцатом — pgx.Batch и сотни запросов за одну поездку; в девятнадцатом — COPY FROM для миллионов строк, где конфликты решаются staging-таблицей; в шестнадцатом — общая анатомия PgError, если поля структуры ещё не отложились. Домашнее задание сводит приёмы воедино: upsert с EXCLUDED, ловля 23505 по констрейнту и честная гонка из живого места на собственной руке. Оба следующих урока — Batch и COPY — продолжат тему: там 23505 перестаёт быть точечным сигналом и становится вопросом архитектуры массовой загрузки.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 23505 — уникальный конфликт; Detail показывает значение, ConstraintName — имя констрейнта.
- ON CONFLICT (col) DO NOTHING атомарно пропускает дубликат: ошибки нет, RowsAffected 0.
- ON CONFLICT DO UPDATE строит upsert: EXCLUDED хранит предложенную строку, RETURNING возвращает результат.
- Цель конфликта должна опираться на уникальный индекс; частичный индекс требует WHERE в цели.
- Безцельный DO NOTHING глушит все конфликты — оставьте его идемпотентным воркерам.
- Разные констрейнты одного кода различаются по ConstraintName; наружу — 409 и своё поле формы.
- SELECT перед INSERT — гонка: гарантии даёт только атомарная команда.
- Апсерты горячей строки выстраиваются в очередь; перекрёстные — путь к 40P01.
- MERGE из PostgreSQL 15 решает смежные задачи; pgx исполняет его как обычный SQL-текст.
Домашнее задание
Сделайте таблицу product_stats с PK по sku и напишите upsert из урока: добейтесь и вставки, и инкремента одной командой, различите их по xmax. Добавьте второй уникальный индекс по phone и напишите реестр ConstraintName с доменными ошибками: два разных 23505 должны давать два разных ответа. Воспроизведите гонку из живого места: две горутины с SELECT-then-INSERT на одном email — и замените код на ON CONFLICT, чтобы дубликат исчез. Замерьте время обеих версий на тысяче повторных вставок — разница покажет цену лишнего SELECT в вашей сети. В конце проверьте 42P10: уберите индекс и посмотрите, как сервер откажется от цели конфликта.
Личные заметкиПусто
Вопросы и ответы по уроку
Как отличить вставку от обновления в upsert?
По трюку RETURNING id, (xmax = 0) AS inserted: xmax — служебная колонка версий строк, у только что вставленной строки она нулевая, у обновлённой — нет. Способ неофициальный, но используется широко и стабилен. Альтернатива — читать RowsAffected: и вставка, и DO UPDATE дают единицу, так что для различения он не годится.
DO NOTHING или ловля 23505 — что выбрать?
DO NOTHING, когда дубликат — штатный повтор и реакция не нужна: идемпотентные воркеры, повторная доставка. Ловля кода — когда конфликт информативен: пользователю нужен 409 с полем, метрикам — событие повторной регистрации. Часто в одном сервисе оба: апсерт для внутренней синхронизации, ловля — для публичных форм. Рост доли нулевых RowsAffected у DO NOTHING — тот же сигнал в метриках, что и частота 23505 при ловле кода.
Почему нельзя просто SELECT и вставить, если пусто?
Потому что между SELECT и INSERT приезжает параллельный запрос: оба увидят пустоту, оба вставят — один упадёт с 23505 или, без констрейнта, дубликат останется в базе навсегда. Окно гонки маленькое, но на нагрузке оно открывается регулярно. ON CONFLICT сливает проверку со вставкой в одну атомарную команду на уровне сервера. Помните и про вторую половину гонки: без уникального индекса база промолчит, и дубликат станет данными, а не ошибкой.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
MongoDB в Go: официальный драйвер mongo-driver v2
Курс MongoDB для Go: подключение, bson-теги, CRUD, фильтры, агрегации, индексы, транзакции и Change Streams. 25 уроков по mongo-driver v2.
- Подключаться к MongoDB и настраивать клиента
- Моделировать документы структурами и bson-тегами
- Делать CRUD, сложные фильтры и проекции
- Строить агрегации $match/$group/$lookup