Состояние гонки — это ситуация, когда результат выполнения программы зависит от непредсказуемого порядка выполнения операций в параллельных процессах/потоках. В PHP это чаще всего возникает при работе с общими ресурсами (файлы, базы данных, сессии).
Пример проблемы
// Два параллельных запроса к скрипту
$balance = file_get_contents('balance.txt'); // Читаем 100
$balance += 10; // Увеличиваем
file_put_contents('balance.txt', $balance); // Сохраняем 110
// Если два запроса прочитают 100 одновременно,
// оба сохранят 110 вместо ожидаемых 120
Важно помнить
- PHP-FPM работает в многопроцессном режиме — race conditions реальны
- Сессии PHP автоматически блокируются но только для одного запроса от пользователя
- Не все операции в БД атомарны — проверяйте документацию
- Блокировки снижают производительность — выбирайте минимально необходимый уровень изоляции
Решения состояния гонки на примере: Блокировки файлов (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;
}
}
Главные принципы
- Предпочитайте атомарные операции вместо SELECT + UPDATE
- Используйте транзакции когда нужно несколько связанных операций
- FOR UPDATE блокирует строку — другие транзакции не могут её изменить или заблокировать
- Optimistic Locking лучше для систем с низкой конкуренцией
- Всегда обрабатывайте deadlock и повторяйте операции при ошибках