Разберите ошибку в конкурентном выполнении: почему две транзакции могут добавить один и тот же код, хотя ка...

Разберите ошибку в конкурентном выполнении: почему две транзакции могут добавить один и тот же код, хотя каждая перед вставкой проверяет его отсутствие?

CREATE TABLE coupon_codes (
    code text NOT NULL
);

-- Каждая сессия выполняет независимо
BEGIN;
SELECT 1 FROM coupon_codes WHERE code = 'SPRING';
INSERT INTO coupon_codes(code) VALUES ('SPRING');
COMMIT;
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Проверка и вставка — это две отдельные операции, между которыми другая транзакция может выполнить такую же проверку и вставку. При отсутствии ограничения уникальности обе транзакции увидят, что кода ещё нет, и успешно добавят дубликат.

Надёжное решение — перенести инвариант уникальности в базу данных: создать уникальное ограничение и корректно обработать конфликт вставки. Одна только предварительная проверка через SELECT не обеспечивает взаимное исключение.

Исторический контекст

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

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

Постановка проблемы

Пусть транзакции A и B почти одновременно выполняют SELECT. Обе получают пустой результат, потому что ни одна ещё не зафиксировала вставку. Затем обе выполняют INSERT и фиксируют изменения.

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

Подробное решение

Проверка существования и последующая вставка образуют шаблон check-then-act. Между проверкой и изменением существует окно гонки, поэтому READ COMMITTED не гарантирует, что результат проверки останется актуальным до конца транзакции.

Инвариант следует выразить в схеме:

CREATE TABLE coupon_codes ( code text NOT NULL UNIQUE ); INSERT INTO coupon_codes(code) VALUES ('SPRING'); -- Повторная вставка того же кода завершается ошибкой ограничения.

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

Повышение уровня изоляции само по себе не является универсальной заменой ограничению. Например, при SERIALIZABLE одна из транзакций может быть прервана ошибкой сериализации, но приложение всё равно обязано повторить транзакцию; кроме того, бизнес-правило лучше явно зафиксировать ограничением, если оно поддерживается схемой.

Если условие сложнее простого равенства, используют подходящий механизм: составное уникальное ограничение, частичный уникальный индекс, внешний ключ, CHECK или явно спроектированную сериализацию. Блокировка отдельной строки не помогает, если строка ещё не существует: блокировать нечего, поэтому для диапазонов и отсутствующих ключей требуются специальные механизмы блокировок или ограничений, зависящие от СУБД.

Ситуация из практики

Сервис выдаёт промокод пользователю. Два HTTP-запроса одновременно выполняют проверку и вставку. Вариант с одной лишь предварительной проверкой прост и быстр в обычном случае, но допускает дубликаты при гонке.

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

Выбранное решение — уникальное ограничение на code, а в приложении — обработка ошибки конфликта как ответа «код уже занят». Это минимизирует область блокировки, сохраняет корректность при любом числе клиентов и не требует доверять предварительной проверке.

Что кандидаты часто упускают

1. Почему повторная проверка перед вставкой не устраняет гонку?

Если обе транзакции повторно выполняют SELECT до INSERT, они всё равно могут сделать это до фиксации изменения конкурентной транзакции. Проблема состоит не в количестве проверок, а в отсутствии атомарной координации между проверкой и изменением.

2. Что произойдёт при наличии уникального ограничения?

Одна транзакция вставит значение первой. Конкурирующая вставка того же значения не сможет также успешно зафиксироваться: СУБД дождётся разрешения конфликта или проверит состояние конкурирующей операции, после чего вернёт ошибку уникальности либо результат, зависящий от используемого оператора и СУБД.

Приложение не должно сначала полагаться на SELECT, а затем считать вставку гарантированно успешной. Источником истины является результат операции, защищённой ограничением.

3. Когда допустимо использовать INSERT ... ON CONFLICT?

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

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