Состояние гонки (Race Condition) в PHP

Состояние гонки — это ситуация, когда результат выполнения программы зависит от непредсказуемого порядка выполнения операций в параллельных процессах/потоках. В PHP это чаще всего возникает при работе с общими ресурсами (файлы, базы данных, сессии).

Пример проблемы

// Два параллельных запроса к скрипту
$balance = file_get_contents('balance.txt'); // Читаем 100
$balance += 10; // Увеличиваем
file_put_contents('balance.txt', $balance); // Сохраняем 110

// Если два запроса прочитают 100 одновременно,
// оба сохранят 110 вместо ожидаемых 120

Важно помнить

  1. PHP-FPM работает в многопроцессном режиме — race conditions реальны
  2. Сессии PHP автоматически блокируются но только для одного запроса от пользователя
  3. Не все операции в БД атомарны — проверяйте документацию
  4. Блокировки снижают производительность — выбирайте минимально необходимый уровень изоляции

Решения состояния гонки на примере: Блокировки файлов (flock)

$file = fopen('balance.txt', 'r+');
if (flock($file, LOCK_EX)) { // Эксклюзивная блокировка
    $balance = fread($file, filesize('balance.txt'));
    $balance += 10;
    ftruncate($file, 0);
    fwrite($file, $balance);
    flock($file, LOCK_UN); // Снятие блокировки
}
fclose($file);

Решения состояния гонки на примере: Работы с базой данных

1. Атомарные UPDATE (самое простое и эффективное)

// Вместо SELECT + UPDATE в два запроса
// Плохо (состояние гонки):
$stmt = $pdo->prepare("SELECT balance FROM users WHERE id = ?");
$stmt->execute([$userId]);
$balance = $stmt->fetchColumn();
$pdo->prepare("UPDATE users SET balance = ? WHERE id = ?")
    ->execute([$balance + 10, $userId]);

// Хорошо (атомарно):
$pdo->prepare("UPDATE users SET balance = balance + 10 WHERE id = ?")
    ->execute([$userId]);

2. FOR UPDATE (блокировка строки)

$pdo->beginTransaction();
try {
    // Блокируем строку для чтения другими транзакциями
    $stmt = $pdo->prepare("SELECT balance FROM users WHERE id = ? FOR UPDATE");
    $stmt->execute([$userId]);
    $balance = $stmt->fetchColumn();
    
    // Сложная логика, которую нельзя выразить атомарно
    $newBalance = $balance > 100 ? $balance - 10 : $balance + 10;
    
    $pdo->prepare("UPDATE users SET balance = ? WHERE id = ?")
        ->execute([$newBalance, $userId]);
    
    $pdo->commit();
} catch (Exception $e) {
    $pdo->rollBack();
    throw $e;
}

3. Optimistic Locking (версионирование)

$maxRetries = 3;
for ($i = 0; $i < $maxRetries; $i++) {
    $stmt = $pdo->prepare("SELECT balance, version FROM users WHERE id = ?");
    $stmt->execute([$userId]);
    $user = $stmt->fetch(PDO::FETCH_ASSOC);
    
    $newBalance = $user['balance'] + 10;
    $newVersion = $user['version'] + 1;
    
    // Обновляем только если версия не изменилась
    $stmt = $pdo->prepare("UPDATE users 
        SET balance = ?, version = ? 
        WHERE id = ? AND version = ?");
    
    $updated = $stmt->execute([$newBalance, $newVersion, $userId, $user['version']]);
    
    if ($updated) {
        break; // Успех
    }
    // Иначе повторяем с новой версией
}

4. Уровни изоляции транзакций

// SERIALIZABLE - самый строгий уровень
$pdo->exec("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
$pdo->beginTransaction();
try {
    // Даже обычный SELECT будет вести себя как FOR UPDATE
    $stmt = $pdo->prepare("SELECT balance FROM users WHERE id = ?");
    $stmt->execute([$userId]);
    $balance = $stmt->fetchColumn();
    
    $pdo->prepare("UPDATE users SET balance = ? WHERE id = ?")
        ->execute([$balance + 10, $userId]);
    
    $pdo->commit();
} catch (Exception $e) {
    $pdo->rollBack();
    // Может выбросить deadlock или сериализационный конфликт
}

6. GET_LOCK() для MySQL

// Именованная блокировка приложения
$pdo->prepare("SELECT GET_LOCK('payment_process_" . $orderId . "', 5)")->execute();
$locked = $pdo->fetchColumn();

if ($locked) {
    try {
        // Критическая секция
        $pdo->prepare("UPDATE orders SET status = 'paid' WHERE id = ?")->execute([$orderId]);
    } finally {
        $pdo->prepare("SELECT RELEASE_LOCK('payment_process_" . $orderId . "')")->execute();
    }
}

Сравнение решений

РешениеКогда использоватьМинусы
Атомарный UPDATEПростые инкременты/декрементыТолько для арифметики
FOR UPDATEСложная логика на основе прочитанных данныхСнижает параллелизм
Optimistic LockingРедкие конфликты, много чтенияНужны повторы, сложнее
SERIALIZABLEМаксимальная надежностьСильное падение производительности
Уникальный ключЗащита от дублирующих операцийТолько для вставок
GET_LOCKМеханизм mutex поверх MySQLНе реплицируется

Практический пример: дебетование счета

function debitAccount(PDO $pdo, int $userId, float $amount): bool {
    // Правильное решение - атомарная проверка и обновление
    $stmt = $pdo->prepare("
        UPDATE users 
        SET balance = balance - ? 
        WHERE id = ? AND balance >= ?
    ");
    
    $stmt->execute([$amount, $userId, $amount]);
    return $stmt->rowCount() > 0;
}

// Альтернатива с FOR UPDATE для сложной логики
function debitAccountComplex(PDO $pdo, int $userId, float $amount): bool {
    $pdo->beginTransaction();
    try {
        $stmt = $pdo->prepare("SELECT balance FROM users WHERE id = ? FOR UPDATE");
        $stmt->execute([$userId]);
        $balance = $stmt->fetchColumn();
        
        if ($balance < $amount) {
            $pdo->rollBack();
            return false;
        }
        
        // Можно добавить аудит, проверки лимитов и т.д.
        $pdo->prepare("UPDATE users SET balance = ? WHERE id = ?")
            ->execute([$balance - $amount, $userId]);
        
        $pdo->commit();
        return true;
    } catch (Exception $e) {
        $pdo->rollBack();
        throw $e;
    }
}

Главные принципы

  1. Предпочитайте атомарные операции вместо SELECT + UPDATE
  2. Используйте транзакции когда нужно несколько связанных операций
  3. FOR UPDATE блокирует строку — другие транзакции не могут её изменить или заблокировать
  4. Optimistic Locking лучше для систем с низкой конкуренцией
  5. Всегда обрабатывайте deadlock и повторяйте операции при ошибках

Запись опубликована в рубрике Web. Добавьте в закладки постоянную ссылку.

Добавить комментарий