Laravel: race condition
Glossary overview

Laravel: race condition

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
T3500 >= 400 ✓
T4500 >= 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()

Как не попасться: чеклист

  1. Никогда не читай → проверяй → пиши в три шага без защиты. Это классическая точка гонки.
  2. Используй DB::raw() для инкрементов и декрементов вместо чтения значения в PHP.
  3. Оборачивай критические секции в DB::transaction() с lockForUpdate().
  4. Помни про идемпотентность — повторный вызов с теми же данными не должен создавать дубликатов.
  5. Тестируй параллельными запросами. Race condition не воспроизводится при обычном тестировании. Используй ab, wrk или простой скрипт с curl в цикле.

Итог

Race condition — коварная штука, потому что код выглядит правильным и обычно работает. Баг проявляется только под нагрузкой и почти невозможно воспроизвести в дебаггере. Поэтому защиту нужно закладывать на этапе написания кода, а не после того, как у пользователя списался минусовой баланс.

Laravel даёт все инструменты для борьбы с гонками — от DB::raw() до распределённых блокировок. Главное — помнить, что если два запроса могут прийти одновременно, они обязательно придут одновременно.