Как обнаруживать и устранять race condition в приложениях Laravel

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

Такие ошибки возникают, когда несколько процессов одновременно обращаются к одним и тем же данным и изменяют их без должной синхронизации. В Laravel это чаще всего происходит при высоком уровне параллельных запросов. Например, во время распродаж или при массовых операциях с очередями.

Разберёмся, как выявлять race condition и какие инструменты Laravel помогают их предотвратить.

Что такое race condition

Race condition возникает, когда несколько запросов выполняют последовательность действий:

  1. читают данные;

  2. принимают решение на основе этих данных;

  3. изменяют их.

Если два запроса выполняют эти шаги одновременно, они могут работать с устаревшими значениями. В результате система принимает неверные решения.

Классический пример - покупка последнего товара на складе.

Представим, что в базе есть одна единица товара:

  1. Запрос A читает stock = 1.

  2. Почти одновременно запрос B тоже читает stock = 1.

  3. Оба уменьшают значение на единицу.

  4. В итоге продано два товара, хотя был только один.

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

Почему race condition сложно обнаружить

Основная сложность в том, что такие ошибки практически невозможно воспроизвести в обычной разработке.

Причины:

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

  • тестовая нагрузка значительно ниже реальной;

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

Например, если используется драйвер очередей sync, задачи выполняются в том же процессе и race condition не возникает. Но после перехода на Redis или SQS воркеры начинают обрабатывать задания параллельно, и ошибка проявляется.

Как воспроизвести race condition

Один из способов воспроизведения это написать тест, который запускает несколько параллельных запросов.

Типичный сценарий тестирования:

  • создать ресурс с ограничением (например, stock = 1);

  • отправить несколько запросов на его изменение;

  • проверить итоговое состояние.

Если после выполнения теста значение выходит за допустимые пределы, то в коде есть race condition.

Для нагрузки можно использовать:

  • HTTP-тесты Laravel

  • инструменты вроде k6, JMeter или ApacheBench

  • параллельные queue-воркеры

Способы устранения race condition в Laravel

Laravel предоставляет несколько механизмов, которые позволяют защитить код от одновременных изменений данных.

Транзакции базы данных

Первый шаг это объединить связанные операции в транзакцию.

DB::transaction(function () {
    // операции чтения и записи
});

Если что-то идёт не так, транзакция откатывается и база возвращается в прежнее состояние. Это обеспечивает целостность данных. Но одной транзакции часто недостаточно т.к нужно ещё заблокировать запись.

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

Самый распространённый способ это блокировка строки в базе данных.

$product = Product::where('id', $id)
    ->lockForUpdate()
    ->first();

Теперь, пока транзакция не завершится, другая операция не сможет изменить эту запись.

Пример:

DB::transaction(function () use ($productId) {
    $product = Product::lockForUpdate()->find($productId);

    if ($product->stock > 0) {
        $product->stock--;
        $product->save();
    }
});

В такой ситуации второй запрос будет ждать завершения первого.

Атомарные операции

Иногда можно обойтись без чтения данных, выполнив изменение одной SQL-операцией.

Например:

Product::where('id', $id)->decrement('stock');

Здесь база сама выполняет операцию stock = stock - 1, и это происходит атомарно без промежуточного состояния.

Такой подход особенно полезен для:

  • счётчиков

  • статистики

  • лайков

  • количества просмотров

Distributed locks через Cache::lock()

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

Laravel предоставляет метод:

Cache::lock('process-order', 10)->block(5, function () {
    // критическая секция
});

Пока один процесс держит блокировку, остальные не смогут выполнить этот код.

Такие блокировки обычно работают через Redis или Memcached и позволяют синхронизировать операции между разными серверами.

Отложенная отправка задач (afterCommit)

Если задача ставится в очередь внутри транзакции, воркер может начать выполнять её до того, как данные будут зафиксированы в базе. Laravel решает эту проблему с помощью:

dispatch(new ProcessOrder($order))->afterCommit();

Теперь задача попадёт в очередь только после успешного завершения транзакции.

Лучшие практики

Чтобы минимизировать вероятность race condition:

  • используйте транзакции для критических операций;

  • применяйте lockForUpdate() при работе с ограниченными ресурсами;

  • предпочитайте атомарные SQL-операции;

  • используйте Cache::lock() для распределённых систем;

  • отправляйте задания в очередь после коммита транзакции.

В большинстве реальных приложений применяется комбинация этих подходов.

Комментарии (0)

Войдите, чтобы оставить комментарий

Похожие статьи

Как скачивать файлы по URL в Laravel

Подробное руководство по скачиванию файлов из внешних URL в Laravel: от простого стриминга до продакшен-ориентированных подходов с сохранением и раздачей файлов пользователям.

Laravel AI SDK: создавайте умные приложения с ИИ

Laravel AI SDK - что это, как установить, как он работает и чем полезен разработчикам. Показаны ключевые возможности для создания интеллектуальных AI‑функций в Laravel‑приложениях.