Race condition это один из самых коварных типов ошибок в веб-приложениях. Они редко проявляются на этапе разработки, но могут приводить к серьёзным проблемам в продакшене: двойным платежам, отрицательному балансу пользователей или продаже большего количества товаров, чем есть на складе.
Такие ошибки возникают, когда несколько процессов одновременно обращаются к одним и тем же данным и изменяют их без должной синхронизации. В Laravel это чаще всего происходит при высоком уровне параллельных запросов. Например, во время распродаж или при массовых операциях с очередями.
Разберёмся, как выявлять race condition и какие инструменты Laravel помогают их предотвратить.
Что такое race condition
Race condition возникает, когда несколько запросов выполняют последовательность действий:
читают данные;
принимают решение на основе этих данных;
изменяют их.
Если два запроса выполняют эти шаги одновременно, они могут работать с устаревшими значениями. В результате система принимает неверные решения.
Классический пример - покупка последнего товара на складе.
Представим, что в базе есть одна единица товара:
Запрос A читает
stock = 1.Почти одновременно запрос B тоже читает
stock = 1.Оба уменьшают значение на единицу.
В итоге продано два товара, хотя был только один.
Проблема не проявляется при обычной нагрузке и часто остаётся незаметной до момента, когда система начинает обрабатывать большое количество одновременных запросов.
Почему 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()для распределённых систем;отправляйте задания в очередь после коммита транзакции.
В большинстве реальных приложений применяется комбинация этих подходов.