Race condition (состояние гонки) — это ситуация, когда результат работы программы зависит от того, в каком порядке выполнятся параллельные операции. Два процесса одновременно читают и меняют одни и те же данные — и кто-то из них «затирает» результат другого.
В Laravel с этим можно столкнуться чаще, чем кажется: очереди, параллельные HTTP-запросы, крон-задачи — всё это потенциальные точки гонки.
Классический пример: списание баланса
Представим интернет-магазин. У пользователя на балансе 500 грн, и он одновременно отправляет два запроса на покупку товара за 400 грн.
// ❌ Опасный код
public function purchase(Request $request)
{
$user = User::find($request->user()->id);
if ($user->balance >= 400) {
// Оба запроса прочитали баланс 500
// Оба прошли проверку
$user->balance -= 400;
$user->save();
}
}
Что произойдёт:
| Время | Запрос A | Запрос B |
|---|---|---|
| T1 | Читает баланс: 500 | |
| T2 | Читает баланс: 500 | |
| T3 | 500 >= 400 ✓ | |
| T4 | 500 >= 400 ✓ | |
| T5 | Записывает: 100 | |
| T6 | Записывает: 100 |
Итог: пользователь купил два товара за 800 грн, а с баланса списалось только 400. Магазин потерял деньги.
Способ 1: Атомарные операции в БД
Самое простое решение — переложить проверку и обновление на базу данных в один запрос.
// ✅ Атомарное обновление
$affected = DB::table('users')
->where('id', $user->id)
->where('balance', '>=', 400)
->update([
'balance' => DB::raw('balance - 400')
]);
if ($affected === 0) {
return response()->json(['error' => 'Недостаточно средств'], 422);
}
Ключевое отличие: WHERE balance >= 400 и SET balance = balance - 400 выполняются в одном SQL-запросе. База данных гарантирует атомарность — второй запрос увидит уже обновлённый баланс и не пройдёт проверку.
Способ 2: Пессимистическая блокировка (lockForUpdate)
Блокируем строку в базе, пока работаем с ней. Другие запросы будут ждать.
// ✅ Пессимистическая блокировка
DB::transaction(function () use ($user) {
$user = User::where('id', $user->id)
->lockForUpdate() // SELECT ... FOR UPDATE
->first();
if ($user->balance < 400) {
throw new \Exception('Недостаточно средств');
}
$user->balance -= 400;
$user->save();
});
lockForUpdate() говорит базе данных: «Заблокируй эту строку, пока я не закончу транзакцию». Второй запрос будет висеть на SELECT, пока первый не сделает COMMIT.
Важно: всегда оборачивайте lockForUpdate() в DB::transaction(), иначе блокировка не имеет смысла.
Способ 3: Оптимистическая блокировка (версионирование)
Не блокируем строку, а проверяем, что данные не изменились с момента чтения.
// Добавляем колонку version в миграцию
Schema::table('users', function (Blueprint $table) {
$table->unsignedInteger('version')->default(0);
});
// ✅ Оптимистическая блокировка
$user = User::find($userId);
$affected = DB::table('users')
->where('id', $user->id)
->where('version', $user->version) // Проверяем версию
->update([
'balance' => $user->balance - 400,
'version' => $user->version + 1, // Увеличиваем версию
]);
if ($affected === 0) {
// Данные изменились — кто-то был быстрее
// Можно повторить попытку или вернуть ошибку
return response()->json(['error' => 'Попробуйте ещё раз'], 409);
}
Подход хорош, когда конфликты случаются редко. Нет блокировок — нет ожидания.
Способ 4: Атомарные блокировки Laravel (Cache Lock)
Laravel предоставляет механизм распределённых блокировок через кеш. Это полезно, когда проблема не в базе, а в бизнес-логике.
use Illuminate\Support\Facades\Cache;
// ✅ Атомарная блокировка
$lock = Cache::lock('purchase:user:' . $user->id, 10); // 10 сек таймаут
if ($lock->get()) {
try {
$user->refresh();
if ($user->balance < 400) {
return response()->json(['error' => 'Недостаточно средств'], 422);
}
$user->balance -= 400;
$user->save();
} finally {
$lock->release();
}
} else {
return response()->json(['error' => 'Запрос обрабатывается'], 429);
}
Или более элегантно через block():
// Ждать блокировку до 5 секунд
Cache::lock('purchase:user:' . $user->id, 10)->block(5, function () use ($user) {
$user->refresh();
if ($user->balance >= 400) {
$user->balance -= 400;
$user->save();
}
});
Для работы блокировок нужен драйвер кеша, поддерживающий атомарные операции: Redis, Memcached или DynamoDB. Файловый и массивный драйверы не подойдут.
Способ 5: Unique Jobs в очередях
Race condition часто возникает в очередях, когда одна и та же задача попадает туда дважды.
use Illuminate\Contracts\Queue\ShouldBeUnique;
class ProcessPayment implements ShouldQueue, ShouldBeUnique
{
public function __construct(
public int $userId,
public int $amount,
) {}
// Ключ уникальности — один пользователь не обрабатывается параллельно
public function uniqueId(): string
{
return 'payment:' . $this->userId;
}
// Сколько секунд удерживать блокировку
public function uniqueFor(): int
{
return 60;
}
}
Где чаще всего ловить race condition
| Ситуация | Решение |
|---|---|
| Списание баланса / остатков | Атомарный UPDATE или lockForUpdate |
| Бронирование (билеты, слоты) | lockForUpdate + транзакция |
| Генерация уникальных номеров | Атомарный инкремент в БД |
| Обработка вебхуков | Cache::lock по ID события |
| Дублирование задач в очереди | ShouldBeUnique |
| Обновление счётчиков | DB::raw('column + 1') |
| Крон-задачи | withoutOverlapping() |
Как не попасться: чеклист
- Никогда не читай → проверяй → пиши в три шага без защиты. Это классическая точка гонки.
- Используй
DB::raw()для инкрементов и декрементов вместо чтения значения в PHP. - Оборачивай критические секции в
DB::transaction()сlockForUpdate(). - Помни про идемпотентность — повторный вызов с теми же данными не должен создавать дубликатов.
- Тестируй параллельными запросами. Race condition не воспроизводится при обычном тестировании. Используй
ab,wrkили простой скрипт сcurlв цикле.
Итог
Race condition — коварная штука, потому что код выглядит правильным и обычно работает. Баг проявляется только под нагрузкой и почти невозможно воспроизвести в дебаггере. Поэтому защиту нужно закладывать на этапе написания кода, а не после того, как у пользователя списался минусовой баланс.
Laravel даёт все инструменты для борьбы с гонками — от DB::raw() до распределённых блокировок. Главное — помнить, что если два запроса могут прийти одновременно, они обязательно придут одновременно.