Перейти к основному содержимому

Гонка событий (Race Condition)

Гонка событий — ситуация, при которой результат работы системы зависит от порядка выполнения операций. Если несколько процессов одновременно работают с одним состоянием, итог может быть непредсказуемым.

Одна из самых распространённых проблем в современных системах. Может возникать в:

  • микросервисах;
  • распределённых системах;
  • очередях сообщений;
  • асинхронных API;
  • frontend-приложениях;
  • потоковой обработке данных.

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

  • двойное списание денег;
  • отрицательный остаток;
  • потерю обновления;
  • дублирование операции;
  • рассинхронизацию между сервисами.

Пример гонки событий

На складе остался один товар. Два пользователя одновременно нажимают кнопку «Купить». Оба запроса читают остаток как 1. Оба успешно проходят проверку и уменьшают остаток.

В результате:

  • продано два товара вместо одного;
  • остаток становится отрицательным;
  • система теряет консистентность.

Data Race vs Race Condition

  • Data Race — низкоуровневая проблема многопоточного доступа к памяти.
  • Race Condition — логическая проблема порядка выполнения операций.

В enterprise-системах аналитики и архитекторы в основном работают с Race Condition.

Когда возникает

несколько процессов работают с одним состоянием (например, одновременно читают и изменяют);

  • хотя бы один процесс изменяет данные;
  • порядок выполнения не контролируется;
  • нарушение порядка доставки (события отправляются в одном порядке, а доставляются — в другом.);
  • итог зависит от случайных факторов;
  • отсутствие координации между сервисами (каждый сервис видит только локальное состояние.)
  • deadlock (несколько транзакций блокируют друг друга)

Типичные признаки

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

Race condition — не обязательно ошибка разработчика.

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

Причины:

  • сетевые задержки;
  • ретраи;
  • повторная доставка сообщений;
  • независимая работа сервисов;
  • параллельные консьюмеры;
  • различная скорость обработки.

Ключевая проблема — порядок выполнения нельзя гарантировать.

Гонка всегда связана с одним принципом:

корректность системы зависит от последовательности событий.

Если изменение порядка приводит к разному результату — система подвержена гонке.

В архитектуре

Backend и микросервисы

Самый частый источник гонок — параллельные запросы к одному ресурсу.

Например:

  • несколько сервисов обновляют один заказ;
  • несколько обработчиков меняют баланс;
  • несколько консьюмеров читают одну очередь.

Особенно опасны:

Базы данных

При использовании транзакций гонка не исчезает.

Типовые проблемы:

  • Lost Update (ситуация, когда изменения одного процесса перезаписываются изменениями другого, и часть данных теряется);
  • dirty read (чтение данных, которые были изменены другой транзакцией, но ещё не зафиксированы (могут быть отменены);
  • non-repeatable read (повторное чтение одной и той же записи внутри транзакции возвращает разные значения, потому что другая транзакция изменила данные);
  • перезапись изменений;
  • конфликт версий.

Классическая ошибка:

SELECT balance FROM accounts;
UPDATE accounts SET balance = ?;

Между чтением и обновлением другой процесс может изменить данные.

Брокеры сообщений

Брокеры не гарантируют отсутствие гонок.

Проблемы:

  • задвоенные события;
  • доставка не по очереди;
  • повторная доставка;
  • параллельные консьюмеры.

Например:

  • событие отмены заказа пришло раньше события создания;
  • одно сообщение обработалось дважды;
  • retry изменил порядок обработки.

Frontend

Race condition часто возникает даже в UI.

Пример:

  1. Пользователь быстро меняет поле профиля.
  2. Отправляется два запроса.
  3. Второй запрос завершается раньше первого.
  4. Первый ответ перезаписывает новое значение старым.

Пользователь видит устаревшие данные.

Особенно часто это происходит при:

  • optimistic update (когда интерфейс показывает результат изменения сразу, не дожидаясь ответа сервера);
  • debounce (механизм задержки выполнения действия до момента, пока пользователь не прекратит выполнять повторяющиеся действия (например, ввод текста);
  • асинхронных запросах;
  • локальном кеше.

Распределенные системы и стриминги

В распределённых системах гонка — нормальное состояние среды.

Причины:

  • eventual consistency;
  • независимые сервисы;
  • сетевые лаги;
  • разные каналы доставки;
  • репликация.

В стриминг-системах события могут приходить позже/раньше; дублироваться; теряться; переупорядочиваться.

Потоковая обработка почти всегда строится с учётом гонки событий.

Примеры

Микросервисы с общей БД

Сервис заказов и сервис оплаты работают с одной таблицей.

Сервис оплаты меняет статус заказа:

status = 'paid'

Сервис заказов одновременно обновляет адрес доставки.

Оба сервиса:

  1. читают одну запись;
  2. меняют локальную копию;
  3. сохраняют объект полностью.

Последний UPDATE уничтожает изменения другого сервиса.

Как решать

  • обновлять только нужные поля;
  • использовать оптимистическая блокировка;
  • отказаться от общей БД.

Event-driven архитектура

Сервис публикует:

  • OrderCreated
  • OrderCancelled

Потребитель получает их в обратном порядке.

  • Клиент сначала получает письмо: «Заказ отменён»
  • А затем: «Заказ успешно создан»

Как решать

  • партицирование по orderId;
  • сериализация обработки;
  • версионирование событий;
  • occurredAt timestamp.

Event Sourcing

Проекция получает:

  • PaymentAccepted
  • OrderCreated

Если платёж пришёл раньше создания заказа:

  • агрегат не существует;
  • обработчик падает;
  • проекция становится неконсистентной.

Как решать

  • буферизация out-of-order событий;
  • очередь ожидания / очередь отложенных событий;
  • единый источник событий.

Frontend с optimistic update

UI сразу показывает новое значение без ожидания ответа сервера. Пользователь меняет поле дважды. Второй запрос выполняется быстрее первого. Первый ответ приходит позже и перезаписывает новое состояние.

Как решать

  • версионирование;
  • AbortController;
  • повторное получение данных после изменения;
  • оптимистическая конкуренция.

Как выявлять гонку событий

Главная проблема race condition — нестабильность.

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

  • раз в неделю;
  • только под нагрузкой;
  • только при определённой задержке сети;
  • только в продакшн.

Выявлять:

  • Мониторинг и анализ логов (логировать sequence операций; версию ; timestamps;correlationId; retry count)
  • Трассировка (позволяет увидеть: порядок вызовов, задержки, ретраи, конфликтующие операции)
  • Нагрузочное тестирование
  • Хаосное тестирование (в систему искусственно добавляют: лаги; packet loss; смена порядка отправки; дублирование доставки)

Как предотвращать гонку событий

Идемпотентность

Идемпотентная операция даёт одинаковый результат при повторной обработке.

Обычно реализуется через:

  • idempotency key;
  • уникальный eventId;
  • таблица дедубликации.

Без идемпотентности невозможно безопасно работать с retry.

Оптимистическая блокировка

Запись хранит версию:

version = 5

При обновлении проверяется:

WHERE version = 5

Если запись уже изменилась:

  • UPDATE не выполнится;
  • система обнаружит конфликт.

Это основной механизм защиты от Lost Update.

Пессимистическая блокировка

Запись блокируется на время транзакции. Другие операции ждут освобождения.

Плюс - строгая консистентность.

Минусы:

  • блокировки;
  • деградация производительности;
  • deadlock.

Transactional Outbox

Проблема:

  • запись в БД сохранилась;
  • событие в брокер не отправилось.

Outbox решает это через атомарную запись:

  1. бизнес-данные;
  2. событие в outbox table.

Отдельный publisher читает outbox и публикует сообщения.

Сериализация обработки

Если порядок критичен — события должны обрабатываться последовательно.

Обычно используется:

  • партицирование по ключу;
  • единый консьюмер;

Например, все события заказа идут в одну партицию Kafka.

Saga Pattern

Saga координирует распределённую транзакцию между сервисами.

Каждый шаг:

  • выполняет локальную операцию;
  • публикует событие;
  • при ошибке запускает компенсацию.

Saga не устраняет гонку полностью, но помогает контролировать состояние распределённого процесса.


Роль системного аналитика

Ошибки проектирования часто появляются ещё на этапе требований.

Например, аналитику следует:

  • выявлять конкурентные сценарии;
  • определять критичность порядка событий;
  • фиксировать требования к консистентности;
  • понимать гарантии доставки сообщений;
  • учитывать retry и duplicate delivery.

Ключевые вопросы при проектировании:

  • может ли операция выполниться дважды;
  • допустима ли eventual consistency;
  • что произойдёт при out-of-order событиях;
  • какие данные являются источником истины;
  • нужна ли оптимистичная блокировка
  • требуется ли идемпотентность.

Частые ошибки

  • Надежда на frontend-валидацию (Frontend не защищает от параллельных запросов. Все проверки должны выполняться на backend.)
  • Отсутствие идемпотентности
  • Общая БД между сервисами (создаёт сильную связанность и повышает вероятность Lost Update.)
  • Полное обновление записи (безопаснее обновлять только нужные поля.)
  • Игнорирование гарантий брокера (ошибочно считать, что сообщения не дублируются; порядок всегда сохраняется; retry безопасен.)
  • Отсутствие версионирования

Материалы

  1. Небезопасная многопоточность или Race Condition
  2. Что такое состояние гонки (гонка данных, race condition)
  3. Почему стоит проверять приложения на устойчивость к race condition
  4. Быстрее улитки или Race Conditions в Websocket-ах
  5. Разница между Data Race и Race Condition
  6. Race Condition убил SQLite в нашем проекте: как мы пришли к RediSearch
  7. Race condition в веб-приложениях
  8. Concurrency testing — отлавливаем состояния гонки
  9. Борьба с гонками (race conditions) в JavaScript на примере работы с кешем
  10. Concurrency и паттерны ошибок, скрытые в коде: Deadlock
  11. Разбор основных концепций параллелизма

Видео

  1. Проблемы многопоточности: Deadlock, Race Condition. Кооперативная многозадачность
  2. Гонки данных или race condition (PostgreSQL, isolation level, Python)
  3. Дополнительные главы практической безопасности: Веб-безопасность — атаки на race condition
  4. Race condition и как с ним бороться // Демо-занятие курса «Symfony Framework»
  5. Нина Лукина — Пишем тесты на race conditions, deadlocks и остальной concurrency hell

Конференции

  1. PiterPy: Опередить себя: race conditions and deadlocks, Андрей Захаревич

Книги

  1. Высоконагруженные приложения. Программирование, масштабирование, поддержка - Мартин Клеппман
  2. Микросервисы. Паттерны разработки и рефакторинга - Крис Ричардсон
  3. Шаблоны корпоративных приложений - Мартин Фаулер
  4. Release it! Проектирование и дизайн ПО для тех, кому не всё равно - Майкл Нейгард