Предотвращение дубликатов в CRUD‑приложениях требует нескольких слоёв: уникальных ограничений в базе, идемпотентных ключей и состояний UI, которые блокируют двойные отправки.

Дубликат — это когда приложение сохраняет одно и то же дважды. Это могут быть два заказа для одной и той же покупки, два тикета с одинаковыми данными или два аккаунта, созданных в одном и том же потоке регистрации. В CRUD‑приложении дубликаты часто выглядят как обычные строки, но становятся ошибкой, когда смотреть на данные в целом.
Большинство дубликатов начинается с нормального поведения. Кто‑то нажимает «Создать» дважды, потому что страница кажется медленной. На мобильных устройствах двойное касание легко не заметить. Даже аккуратные пользователи попробуют ещё раз, если кнопка всё ещё активна и нет явного признака, что что‑то происходит.
Затем вступают в игру сеть и серверы. Запрос может таймаутиться и быть повторён автоматически. Клиентская библиотека может повторить POST, если думает, что первая попытка не удалась. Первый запрос мог выполниться, но ответ потерялся, и пользователь пытается снова — создаётся вторая копия.
Нельзя решить эту проблему только на одном уровне, потому что каждый уровень видит лишь часть истории. UI может сократить случайные двойные отправки, но не остановит повторные попытки из‑за плохого соединения. Сервер может обнаружить повторные запросы, но ему нужен надёжный способ распознать «это то же самое создание». База данных может принудительно применить правила, но только если вы чётко определите, что значит «то же самое».
Цель проста: сделать операции создания безопасными даже при повторной отправке одного и того же запроса. Вторая попытка должна стать no‑op, аккуратным ответом «уже создано» или контролируемым конфликтом, а не второй строкой.
Многие команды считают дубликаты проблемой базы данных. На практике они обычно рождаются раньше — когда одно и то же действие создания запускается более одного раза.
Пользователь нажимает «Создать», и кажется, что ничего не происходит, поэтому он нажимает ещё раз. Или нажимает Enter, затем кликает кнопку. На мобильных устройствах бывают два быстрых касания, перекрывающиеся события touch и click или жест, зарегистрированный дважды.
Даже если пользователь отправил один раз, сеть может повторить запрос. Таймаут может вызвать повторную попытку. Офлайн‑приложение может поставить «Сохранить» в очередь и отослать при повторном подключении. Некоторые HTTP‑библиотеки автоматически повторяют при определённых ошибках, и вы заметите проблему только по появившимся дубликатам в базе.
Серверы намеренно повторяют работу. Очереди задач ретраят упавшие джобы. Провайдеры вебхуков часто доставляют одно и то же событие несколько раз, особенно если ваш endpoint медленный или возвращает не‑2xx статус. Если логика создания триггерится этими событиями, считайте, что дубликаты будут.
Конкурентность даёт самые хитрые дубликаты. Две вкладки отправляют одну и ту же форму за миллисекунды. Если сервер делает «существует ли?» затем вставляет, оба запроса могут пройти проверку до того, как произойдёт вставка.
Рассматривайте клиент, сеть и сервер как отдельные источники повторов — защищаться придётся на всех трёх уровнях.
Если нужен один надёжный способ остановить дубликаты, определите правило в базе данных. Исправления в UI и серверные проверки помогают, но они могут не сработать при повторах, задержках или двух пользователях одновременно. Уникальное ограничение в базе — финальный авторитет.
Сначала выберите правило уникальности, которое соответствует тому, как люди думают о записи в реальном мире. Примеры:
Будьте осторожны с полями, которые кажутся уникальными, но не являются такими, например полное имя.
Когда правило есть, примените уникальное ограничение (или уникальный индекс). Это заставит базу отклонять вторую вставку, которая нарушила бы правило, даже если два запроса пришли одновременно.
Когда ограничение срабатывает, решите, какой должен быть опыт пользователя. Если создание дубликата всегда неверно — блокируйте это с понятным сообщением («Этот email уже используется»). Если повторы часты и запись уже существует, часто лучше трактовать повтор как успех и вернуть существующую запись («Ваш заказ уже создан»).
Если ваше действие — «создать или переиспользовать», upsert может быть самым чистым паттерном. Пример: «создать клиента по email» может вставить новую строку или вернуть существующую. Используйте это только если это соответствует бизнес‑логике. Если приходят слегка разные полезные нагрузки для одного ключа, решите, какие поля можно обновлять, а какие должны оставаться неизменными.
Уникальные ограничения не заменяют идемпотентные ключи и корректные состояния UI, но дают жёсткую опору, на которую можно опереться.
Идемпотентный ключ — это уникальный токен, представляющий одно пользовательское намерение, например «создать этот заказ один раз». Если тот же запрос отправляется снова (двойной клик, повторная попытка сети, возобновление на мобильном), сервер трактует его как повторение, а не новое создание.
Это один из самых практичных инструментов для защиты эндпоинтов создания, когда клиент не уверен, прошёл ли первый запрос.
Наиболее полезны для идемпотентности эндпоинты, где дубликат дорог или запутан — заказы, счета, платежи, приглашения, подписки и формы, которые триггерят письма или вебхуки.
При повторной попытке сервер должен вернуть исходный результат первой успешной попытки, включая тот же ID созданной записи и тот же код ответа. Для этого сохраните небольшую запись идемпотентности, ключированную по (пользователь или аккаунт) + эндпоинт + идемпотентный ключ. Сохраните и итог (ID записи, тело ответа), и состояние «в процессе», чтобы две почти одновременные попытки не создали две строки.
Держите записи идемпотентности достаточно долго, чтобы покрыть реальные повторы. Общая основа — 24 часа. Для платежей команды часто держат 48–72 часа. TTL ограничивает рост хранения и соответствует тому, как долго вероятна повторная попытка.
Если вы генерируете API с помощью конструктора вроде Koder.ai, всё равно делайте идемпотентность явной: принимайте ключ от клиента (в заголовке или поле) и добивайтесь «один и тот же ключ — один и тот же результат» на сервере.
Идемпотентность делает запрос создания безопасным для повторения. Если клиент повторяет из‑за таймаута (или пользователь нажимает дважды), сервер возвращает тот же результат вместо создания второй строки.
Idempotency-Key), но можно отправлять и в JSON‑теле.Ключевая деталь: «проверка + сохранение» должны быть безопасны при конкуренции. На практике вы храните запись идемпотентности с уникальным ограничением по (scope, key) и относитесь к конфликтам как к сигналу переиспользовать результат.
if idempotency_record exists for (user_id, key):
return saved_response
else:
begin transaction
create the row
save response under (user_id, key)
commit
return response
Пример: клиент нажал «Создать счёт», приложение отправляет ключ abc123, и сервер создаёт счёт inv_1007. Если телефон теряет связь и повторяет запрос, сервер отвечает тем же inv_1007, а не inv_1008.
При тестировании не ограничивайтесь «двойным кликом». Смоделируйте ситуацию, когда запрос таймаутится у клиента, но всё же завершается на сервере, затем повторите с тем же ключом.
Серверные защиты важны, но многие дубликаты начинаются с того, что человек случайно делает одно и то же действие дважды. Хороший UI делает безопасный путь очевидным.
Отключайте кнопку отправки сразу после того, как пользователь отправил форму. Делайте это на первом клике, а не после валидации или после старта запроса. Если форма может отправляться несколькими средствами (кнопка и Enter), блокируйте всё состояние формы, а не только одну кнопку.
Покажите явное состояние прогресса, которое отвечает на вопрос: работает ли сейчас что‑то? Простая надпись «Сохранение...» или спиннер — достаточно. Стабилизируйте верстку, чтобы кнопка не скакала и не вызывала дополнительного клика.
Набор простых правил предотвращает большинство двойных отправок: выставьте флаг isSubmitting в начале обработчика отправки, игнорируйте новые отправки, пока он true (для клика и Enter), и не сбрасывайте его, пока не получите реальный ответ.
Медленные ответы — классическая проблема. Если вы повторно активируете кнопку по таймеру (например, через 2 секунды), пользователи смогут отправить снова, пока первый запрос всё ещё в полёте. Разблокируйте кнопку только после завершения попытки.
После успеха сделайте повторную отправку маловероятной: перенаправьте пользователя на страницу созданной записи или список, или покажите чёткое состояние успеха с видимой созданной записью. Не оставляйте ту же заполненную форму на экране с включённой кнопкой.
Упорные баги с дубликатами появляются из повседневных «странных, но обычных» сценариев: две вкладки, обновление страницы или телефон, теряющий связь.
Во‑первых, правильно определите область уникальности. «Уникально» редко значит «по всей базе». Обычно это — уникально для пользователя, для воркспейса или для тенанта. Если синхронизируетесь с внешней системой, может потребоваться уникальность по внешнему источнику плюс внешний ID. Безопасный подход — написать точную формулировку (например, «Один номер счёта на тенант в год»), а затем её применять.
Поведение с несколькими вкладками — классическая ловушка. Состояния загрузки помогают в одной вкладке, но не работают между вкладками. Здесь серверные защиты должны оставаться авторитетом.
Кнопка «Назад» и обновление страницы могут вызвать случайные повторные отправки. После успешного создания пользователи часто обновляют страницу, чтобы «проверить», или нажимают назад и вновь отправляют форму, которая всё ещё выглядит редактируемой. Предпочитайте показывать созданную запись вместо исходной формы и делайте сервер устойчивым к повторному воспроизведению.
Мобильные устройства добавляют прерывания: сворачивание приложения, ненадёжные сети и автоматические повторы. Запрос мог выполниться, но приложение не получило ответ, поэтому оно пытается снова при возобновлении.
Самая распространённая ошибка — считать UI единственной защитой. Отключённая кнопка и спиннер помогают, но не покрывают обновления, ненадёжные мобильные сети, открытие второй вкладки или баги в клиенте. Сервер и база всё равно должны уметь сказать «это создание уже произошло».
Другая ловушка — выбор неправильного поля для уникальности. Если вы навяжете уникальность по полю, которое не является истинным идентификатором (фамилия, округлённая метка времени, произвольный заголовок), вы заблокируете валидные записи. Лучше использовать реальный идентификатор (например, внешний provider ID) или ограниченную область (уникально на пользователя, за день или для родительской записи).
Идемпотентные ключи тоже легко реализовать неправильно. Если клиент генерирует новый ключ при каждой повторной попытке, вы получите новое создание каждый раз. Держите один и тот же ключ для всего пользовательского намерения — от первого клика до всех повторов.
Также следите за тем, что вы возвращаете при повторных попытках. Если первая попытка создала запись, повтор должен вернуть тот же результат (или как минимум тот же ID), а не расплывчатую ошибку, которая заставит пользователя попытаться снова.
Если уникальное ограничение блокирует дубликат, не прячьте это под «Что‑то пошло не так». Скажите прямо: «Этот номер счёта уже существует. Мы сохранили оригинал и не создали второй».
Перед выпуском пройдитесь специально по путям создания. Лучшие результаты даёт наложение слоёв защиты, чтобы пропущенный клик, повторная попытка или медленная сеть не могли создать две строки.
Подтвердите три вещи:
Практический тест: откройте форму, нажмите submit дважды быстро, затем обновите страницу в середине отправки и попробуйте снова. Если вы можете создать две записи — так и будут делать реальные пользователи.
Представьте простое приложение для выставления счетов. Пользователь заполняет новый счёт и нажимает «Создать». Сеть медленная, экран сразу не меняется, и он нажимает ещё раз.
Только с UI‑защитой вы могли бы отключить кнопку и показать спиннер. Это помогает, но не достаточно. Двойной тап может пройти на некоторых устройствах, запрос может быть повторён после таймаута, или форма может быть отправлена из двух вкладок.
Только с уникальным ограничением в базе вы остановите точные дубликаты, но опыт может быть неприятным: первый запрос прошёл, второй упёрся в ограничение, и пользователь увидел ошибку, хотя счёт был создан.
Чистый результат — идемпотентность плюс уникальное ограничение:
Небольшое сообщение UI после второго клика: «Счёт создан — мы проигнорировали дубликат и сохранили вашу первую попытку.»
Когда базовый уровень настроен, следующие улучшения касаются видимости, очистки и согласованности.
Добавьте лёгкое логирование на путях создания, чтобы понимать разницу между реальным действием пользователя и повторной попыткой. Логируйте идемпотентный ключ, уникальные поля и исход результата (создано vs возвращено существующее vs отклонено). Для начала не нужно громоздкое туло; достаточно простых логов.
Если дубликаты уже есть, очистите их по ясному правилу и с аудиторным следом. Например, оставьте самую старую запись как «победитель», перепривяжите связанные строки (платежи, позиции), и пометьте остальные как объединённые, а не удаляйте. Это упрощает поддержку и отчётность.
Занесите правила уникальности и идемпотентности в одно место: что уникально и в каком скоупе, как долго живут ключи, как выглядят ошибки и что должен делать UI при повторах. Это предотвратит ситуации, когда новые эндпоинты обходят защиту.
Если вы быстро строите CRUD‑экраны в Koder.ai (koder.ai), имеет смысл сделать эти поведения частью шаблона по умолчанию: уникальные ограничения в схеме, идемпотентные эндпоинты в API и явные состояния загрузки в UI. Тогда скорость разработки не будет приносить хаос в данные.
Дубликатом называют ситуацию, когда одно и то же реальное событие сохраняется в базе дважды — например, два заказа для одной и той же оплаты или два тикета по одной проблеме. Обычно это происходит потому, что действие «создать» выполняется более одного раза из‑за двойной отправки пользователем, повторных попыток клиента или конкурентных запросов.
Потому что второй запрос может возникнуть без явного дополнительного клика — двойное нажатие на мобильном, Enter плюс клик, или повторная попытка клиента после таймаута. Клиент, сеть или сервер могут повторно отправить запрос, поэтому нельзя полагаться на то, что POST выполнится ровно один раз.
Нет, этого недостаточно. Отключение кнопки и индикатор «Сохранение…» уменьшают случайные двойные отправки, но не предотвращают повторные запросы из‑за ненадёжной сети, обновления страницы, нескольких вкладок, фоновых задач или повторных доставок вебхуков. Нужны также серверные и базовые ограничения.
Уникальное ограничение — это последняя инстанция, которая не даст вставить вторую строку, даже если два запроса придут одновременно. Оно работает лучше всего, когда вы явно определяете правило уникальности в реальном мире и, при необходимости, ограничиваете его область (например, по тенанту или рабочей области).
Они решают разные задачи. Уникальные ограничения блокируют повторные строки по полю (например, номер счёта), а идемпотентные ключи делают конкретную попытку создания безопасной для повторения (один и тот же ключ — тот же результат). Вместе они обеспечивают защиту и удобный пользовательский опыт при повторных попытках.
Генерируйте один ключ на пользовательское намерение (один клик «Создать»), повторно используйте его для всех повторных попыток этого же действия и отправляйте его вместе с запросом. Ключ должен сохраняться при таймаутах и возобновлениях приложения, но не должен переиспользоваться для других созданий.
Сохраняйте запись идемпотентности, скоупированную по тому, кто вызывает (пользователь или аккаунт), по эндпоинту и по самому ключу, и храните ответ, возвращённый при первой успешной попытке. Если тот же ключ приходит снова, возвращайте сохранённый ответ и тот же ID созданной записи вместо создания новой.
Обеспечьте безопасную проверку и сохранение: обычно это делается через уникальное ограничение на запись идемпотентности (scope + key). Тогда две почти одновременные попытки не смогут обе считать себя «первой», и одна из них переиспользует уже сохранённый результат.
Храните их достаточно долго, чтобы покрыть реалистичные повторные попытки: распространённая настройка — около 24 часов, для платёжных потоков обычно 48–72 часа. Добавьте TTL, чтобы хранилище не росло бесконечно, и выберите срок, соответствующий правдоподобной длительности повторных попыток клиента.
Когда очевидно, что это повтор той же операции, лучше трактовать дубликат как успешную повторную попытку и вернуть оригинальную запись (тот же ID), а не общую ошибку. Если же поле действительно должно быть уникальным (например, email), верните понятное сообщение конфликта, объясняющее, что уже существует и что было сделано.