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

Как выглядят непостоянные асинхронные ошибки в реальной жизни
Непостоянная (flaky) асинхронная ошибка — это когда вы делаете одно и то же действие дважды и получаете два разных результата. Вы нажимаете ту же кнопку, отправляете ту же форму или запускаете ту же задачу, а результат меняется: в один раз всё работает, в другой — падает или выполняется наполовину, оставляя данные в странном состоянии.
Асинхронность увеличивает вероятность таких проблем, потому что задачи не завершаются строго одна за другой. Запросы, задания в очереди, таймеры и записи в базу данных могут перекрываться. Порядок действий меняется из-за небольших задержек: сеть, медленный запрос к БД, повторная попытка или пользователь, который делает то же действие дважды.
Поэтому гонки за ресурсами часто кажутся «случайными». Баг реальный, но проявляется только тогда, когда два события приходят в неудачном порядке. Примеры:
- Пользователь дважды кликает «Оплатить», и два запроса создают два заказа.
- Фоновая задача повторяется после таймаута, хотя первый запрос уже успешно завершился.
- Две вкладки обновляют один профиль, и последний ответ перезаписывает более новые данные.
- Вебхук приходит до того, как запись, от которой он зависит, зафиксирована в базе.
Чтобы диагностировать это, не нужно быть экспертом. Если вы можете ответить на вопрос «что произошло первым?», вы уже мыслите правильно. Цель — перестать гадать и начать наблюдать: какие действия выполнялись, в каком порядке и какое состояние каждое из них считало текущим на тот момент.
Где обычно прячутся гонки за ресурсами
Гонки редко лежат в той строке кода, на которую вы смотрите. Они прячутся в промежутках между шагами: после клика, до завершения повтора, пока фоновая задача ещё выполняется. Если вы хотите исправить гонки, начните с карты всех мест, где работа может выполниться дважды или в непредвиденном порядке.
Частое место скрытия — очереди и фоновые задачи. Одно событие порождает два задания (или задание повторяется), и каждое из них по‑отдельности проходит нормально. Вместе они создают дубли, обрабатываются не в том порядке или вызывают шквал повторов, из‑за которого система выглядит случайной.
Веб‑запросы — ещё один классический источник. Пользователи кликают дважды, мобильные сети повторяют запросы, а браузеры держат параллельные вкладки дольше, чем вы думаете. Два запроса попадают на один и тот же эндпоинт: оба читают одно и то же старое состояние, затем оба записывают, и последний тихо «побеждает».
Обновления состояния — там всё становится тонким. Два корректных обновления могут столкнуться. Позднее обновление может перезаписать более новое значение, потому что начало выполнялось раньше, или потому что код подразумевает единственного писателя.
Проверьте в первую очередь:
- Потребители очередей и воркеры, которые могут выполняться параллельно
- Cron‑задачи, которые накладываются друг на друга при медленном выполнении
- Логику “повтор при ошибке”, которая не идемпотентна
- Вызовы сторонних API, которые могут частично пройти до таймаута
- Любой поток, который делает чтение‑модификацию‑запись без защиты
Пример: приложение, сгенерированное AI, отправляет «приветственное письмо» и из веб‑запроса, и из фоновой задачи «на всякий случай». Под нагрузкой срабатывают оба пути, и дубликаты видны лишь иногда. Баг не в коде отправки письма, а в отсутствии правила, кто и сколько раз может отправлять письмо.
Быстрые сигналы, указывающие на недетерминированность
Недетерминированные баги кажутся удачей: сервер вернул 500, вы обновили страницу — и всё работает. Паттерн «исчезает при повторе» — один из самых явных признаков того, что проблема во времени, а не в одной сломанной строке.
Следите за данными. Если запись иногда отсутствует, иногда дублируется или иногда перезаписывается более старым значением — значит, одно и то же обновляется из двух мест. Часто это выглядит как «платёж снят дважды», «письмо отправлено дважды» или «профиль сохранён, но поля откатились».
Логи обычно выдают вас, даже когда баг не воспроизводится. Если вы видите одно и то же действие дважды с тем же пользователем и полезной нагрузкой (два запуска задачи, два API‑вызова, два обработчика вебхука), предполагайте конкуренцию или повторы, пока не доказано обратное.
Быстрые шаблоны для поиска
Эти закономерности часто встречаются при поиске гонок:
- Отчёты о баге «не могу воспроизвести», особенно на разных устройствах или сетях
- Ошибки, которые всплескивают при увеличении трафика или при замедлении базы данных
- Рабочий процесс, который падает только при открытых нескольких вкладках или двойном клике
- Очередь задач, которая «иногда» обрабатывается в неправильном порядке или даёт дубли после повторов
- Тикеты поддержки, которые концентрируются вокруг таймаутов, повторов или деградации производительности
Конкретный пример: два запроса на оплату приходят рядом (двойной клик + медленный ответ). Оба видят «товар в наличии», оба резервируют, и только один позже не проходит. Повтор затем «исправляет» ситуацию, маскируя реальную проблему.
Если вы унаследовали прототип, сгенерированный AI, эти симптомы часты: асинхронные потоки часто склеены без явного владельца. FixMyMess быстро проводит аудит таких путей, трассируя каждый запрос и запуск задачи от начала до конца, прежде чем менять логику.
Сначала инструментируйте: минимальное логирование, которое окупается
Если вы пытаетесь исправить гонки, добавляя ожидания или sleep, вы обычно только усложняете видимость бага. Несколько хорошо подобранных логов скажут, что происходит на самом деле, даже когда сбой редкий.
Начните с присвоения каждому пользовательскому действию или запуску задачи correlation ID. Используйте его везде: веб‑запрос, задача в очереди и все последующие вызовы. Когда кто‑то сообщает «упало один раз», вы сможете проследить одну нитку через всю систему, а не читать несвязанный шум.
Логируйте начало и конец каждого важного шага с временными отметками. Делайте это просто и последовательно, чтобы можно было сравнивать запуски. Также фиксируйте общий ресурс, к которому идёт обращение: ID строки в базе, ключ кеша, адрес электронной почты или имя файла. Большинство flaky ошибок происходят, когда два потока трогают одно и то же в разном порядке.
Минимальный набор логов, который окупается:
- correlation_id, user_id (или session_id) и request_id/job_id
- имя события и имя шага (start, finish)
- временная метка и длительность
- идентификаторы ресурсов (ID строки, ключ кеша, имя файла)
- retry_count и error_type (таймаут vs валидация vs конфликт)
Делайте логи безопасными. Никогда не выводите секреты, токены, пароли или полные данные карт. Если нужно подтвердить «одно и то же значение», логируйте короткий отпечаток (например, последние 4 символа или замаскированную версию).
Пример: пользователь дважды кликает «Оплатить». С correlation ID и логами start/finish вы увидите два запроса, соревнующихся за один и тот же order_id, один из которых повторяется после таймаута. Это тот момент, когда команды часто привлекают FixMyMess: мы делаем аудит и добавляем нужную инструментализацию перед изменением логики, чтобы причина стала очевидной.
Создайте надёжное воспроизведение, а не гадания
Flaky баги исчезают, когда вы на них смотрите. Самый быстрый путь исправить гонки — перестать «пробовать разные вещи» и создать повторяемый кейс с падением.
Выберите поток, который падает, и опишите ожидаемую последовательность простыми словами. Коротко: “Пользователь кликает Оплатить -> запрос создаёт заказ -> задача резервирует инвентарь -> UI показывает успех.” Это ваша базовая линия: что должно происходить и что происходит вместо этого.
Сделайте баг легче воспроизводимым, усилив давление на тайминги и конкуренцию. Не ждите, когда он случится сам.
- Форсируйте параллельные действия: двойной клик, две вкладки, два воркера против одной очереди.
- Добавьте намеренную задержку на подозрительном шаге (перед записью, после чтения, перед внешним вызовом).
- Замедлите окружение: ограничьте сеть, добавьте задержку БД или запустите задачу в tight‑loop.
- Сузьте область: воспроизводите в минимальном обработчике или задаче.
- Запишите точные входные данные и настройку таймингов, чтобы любой мог повторить.
Конкретный пример: кнопка «Создать проект» иногда создаёт два проекта. Поставьте задержку 300 ms перед вставкой, затем быстро нажмите дважды или отправьте с двух вкладок. Если дубли воспроизводятся 8 из 10 раз, у вас есть надёжный кейс для правки.
Сохраните короткий repro‑скрипт (даже пара ручных шагов) и относитесь к нему как к тесту: запускайте до и после каждого изменения. Команды, работающие с AI‑генерированными прототипами, обычно начинают с построения такого repro — это превращает расплывчатую жалобу в измеримый сбой.
Шаг за шагом: стабилизируйте поток, а не гоняйтесь за таймингом
Если вы пытаетесь исправить гонки, добавляя задержки или «ждать, пока всё закончится», баг часто просто перемещается. Цель — сделать поток безопасным, даже если действия выполняются дважды, в неверном порядке или одновременно.
Начните с определения общего ресурса, который может испортиться. Это часто одна строка (запись пользователя), счётчик (баланс) или ограниченный ресурс (инвентарь). Если два пути могут его трогать одновременно — в этом реальная проблема, а не в тайминге.
Практический способ — выбрать одну стратегию защиты и применять её последовательно:
- Спрогнозируйте, кто читает и кто пишет общий ресурс (запрос A, задача B, вебхук C).
- Решите правило: сериализовать работу (по одному), заблокировать ресурс или сделать операцию идемпотентной.
- Добавьте идемпотентный ключ для любой операции, которая может повториться (двойной клик, повтор сети, повтор очереди).
- Защитите записи транзакцией или условным обновлением, чтобы не потерять чужую запись.
- Подтвердите исправление тестами на конкурентность и многократными прогонами, а не «всё хорошо на моей машине».
Пример: два запроса «Оформить заказ» приходят одновременно. Без защиты оба читают inventory=1, оба вычитают, и вы отправляете два товара. С идемпотентностью второй запрос использует результат первого. С условным обновлением (вычитать только если inventory всё ещё 1) внутри транзакции — только один запрос пройдёт.
В AI‑сгенерированном коде такие защиты часто отсутствуют или применяются непоследовательно. FixMyMess обычно добавляет минимальную идемпотентность и правила безопасной записи, затем прогоняет сценарий десятки раз, пока поведение не станет рутинным.
Очереди и фоновые задачи: дубли, повторы, порядок
Большинство очередей доставляет задачи как минимум один раз. Это значит, что одно и то же задание может выполниться дважды или прийти позже нового задания, даже если вы нажали кнопку один раз. Если обработчик считает, что он единственный запуск, вы увидите странные результаты: двойные списания, дубли писем или запись, которая дергается между состояниями.
Самый безопасный подход — сделать каждое задание безопасным для повторного выполнения. Думайте о результатах, а не об попытках. Задача должна уметь выполниться повторно и в итоге привести систему к одному и тому же финальному состоянию.
Практичные паттерны для фоновой обработки:
- Добавьте идемпотентный ключ (orderId, userId + action + date) и фиксируйте «уже обработано» до совершения сайд‑эффектов.
- Записывайте явный статус задания (pending, running, done, failed), чтобы повторы могли быстро завершаться.
- Внешние вызовы (платёж, письмо, загрузка файла) рассматривайте как «выполнить один раз» с собственными проверками дедупа.
- Защищайте от событий вне порядка с помощью номера версии или временной метки и игнорируйте старые обновления.
- Разделяйте ошибки, которые стоит ретраить (таймауты, rate limit), и те, что не имеют смысла повторять (плохой ввод), и останавливайте повторы, когда они не помогут.
Проблемы с порядком легко проскакивают. Пример: задача «отменить подписку» проходит, затем приходит отложенная задача «продлить подписку» и снова активирует пользователя. Если хранить монотонно растущую версию или updatedAt при каждом изменении, обработчик сможет отвергнуть устаревшие сообщения и сохранить актуальную правду.
Будьте осторожны с глобальными блокировками. Они могут скрыть баг, но в продакшене будут блокировать всю работу и приводить к новым проблемам. Предпочитайте локальные блоки по сущности (один пользователь или заказ за раз) или проверки идемпотентности.
Если вы унаследовали AI‑генерированный обработчик очереди, который случайно дублирует работу, команды вроде FixMyMess часто начинают с добавления идемпотентности и проверок «устаревшего события». Эти два изменения обычно быстро делают поведение предсказуемым.
Веб‑запросы: двойные клики, повторы и параллельные сессии
Большинство flaky ошибок с запросами происходят, когда одно и то же действие отправляется дважды или когда два действия приходят в неправильном порядке. Пользователи кликают дважды, клиенты повторяют при медленной сети, мобильные приложения пересылают по таймауту, а несколько вкладок ведут себя как отдельные люди.
Чтобы исправить гонки на этом уровне, предполагайте, что клиент будет слать дубли. Сервер должен быть корректен, даже если UI ошибочен, медленный или офлайн.
Сделайте эндпоинты «выполнить действие» безопасными для повторного вызова
Если запрос создаёт сайд‑эффекты (снять со счёта, создать заказ, отправить письмо), сделайте его идемпотентным. Простой способ — идемпотентный ключ на операцию. Фиксируйте результат по ключу, и при повторном приходе возвращайте тот же результат.
Следите за таймаутами. Обычная ошибка: сервер всё ещё обрабатывает, клиент таймаутит и повторяет. Без дедупа вы получаете два заказа, два письма или две «welcome»‑нотификации.
Набор защит, который обычно окупается:
- Требовать идемпотентный ключ для create/submit эндпоинтов и дедупить по (user, key)
- Последовательные и предсказуемые ошибки, чтобы клиенты ретрайлились только на безопасные ошибки
- Логировать request ID и idempotency key на каждой попытке
- Выполнять сайд‑эффекты после того, как вы зафиксировали запись «это действие произошло»
- Рассматривать «уже сделано» как успех, а не как страшную ошибку
Не допускайте перезаписи состояния параллельными запросами
Перезаписи случаются, когда два запроса читают одно и то же старое состояние и оба записывают обновления. Пример: две вкладки правят профиль, и последний выигравший тихо отменяет изменения другого.
Предпочитайте серверные проверки: номера версий (optimistic locking) или явные правила вроде «обновляй только если статус всё ещё PENDING». В AI‑сгенерированных обработчиках, которые делают read‑modify‑write без защиты, это частая причина случайных жалоб «иногда сохраняется, иногда нет».
Обновления состояния: предотвращение перезаписей и несогласованных данных
Много flaky‑поведения связано не с очередями или сетью, а с тем, что две части приложения обновляют один и тот же кусок состояния в разном порядке. Один запрос выигрывает сегодня, другой — завтра.
Классическая проблема — потерянное обновление: два воркера читают одно и то же старое значение, оба вычисляют новое, и последний перезаписывает первый. Пример: два устройства меняют настройки уведомлений пользователя. Оба читают «включено», один выключает, второй меняет звук, и в финальной записи одна из изменений теряется.
По возможности предпочитайте атомарные операции вместо «прочитал — вычислил — записал». Базы данных часто поддерживают безопасные примитивы: increment, compare‑and‑set, «update where version = X». Это превращает тайминг в понятное правило: только одно обновление может пройти, а проигравший повторяет с актуальными данными.
Второй способ — валидировать переходы состояния. Если заказ может идти только pending -> paid -> shipped, отвергайте shipped -> paid, даже если он пришёл позже. Это предотвращает поздние запросы, повторы или фоновые задачи, которые откатывают состояние назад.
Кеширование усугубляет проблему. Устаревшее чтение из кеша может вызвать «правильную» запись на базе старых данных. Если вы кешируете состояние, которое ведёт к записям, либо инвалидируйте кеш при обновлениях, либо читайте источник правды прямо перед записью.
Один простой подход — владение (ownership): решите, где разрешено обновлять данные, и направляйте все изменения через это место. Хорошие правила владения обычно выглядят так:
- Один сервис владеет записью, другие только запрашивают изменения
- Одна таблица/документ — источник правды
- Обновления содержат номер версии (или метку времени) и отклоняются, если устарели
- Принимаются только разрешённые переходы состояния
В FixMyMess мы часто видим AI‑сгенерированные приложения, где UI, API и фоновые задачи одновременно меняют одну запись. Явное назначение владельца — быстрый способ прекратить несогласованность данных.
Распространённые ловушки, которые поддерживают flaky баги
Самый быстрый способ потерять неделю на flaky баги — лечить время, а не причину. Если баг зависит от тайминга, вы можете на день скрыть его и всё равно выпустить.
Одна частая ошибка — добавление больше повторов, когда у вас нет идемпотентности. Если платёжная задача падает на середине и повторяется, вы рискуете снять платёж дважды. Повторы безопасны только тогда, когда каждая попытка может выполниться снова без изменения результата.
Ещё одна ловушка — рассыпающие задержки, чтобы «разбавить» столкновения. Это часто скрывает проблему в staging, но ухудшает ситуацию в продакшене, потому что шаблоны нагрузки меняются. Задержки также замедляют систему и усложняют понимание.
Крупные блокировки тоже вредны. Одиночный большой mutex вокруг «всего потока» может остановить флак, но создаст новые проблемы: длинные ожидания, таймауты и каскадные повторы, которые вернут баг в иной форме.
Следите за шаблонами, которые поддерживают недетерминированность:
- Считать любую ошибку ретраибельной (таймауты, ошибки валидации, аутентификации и конфликты требуют разной обработки)
- Объявить победу потому что локально один прогон прошёл (реальная конкуренция редко проявляется на тихом ноутбуке)
- Логировать только «произошла ошибка» без request/job id, номера попытки или версии состояния
- Лечить симптомы на одном уровне, пока гонка остаётся внизу (UI, API и воркер могут перекрываться)
- «Временные» хаки, ставшие постоянными (лишние повторы, sleep, catch‑all блоки)
Если вы унаследовали кодовую базу с такими пластырями, целенаправленный аудит быстро покажет, где повторы, блокировки и отсутствие идемпотентности маскируют истинную гонку. На этом этапе целенаправленный ремонт эффективнее догадок.
Короткий чеклист перед релизом исправления
Перед тем как считать задачу сделанной, убедитесь, что вы не выигрываете «лотерею таймингов». Цель — сделать так, чтобы одинаковые входные данные всегда давали одинаковый результат.
Короткий предрелизный чеклист, который ловит большинство повторяющихся ошибок:
- Запустите действие дважды намеренно. Тот же сценарий (двойной клик, два воркера, две вкладки) должен приводить к корректному результату, а не просто «не падать».
- Найдите общий ресурс. Идентифицируйте row/file/cache key/balance/inbox и решите, как он защищён: транзакция, лок, уникальное ограничение или условное обновление.
- Проверьте повторы на сайд‑эффекты. Если при повторе отправляется ещё одно письмо или списывается ещё один платёж — добавьте идемпотентность.
- Сравните порядок событий в логах. В хорошем и плохом прогонах события приходят в разном порядке? Различия в порядке означают, что вы лечите симптомы, а не причину.
- Предпочитайте атомарные гарантии вместо sleep. Если транзакция, уникальный индекс или «update where version = X» устраняют баг — это безопаснее, чем задержки.
Пример: если «Создать подписку» иногда снимает платёж дважды, убедитесь, что вызов платёжного провайдера привязан к идемпотентному токену, а запись в базе защищена уникальным ограничением по этому токену. Тогда дубли превращаются в безобидный noop, а не в пожар у службы поддержки.
Пример: стабилизация ненадёжного рабочего процесса end‑to‑end
Представьте простой сценарий: двое коллег редактируют одну карточку клиента, а фоновая задача обновляет ту же карточку после импорта. В демо всё ок, но при реальных пользователях появляются странные результаты.
Сейчас приложение использует «последний записавшийся побеждает». Пользователь A сохраняет, затем пользователь B — и перезаписывает изменения A. Фоновая задача повторяется после таймаута и отправляет уведомление «Клиент обновлён» дважды.
Чтобы подтвердить недетерминированность и исправить гонки вместо гаданий, соберите воспроизведение:
- Откройте запись в двух вкладках (Вкладка A и Вкладка B)
- Измените разные поля в каждой вкладке
- Нажмите сохранить в обеих вкладках в течение секунды
- Запустите фоновую задачу и принудительно вызовите повтор (например, временно бросив ошибку после отправки)
- Проверьте итоговое состояние записи и количество уведомлений
Если результаты отличаются от прогона к прогону, вы нашли баг, зависящий от таймингов.
Стабилизация обычно требует двух изменений. Во‑первых, сделайте уведомления идемпотентными: добавьте ключ вроде customerId + eventType + version и храните его, чтобы одна и та же нотификация не отправлялась дважды.
Во‑вторых, сделайте обновление записи атомарным. Оберните запись в транзакцию и добавьте проверку версии (optimistic locking). Если Вкладка B пытается сохранить устаревшую версию, верните понятное сообщение «Запись изменилась — обновите и попробуйте снова», вместо тихого перезаписывания.
Прогоните тот же repro 50 раз. Вы должны получать идентичный результат: одно финальное состояние и ровно одно уведомление.
Это типичный случай, который FixMyMess видит в AI‑сгенерированных приложениях: повторы и асинхронность есть, но защитные ограждения (идемпотентность, блокировки, транзакции) отсутствуют.
Следующие шаги: сделайте систему снова предсказуемой
Выберите 2–3 критичных потока, где флаки наиболее болезнен: списание средств, создание аккаунта, оформление заказа, отправка письма или обновление инвентаря. Не беритесь сразу за всё приложение.
Опишите эти потоки простыми шагами (что запускает, что вызывает, какие данные меняются). Эта маленькая карта даёт общую правду, когда люди спорят о порядке событий, и помогает фиксировать гонки без гаданий.
Выберите одну защиту, которую можно выпустить за неделю. Маленькие изменения часто убирают большую часть риска:
- Добавьте идемпотентный ключ для действий, создающих сущности (платежи, заказы, письма)
- Используйте условное обновление (только если версия совпадает или статус ожидаемый)
- Добавьте уникальное ограничение, чтобы дубли падали быстро и безопасно
- Обеспечьте порядок для конкретной темы очереди (или сведите несколько задач в одну «latest state» задачу)
- Поставьте таймаут и лимит повторов там, где повторы сейчас бесконечны
Если кодовая база была сгенерирована AI и её трудно понять, запланируйте фокусный рефакторинг: один поток, один владелец и неделя, чтобы убрать скрытое общее состояние и «магические» повторы.
Пример: если «Создать заказ» иногда отправляет два письма, сначала сделайте отправку письма идемпотентной, затем доработайте воркер очереди, чтобы он ретраил безопасно, не меняя результата.
Если хотите быстрый план действий, FixMyMess может провести бесплатный аудит кода, чтобы найти гонки, повторы и небезопасные записи. А если нужно быстро стабилизировать, мы можем диагностировать и исправить AI‑сгенерированные прототипы в 48–72 часа с экспертной проверкой.