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

Условие гонки возникает, когда два (или больше) запроса обновляют одни и те же данные почти одновременно, и итог зависит от тайминга. Каждый запрос по отдельности выглядит корректно. Вместе они дают неправильный результат.
Простой пример: двое нажимают «Сохранить» на одной и той же карточке клиента в течение секунды. Один меняет e‑mail, другой — телефон. Если оба запроса отправляют весь объект, второй запись может перезаписать первую, и одно изменение пропадёт без ошибки.
Вы чаще видите это в быстрых приложениях, потому что пользователи совершают больше действий в минуту. Пики тоже случаются в загруженные моменты: распродажи, отчётность в конце месяца, рассылка или любой момент, когда очередь запросов бьёт по одним и тем же строкам.
Пользователи редко сообщают «условие гонки». Они описывают симптомы: дублированные заказы или комментарии, исчезнувшие изменения ("я сохранил, а оно вернулось"), странные итоги (запасы ушли в отрицательное, счётчики отскакивают назад) или статусы, которые неожиданно меняются (одобрено, затем снова в ожидании).
Повторы только усугубляют проблему. Люди двойным кликом, обновляют страницу после медленного ответа, отправляют форму из двух вкладок или пользуются ненадёжной сетью, что приводит к повторной отправке. Если сервер обрабатывает каждый запрос как новую запись, вы получите два создания, два списания или два изменения статуса, которые должны были произойти один раз.
Большинство CRUD-приложений кажутся простыми: прочитал строку, изменил поле, сохранил. Подвох в том, что ваше приложение не контролирует тайминг. База данных, сеть, ретраи, фоновые задачи и поведение пользователей пересекаются друг с другом.
Один распространённый триггер — двое людей редактируют одну запись. Оба загрузили одни и те же «текущие» значения, оба внесли корректные изменения, и последний «сохранивший» тихо перезаписывает первого. Никто не сделал ничего неправильно, но одно обновление потерялось.
То же случается и с одним человеком. Двойной клик по кнопке Сохранить, нажатие Повторить из‑за медленного соединения или два устройства с одной учётной записью — всё это может отправить одинаковую запись дважды. Если конечная точка не идемпотентна, появляются дубликаты, двойные списания или статус двигается на два шага.
Современное использование добавляет перекрытия. Несколько вкладок или устройств, вошедших в один аккаунт, могут запускать конфликтующие обновления. Фоновые задания (рассылка, биллинг, синхронизация, очистка) могут трогать те же строки, что и веб‑запросы. Автоматические ретраи на клиенте, балансировщике или раннере задач могут повторить уже прошедший запрос.
Если вы быстро выпускаете фичи, одну и ту же запись часто обновляют из большего числа мест, чем кто‑то помнит. Если вы используете генератор приложений вроде Koder.ai, приложение растёт ещё быстрее, поэтому лучше считать конкуренцию нормой, а не краевым случаем.
Условия гонки редко показывают себя на demo «создать запись». Они появляются там, где два запроса одновременно трогают одну и ту же истину. Знание обычных точек горячих конфликтов помогает проектировать безопасные записи с самого начала.
Всё, что похоже на «просто прибавить 1», ломается под нагрузкой: лайки, просмотры, итоги, номера счетов, номера билетов. Рискованная схема — прочитать значение, добавить, затем записать обратно. Два запроса могут прочитать одно и то же стартовое значение и перезаписать друг друга.
Рабочие процессы вроде Черновик -> Отправлено -> Одобрено -> Оплачено кажутся простыми, но коллизии часты. Проблемы начинаются, когда одновременно возможны два действия (одобрить и редактировать, отменить и оплатить). Без защит запись может пропустить шаги, откатиться или показывать разные состояния в разных таблицах.
Относитесь к изменению статуса как к контракту: разрешайте только следующий валидный шаг и отклоняйте всё остальное.
Осталось мест, количество на складе, слоты записи и поля «остаток вместимости» — классическая проблема перепродажи. Два покупателя оформляют заказ одновременно, оба видят доступность и оба проходят. Если база данных не является последним арбитром, вы в итоге продадите больше, чем есть.
Некоторые правила абсолютны: один e‑mail на аккаунт, одна активная подписка на пользователя, одна открытая корзина. Часто это рушится, когда вы сначала проверяете («существует ли?»), а потом вставляете. При конкуренции оба запроса могут пройти проверку.
Если вы быстро генерируете CRUD‑потоки (например, генерируя приложение из чата в Koder.ai), зафиксируйте эти точки и подкрепите их ограничениями и безопасными записями, а не только проверками в UI.
Большинство условий гонки начинаются с простого: одно и то же действие отправляется дважды. Пользователи кликают дважды, сеть задерживает ответ, телефон регистрирует два нажатия. Иногда это неумышленно: страница обновляется после POST и браузер предлагает переслать форму.
Когда это происходит, бэкенд может параллельно выполнить два создания или обновления. Если оба пройдут, вы получите дубликаты, неверные итоги или дважды выполненное изменение статуса (например, два одобрения). Это кажется случайным, потому что зависит от тайминга.
Самый безопасный подход — «защита в глубину». Исправьте UI, но предполагайте, что UI всё равно может подвести.
Практические изменения, которые можно применить в большинстве потоков записи:
Пример: пользователь нажал «Оплатить счёт» дважды на мобильном. UI должен блокировать второй клик. Сервер должен отвергнуть второй запрос, увидев тот же идемпотентный ключ, и вернуть исходный результат, а не списывать повторно.
Поля статуса кажутся простыми, пока два процесса не пытаются изменить их одновременно. Пользователь нажал Одобрить, пока фоновая задача пометила запись как Просрочена, или два сотрудника работают в разных вкладках. Оба обновления могут пройти, но итог будет зависеть от тайминга, а не от ваших правил.
Рассматривайте статус как простую машину состояний. Держите небольшой список допустимых переходов (например: Черновик -> Отправлено -> Одобрено, и Отправлено -> Отклонено). Тогда каждая запись проверяет: «Разрешён ли этот переход из текущего статуса?» Если нет — отклоняет, вместо тихого перезаписывания.
Оптимистичная блокировка помогает обнаруживать устаревшие обновления без блокировки других пользователей. Добавьте номер версии (или updated_at) и требуйте совпадения при сохранении. Если кто‑то изменил строку после того, как вы её загрузили, ваш update затронет 0 строк, и вы сможете показать понятное сообщение вроде: «Элемент изменился, обновите страницу и попробуйте снова.»
Простой шаблон для обновлений статуса:
Также держите изменения статуса в одном месте. Если обновления разбросаны по экранам, фоновым задачам и вебхукам, вы пропустите правило. Поместите их за одной функцией или конечной точкой, которая каждый раз применяет одни и те же проверки переходов.
Самая частая ошибка со счётчиками выглядит безобидно: приложение читает значение, прибавляет 1, затем записывает обратно. Под нагрузкой два запроса могут прочитать одно и то же число и оба записать один и тот же новый результат, поэтому одно инкрементирование теряется. Это легко пропустить, потому что в тестах «обычно работает».
Если значение просто инкрементируется или декрементируется, поручите это базе одним оператором. Тогда база корректно применит изменения, даже когда много запросов падает одновременно.
UPDATE posts
SET like_count = like_count + 1
WHERE id = $1;
Та же идея для инвентаря, счётчиков просмотров, повторных попыток и всего, что выражается как "новое = старое + дельта".
Итоги часто идут неверно, когда вы храните производное число (order_total, account_balance, project_hours) и обновляете его из нескольких мест. Если вы можете вычислять итог по исходным строкам (позиции заказа, проводки), вы избегаете целого класса ошибок с дрейфом.
Если нужно хранить итог ради скорости, относитесь к нему как к критической записи. Делайте обновления исходных строк и сохранённого итога в одной транзакции. Обеспечьте, чтобы только один писатель мог менять тот же итог одновременно (блокировки, защищённые обновления или единый путь‑владелец). Добавьте ограничения, которые не позволят невозможные значения (например, inventory >= 0). Периодически выполняйте сверку, пересчитывая и помечая расхождения.
Конкретный пример: двое пользователей одновременно добавляют товары в одну корзину. Если каждый запрос читает cart_total, прибавляет цену и записывает обратно, одно добавление может пропасть. Если вы обновляете позиции корзины и итог в одной транзакции, итог остаётся корректным даже при интенсивных параллельных кликах.
Если хотите меньше условий гонки — начните с базы. Код приложения может ретрайить, таймаутиться или выполняться дважды. Ограничение в базе — это финальная преграда, которая остаётся корректной даже когда два запроса попадают одновременно.
Уникальные ограничения останавливают дубли, которые «никогда не должны происходить», но всё же случаются: e‑mail, номера заказов, invoice_id или правило «одна активная подписка на пользователя». Когда два сабмита попадают одновременно, база записывает одну строку и отвергает другую.
Внешние ключи предотвращают битые ссылки. Без них один запрос может удалить родительскую запись, пока другой создаёт дочернюю, указывающую на ничего, оставляя осиротевшие строки, которые трудно чистить.
CHECK‑ограничения держат значения в безопасном диапазоне и принуждают простые правила состояния. Например, quantity >= 0, рейтинг между 1 и 5 или статус, ограниченный набором допустимых значений.
Относитесь к ошибкам ограничений как к ожидаемому результату, а не к «ошибке сервера». Перехватывайте уникальные, foreign key и check‑ошибки, возвращайте понятное сообщение типа «Этот e‑mail уже используется» и логируйте детали для отладки, не сливая внутреннюю информацию.
Пример: двое нажали «Создать заказ» во время задержки. С уникальным ограничением на (user_id, cart_id) вы не получите два заказа. Вы получите один заказ и одно аккуратное, объяснимое отклонение.
Некоторые записи — не одно выражение. Вы читаете строку, проверяете правило, обновляете статус и, возможно, вставляете запись аудита. Если два запроса делают это одновременно, оба могут пройти проверку и оба записать. Это классическая ошибка.
Оборачивайте многосшаговую запись в транзакцию, чтобы все шаги выполнялись вместе или не выполнялись вовсе. Важнее: транзакция даёт место, где можно контролировать, кто может менять одни и те же данные одновременно.
Когда только один участник должен редактировать запись одновременно, используйте блокировку на уровне строки. Например: заблокируйте строку заказа, подтвердите, что он всё ещё в «pending», затем переведите в «approved» и запишите запись аудита. Второй запрос подождёт, затем повторно проверит состояние и остановится.
Выбирайте по частоте коллизий:
Держите время удержания блокировки коротким. Дела минимум работы под блокировкой: никаких внешних API‑вызовов, никаких медленных файловых операций, никаких тяжёлых циклов. Если вы строите потоки в инструменте вроде Koder.ai, держите транзакцию только на шаги с базой, а остальное делайте после фиксации.
Выберите поток, который может стоить денег или доверия при коллизии. Частый пример: создать заказ, зарезервировать запас, затем перевести заказ в подтверждённый.
Запишите точные шаги, которые сейчас делает код, в порядке. Будьте конкретны: что читается, что пишется и что значит «успех». Коллизии прячутся в разрыве между чтением и последующей записью.
Путь укрепления, который работает в большинстве стеков:
Добавьте один тест, который доказывает исправление. Запустите два запроса одновременно против одного и того же товара и количества. Утверждайте, что ровно один заказ становится подтверждённым, а другой терпит контролируемый провал (без отрицательного запаса, без дубликатов резерваций).
Если вы быстро генерируете приложения (включая через Koder.ai), этот чеклист всё равно стоит применить для тех нескольких путей записи, которые наиболее важны.
Одна из главных причин — доверие UI. Отключённые кнопки и проверки на клиенте помогают, но пользователи могут кликнуть дважды, обновить страницу, открыть две вкладки или воспроизвести запрос с ненадёжной сети. Если сервер не идемпотентен, дубликаты пройдут.
Тихая ошибка: вы ловите ошибку базы (например, уникальное ограничение), но продолжаете workflow как ни в чём не бывало. Часто это превращается в «создание провалилось, но мы всё равно отправили письмо» или «платёж не прошёл, но мы пометили заказ оплаченным». Как только побочные эффекты произошли, их тяжело откатить.
Долгие транзакции — ещё одна ловушка. Если держать транзакцию открытой во время отправки письма, вызова платёжного шлюза или третьего API, вы удерживаете блокировки дольше, чем нужно. Это увеличивает ожидание, таймауты и вероятность блокировок между запросами.
Смешивание фоновых задач и пользовательских действий без единого источника правды создаёт split‑brain состояние. Задача ретраит и обновляет строку, пока пользователь её редактирует, и в результате оба думают, что они последние писатели.
Некоторые «исправления», которые на самом деле не решают проблему:
Если вы строите с помощью chat‑to‑app инструментов вроде Koder.ai, те же правила применимы: требуйте серверных ограничений и чётких транзакционных границ, а не только красивых UI‑защит.
Условия гонки часто проявляются только при реальном трафике. Быстрый предрелизный проход может поймать самые типичные точки столкновений без полной переработки.
Начните с базы. Если что‑то должно быть уникальным (e‑mail, номер счёта, одна активная подписка на пользователя), сделайте это реальным уникальным ограничением, а не проверкой на уровне приложения. Затем убедитесь, что код ожидает, что ограничение иногда сработает и возвращает понятный, безопасный ответ.
Дальше посмотрите на состояние. Любое изменение статуса (Черновик -> Отправлено -> Одобрено) должно проверяться относительно явного набора допустимых переходов. Если два запроса пытаются сдвинуть одну и ту же запись, второй должен быть отклонён или стать no‑op, а не создавать промежуточное состояние.
Практический чеклист перед выпуском:
Если вы генерируете потоки в Koder.ai, воспринимайте это как критерии приёмки: сгенерированное приложение должно корректно падать при повторах и параллелях, а не только проходить «happy path».
Двое сотрудников открывают один и тот же запрос на покупку. Оба нажимают Одобрить в течение нескольких секунд. Оба запроса доходят до сервера.
Что может пойти не так — запутанно: запрос может оказаться «одобрен» дважды, уйдёт два уведомления, и любые итоги, связанные с одобрениями (использованный бюджет, дневной счётчик одобрений) могут увеличиться на 2. Оба обновления допустимы по отдельности, но они сталкиваются.
Вот план исправления, который хорошо работает с базой уровня PostgreSQL.
Добавьте правило, которое гарантирует существование только одной записи одобрения для запроса. Например, храните подтверждения в отдельной таблице и навяжите уникальное ограничение по request_id. Тогда вторая вставка упадёт, даже если в коде есть баг.
При одобрении выполните весь переход в одной транзакции:
Если второй сотрудник придёт позже, он либо увидит 0 обновлённых строк, либо получит ошибку уникального ограничения. В любом случае победит только одно изменение.
После исправления первый сотрудник видит Approved и нормальное подтверждение. Второй получает дружелюбное сообщение вроде: «Этот запрос уже одобрен другим пользователем. Обновите страницу, чтобы увидеть актуальный статус.» Никаких лишних уведомлений, никаких тихих сбоев.
Если вы генерируете CRUD‑поток в платформе типа Koder.ai (бэкенд на Go с PostgreSQL), вы можете встроить эти проверки в действие «approve» один раз и переиспользовать паттерн для других операций «только один победитель».
Условия гонки легче исправлять, если воспринимать их как повторяемую рутину, а не как разовую охоту на баги. Сфокусируйтесь на нескольких потоках записи, которые важны больше всего, и сделайте их надёжно корректными прежде, чем заниматься остальным.
Начните с того, чтобы назвать ваши основные точки столкновений. Во многих CRUD‑приложениях это одна и та же тройка: счётчики (лайки, инвентарь, балансы), изменения статусов (Черновик -> Отправлено -> Одобрено) и двойные отправки (двойной клик, ретраи, медленная сеть).
Рутина, которая выдерживает проверку:
Если вы строите на Koder.ai, Planning Mode — удобное место, чтобы заранее описать шаги и правила для каждой записи перед генерацией кода на Go и PostgreSQL. Снимки состояния и откат помогают при выпуске новых ограничений или поведения блокировок, если вдруг появляется неожиданный краевой случай.
Со временем это станет привычкой: каждая новая функция записи получает ограничение, план транзакции и тест конкурентности. Так условия гонки в CRUD‑приложениях перестанут быть сюрпризом.