Как защитить приложение от race condition в многопоточной среде

Race condition появляется там, где два или больше потоков одновременно читают состояние, принимают решение и меняют данные. Сама по себе многопоточность не проблема. Проблема начинается, когда между проверкой и действием кто-то успевает изменить состояние. Для пользователя это выглядит как «приложение пропустило два платежа», «промокод сработал дважды», «лимит заявок обошёл проверку» или «роль поменялась не так, как ожидалось».

В безопасной разработке race condition опасен не только сбоями. Это уязвимость, если через неё можно получить лишние деньги, доступ, товар, бонус, повторное использование токена или обойти ограничение.

Где race condition чаще всего превращается в уязвимость

Не каждая гонка потоков одинаково критична. Если два запроса одновременно обновили аватар, это неприятно, но обычно не опасно. А вот если два запроса одновременно снимают деньги, активируют купон или подтверждают одноразовый код, это уже зона риска.

Особенно внимательно проверяйте такие места:

  • списание баланса, списание бонусов, начисление вознаграждений;
  • покупка товара с остатком «1 штука на складе»;
  • использование промокода, подарочного сертификата, одноразового токена;
  • лимиты: количество заявок, попыток входа, SMS, запросов к API;
  • смена статусов: «создан» → «оплачен» → «доставлен»;
  • проверка файла перед записью или чтением;
  • обработка повторных webhook-запросов от платёжной системы;
  • обновление настроек безопасности: пароль, 2FA, роли, права.

Главная мысль простая: если результат операции должен быть только один, нельзя делать его через схему «проверил — потом изменил» без защиты.

Классическая ошибка: проверка и действие разделены

Типичный уязвимый код выглядит так:

if (user.balance >= amount) {
    user.balance = user.balance - amount;
    save(user);
}

На первый взгляд всё нормально. Если денег хватает — списываем. Но если два потока одновременно дошли до условия, оба увидят старый баланс. Например, на счёте 100 рублей, списание по 100 рублей. Оба потока проверят баланс, оба пройдут проверку, и в итоге пользователь получит две услуги за один баланс.

Проблема не в языке программирования. Это может случиться в Java, Go, Python, Node.js, PHP, .NET — где угодно. Особенно если приложение работает в несколько потоков, несколько процессов или несколько инстансов.

Безопаснее сделать изменение атомарным:

update accounts
set balance = balance - :amount
where id = :user_id
  and balance >= :amount;

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

Что использовать для защиты: выбор зависит от типа данных

Не стоит лечить все race condition одним mutex. Иногда он подходит, а иногда только создаёт иллюзию защиты. Ниже — практическая таблица по инструментам.

Инструмент Что защищает Когда использовать Ограничения
mutex, synchronized, блокировки потока Общую память внутри одного процесса Короткие критические секции, локальные структуры данных, счётчики в памяти Не защищает при нескольких инстансах приложения и не спасает, если данные живут в базе
Атомарные операции: CAS, AtomicInteger, increment Простые операции вроде увеличения счётчика Счётчики, флаги, простые ограничения, локальные лимиты Не подходит для сложных условий вроде «проверить баланс и изменить несколько связанных строк»
Транзакция базы данных Несколько связанных изменений как один блок Деньги, заказы, списания, резервирование товара, смена статусов Транзакции должны быть короткими; внешние API нельзя безопасно держать внутри долгой транзакции
Ограничения базы: unique, check, частичные индексы Невозможность нарушить правило даже при параллельных запросах Одноразовые токены, промокоды, idempotency key, уникальные заявки Нужно правильно обрабатывать ошибки дублирования, иначе пользователь увидит технический сбой
Очереди и один worker Последовательную обработку однотипных операций Платежи, рассылки, расчёты, обработка webhook, долгие задачи Добавляет задержку; нужны повторные попытки, дедлайны и обработка зависших задач
Оптимистичная блокировка по версии Конфликтующие изменения одной записи Редактирование профиля, заявки, документов, статусов Нужна политика при конфликте: повторить, отказать, попросить пользователя обновить данные
Распределённый lock Исключительный доступ между несколькими инстансами Фоновые задачи, периодические джобы, редкие операции, где нельзя использовать базу как источник истины Нельзя слепо полагаться на него для денег; нужны таймауты, проверка владельца и запасная защита

Схемы защиты для типовых задач

Деньги, баланс, бонусы

Для денег правило жёсткое: не доверяйте проверке в приложении. Проверка должна быть частью атомарного изменения.

Плохой вариант:

balance = loadBalance(userId);

if (balance < amount) {
    reject();
    return;
}

saveBalance(userId, balance - amount);

Лучше так:

affectedRows = update accounts
set balance = balance - :amount
where id = :user_id
  and balance >= :amount;

Если обновилась одна строка — списание прошло. Если ноль — операция отклоняется. Это защищает даже при десятках параллельных запросов.

Если нужно изменить несколько связанных сущностей, используйте транзакцию. Например, списать баланс, создать платёж, записать историю операции. Но держите транзакцию короткой. Не вызывайте внутри неё внешние API, долгие HTTP-запросы и отправку email.

Промокоды, подарочные коды, одноразовые ссылки

Здесь частая ошибка — сначала проверить, не использован ли код, а потом записать использование:

if (!code.used) {
    code.used = true;
    save(code);
}

Два потока могут одновременно увидеть used = false. Защита должна быть на уровне базы:

  • либо атомарное обновление: update codes set used_by = :user_id where id = :code_id and used_by is null;
  • либо уникальное ограничение: один пользователь может использовать один код только один раз;
  • либо отдельная таблица активаций с unique(user_id, code_id).

Для одноразовых ссылок сброса пароля или подтверждения email хорошо работает схема: создать запись использования с уникальным ключом. Если вставка не прошла — ссылка уже была использована.

Лимиты запросов и попыток

Лимиты часто ломаются на конструкции вида:

count = getCount(userId);

if (count < limit) {
    createRequest();
}

Если лимит критичен — например, количество SMS, заявок на кредит, попыток входа или бесплатных генераций — счётчик должен изменяться атомарно.

В базе это может быть условие на вставку:

insert into user_requests(user_id, created_at)
select :user_id, now()
where (
    select count(*)
    from user_requests
    where user_id = :user_id
      and created_at > now() - interval '1 hour'
) < :limit;

В Redis для лимитов лучше использовать атомарные команды или Lua-скрипт, а не схему GET → проверить → SET. Если лимит не связан с безопасностью, можно использовать очередь или worker, который будет принимать решения последовательно.

Смена статусов

Для статусов хорошо работает переход по условию:

update orders
set status = 'paid'
where id = :order_id
  and status = 'created';

Если обновилась одна строка — статус изменился. Если ноль — заказ уже оплачен, отменён или кто-то опередил этот запрос. Такой подход защищает от повторных webhook-запросов и параллельных действий оператора.

Файлы и временные данные

Файловые race condition часто выглядят так: проверить, существует ли файл, потом создать его. Между этими действиями другой процесс может создать файл с тем же именем или подменить путь.

Безопаснее:

  • использовать уникальные имена файлов;
  • создавать файл атомарно с флагом «только если не существует»;
  • не полагаться на путь, который пользователь контролирует;
  • ограничивать права доступа к временным каталогам;
  • не читать файл после проверки, если его мог заменить другой процесс.

Как искать race condition в своём коде

  1. Выпишите критичные инварианты. Например: баланс не может стать отрицательным, промокод нельзя использовать дважды, заказ не может перейти из «доставлен» обратно в «создан».
  2. Найдите места, где есть схема «проверить — потом изменить». Особенно опасны if перед записью, чтение перед обновлением, отдельный select перед insert.
  3. Проверьте все входы в операцию. Race condition часто появляется не в основном endpoint, а в webhook, фоновой задаче, админке, retry-механизме или импорте данных.
  4. Смотрите на повторяемость. Если один и тот же запрос можно отправить дважды, он должен либо возвращать тот же результат, либо явно отклоняться.
  5. Тестируйте параллельно. Для подозрительной операции запустите 20–100 одновременных запросов и проверьте инвариант после теста. Это не доказывает отсутствие проблемы, но быстро показывает грубые ошибки.
  6. Добавьте мониторинг. Логируйте конфликты обновлений, дубли idempotency key, отклонённые переходы статусов и попытки нарушить лимит.

Идемпотентность: защита от повторных запросов

Много race condition приходят не из злого пользователя, а из реальности сети. Клиент отправил запрос, таймаут истёк, он отправил ещё раз. Платёжный провайдер прислал webhook повторно. Worker упал после изменения данных, но перед подтверждением задачи.

Для таких случаев нужна идемпотентность. Простыми словами: один и тот же запрос должен приводить к одному и тому же результату.

Практическая схема:

  • клиент или внешняя система передаёт idempotency_key;
  • сервер сохраняет этот ключ в таблице с уникальным ограничением;
  • при первом запросе операция выполняется;
  • при повторном запросе сервер возвращает сохранённый результат, а не запускает операцию заново.

Но идемпотентность не заменяет атомарные обновления. Она защищает от повторного запуска, а не от двух разных пользователей, которые одновременно пытаются использовать один и тот же ресурс.

Что выбрать в зависимости от ситуации

  • Если данные хранятся в базе и операция критична — используйте транзакции, атомарные UPDATE с условиями и ограничения базы. Не держите защиту только в коде приложения.
  • Если операция должна выполняться строго по очереди — вынесите её в очередь с одним worker или отдельным сериализованным обработчиком.
  • Если нужно защитить локальный счётчик в одном процессе — достаточно mutex или атомарной переменной, но только если состояние не должно быть общим между инстансами.
  • Если запросы могут повторяться — добавьте idempotency key и хранение результата.
  • Если несколько инстансов приложения работают с одной базой — не рассчитывайте на блокировки языка программирования. Переносите синхронизацию в базу, очередь или распределённый механизм.
  • Если речь о файлах — используйте атомарное создание, уникальные имена и безопасные права доступа.
  • Если спорная операция редкая, но дорогая — распределённый lock допустим, но всё равно добавьте проверку в базе на случай истёкшей аренды или сбоя lock-сервиса.

Частые ошибки при защите от race condition

  • Добавлять sleep между проверкой и действием. Это не защита, а способ сделать уязвимость менее стабильной.
  • Ставить mutex только на чтение. Блокировка должна защищать весь участок «проверка + изменение».
  • Делать select, а потом update без for update или без атомарного условия. Между ними состояние может измениться.
  • Полагаться только на уровень изоляции транзакции. Даже хорошая изоляция не заменяет ограничения базы и атомарные операции.
  • Держать транзакцию слишком долго. Долгие транзакции создают блокировки, таймауты и соблазн вызвать внешний API внутри критической секции.
  • Использовать распределённый lock как единственную защиту для денег. Lock может истечь, сервис может стать недоступен, часы могут расходиться.
  • Не обрабатывать конфликты. Если уникальная вставка не прошла или UPDATE затронул ноль строк, приложение должно вернуть понятный результат, а не падать.
  • Проверять лимиты на клиенте. Клиентская проверка удобна для интерфейса, но не является защитой.
  • Тестировать только один успешный сценарий. Race condition часто проявляется именно при повторных, параллельных и ошибочных запросах.

Практический чек-лист перед релизом

Перед выпуском функции, где есть деньги, доступ, лимиты или одноразовые действия, пройдитесь по списку:

  1. Есть ли у операции чёткий инвариант, который нельзя нарушить?
  2. Защищена ли вся цепочка «проверка → изменение», а не только часть кода?
  3. Может ли операция выполниться повторно из-за retry, webhook или таймаута?
  4. Есть ли ограничение в базе, которое не даст создать дубликат?
  5. Проверяется ли количество изменённых строк после атомарного обновления?
  6. Есть ли обработка конфликтов для пользователя?
  7. Не слишком ли длинная транзакция?
  8. Не вызывается ли внешний API внутри критической секции?
  9. Проверена ли операция параллельными запросами?
  10. Есть ли логи и метрики для отклонённых попыток, дублей и конфликтов?

Минимальная стратегия защиты

Если нужно коротко, действуйте так:

  1. Найдите операции, где параллельное выполнение может дать выгоду или обойти правило.
  2. Сформулируйте инвариант: что никогда не должно случиться.
  3. Перенесите проверку внутрь атомарного изменения.
  4. Добавьте ограничения базы там, где дубликат недопустим.
  5. Сделайте повторяющиеся запросы идемпотентными.
  6. Для долгих операций используйте очередь.
  7. Протестируйте параллельные сценарии и конфликтные ответы.

Итог

Race condition в многопоточных приложениях закрывается не «более аккуратными if», а правильной атомарностью. Если операция критична, проверка и изменение должны быть неделимыми: атомарный UPDATE, транзакция, уникальное ограничение, очередь, idempotency key или другой механизм, который не позволит двум потокам получить желаемый результат одновременно.

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

Оцените статью
PEFile — Безопасность и технологии простым языком